models.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657
  1. """Variation fonts interpolation models."""
  2. from __future__ import annotations
  3. __all__ = [
  4. "normalizeValue",
  5. "normalizeLocation",
  6. "supportScalar",
  7. "piecewiseLinearMap",
  8. "VariationModel",
  9. ]
  10. from typing import TYPE_CHECKING
  11. from fontTools.misc.roundTools import noRound
  12. from .errors import VariationModelError
  13. if TYPE_CHECKING:
  14. from typing import Mapping, Sequence
  15. def nonNone(lst):
  16. return [l for l in lst if l is not None]
  17. def allNone(lst):
  18. return all(l is None for l in lst)
  19. def allEqualTo(ref, lst, mapper=None):
  20. if mapper is None:
  21. return all(ref == item for item in lst)
  22. mapped = mapper(ref)
  23. return all(mapped == mapper(item) for item in lst)
  24. def allEqual(lst, mapper=None):
  25. if not lst:
  26. return True
  27. it = iter(lst)
  28. try:
  29. first = next(it)
  30. except StopIteration:
  31. return True
  32. return allEqualTo(first, it, mapper=mapper)
  33. def subList(truth, lst):
  34. assert len(truth) == len(lst)
  35. return [l for l, t in zip(lst, truth) if t]
  36. def normalizeValue(
  37. v: float, triple: Sequence[float], extrapolate: bool = False
  38. ) -> float:
  39. """Normalizes value based on a min/default/max triple.
  40. >>> normalizeValue(400, (100, 400, 900))
  41. 0.0
  42. >>> normalizeValue(100, (100, 400, 900))
  43. -1.0
  44. >>> normalizeValue(650, (100, 400, 900))
  45. 0.5
  46. """
  47. lower, default, upper = triple
  48. if not (lower <= default <= upper):
  49. raise ValueError(
  50. f"Invalid axis values, must be minimum, default, maximum: "
  51. f"{lower:3.3f}, {default:3.3f}, {upper:3.3f}"
  52. )
  53. if not extrapolate:
  54. v = max(min(v, upper), lower)
  55. if v == default or lower == upper:
  56. return 0.0
  57. if (v < default and lower != default) or (v > default and upper == default):
  58. return (v - default) / (default - lower)
  59. else:
  60. assert (v > default and upper != default) or (
  61. v < default and lower == default
  62. ), f"Ooops... v={v}, triple=({lower}, {default}, {upper})"
  63. return (v - default) / (upper - default)
  64. def normalizeLocation(
  65. location: Mapping[str, float],
  66. axes: Mapping[str, tuple[float, float, float]],
  67. extrapolate: bool = False,
  68. *,
  69. validate: bool = False,
  70. ) -> dict[str, float]:
  71. """Normalizes location based on axis min/default/max values from axes.
  72. >>> axes = {"wght": (100, 400, 900)}
  73. >>> normalizeLocation({"wght": 400}, axes)
  74. {'wght': 0.0}
  75. >>> normalizeLocation({"wght": 100}, axes)
  76. {'wght': -1.0}
  77. >>> normalizeLocation({"wght": 900}, axes)
  78. {'wght': 1.0}
  79. >>> normalizeLocation({"wght": 650}, axes)
  80. {'wght': 0.5}
  81. >>> normalizeLocation({"wght": 1000}, axes)
  82. {'wght': 1.0}
  83. >>> normalizeLocation({"wght": 0}, axes)
  84. {'wght': -1.0}
  85. >>> axes = {"wght": (0, 0, 1000)}
  86. >>> normalizeLocation({"wght": 0}, axes)
  87. {'wght': 0.0}
  88. >>> normalizeLocation({"wght": -1}, axes)
  89. {'wght': 0.0}
  90. >>> normalizeLocation({"wght": 1000}, axes)
  91. {'wght': 1.0}
  92. >>> normalizeLocation({"wght": 500}, axes)
  93. {'wght': 0.5}
  94. >>> normalizeLocation({"wght": 1001}, axes)
  95. {'wght': 1.0}
  96. >>> axes = {"wght": (0, 1000, 1000)}
  97. >>> normalizeLocation({"wght": 0}, axes)
  98. {'wght': -1.0}
  99. >>> normalizeLocation({"wght": -1}, axes)
  100. {'wght': -1.0}
  101. >>> normalizeLocation({"wght": 500}, axes)
  102. {'wght': -0.5}
  103. >>> normalizeLocation({"wght": 1000}, axes)
  104. {'wght': 0.0}
  105. >>> normalizeLocation({"wght": 1001}, axes)
  106. {'wght': 0.0}
  107. """
  108. if validate:
  109. assert set(location.keys()) <= set(axes.keys()), set(location.keys()) - set(
  110. axes.keys()
  111. )
  112. out = {}
  113. for tag, triple in axes.items():
  114. v = location.get(tag, triple[1])
  115. out[tag] = normalizeValue(v, triple, extrapolate=extrapolate)
  116. return out
  117. def supportScalar(location, support, ot=True, extrapolate=False, axisRanges=None):
  118. """Returns the scalar multiplier at location, for a master
  119. with support. If ot is True, then a peak value of zero
  120. for support of an axis means "axis does not participate". That
  121. is how OpenType Variation Font technology works.
  122. If extrapolate is True, axisRanges must be a dict that maps axis
  123. names to (axisMin, axisMax) tuples.
  124. >>> supportScalar({}, {})
  125. 1.0
  126. >>> supportScalar({'wght':.2}, {})
  127. 1.0
  128. >>> supportScalar({'wght':.2}, {'wght':(0,2,3)})
  129. 0.1
  130. >>> supportScalar({'wght':2.5}, {'wght':(0,2,4)})
  131. 0.75
  132. >>> supportScalar({'wght':2.5, 'wdth':0}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
  133. 0.75
  134. >>> supportScalar({'wght':2.5, 'wdth':.5}, {'wght':(0,2,4), 'wdth':(-1,0,+1)}, ot=False)
  135. 0.375
  136. >>> supportScalar({'wght':2.5, 'wdth':0}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
  137. 0.75
  138. >>> supportScalar({'wght':2.5, 'wdth':.5}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
  139. 0.75
  140. >>> supportScalar({'wght':3}, {'wght':(0,1,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
  141. -1.0
  142. >>> supportScalar({'wght':-1}, {'wght':(0,1,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
  143. -1.0
  144. >>> supportScalar({'wght':3}, {'wght':(0,2,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
  145. 1.5
  146. >>> supportScalar({'wght':-1}, {'wght':(0,2,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
  147. -0.5
  148. """
  149. if extrapolate and axisRanges is None:
  150. raise TypeError("axisRanges must be passed when extrapolate is True")
  151. scalar = 1.0
  152. for axis, (lower, peak, upper) in support.items():
  153. if ot:
  154. # OpenType-specific case handling
  155. if peak == 0.0:
  156. continue
  157. if lower > peak or peak > upper:
  158. continue
  159. if lower < 0.0 and upper > 0.0:
  160. continue
  161. v = location.get(axis, 0.0)
  162. else:
  163. assert axis in location
  164. v = location[axis]
  165. if v == peak:
  166. continue
  167. if extrapolate:
  168. axisMin, axisMax = axisRanges[axis]
  169. if v < axisMin and lower <= axisMin:
  170. if peak <= axisMin and peak < upper:
  171. scalar *= (v - upper) / (peak - upper)
  172. continue
  173. elif axisMin < peak:
  174. scalar *= (v - lower) / (peak - lower)
  175. continue
  176. elif axisMax < v and axisMax <= upper:
  177. if axisMax <= peak and lower < peak:
  178. scalar *= (v - lower) / (peak - lower)
  179. continue
  180. elif peak < axisMax:
  181. scalar *= (v - upper) / (peak - upper)
  182. continue
  183. if v <= lower or upper <= v:
  184. scalar = 0.0
  185. break
  186. if v < peak:
  187. scalar *= (v - lower) / (peak - lower)
  188. else: # v > peak
  189. scalar *= (v - upper) / (peak - upper)
  190. return scalar
  191. class VariationModel(object):
  192. """Locations must have the base master at the origin (ie. 0).
  193. If axis-ranges are not provided, values are assumed to be normalized to
  194. the range [-1, 1].
  195. If the extrapolate argument is set to True, then values are extrapolated
  196. outside the axis range.
  197. >>> from pprint import pprint
  198. >>> axisRanges = {'wght': (-180, +180), 'wdth': (-1, +1)}
  199. >>> locations = [ \
  200. {'wght':100}, \
  201. {'wght':-100}, \
  202. {'wght':-180}, \
  203. {'wdth':+.3}, \
  204. {'wght':+120,'wdth':.3}, \
  205. {'wght':+120,'wdth':.2}, \
  206. {}, \
  207. {'wght':+180,'wdth':.3}, \
  208. {'wght':+180}, \
  209. ]
  210. >>> model = VariationModel(locations, axisOrder=['wght'], axisRanges=axisRanges)
  211. >>> pprint(model.locations)
  212. [{},
  213. {'wght': -100},
  214. {'wght': -180},
  215. {'wght': 100},
  216. {'wght': 180},
  217. {'wdth': 0.3},
  218. {'wdth': 0.3, 'wght': 180},
  219. {'wdth': 0.3, 'wght': 120},
  220. {'wdth': 0.2, 'wght': 120}]
  221. >>> pprint(model.deltaWeights)
  222. [{},
  223. {0: 1.0},
  224. {0: 1.0},
  225. {0: 1.0},
  226. {0: 1.0},
  227. {0: 1.0},
  228. {0: 1.0, 4: 1.0, 5: 1.0},
  229. {0: 1.0, 3: 0.75, 4: 0.25, 5: 1.0, 6: 0.6666666666666666},
  230. {0: 1.0,
  231. 3: 0.75,
  232. 4: 0.25,
  233. 5: 0.6666666666666667,
  234. 6: 0.4444444444444445,
  235. 7: 0.6666666666666667}]
  236. """
  237. def __init__(
  238. self, locations, axisOrder=None, extrapolate=False, *, axisRanges=None
  239. ):
  240. if len(set(tuple(sorted(l.items())) for l in locations)) != len(locations):
  241. raise VariationModelError("Locations must be unique.")
  242. self.origLocations = locations
  243. self.axisOrder = axisOrder if axisOrder is not None else []
  244. self.extrapolate = extrapolate
  245. if axisRanges is None:
  246. if extrapolate:
  247. axisRanges = self.computeAxisRanges(locations)
  248. else:
  249. allAxes = {axis for loc in locations for axis in loc.keys()}
  250. axisRanges = {axis: (-1, 1) for axis in allAxes}
  251. self.axisRanges = axisRanges
  252. locations = [{k: v for k, v in loc.items() if v != 0.0} for loc in locations]
  253. keyFunc = self.getMasterLocationsSortKeyFunc(
  254. locations, axisOrder=self.axisOrder
  255. )
  256. self.locations = sorted(locations, key=keyFunc)
  257. # Mapping from user's master order to our master order
  258. self.mapping = [self.locations.index(l) for l in locations]
  259. self.reverseMapping = [locations.index(l) for l in self.locations]
  260. self._computeMasterSupports()
  261. self._subModels = {}
  262. def getSubModel(self, items):
  263. """Return a sub-model and the items that are not None.
  264. The sub-model is necessary for working with the subset
  265. of items when some are None.
  266. The sub-model is cached."""
  267. if None not in items:
  268. return self, items
  269. key = tuple(v is not None for v in items)
  270. subModel = self._subModels.get(key)
  271. if subModel is None:
  272. subModel = VariationModel(subList(key, self.origLocations), self.axisOrder)
  273. self._subModels[key] = subModel
  274. return subModel, subList(key, items)
  275. @staticmethod
  276. def computeAxisRanges(locations):
  277. axisRanges = {}
  278. allAxes = {axis for loc in locations for axis in loc.keys()}
  279. for loc in locations:
  280. for axis in allAxes:
  281. value = loc.get(axis, 0)
  282. axisMin, axisMax = axisRanges.get(axis, (value, value))
  283. axisRanges[axis] = min(value, axisMin), max(value, axisMax)
  284. return axisRanges
  285. @staticmethod
  286. def getMasterLocationsSortKeyFunc(locations, axisOrder=[]):
  287. if {} not in locations:
  288. raise VariationModelError("Base master not found.")
  289. axisPoints = {}
  290. for loc in locations:
  291. if len(loc) != 1:
  292. continue
  293. axis = next(iter(loc))
  294. value = loc[axis]
  295. if axis not in axisPoints:
  296. axisPoints[axis] = {0.0}
  297. assert (
  298. value not in axisPoints[axis]
  299. ), 'Value "%s" in axisPoints["%s"] --> %s' % (value, axis, axisPoints)
  300. axisPoints[axis].add(value)
  301. def getKey(axisPoints, axisOrder):
  302. def sign(v):
  303. return -1 if v < 0 else +1 if v > 0 else 0
  304. def key(loc):
  305. rank = len(loc)
  306. onPointAxes = [
  307. axis
  308. for axis, value in loc.items()
  309. if axis in axisPoints and value in axisPoints[axis]
  310. ]
  311. orderedAxes = [axis for axis in axisOrder if axis in loc]
  312. orderedAxes.extend(
  313. [axis for axis in sorted(loc.keys()) if axis not in axisOrder]
  314. )
  315. return (
  316. rank, # First, order by increasing rank
  317. -len(onPointAxes), # Next, by decreasing number of onPoint axes
  318. tuple(
  319. axisOrder.index(axis) if axis in axisOrder else 0x10000
  320. for axis in orderedAxes
  321. ), # Next, by known axes
  322. tuple(orderedAxes), # Next, by all axes
  323. tuple(
  324. sign(loc[axis]) for axis in orderedAxes
  325. ), # Next, by signs of axis values
  326. tuple(
  327. abs(loc[axis]) for axis in orderedAxes
  328. ), # Next, by absolute value of axis values
  329. )
  330. return key
  331. ret = getKey(axisPoints, axisOrder)
  332. return ret
  333. def reorderMasters(self, master_list, mapping):
  334. # For changing the master data order without
  335. # recomputing supports and deltaWeights.
  336. new_list = [master_list[idx] for idx in mapping]
  337. self.origLocations = [self.origLocations[idx] for idx in mapping]
  338. locations = [
  339. {k: v for k, v in loc.items() if v != 0.0} for loc in self.origLocations
  340. ]
  341. self.mapping = [self.locations.index(l) for l in locations]
  342. self.reverseMapping = [locations.index(l) for l in self.locations]
  343. self._subModels = {}
  344. return new_list
  345. def _computeMasterSupports(self):
  346. self.supports = []
  347. regions = self._locationsToRegions()
  348. for i, region in enumerate(regions):
  349. locAxes = set(region.keys())
  350. # Walk over previous masters now
  351. for prev_region in regions[:i]:
  352. # Master with different axes do not participte
  353. if set(prev_region.keys()) != locAxes:
  354. continue
  355. # If it's NOT in the current box, it does not participate
  356. relevant = True
  357. for axis, (lower, peak, upper) in region.items():
  358. if not (
  359. prev_region[axis][1] == peak
  360. or lower < prev_region[axis][1] < upper
  361. ):
  362. relevant = False
  363. break
  364. if not relevant:
  365. continue
  366. # Split the box for new master; split in whatever direction
  367. # that has largest range ratio.
  368. #
  369. # For symmetry, we actually cut across multiple axes
  370. # if they have the largest, equal, ratio.
  371. # https://github.com/fonttools/fonttools/commit/7ee81c8821671157968b097f3e55309a1faa511e#commitcomment-31054804
  372. bestAxes = {}
  373. bestRatio = -1
  374. for axis in prev_region.keys():
  375. val = prev_region[axis][1]
  376. assert axis in region
  377. lower, locV, upper = region[axis]
  378. newLower, newUpper = lower, upper
  379. if val < locV:
  380. newLower = val
  381. ratio = (val - locV) / (lower - locV)
  382. elif locV < val:
  383. newUpper = val
  384. ratio = (val - locV) / (upper - locV)
  385. else: # val == locV
  386. # Can't split box in this direction.
  387. continue
  388. if ratio > bestRatio:
  389. bestAxes = {}
  390. bestRatio = ratio
  391. if ratio == bestRatio:
  392. bestAxes[axis] = (newLower, locV, newUpper)
  393. for axis, triple in bestAxes.items():
  394. region[axis] = triple
  395. self.supports.append(region)
  396. self._computeDeltaWeights()
  397. def _locationsToRegions(self):
  398. locations = self.locations
  399. axisRanges = self.axisRanges
  400. regions = []
  401. for loc in locations:
  402. region = {}
  403. for axis, locV in loc.items():
  404. if locV > 0:
  405. region[axis] = (0, locV, axisRanges[axis][1])
  406. else:
  407. region[axis] = (axisRanges[axis][0], locV, 0)
  408. regions.append(region)
  409. return regions
  410. def _computeDeltaWeights(self):
  411. self.deltaWeights = []
  412. for i, loc in enumerate(self.locations):
  413. deltaWeight = {}
  414. # Walk over previous masters now, populate deltaWeight
  415. for j, support in enumerate(self.supports[:i]):
  416. scalar = supportScalar(loc, support)
  417. if scalar:
  418. deltaWeight[j] = scalar
  419. self.deltaWeights.append(deltaWeight)
  420. def getDeltas(self, masterValues, *, round=noRound):
  421. assert len(masterValues) == len(self.deltaWeights), (
  422. len(masterValues),
  423. len(self.deltaWeights),
  424. )
  425. mapping = self.reverseMapping
  426. out = []
  427. for i, weights in enumerate(self.deltaWeights):
  428. delta = masterValues[mapping[i]]
  429. for j, weight in weights.items():
  430. if weight == 1:
  431. delta -= out[j]
  432. else:
  433. delta -= out[j] * weight
  434. out.append(round(delta))
  435. return out
  436. def getDeltasAndSupports(self, items, *, round=noRound):
  437. model, items = self.getSubModel(items)
  438. return model.getDeltas(items, round=round), model.supports
  439. def getScalars(self, loc):
  440. """Return scalars for each delta, for the given location.
  441. If interpolating many master-values at the same location,
  442. this function allows speed up by fetching the scalars once
  443. and using them with interpolateFromMastersAndScalars()."""
  444. return [
  445. supportScalar(
  446. loc, support, extrapolate=self.extrapolate, axisRanges=self.axisRanges
  447. )
  448. for support in self.supports
  449. ]
  450. def getMasterScalars(self, targetLocation):
  451. """Return multipliers for each master, for the given location.
  452. If interpolating many master-values at the same location,
  453. this function allows speed up by fetching the scalars once
  454. and using them with interpolateFromValuesAndScalars().
  455. Note that the scalars used in interpolateFromMastersAndScalars(),
  456. are *not* the same as the ones returned here. They are the result
  457. of getScalars()."""
  458. out = self.getScalars(targetLocation)
  459. for i, weights in reversed(list(enumerate(self.deltaWeights))):
  460. for j, weight in weights.items():
  461. out[j] -= out[i] * weight
  462. out = [out[self.mapping[i]] for i in range(len(out))]
  463. return out
  464. @staticmethod
  465. def interpolateFromValuesAndScalars(values, scalars):
  466. """Interpolate from values and scalars coefficients.
  467. If the values are master-values, then the scalars should be
  468. fetched from getMasterScalars().
  469. If the values are deltas, then the scalars should be fetched
  470. from getScalars(); in which case this is the same as
  471. interpolateFromDeltasAndScalars().
  472. """
  473. v = None
  474. assert len(values) == len(scalars)
  475. for value, scalar in zip(values, scalars):
  476. if not scalar:
  477. continue
  478. contribution = value * scalar
  479. if v is None:
  480. v = contribution
  481. else:
  482. v += contribution
  483. return v
  484. @staticmethod
  485. def interpolateFromDeltasAndScalars(deltas, scalars):
  486. """Interpolate from deltas and scalars fetched from getScalars()."""
  487. return VariationModel.interpolateFromValuesAndScalars(deltas, scalars)
  488. def interpolateFromDeltas(self, loc, deltas):
  489. """Interpolate from deltas, at location loc."""
  490. scalars = self.getScalars(loc)
  491. return self.interpolateFromDeltasAndScalars(deltas, scalars)
  492. def interpolateFromMasters(self, loc, masterValues, *, round=noRound):
  493. """Interpolate from master-values, at location loc."""
  494. scalars = self.getMasterScalars(loc)
  495. return self.interpolateFromValuesAndScalars(masterValues, scalars)
  496. def interpolateFromMastersAndScalars(self, masterValues, scalars, *, round=noRound):
  497. """Interpolate from master-values, and scalars fetched from
  498. getScalars(), which is useful when you want to interpolate
  499. multiple master-values with the same location."""
  500. deltas = self.getDeltas(masterValues, round=round)
  501. return self.interpolateFromDeltasAndScalars(deltas, scalars)
  502. def piecewiseLinearMap(v, mapping):
  503. keys = mapping.keys()
  504. if not keys:
  505. return v
  506. if v in keys:
  507. return mapping[v]
  508. k = min(keys)
  509. if v < k:
  510. return v + mapping[k] - k
  511. k = max(keys)
  512. if v > k:
  513. return v + mapping[k] - k
  514. # Interpolate
  515. a = max(k for k in keys if k < v)
  516. b = min(k for k in keys if k > v)
  517. va = mapping[a]
  518. vb = mapping[b]
  519. return va + (vb - va) * (v - a) / (b - a)
  520. def main(args=None):
  521. """Normalize locations on a given designspace"""
  522. from fontTools import configLogger
  523. import argparse
  524. parser = argparse.ArgumentParser(
  525. "fonttools varLib.models",
  526. description=main.__doc__,
  527. )
  528. parser.add_argument(
  529. "--loglevel",
  530. metavar="LEVEL",
  531. default="INFO",
  532. help="Logging level (defaults to INFO)",
  533. )
  534. group = parser.add_mutually_exclusive_group(required=True)
  535. group.add_argument("-d", "--designspace", metavar="DESIGNSPACE", type=str)
  536. group.add_argument(
  537. "-l",
  538. "--locations",
  539. metavar="LOCATION",
  540. nargs="+",
  541. help="Master locations as comma-separate coordinates. One must be all zeros.",
  542. )
  543. args = parser.parse_args(args)
  544. configLogger(level=args.loglevel)
  545. from pprint import pprint
  546. if args.designspace:
  547. from fontTools.designspaceLib import DesignSpaceDocument
  548. doc = DesignSpaceDocument()
  549. doc.read(args.designspace)
  550. locs = [s.location for s in doc.sources]
  551. print("Original locations:")
  552. pprint(locs)
  553. doc.normalize()
  554. print("Normalized locations:")
  555. locs = [s.location for s in doc.sources]
  556. pprint(locs)
  557. else:
  558. axes = [chr(c) for c in range(ord("A"), ord("Z") + 1)]
  559. locs = [
  560. dict(zip(axes, (float(v) for v in s.split(",")))) for s in args.locations
  561. ]
  562. model = VariationModel(locs)
  563. print("Sorted locations:")
  564. pprint(model.locations)
  565. print("Supports:")
  566. pprint(model.supports)
  567. if __name__ == "__main__":
  568. import doctest, sys
  569. if len(sys.argv) > 1:
  570. sys.exit(main())
  571. sys.exit(doctest.testmod().failed)