weavingspace.tiling_utils

Utility functions for the weavingspace module.

Most are geometric convenience functions for commonly applied operations.

   1"""Utility functions for the `weavingspace` module.
   2
   3Most are geometric convenience functions for commonly applied
   4operations.
   5"""
   6
   7from __future__ import annotations
   8
   9import re
  10import string
  11from typing import TYPE_CHECKING, NamedTuple
  12
  13import geopandas as gpd
  14import numpy as np
  15import pandas as pd
  16import shapely.affinity as affine
  17import shapely.geometry as geom
  18import shapely.ops
  19from shapely.algorithms import polylabel
  20
  21if TYPE_CHECKING:
  22  from collections.abc import Iterable
  23
  24PRECISION = 6
  25RESOLUTION = 1e-6
  26
  27def _parse_strand_label(s:str) -> list[str]:
  28  """Break a strand label specification in to a list of labels.
  29
  30  Args:
  31    s (str): see get_strand_ids() for details
  32
  33  Returns:
  34    list[str]: list of strand labels.
  35
  36  """
  37  # replace multiple open and close parentheses with single parentheses
  38  clean_s = re.sub("[(]+", "(", re.sub("[)]+", ")", s))
  39  is_combo = False
  40  output = []
  41  current = ""
  42  for c in clean_s:
  43    if is_combo:
  44      if c == ")":
  45        # end of a combination
  46        output.append(current)
  47        current = ""
  48        is_combo = False
  49      else:
  50        current = current + c
  51    elif c == "(":
  52      # start of a combination
  53      is_combo = True
  54    else:
  55      output.append(c)
  56  return output
  57
  58
  59def get_strand_ids(strands_spec: str) -> tuple[list[str]]:
  60  """Convert strands specification string to list of lists of strand labels.
  61
  62  String format is "a|bc|(de)f" where | separates strands in each
  63  direction and () designates combining labels into a single strand that
  64  will be sliced lengthwise. Example output:
  65
  66        "a|bc|(de)f" -> (["a"], ["b", "c"], ["de", "f"])
  67
  68  Superflous parentheses are removed, but no other error-checks are
  69  applied.
  70
  71  Args:
  72    strands_spec (str): the strands specification to be parsed.
  73
  74  Returns:
  75    tuple[str]: tuple of lists of labels for each set of strands.
  76
  77  """
  78  strand_ids = [_parse_strand_label(s) for s in strands_spec.split("|")]
  79  strand_ids = strand_ids if len(strand_ids) == 3 else [*strand_ids, [""]]
  80  return tuple(strand_ids)
  81
  82
  83def get_regular_polygon(spacing:float, n:int) -> geom.Polygon:
  84  r"""Return regular n-gon centered on (0, 0) with height specified.
  85
  86  n-gon has horizontal base and height given by spacing. Generation is based on
  87  equally spaced points around a circle with a radius calculated slightly
  88  differently, depending on whether the number of sides is even or odd.
  89
  90            Even         Odd
  91        \  /    \  /      |
  92         \/   ___\/___    |
  93         /\      /\      / \
  94        /  \    /  \    /   \
  95
  96  The returned polygon is gridified. Note also that shapely silently reorders
  97  the coordinates clockwise from the lower left corner, regardless of the CCW
  98  order in which we supply them.
  99
 100  Args:
 101    spacing (_type_): required height.
 102    n (int): number of sides.
 103
 104  Returns:
 105    geom.Polygon: required geom.Polygon.
 106
 107  """
 108  if n % 2 == 0:
 109    R = spacing / np.cos(np.pi / n) / 2
 110  else:
 111    R = spacing / (1 + np.cos(np.pi / n))
 112  angle_0 = -np.pi / 2 + np.pi / n
 113  a_diff = 2 * np.pi / n
 114  angles = [angle_0 + a_diff * i for i in range(n)]
 115  corners = [(R * np.cos(angle), R * np.sin(angle)) for angle in angles]
 116  return geom.Polygon(corners)
 117
 118
 119def get_corners(shape:geom.Polygon,
 120                repeat_first:bool = True) -> list[geom.Point]:
 121  """Return list of geom.Points on boundary of a polygon.
 122
 123  The first point is optionally repeated. No simplification is carried out
 124  (e.g. if a line segment has a 'corner' along its length, it is NOT removed;
 125  see get_clean_polygon for that). Points have precision set to the
 126  packagedefault tiling_utils.RESOLUTION.
 127
 128  Args:
 129    shape (geom.Polygon): polygon whose corners are required.
 130    repeat_first (bool, optional): if True the first corner is repeated in the
 131      returned list, if False it is omitted. Defaults to True.
 132
 133  Returns:
 134    list[geom.Point]: list of geom.Point vertices of the polygon.
 135
 136  """
 137  corners = [geom.Point(pt) for pt in shape.exterior.coords]
 138  return corners if repeat_first else corners[:-1]
 139
 140
 141def get_sides(shape:geom.Polygon) -> list[geom.LineString]:
 142  """Return polygon sides as list of geom.LineStrings.
 143
 144  Resolution is set to the package default tiling_utils.RESOLUTION. No
 145  simplification for successive colinear sides is carried out.
 146
 147  Args:
 148    shape (geom.Polygon): polygon whose edges are required.
 149
 150  Returns:
 151    list[geom.LineString]: list of geom.LineString sides of the polygon.
 152
 153  """
 154  corners = get_corners(shape)
 155  return [geom.LineString([p1, p2])
 156          for p1, p2 in zip(corners[:-1], corners[1:], strict = True)]
 157
 158
 159def get_side_lengths(shape:geom.Polygon) -> list[float]:
 160  """Return list of lengths of polygon sides.
 161
 162  No simplification for corners along sides is carried out.
 163
 164  Args:
 165    shape (geom.Polygon): polygon whose edge lengths are required.
 166
 167  Returns:
 168    list[float]: list of side lengths.
 169
 170  """
 171  corners = get_corners(shape)
 172  return [
 173    p1.distance(p2) for p1, p2 in zip(corners[:-1], corners[1:], strict = True)]
 174
 175
 176def get_side_bearings(shape:geom.Polygon) -> tuple[float,...]:
 177  """Return list of angles between sides of polygon and the positive x-axis.
 178
 179  Angle is in degrees and calculated when proceeding from the first point in
 180  each side to its end point. This should usually be CW around the polygon.
 181
 182  Args:
 183    shape (geom.Polygon): polygon whose side bearings are required.
 184
 185  Returns:
 186    tuple[float,...]: tuple of bearings of each edge.
 187
 188  """
 189  return tuple([np.degrees(np.arctan2(
 190    e.coords[1][1] - e.coords[0][1], e.coords[1][0] - e.coords[0][0]))
 191    for e in get_sides(shape)])
 192
 193
 194def get_interior_angles(shape:geom.Polygon) -> list[float]:
 195  """Return angles (in degrees) between successive edges of a polygon.
 196
 197  No polygon simplification is carried out so some angles may be 180 (i.e. a
 198  'corner' along a side, such that successive sides are colinear).
 199
 200  Args:
 201    shape (geom.Polygon): polygon whose angles are required.
 202
 203  Returns:
 204    list[float]: list of angles.
 205
 206  """
 207  corners = get_corners(shape, repeat_first = False)
 208  wrap_corners = corners[-1:] + corners + corners[:1]
 209  triples = zip(wrap_corners[:-2],
 210                wrap_corners[1:-1],
 211                wrap_corners[2:], strict = True)
 212  return [get_inner_angle(p1, p2, p3) for p1, p2, p3 in triples]
 213
 214
 215def get_clean_polygon(
 216      shape:geom.MultiPolygon|geom.Polygon|gpd.GeoSeries,
 217    ) ->  geom.MultiPolygon|geom.Polygon|gpd.GeoSeries:
 218  """Return polygon with successive co-linear or very close corners removed.
 219
 220  Particularly useful for tidying polygons weave tilings that have been
 221  assembled from multiple 'cells' in the weave grid.
 222
 223  Args:
 224    shape (geom.MultiPolygon|geom.Polygon): polygon to clean.
 225
 226  Returns:
 227    geom.Polygon: cleaned polygon.
 228
 229  """
 230  if isinstance(shape, geom.MultiPolygon):
 231    return geom.MultiPolygon([get_clean_polygon(p) for p in shape.geoms])
 232  if isinstance(shape, geom.GeometryCollection):
 233    elements = [p for p in shape.geoms if isinstance(p, geom.Polygon)]
 234    if len(elements) > 1:
 235      return get_clean_polygon(geom.MultiPolygon(elements))
 236    return get_clean_polygon(elements[0])
 237  corners = get_corners(shape, repeat_first = False)
 238  # first remove any 'near neighbour' corners
 239  distances = get_side_lengths(shape)
 240  to_remove = [np.isclose(d, 0, rtol = RESOLUTION, atol = 10 * RESOLUTION)
 241                for d in distances]
 242  corners = [c for c, r in zip(corners, to_remove, strict = True) if not r]
 243  # next remove any that are colinear
 244  p = geom.Polygon(corners)
 245  corners = get_corners(p, repeat_first = False)
 246  angles = get_interior_angles(p)
 247  to_remove = [np.isclose(a,   0, rtol = RESOLUTION, atol = RESOLUTION) or
 248               np.isclose(a, 180, rtol = RESOLUTION, atol = RESOLUTION)
 249               for a in angles]
 250  corners = [c for c, r in zip(corners, to_remove, strict = True) if not r]
 251  return gridify(geom.Polygon(corners))
 252
 253
 254def rotate_preserving_order(
 255    polygon:geom.Polygon,
 256    angle:float,
 257    centre:geom.Point = None,
 258  ) -> geom.Polygon:
 259  """Return supplied polygon rotated with order of corners preserved.
 260
 261  Order of polygon corners is not guaranteed to be preserved by shapely.
 262  affinity.rotate).
 263
 264  Args:
 265    polygon (geom.Polygon): polygon to rotate.
 266    angle (float): desired angle of rotation (in degrees).
 267    centre (geom.Point): the rotation centre (passed on to shapely.affinity.
 268      rotate).
 269
 270  Returns:
 271    geom.Polygon: rotated polygon.
 272
 273  """
 274  ctr = "center" if centre is None else centre
 275  corners = get_corners(polygon, repeat_first = False)
 276  return geom.Polygon([affine.rotate(c, angle, ctr) for c in corners])
 277
 278
 279def ensure_cw(shape:geom.Polygon) -> geom.Polygon:
 280  """Return the polygon with its outer boundary vertices in clockwise order.
 281
 282  It is important to note that shapely.set_precision() imposes clockwise order
 283  on polygons, and since it is used widely throughout theses modules, it makes
 284  sense to impose this order.
 285
 286  Args:
 287    shape (geom.Polygon): the polygon.
 288
 289  Returns:
 290    geom.Polygon: the polygon in clockwise order.
 291
 292  """
 293  if geom.LinearRing(shape.exterior.coords).is_ccw:
 294    return shape.reverse()
 295  return shape
 296
 297
 298def get_inner_angle(p1:geom.Point, p2:geom.Point, p3:geom.Point) -> float:
 299  r"""Return angle between line p1-p2 and p2-p3, i.e., the angle A below.
 300
 301            p2
 302           / \
 303          / A \
 304        p1     p3
 305
 306  Args:
 307    p1 (geom.Point): first point.
 308    p2 (geom.Point): second 'corner' point.
 309    p3 (geom.Point): third point.
 310
 311  Returns:
 312    float: angle in degrees.
 313
 314  """
 315  return np.degrees(np.arctan2(p3.y - p2.y, p3.x - p2.x) -
 316                    np.arctan2(p1.y - p2.y, p1.x - p2.x)) % 360
 317
 318
 319def is_regular_polygon(shape:geom.Polygon) -> bool:
 320  """Test if supplied polygon is regular (i.e. equal sides and angles).
 321
 322  Args:
 323    shape (geom.Polygon): polygon to test.
 324
 325  Returns:
 326    bool: True if polygon is regular, False if not.
 327
 328  """
 329  side_lengths = get_side_lengths(shape)
 330  angles = get_interior_angles(shape)
 331  return all(np.isclose(side_lengths, side_lengths[0])) \
 332     and all(np.isclose(angles, angles[0]))
 333
 334
 335def is_tangential(shape:geom.Polygon) -> bool:
 336  """Determine if supplied polygon is tangential.
 337
 338  A tangential polygon can have a circle inscribed tangential to all its sides.
 339
 340  NOTE: not currently used but retained in case incentre is restored to use
 341  'home made' code rather than polylabel.polylabel.
 342
 343  Note that this will fail for polygons with successive colinear sides, meaning
 344  that polygons should be fully simplified...
 345
 346  Args:
 347    shape (geom.Polygon): polygon to test.
 348
 349  Returns:
 350    bool: True if polygon is tangential, False if not.
 351
 352  """
 353  if is_regular_polygon(shape):
 354    return True
 355  side_lengths = get_side_lengths(shape)
 356  n = len(side_lengths)
 357  if n % 2 == 1:
 358    if n == 3: #triangles are easy!
 359      return True
 360    # odd number of sides there is a nice solvable system  of equations see
 361    # https://math.stackexchange.com/questions/4065370/tangential-polygons-conditions-on-edge-lengths
 362    #
 363    # TODO: this is not reliable for reasons I don't fully understand. There is
 364    # a corresponding crude catch exception in incentre... but this needs a
 365    # more complete investigation
 366    mat = np.identity(n) + np.roll(np.identity(n), 1, axis = 1)
 367    fractions = np.linalg.inv(mat) @ side_lengths / side_lengths
 368    if not(np.isclose(np.mean(fractions),
 369                      0.5, rtol= RESOLUTION, atol = RESOLUTION)):
 370      return False
 371    ones = [np.isclose(f, 1, rtol = RESOLUTION, atol = RESOLUTION)
 372            for f in fractions]
 373    zeros = [np.isclose(f, 0, rtol = RESOLUTION, atol = RESOLUTION)
 374             for f in fractions]
 375    negatives = [f < 0 for f in fractions]
 376    return not (any(ones) or any(zeros) or any(negatives))
 377  if n == 4:
 378    # for quadrilaterals odd and even side lengths are equal
 379    return np.isclose(sum(side_lengths[0::2]),
 380                      sum(side_lengths[1::2]), rtol = RESOLUTION)
 381  # other even numbers of sides... hard to avoid brute force
 382  bisectors = [get_angle_bisector(shape, i) for i in range(n)]
 383  intersects = [b1.intersection(b2) for b1, b2 in
 384                zip(bisectors, bisectors[1:] + bisectors[:1], strict = False)
 385                if b1.intersects(b2)]
 386  distances = [i1.distance(i2) for i1, i2 in
 387               zip(intersects, intersects[1:] + intersects[:1],
 388                   strict = False)]
 389  return all([d <= 2 * RESOLUTION for d in distances])  # noqa: C419
 390
 391
 392def get_incentre(shape:geom.Polygon) -> geom.Point:
 393  """Get a polygon incentre.
 394
 395  See https://en.wikipedia.org/wiki/Incenter.
 396
 397  This method relies on the polygon being tangential, i.e. there is an
 398  inscribed circle to which all sides of the polygon are tangent. It will work
 399  on all the polygons encountered in the Laves tilings, but is not guaranteed
 400  to work on all polygons.
 401
 402  Given that the polygon is tangential, the radius of the inscribed circle is
 403  the [apothem of the polygon](https://en.wikipedia.org/wiki/Apothem) given by
 404  2 x Area / Perimeter. We apply a parallel offset of this size to two sides of
 405  the polygon and find their intersection to determine the centre of the circle
 406  givng the incentre of the polygon.
 407
 408  Args:
 409    shape (geom.Polygon): the polygon.
 410
 411  Returns:
 412    geom.Point: the incentre of the polygon.
 413
 414  """
 415  if is_regular_polygon(shape):
 416    return shape.centroid
 417  return polylabel.polylabel(shape)
 418
 419
 420def get_incircle(shape:geom.Polygon) -> geom.Polygon:
 421  """Return incircle of supplied polygon.
 422
 423  Args:
 424    shape(geom.Polygon): polygon for which incircle is required.
 425
 426  Returns:
 427    geom.Polygon: a polygon representation of the incircle
 428
 429  """
 430  c = get_incentre(shape)
 431  r = min([c.distance(e) for e in get_sides(shape)])
 432  return c.buffer(r)
 433
 434
 435def get_apothem(shape:geom.Polygon) -> float:
 436  """Return length of the apothem of supplied polygon.
 437
 438  Args:
 439    shape(geom.Polygon): polygon for which apothem length is required.
 440
 441  Returns:
 442    float: an estimate of the apothem length.
 443
 444  """
 445  if is_regular_polygon(shape):
 446    return 2 * shape.area / geom.LineString(shape.exterior.coords).length
 447  c = polylabel.polylabel(shape, 1)
 448  return min([c.distance(e) for e in get_sides(shape)])
 449
 450
 451def get_angle_bisector(shape:geom.Polygon, v = 0) -> geom.LineString:
 452  """Return angle bisector of specified corner of supplied polygon.
 453
 454  Args:
 455    shape (geom.Polygon): the polygon
 456    v (int, optional): index of the corner whose bisector is required.
 457    Defaults to 0.
 458
 459  Returns:
 460    geom.LineString: line which bisects the specified corner.
 461
 462  """
 463  pts = get_corners(shape, repeat_first = False)
 464  n = len(pts)
 465  p0 = pts[v % n]
 466  p1 = pts[(v + 1) % n]
 467  p2 = pts[(v - 1) % n]
 468  d01 = p0.distance(p1)
 469  d02 = p0.distance(p2)
 470  if d01 < d02:
 471    p2 = geom.LineString([p0, p2]).interpolate(d01)
 472  else:
 473    p1 = geom.LineString([p0, p1]).interpolate(d02)
 474  m = geom.Point((p1.x + p2.x) / 2, (p1.y + p2.y) / 2)
 475  # we have to scale this to much larger in case of an angle near 180
 476  return affine.scale(geom.LineString([p0, m]), 10, 10, origin = p0)
 477
 478
 479def _get_interior_vertices(tiles:gpd.GeoDataFrame) -> gpd.GeoSeries:
 480  """Return points not on outer boundary of supplied set of tiles.
 481
 482  Args:
 483    tiles (gpd.GeoDataFrame): a set of polygons.
 484
 485  Returns:
 486    gpd.GeoSeries: interior vertices of the set of polygons.
 487
 488  """
 489  tiles:gpd.GeoSeries = gridify(tiles.geometry)
 490  uu:geom.Polygon = safe_union(tiles, as_polygon = True)
 491  interior_pts = set()
 492  for tile in tiles:
 493    for corner in tile.exterior.coords:
 494      if uu.contains(geom.Point(corner).buffer(1e-3, cap_style = "square")):
 495        interior_pts.add(corner)
 496  return gridify(gpd.GeoSeries([geom.Point(p) for p in interior_pts]))
 497
 498
 499def gridify(
 500    gs:gpd.GeoSeries | gpd.GeoDataFrame | shapely.Geometry,
 501  ) -> gpd.GeoSeries | gpd.GeoDataFrame | shapely.Geometry:
 502  """Return supplied GeoSeries rounded to tiling_utils.RESOLUTION.
 503
 504  Args:
 505    gs (gpd.GeoSeries): geometries to gridify.
 506    precision (int, optional): digits of precision. Defaults to 6.
 507
 508  Returns:
 509    gpd.GeoSeries: the rounded geometries.
 510
 511  """
 512  if isinstance(gs, shapely.Geometry) :
 513    return shapely.set_precision(gs, grid_size = RESOLUTION)
 514  if isinstance(gs, gpd.GeoSeries):
 515    return gpd.GeoSeries(
 516      [shapely.set_precision(g, grid_size = RESOLUTION) for g in list(gs)],
 517      crs = gs.crs)
 518  if isinstance(gs, gpd.GeoDataFrame):
 519    gs.geometry = gpd.GeoSeries(
 520      [shapely.set_precision(g, grid_size = RESOLUTION)
 521       for g in list(gs.geometry)], crs = gs.crs)
 522    return gs
 523  return None
 524
 525
 526def get_dual_tile_unit(unit) -> gpd.GeoDataFrame:
 527  """Convert supplied TileUnit to candidate GeoDataFrame of its dual TileUnit.
 528
 529  NOTE: this is complicated and not remotely guaranteed to work!
 530  a particular issue is that where to place the vertices of the faces
 531  of the dual with respect to the tiles in the original is ill-defined.
 532  This is because the dual process is topologically not metrically defined,
 533  so that exact vertex locations are ambiguous. Tiling duality is defined in
 534  Section 4.2 of Grunbaum B, Shephard G C, 1987 _Tilings and Patterns_ (W. H.
 535  Freeman and Company, New York)
 536
 537  NOTE: In general, this method will work only if all supplied tiles are
 538  regular polygons. A known exception is if the only non-regular polygons are
 539  triangles.
 540
 541  NOTE: 'clean' polygons are required. If supplied polygons have messy
 542  vertices with multiple points where there is only one proper point, bad
 543  things are likely to happen! Consider using `clean_polygon()` on the
 544  tile geometries.
 545
 546  Because of the above limitations, we only return a GeoDataFrame
 547  for inspection. However some `weavingspace.tile_unit.TileUnit` setup
 548  methods in `weavingspace.tiling_geometries` use this method, where we are
 549  confident the returned dual is valid.
 550
 551  Args:
 552    unit (TileUnit): the tiling for which the dual is required.
 553
 554  Returns:
 555    gpd.GeoDataFrame: GeoDataFrame that could be the tiles attribute for
 556      a TileUnit of the dual tiling.
 557
 558  """
 559  # get a local patch of this Tiling
 560  local_patch:gpd.GeoDataFrame = unit.get_local_patch(r = 1, include_0 = True)
 561  # Find the interior points of these tiles - these will be guaranteed
 562  # to have a sequence of surrounding tiles incident on them
 563  interior_pts = _get_interior_vertices(local_patch)
 564  # Compile a list of the polygons incident on the interior points
 565  cycles = [{poly_id for poly_id, p in enumerate(local_patch.geometry)
 566             if pt.distance(p) < RESOLUTION * 2} for pt in interior_pts]
 567  dual_faces = []
 568  for cycle in cycles:
 569    ids, pts = [], []
 570    for poly_id in cycle:
 571      ids.append(local_patch.tile_id[poly_id])
 572      poly = local_patch.geometry[poly_id]
 573      pts.append(get_incentre(poly))
 574    # sort them into CW order so they are well formed
 575    sorted_coords = _sort_cw([(pt.x, pt.y, ID)
 576                              for pt, ID in zip(pts, ids, strict = True)])
 577    dual_faces.append(
 578      (geom.Polygon([(pt_id[0], pt_id[1]) for pt_id in sorted_coords]),
 579       "".join([pt_id[2] for pt_id in sorted_coords])))
 580  # ensure the resulting face centroids are inside the original tile
 581  # displaced a little to avoid uncertainties at corners/edges
 582  # This is a guess at something that _could_ work, which turned out well!
 583  dual_faces = [(f, ID) for f, ID in dual_faces
 584                if affine.translate(
 585                  unit.get_prototile_from_vectors().loc[0, "geometry"],
 586                  RESOLUTION * 10, RESOLUTION * 10).contains(f.centroid)]
 587  gdf = gpd.GeoDataFrame(
 588    data = {"tile_id": [f[1] for f in dual_faces]}, crs = unit.crs,
 589    geometry = gpd.GeoSeries([f[0] for f in dual_faces]))
 590  # ensure no duplicates
 591  gdf = gdf.dissolve(by = "tile_id", as_index = False).explode(
 592    index_parts = False, ignore_index = True)
 593  gdf.tile_id = _relabel(gdf.tile_id)
 594  return gdf
 595
 596
 597def _relabel(data:Iterable) -> list:
 598  """Return supplied data reassigned with unique values from ascii_letters.
 599
 600  Args:
 601    data (Iterable): the data to relabel
 602
 603  Returns:
 604    list: the reassigned data
 605
 606  """
 607  new_data = {}
 608  d_count = 0
 609  for d in data:
 610    if d not in new_data:
 611      new_data[d] = string.ascii_letters[d_count]
 612      d_count = d_count + 1
 613  return [new_data[d] for d in data]
 614
 615
 616def _sort_cw(
 617  pts_ids:list[tuple[float, float, str]]) -> list[tuple[float, float, str]]:
 618  """Sort supplied x,y,ID tuple into CW order relative to their mean centre.
 619
 620  Args:
 621    pts_ids (list[tuple[float, float, str]]): A tuple of a pair of
 622      floats and a string.
 623
 624  Returns:
 625    list: a list in the same format as supplied sorted into
 626      clockwise order of the point locations.
 627
 628  """
 629  x = [p[0] for p in pts_ids]
 630  y = [p[1] for p in pts_ids]
 631  cx, cy = np.mean(x), np.mean(y)
 632  dx = [_ - cx for _ in x]
 633  dy = [_ - cy for _ in y]
 634  angles = [np.arctan2(dy, dx) for dx, dy in zip(dx, dy, strict = True)]
 635  d = dict(zip(angles, pts_ids, strict = True))
 636  return [pt_id for angle, pt_id in sorted(d.items(), reverse = True)]
 637
 638
 639def write_map_to_layers(gdf:gpd.GeoDataFrame,
 640                        fname:str = "output.gpkg",
 641                        tile_var:str = "tile_id") -> None:
 642  """Write supplied GeoDataFrame to a GPKG file.
 643
 644  Geopackage file layers will be based on the `tile_var` attribute.
 645
 646  Args:
 647    gdf (gpd.GeoDataFrame): the GeoDataFrame.
 648    fname (str, optional): filename to write.
 649    tile_var (str, optional): the attribute to use to separate
 650      output file into layers. Defaults to "tile_id".
 651
 652  """
 653  grouped = gdf.groupby(tile_var, as_index = False)
 654  for e in pd.Series.unique(gdf[tile_var]):
 655    grouped.get_group(e).to_file(fname, layer = e, driver = "GPKG")
 656
 657
 658def get_collapse_distance(geometry:geom.Polygon) -> float:
 659  """Return distance under which polygon will disappear if negatively buffered.
 660
 661  Performs a binary search between an upper bound based on the radius of circle
 662  of equal area to the polygon, and 0.
 663
 664  Args:
 665    geometry (geom.Polygon): the polygon.
 666
 667  Returns:
 668    float: its collapse distance.
 669
 670  """
 671  if is_regular_polygon(geometry):
 672    return get_apothem(geometry)
 673  radius = np.sqrt(geometry.area / np.pi)
 674  lower = 0
 675  upper = radius
 676  delta = 1e12
 677  stop_delta = radius / 1000
 678  while delta > stop_delta:
 679    new_r = (lower + upper) / 2
 680    if geometry.buffer(-new_r).area > 0:
 681      lower = new_r
 682      delta = upper - new_r
 683    else:
 684      upper = new_r
 685      delta = new_r - lower
 686  return new_r
 687
 688
 689def get_largest_polygon(polygons:gpd.GeoSeries) -> gpd.GeoSeries:
 690  """Return largest polygon in a GeoSeries as a GeoSeries of one polygon.
 691
 692  NOT CURRENTLY USED ANYWHERE IN MODULE.
 693
 694  Args:
 695    polygons (gpd.GeoSeries): the set of polygons to pick from.
 696
 697  Returns:
 698    gpd.GeoSeries: the largest polygon.
 699
 700  """
 701  actual_polygons = [p for p in polygons.geometry
 702                     if isinstance(p, (geom.Polygon, geom.MultiPolygon))]
 703  areas = sorted(actual_polygons, key = lambda x: x.area, reverse = True)
 704  return gpd.GeoSeries(areas[:1])
 705
 706
 707def touch_along_an_edge(p1:geom.Polygon, p2:geom.Polygon) -> bool:
 708  """Test if two polygons touch along an edge.
 709
 710  Checks that the intersection area of the two polygons buffered by
 711  a small amount is large enough to indicate that they neighbour at more
 712  than a corner.
 713
 714  Args:
 715    p1 (geom.Polygon): First polygon
 716    p2 (geom.Polygon): Second polygon
 717
 718  Returns:
 719    bool: True if they neighbour along an edge
 720
 721  """
 722  return (
 723    p1.buffer(RESOLUTION, join_style = "mitre", cap_style = "square")
 724      .intersection(p2.buffer(RESOLUTION, join_style = "mitre",
 725                                          cap_style = "square"))
 726      .area > 16 * RESOLUTION ** 2)
 727
 728
 729def get_width_height_left_bottom(
 730    gs:gpd.GeoSeries,
 731  ) -> tuple[float,float,float,float]:
 732  """Return width, height, left and bottom limits of a GeoSeries.
 733
 734  Args:
 735    gs (geopandas.GeoSeries): GeoSeries for which limits are required.
 736
 737  Returns:
 738    tuple: four float values of width, height, left and bottom of gs.
 739
 740  """
 741  bb = gs.total_bounds
 742  return (bb[2] - bb[0], bb[3] - bb[1], bb[0], bb[1])
 743
 744
 745def get_bounding_ellipse(
 746    shapes:gpd.GeoSeries,
 747    mag:float = 1.0,
 748  ) -> gpd.GeoSeries:
 749  """Return an ellipse containing the supplied shapes.
 750
 751  The method used is to calculate the size of square that would contain
 752  the shapes, if they had an aspect ratio 1, then stretch the circle in
 753  the x, y directions according to the actual aspect ratio of the shapes.
 754
 755  Args:
 756    shapes (gpd.GeoSeries): the shapes to be contained.
 757    mag (float, optional): optionally increase the size of the returned
 758      ellipse by this scale factor. Defaults to 1.0.
 759
 760  Returns:
 761    gpd.GeoSeries: the set of shapes.
 762
 763  """
 764  width, height, left, bottom = get_width_height_left_bottom(shapes)
 765  c = geom.Point(left + width / 2, bottom + height / 2)
 766  r = min(width, height) * np.sqrt(2)
 767  circle = [c.buffer(r)]
 768  return gpd.GeoSeries(circle, crs = shapes.crs).scale(
 769    width / r * mag / np.sqrt(2), height / r * mag / np.sqrt(2), origin = c)
 770
 771
 772def get_tiling_edges(tiles:gpd.GeoSeries) -> gpd.GeoSeries:
 773  """Return linestring GeoSeries from supplied polygon GeoSeries.
 774
 775  This is used to allow display of edges of tiles in legend when they are
 776  masked by an ellipse (if we instead clip polygons then the ellipse edge
 777  will also show in the result.)
 778
 779  Args:
 780    tiles (gpd.GeoSeries): Polygons to convert.
 781
 782  Returns:
 783    gpd.GeoSeries: LineStrings from the supplied Polygons.
 784
 785  """
 786  tiles = tiles.explode(ignore_index = True)
 787  edges = [geom.LineString(p.exterior.coords) for p in tiles]
 788  return gpd.GeoSeries(edges, crs = tiles.crs)
 789
 790
 791def get_polygon_sector(
 792    shape:geom.Polygon,
 793    start:float = 0.0,
 794    end:float = 1.0,
 795  ) -> geom.Polygon:
 796  """Return a sector of the provided Polygon.
 797
 798  The returned sector is a section of the polygon boundary between the
 799  normalized start and end positions, and including the polygon centroid.
 800  Should (probably) only be applied to convex polygons.
 801
 802  Args:
 803    shape (geom.Polygon): the Polygon.
 804    start (float): normalized start position along the boundary. Defaults to
 805      0.
 806    end (float): normalized start position along the boundary. Defaults to
 807      1.
 808
 809  Returns:
 810    geom.Polygon: the requested polygon sector.
 811
 812  """
 813  if start == end:
 814    # must return a null polygon since the calling context
 815    # expects to get something back... which most likely
 816    # is needed to align with other data
 817    return geom.Polygon()
 818  if start * end < 0:
 819    # either side of 0/1 so assume required sector includes 0
 820    e1, e2 = min(start, end), max(start, end)
 821    arc1 = shapely.ops.substring(geom.LineString(shape.exterior.coords),
 822                                 np.mod(e1, 1), 1, normalized = True)
 823    arc2 = shapely.ops.substring(geom.LineString(shape.exterior.coords),
 824                                 0, e2, normalized = True)
 825    sector = geom.Polygon(
 826      [shape.centroid] + list(arc1.coords) + list(arc2.coords)[1:])
 827  else:
 828    arc = shapely.ops.substring(geom.LineString(shape.exterior.coords),
 829                                start, end, normalized = True)
 830    sector = geom.Polygon([shape.centroid, *list(arc.coords)])
 831  return sector
 832
 833
 834def repair_polygon(
 835    polygon:geom.Polygon|geom.MultiPolygon|gpd.GeoSeries,
 836    shrink_then_grow:bool = True,
 837  ) -> geom.Polygon|gpd.GeoSeries:
 838  """Repair a polyon or GeoSeries by double buffering.
 839
 840  This is method is often unofficially recommended (on stackexchange etc.)
 841  even in the shapely docs, to resolve topology issues and extraneous
 842  additional vertices appearing when spatial operations are repeatedly
 843  applied.
 844
 845  Optionally the buffer may be applied in the opposite order (i.e. grow then
 846  shrink). This operation may also convert a MultiPolygon that has some 'stray'
 847  parts to a Polygon.
 848
 849  Args:
 850    polygon(geom.Polygon|gpd.GeoSeries): Polygon or GeoSeries to clean.
 851    res (float, optional): buffer size to use. Defaults to 1e-3.
 852    shrink_then_grow (bool, optional): if True the negative buffer is
 853      applied first, otherwise the buffer operations are applied in
 854      reverse. Defaults to True.
 855
 856  Returns:
 857    geom.Polygon|gpd.GeoSeries: the cleaned Polygon or GeoSeries.
 858
 859  """
 860  if type(polygon) is gpd.GeoSeries:
 861    return gpd.GeoSeries([repair_polygon(p, shrink_then_grow)
 862                          for p in polygon]).set_crs(polygon.crs)
 863  if polygon is None:
 864    return None
 865  if shrink_then_grow:
 866    return polygon.buffer(
 867      -RESOLUTION * 10, join_style = "mitre", cap_style = "square").buffer(
 868       RESOLUTION * 10, join_style = "mitre", cap_style = "square")
 869  return polygon.buffer(
 870     RESOLUTION * 10, join_style = "mitre", cap_style = "square").buffer(
 871    -RESOLUTION * 10, join_style = "mitre", cap_style = "square")
 872
 873
 874def safe_union(
 875    gs:gpd.GeoSeries,
 876    as_polygon:bool = False,
 877  ) -> gpd.GeoSeries|geom.Polygon:
 878  """Union supplied GeoSeries buffering to avoid gaps and internal edges.
 879
 880  Optionally returns a Polygon or a GeoSeries.
 881
 882  NOTE: APPEARS NOT TO BE VERY SAFE... find workarounds for the few occasions
 883  on which it is used.
 884
 885  Frequently when unioning polygons that are ostensibly adjacent 'rogue'
 886  internal boundaries remain in the result. We can avoid this by buffering the
 887  polygons before unioning them, then reversing the buffer on the unioned
 888  shape.
 889
 890  Args:
 891    gs (gpd.GeoSeries): the Polygons to union.
 892    res (float, optional): size of the buffer to use. Defaults to 1e-3.
 893    as_polygon (bool, optional): if True returns a Polygon, otherwise
 894      returns a one Polygon GeoSeries. Defaults to False.
 895
 896  Returns:
 897    gpd.GeoSeries|geom.Polygon: the resulting union of supplied
 898      polygons.
 899
 900  """
 901  r = RESOLUTION * 10
 902  union = gpd.GeoSeries(
 903    [p.buffer(r, join_style = "mitre", cap_style = "square") for p in gs],
 904    crs = gs.crs).union_all().buffer(-r, join_style = "mitre",
 905                                         cap_style = "square")
 906  if as_polygon:
 907    return union
 908  return gpd.GeoSeries([union], crs = gs.crs)
 909
 910
 911def get_translation_transform(dx:float, dy:float) -> tuple[float,...]:
 912  """Return shapely affine transform tuple for a translation.
 913
 914  Args:
 915      dx (float): translation distance in x direction.
 916      dy (float): translation distance in y direction.
 917
 918  Returns:
 919    list[float]: a six item list of floats, per the shapely.affinity.
 920    affine_transform method, see
 921      https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
 922
 923  """
 924  return (1, 0, 0, 1, dx, dy)
 925
 926
 927def get_rotation_transform(
 928    angle:float, centre:tuple[float,...]|None = None) -> tuple[float,...]:
 929  """Return the shapely affine transform tuple for a rotation.
 930
 931  Rotation is optionally about a supplied centre point.
 932
 933  Args:
 934    angle (float): the angle of rotation (in degrees).
 935    centre (tuple[float], optional): An option centre location. Defaults to
 936    None, which will in turn be converted to (0, 0).
 937
 938  Returns:
 939    list[float]: a six item list of floats, per the shapely.affinity.
 940      affine_transform method, see
 941        https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
 942
 943  """
 944  if centre is None or np.allclose((0, 0), centre, atol = RESOLUTION):
 945    a = np.radians(angle)
 946    return (np.cos(a), -np.sin(a), np.sin(a), np.cos(a), 0, 0)
 947  dx, dy = centre
 948  t1 = get_translation_transform(-dx, -dy)
 949  r = get_rotation_transform(angle)
 950  t2 = get_translation_transform(dx, dy)
 951  return combine_transforms([t1, r, t2])
 952
 953
 954def get_reflection_transform(
 955    angle:float, centre:tuple[float,...]|None = None) -> tuple[float,...]:
 956  """Return a shapely affine reflection transform.
 957
 958  Reflection is in a line at the specified angle, optionally through a
 959  specified centre point.
 960
 961  Args:
 962    angle (float): angle to the x-axis of the line of reflection.
 963    centre (tuple[float], optional): point through which the line of
 964      reflection passes. Defaults to None, which
 965      will in turn be converted to (0, 0).
 966
 967  Returns:
 968    list[float]: a six item list of floats, per the shapely.affinity.
 969      affine_transform method, see
 970        https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
 971
 972  """
 973  if centre is None or np.allclose((0, 0), centre, atol = RESOLUTION):
 974    A = 2 * np.radians(angle)
 975    return (np.cos(A), np.sin(A), np.sin(A), -np.cos(A), 0, 0)
 976  dx, dy = centre
 977  t1 = get_translation_transform(-dx, -dy)
 978  r = get_reflection_transform(angle)
 979  t2 = get_translation_transform(dx, dy)
 980  return combine_transforms([t1, r, t2])
 981
 982
 983def combine_transforms(transforms:list[tuple[float,...]]) -> tuple[float,...]:
 984  """Return shapely affine transform that combines supplied list of transforms.
 985
 986  Transforms are applied in the order of the supplied list
 987
 988  Args:
 989    transforms (list[list[float]]): sequence of transforms to combine.
 990
 991  Returns:
 992    list[float]: a transform tuple combining the supplied transforms applied
 993      in order, see
 994        https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
 995
 996  """
 997  result = np.identity(3)
 998  for t in transforms:
 999    result = as_numpy_matrix(t) @ result
1000  return as_shapely_transform(result)
1001
1002
1003def reverse_transform(transform:tuple[float,...]) -> tuple[float,...]:
1004  """Return inverse shapely affine transform of the supplied transform.
1005
1006  Args:
1007    transform (list[float]): the transform for which the inverse is desired.
1008
1009  Returns:
1010    list[float]: shapely affine transform tuple that will invert the supplied
1011      transform.
1012
1013  """
1014  return as_shapely_transform(
1015    np.linalg.inv(as_numpy_matrix(transform)))
1016
1017
1018def as_shapely_transform(arr:np.ndarray) -> tuple[float,...]:
1019  """Return shapely affine transform equivalent to supplied numpy matrix.
1020
1021  The numpy matrix is a conventional augmented (i.e. 3x3) affine transform
1022  matrix.
1023
1024  Args:
1025    arr (np.array): augmented affine transform matrix of the desired
1026      transform.
1027
1028  Returns:
1029    list[float]: desired shapely affine transform list of floats.
1030
1031  """
1032  return (arr[0][0], arr[0][1], arr[1][0], arr[1][1], arr[0][2], arr[1][2])
1033
1034
1035def as_numpy_matrix(transform:tuple[float,...]) -> np.ndarray:
1036  """Convert shapely affine transform to augmented affine transform matrix.
1037
1038  Args:
1039    transform (list[float]): the transform in shapely format.
1040
1041  Returns:
1042    np.array: the transform in numpy matrix format.
1043
1044  """
1045  return np.array([[transform[0], transform[1], transform[4]],
1046                   [transform[2], transform[3], transform[5]],
1047                   [           0,            0,            1]])
1048
1049
1050class StraightLine(NamedTuple):
1051  """Straight line in canonical Ax+By+C=0 form."""
1052
1053  A: float
1054  B: float
1055  C: float
1056
1057
1058def get_straight_line(
1059    p1:geom.Point, p2:geom.Point,
1060    perpendicular:bool = False,
1061  ) -> StraightLine:
1062  """Return StraightLine p1-p2, optionally perpendicular through the midpoint.
1063
1064  Args:
1065    p1 (geom.Point): First point.
1066    p2 (geom.Point): Second point
1067    perpendicular (bool, optional): If True the perpendicular bisector is
1068      returned. Defaults to False.
1069
1070  Returns:
1071    StraightLine: _description_
1072
1073  """
1074  if perpendicular:
1075    ls = affine.rotate(geom.LineString([p1, p2]), 90)
1076    pts = list(ls.coords)
1077    x1, y1 = pts[0]
1078    x2, y2 = pts[1]
1079  else:
1080    x1, y1 = p1.x, p1.y
1081    x2, y2 = p2.x, p2.y
1082  return StraightLine(y1 - y2, x2 - x1, x1 * y2 - x2 * y1)
1083
1084
1085def get_intersection(line1:StraightLine,
1086                     line2:StraightLine) -> geom.Point|None:
1087  """Return point of intersection of straight lines.
1088
1089  Args:
1090    line1 (StraightLine): First straight line.
1091    line2 (StraightLine): Second straight line.
1092
1093  Returns:
1094    geom.Point|None: Point is returned if the lines intersect, otherwise None.
1095
1096  """
1097  x_set, y_set = False, False
1098  denominator = line1.A * line2.B - line2.A * line1.B
1099  if np.isclose(line1.A, 0, atol = 1e-4, rtol = 1e-4):
1100    y = -line1.C / line1.B
1101    y_set = True
1102  elif np.isclose(line2.A, 0, atol = 1e-4, rtol = 1e-4):
1103    y = -line2.C / line2.B
1104    y_set = True
1105  if np.isclose(line1.B, 0, atol = 1e-4, rtol = 1e-4):
1106    x = -line1.C / line1.A
1107    x_set = True
1108  elif np.isclose(line2.B, 0, atol = 1e-4, rtol = 1e-4):
1109    x = -line2.C / line2.A
1110    x_set = True
1111  if np.isclose(denominator, 0, atol = 1e-4, rtol = 1e-4):
1112    return None
1113  x = x if x_set else (line1.B * line2.C - line2.B * line1.C) / denominator
1114  y = y if y_set else (line1.C * line2.A - line2.C * line1.A) / denominator
1115  return geom.Point(x, y)
PRECISION = 6
RESOLUTION = 1e-06
def get_strand_ids(strands_spec: str) -> tuple[list[str]]:
60def get_strand_ids(strands_spec: str) -> tuple[list[str]]:
61  """Convert strands specification string to list of lists of strand labels.
62
63  String format is "a|bc|(de)f" where | separates strands in each
64  direction and () designates combining labels into a single strand that
65  will be sliced lengthwise. Example output:
66
67        "a|bc|(de)f" -> (["a"], ["b", "c"], ["de", "f"])
68
69  Superflous parentheses are removed, but no other error-checks are
70  applied.
71
72  Args:
73    strands_spec (str): the strands specification to be parsed.
74
75  Returns:
76    tuple[str]: tuple of lists of labels for each set of strands.
77
78  """
79  strand_ids = [_parse_strand_label(s) for s in strands_spec.split("|")]
80  strand_ids = strand_ids if len(strand_ids) == 3 else [*strand_ids, [""]]
81  return tuple(strand_ids)

Convert strands specification string to list of lists of strand labels.

String format is "a|bc|(de)f" where | separates strands in each direction and () designates combining labels into a single strand that will be sliced lengthwise. Example output:

  "a|bc|(de)f" -> (["a"], ["b", "c"], ["de", "f"])

Superflous parentheses are removed, but no other error-checks are applied.

Args: strands_spec (str): the strands specification to be parsed.

Returns: tuple[str]: tuple of lists of labels for each set of strands.

def get_regular_polygon(spacing: float, n: int) -> shapely.geometry.polygon.Polygon:
 84def get_regular_polygon(spacing:float, n:int) -> geom.Polygon:
 85  r"""Return regular n-gon centered on (0, 0) with height specified.
 86
 87  n-gon has horizontal base and height given by spacing. Generation is based on
 88  equally spaced points around a circle with a radius calculated slightly
 89  differently, depending on whether the number of sides is even or odd.
 90
 91            Even         Odd
 92        \  /    \  /      |
 93         \/   ___\/___    |
 94         /\      /\      / \
 95        /  \    /  \    /   \
 96
 97  The returned polygon is gridified. Note also that shapely silently reorders
 98  the coordinates clockwise from the lower left corner, regardless of the CCW
 99  order in which we supply them.
100
101  Args:
102    spacing (_type_): required height.
103    n (int): number of sides.
104
105  Returns:
106    geom.Polygon: required geom.Polygon.
107
108  """
109  if n % 2 == 0:
110    R = spacing / np.cos(np.pi / n) / 2
111  else:
112    R = spacing / (1 + np.cos(np.pi / n))
113  angle_0 = -np.pi / 2 + np.pi / n
114  a_diff = 2 * np.pi / n
115  angles = [angle_0 + a_diff * i for i in range(n)]
116  corners = [(R * np.cos(angle), R * np.sin(angle)) for angle in angles]
117  return geom.Polygon(corners)

Return regular n-gon centered on (0, 0) with height specified.

n-gon has horizontal base and height given by spacing. Generation is based on equally spaced points around a circle with a radius calculated slightly differently, depending on whether the number of sides is even or odd.

      Even         Odd
  \  /    \  /      |
   \/   ___\/___    |
   /\      /\      / \
  /  \    /  \    /   \

The returned polygon is gridified. Note also that shapely silently reorders the coordinates clockwise from the lower left corner, regardless of the CCW order in which we supply them.

Args: spacing (_type_): required height. n (int): number of sides.

Returns: geom.Polygon: required geom.Polygon.

def get_corners( shape: shapely.geometry.polygon.Polygon, repeat_first: bool = True) -> list[shapely.geometry.point.Point]:
120def get_corners(shape:geom.Polygon,
121                repeat_first:bool = True) -> list[geom.Point]:
122  """Return list of geom.Points on boundary of a polygon.
123
124  The first point is optionally repeated. No simplification is carried out
125  (e.g. if a line segment has a 'corner' along its length, it is NOT removed;
126  see get_clean_polygon for that). Points have precision set to the
127  packagedefault tiling_utils.RESOLUTION.
128
129  Args:
130    shape (geom.Polygon): polygon whose corners are required.
131    repeat_first (bool, optional): if True the first corner is repeated in the
132      returned list, if False it is omitted. Defaults to True.
133
134  Returns:
135    list[geom.Point]: list of geom.Point vertices of the polygon.
136
137  """
138  corners = [geom.Point(pt) for pt in shape.exterior.coords]
139  return corners if repeat_first else corners[:-1]

Return list of geom.Points on boundary of a polygon.

The first point is optionally repeated. No simplification is carried out (e.g. if a line segment has a 'corner' along its length, it is NOT removed; see get_clean_polygon for that). Points have precision set to the packagedefault tiling_utils.RESOLUTION.

Args: shape (geom.Polygon): polygon whose corners are required. repeat_first (bool, optional): if True the first corner is repeated in the returned list, if False it is omitted. Defaults to True.

Returns: list[geom.Point]: list of geom.Point vertices of the polygon.

def get_sides( shape: shapely.geometry.polygon.Polygon) -> list[shapely.geometry.linestring.LineString]:
142def get_sides(shape:geom.Polygon) -> list[geom.LineString]:
143  """Return polygon sides as list of geom.LineStrings.
144
145  Resolution is set to the package default tiling_utils.RESOLUTION. No
146  simplification for successive colinear sides is carried out.
147
148  Args:
149    shape (geom.Polygon): polygon whose edges are required.
150
151  Returns:
152    list[geom.LineString]: list of geom.LineString sides of the polygon.
153
154  """
155  corners = get_corners(shape)
156  return [geom.LineString([p1, p2])
157          for p1, p2 in zip(corners[:-1], corners[1:], strict = True)]

Return polygon sides as list of geom.LineStrings.

Resolution is set to the package default tiling_utils.RESOLUTION. No simplification for successive colinear sides is carried out.

Args: shape (geom.Polygon): polygon whose edges are required.

Returns: list[geom.LineString]: list of geom.LineString sides of the polygon.

def get_side_lengths(shape: shapely.geometry.polygon.Polygon) -> list[float]:
160def get_side_lengths(shape:geom.Polygon) -> list[float]:
161  """Return list of lengths of polygon sides.
162
163  No simplification for corners along sides is carried out.
164
165  Args:
166    shape (geom.Polygon): polygon whose edge lengths are required.
167
168  Returns:
169    list[float]: list of side lengths.
170
171  """
172  corners = get_corners(shape)
173  return [
174    p1.distance(p2) for p1, p2 in zip(corners[:-1], corners[1:], strict = True)]

Return list of lengths of polygon sides.

No simplification for corners along sides is carried out.

Args: shape (geom.Polygon): polygon whose edge lengths are required.

Returns: list[float]: list of side lengths.

def get_side_bearings(shape: shapely.geometry.polygon.Polygon) -> tuple[float, ...]:
177def get_side_bearings(shape:geom.Polygon) -> tuple[float,...]:
178  """Return list of angles between sides of polygon and the positive x-axis.
179
180  Angle is in degrees and calculated when proceeding from the first point in
181  each side to its end point. This should usually be CW around the polygon.
182
183  Args:
184    shape (geom.Polygon): polygon whose side bearings are required.
185
186  Returns:
187    tuple[float,...]: tuple of bearings of each edge.
188
189  """
190  return tuple([np.degrees(np.arctan2(
191    e.coords[1][1] - e.coords[0][1], e.coords[1][0] - e.coords[0][0]))
192    for e in get_sides(shape)])

Return list of angles between sides of polygon and the positive x-axis.

Angle is in degrees and calculated when proceeding from the first point in each side to its end point. This should usually be CW around the polygon.

Args: shape (geom.Polygon): polygon whose side bearings are required.

Returns: tuple[float,...]: tuple of bearings of each edge.

def get_interior_angles(shape: shapely.geometry.polygon.Polygon) -> list[float]:
195def get_interior_angles(shape:geom.Polygon) -> list[float]:
196  """Return angles (in degrees) between successive edges of a polygon.
197
198  No polygon simplification is carried out so some angles may be 180 (i.e. a
199  'corner' along a side, such that successive sides are colinear).
200
201  Args:
202    shape (geom.Polygon): polygon whose angles are required.
203
204  Returns:
205    list[float]: list of angles.
206
207  """
208  corners = get_corners(shape, repeat_first = False)
209  wrap_corners = corners[-1:] + corners + corners[:1]
210  triples = zip(wrap_corners[:-2],
211                wrap_corners[1:-1],
212                wrap_corners[2:], strict = True)
213  return [get_inner_angle(p1, p2, p3) for p1, p2, p3 in triples]

Return angles (in degrees) between successive edges of a polygon.

No polygon simplification is carried out so some angles may be 180 (i.e. a 'corner' along a side, such that successive sides are colinear).

Args: shape (geom.Polygon): polygon whose angles are required.

Returns: list[float]: list of angles.

def get_clean_polygon( shape: shapely.geometry.multipolygon.MultiPolygon | shapely.geometry.polygon.Polygon | geopandas.geoseries.GeoSeries) -> shapely.geometry.multipolygon.MultiPolygon | shapely.geometry.polygon.Polygon | geopandas.geoseries.GeoSeries:
216def get_clean_polygon(
217      shape:geom.MultiPolygon|geom.Polygon|gpd.GeoSeries,
218    ) ->  geom.MultiPolygon|geom.Polygon|gpd.GeoSeries:
219  """Return polygon with successive co-linear or very close corners removed.
220
221  Particularly useful for tidying polygons weave tilings that have been
222  assembled from multiple 'cells' in the weave grid.
223
224  Args:
225    shape (geom.MultiPolygon|geom.Polygon): polygon to clean.
226
227  Returns:
228    geom.Polygon: cleaned polygon.
229
230  """
231  if isinstance(shape, geom.MultiPolygon):
232    return geom.MultiPolygon([get_clean_polygon(p) for p in shape.geoms])
233  if isinstance(shape, geom.GeometryCollection):
234    elements = [p for p in shape.geoms if isinstance(p, geom.Polygon)]
235    if len(elements) > 1:
236      return get_clean_polygon(geom.MultiPolygon(elements))
237    return get_clean_polygon(elements[0])
238  corners = get_corners(shape, repeat_first = False)
239  # first remove any 'near neighbour' corners
240  distances = get_side_lengths(shape)
241  to_remove = [np.isclose(d, 0, rtol = RESOLUTION, atol = 10 * RESOLUTION)
242                for d in distances]
243  corners = [c for c, r in zip(corners, to_remove, strict = True) if not r]
244  # next remove any that are colinear
245  p = geom.Polygon(corners)
246  corners = get_corners(p, repeat_first = False)
247  angles = get_interior_angles(p)
248  to_remove = [np.isclose(a,   0, rtol = RESOLUTION, atol = RESOLUTION) or
249               np.isclose(a, 180, rtol = RESOLUTION, atol = RESOLUTION)
250               for a in angles]
251  corners = [c for c, r in zip(corners, to_remove, strict = True) if not r]
252  return gridify(geom.Polygon(corners))

Return polygon with successive co-linear or very close corners removed.

Particularly useful for tidying polygons weave tilings that have been assembled from multiple 'cells' in the weave grid.

Args: shape (geom.MultiPolygon|geom.Polygon): polygon to clean.

Returns: geom.Polygon: cleaned polygon.

def rotate_preserving_order( polygon: shapely.geometry.polygon.Polygon, angle: float, centre: shapely.geometry.point.Point = None) -> shapely.geometry.polygon.Polygon:
255def rotate_preserving_order(
256    polygon:geom.Polygon,
257    angle:float,
258    centre:geom.Point = None,
259  ) -> geom.Polygon:
260  """Return supplied polygon rotated with order of corners preserved.
261
262  Order of polygon corners is not guaranteed to be preserved by shapely.
263  affinity.rotate).
264
265  Args:
266    polygon (geom.Polygon): polygon to rotate.
267    angle (float): desired angle of rotation (in degrees).
268    centre (geom.Point): the rotation centre (passed on to shapely.affinity.
269      rotate).
270
271  Returns:
272    geom.Polygon: rotated polygon.
273
274  """
275  ctr = "center" if centre is None else centre
276  corners = get_corners(polygon, repeat_first = False)
277  return geom.Polygon([affine.rotate(c, angle, ctr) for c in corners])

Return supplied polygon rotated with order of corners preserved.

Order of polygon corners is not guaranteed to be preserved by shapely. affinity.rotate).

Args: polygon (geom.Polygon): polygon to rotate. angle (float): desired angle of rotation (in degrees). centre (geom.Point): the rotation centre (passed on to shapely.affinity. rotate).

Returns: geom.Polygon: rotated polygon.

def ensure_cw( shape: shapely.geometry.polygon.Polygon) -> shapely.geometry.polygon.Polygon:
280def ensure_cw(shape:geom.Polygon) -> geom.Polygon:
281  """Return the polygon with its outer boundary vertices in clockwise order.
282
283  It is important to note that shapely.set_precision() imposes clockwise order
284  on polygons, and since it is used widely throughout theses modules, it makes
285  sense to impose this order.
286
287  Args:
288    shape (geom.Polygon): the polygon.
289
290  Returns:
291    geom.Polygon: the polygon in clockwise order.
292
293  """
294  if geom.LinearRing(shape.exterior.coords).is_ccw:
295    return shape.reverse()
296  return shape

Return the polygon with its outer boundary vertices in clockwise order.

It is important to note that shapely.set_precision() imposes clockwise order on polygons, and since it is used widely throughout theses modules, it makes sense to impose this order.

Args: shape (geom.Polygon): the polygon.

Returns: geom.Polygon: the polygon in clockwise order.

def get_inner_angle( p1: shapely.geometry.point.Point, p2: shapely.geometry.point.Point, p3: shapely.geometry.point.Point) -> float:
299def get_inner_angle(p1:geom.Point, p2:geom.Point, p3:geom.Point) -> float:
300  r"""Return angle between line p1-p2 and p2-p3, i.e., the angle A below.
301
302            p2
303           / \
304          / A \
305        p1     p3
306
307  Args:
308    p1 (geom.Point): first point.
309    p2 (geom.Point): second 'corner' point.
310    p3 (geom.Point): third point.
311
312  Returns:
313    float: angle in degrees.
314
315  """
316  return np.degrees(np.arctan2(p3.y - p2.y, p3.x - p2.x) -
317                    np.arctan2(p1.y - p2.y, p1.x - p2.x)) % 360

Return angle between line p1-p2 and p2-p3, i.e., the angle A below.

      p2
     / \
    / A \
  p1     p3

Args: p1 (geom.Point): first point. p2 (geom.Point): second 'corner' point. p3 (geom.Point): third point.

Returns: float: angle in degrees.

def is_regular_polygon(shape: shapely.geometry.polygon.Polygon) -> bool:
320def is_regular_polygon(shape:geom.Polygon) -> bool:
321  """Test if supplied polygon is regular (i.e. equal sides and angles).
322
323  Args:
324    shape (geom.Polygon): polygon to test.
325
326  Returns:
327    bool: True if polygon is regular, False if not.
328
329  """
330  side_lengths = get_side_lengths(shape)
331  angles = get_interior_angles(shape)
332  return all(np.isclose(side_lengths, side_lengths[0])) \
333     and all(np.isclose(angles, angles[0]))

Test if supplied polygon is regular (i.e. equal sides and angles).

Args: shape (geom.Polygon): polygon to test.

Returns: bool: True if polygon is regular, False if not.

def is_tangential(shape: shapely.geometry.polygon.Polygon) -> bool:
336def is_tangential(shape:geom.Polygon) -> bool:
337  """Determine if supplied polygon is tangential.
338
339  A tangential polygon can have a circle inscribed tangential to all its sides.
340
341  NOTE: not currently used but retained in case incentre is restored to use
342  'home made' code rather than polylabel.polylabel.
343
344  Note that this will fail for polygons with successive colinear sides, meaning
345  that polygons should be fully simplified...
346
347  Args:
348    shape (geom.Polygon): polygon to test.
349
350  Returns:
351    bool: True if polygon is tangential, False if not.
352
353  """
354  if is_regular_polygon(shape):
355    return True
356  side_lengths = get_side_lengths(shape)
357  n = len(side_lengths)
358  if n % 2 == 1:
359    if n == 3: #triangles are easy!
360      return True
361    # odd number of sides there is a nice solvable system  of equations see
362    # https://math.stackexchange.com/questions/4065370/tangential-polygons-conditions-on-edge-lengths
363    #
364    # TODO: this is not reliable for reasons I don't fully understand. There is
365    # a corresponding crude catch exception in incentre... but this needs a
366    # more complete investigation
367    mat = np.identity(n) + np.roll(np.identity(n), 1, axis = 1)
368    fractions = np.linalg.inv(mat) @ side_lengths / side_lengths
369    if not(np.isclose(np.mean(fractions),
370                      0.5, rtol= RESOLUTION, atol = RESOLUTION)):
371      return False
372    ones = [np.isclose(f, 1, rtol = RESOLUTION, atol = RESOLUTION)
373            for f in fractions]
374    zeros = [np.isclose(f, 0, rtol = RESOLUTION, atol = RESOLUTION)
375             for f in fractions]
376    negatives = [f < 0 for f in fractions]
377    return not (any(ones) or any(zeros) or any(negatives))
378  if n == 4:
379    # for quadrilaterals odd and even side lengths are equal
380    return np.isclose(sum(side_lengths[0::2]),
381                      sum(side_lengths[1::2]), rtol = RESOLUTION)
382  # other even numbers of sides... hard to avoid brute force
383  bisectors = [get_angle_bisector(shape, i) for i in range(n)]
384  intersects = [b1.intersection(b2) for b1, b2 in
385                zip(bisectors, bisectors[1:] + bisectors[:1], strict = False)
386                if b1.intersects(b2)]
387  distances = [i1.distance(i2) for i1, i2 in
388               zip(intersects, intersects[1:] + intersects[:1],
389                   strict = False)]
390  return all([d <= 2 * RESOLUTION for d in distances])  # noqa: C419

Determine if supplied polygon is tangential.

A tangential polygon can have a circle inscribed tangential to all its sides.

NOTE: not currently used but retained in case incentre is restored to use 'home made' code rather than polylabel.polylabel.

Note that this will fail for polygons with successive colinear sides, meaning that polygons should be fully simplified...

Args: shape (geom.Polygon): polygon to test.

Returns: bool: True if polygon is tangential, False if not.

def get_incentre(shape: shapely.geometry.polygon.Polygon) -> shapely.geometry.point.Point:
393def get_incentre(shape:geom.Polygon) -> geom.Point:
394  """Get a polygon incentre.
395
396  See https://en.wikipedia.org/wiki/Incenter.
397
398  This method relies on the polygon being tangential, i.e. there is an
399  inscribed circle to which all sides of the polygon are tangent. It will work
400  on all the polygons encountered in the Laves tilings, but is not guaranteed
401  to work on all polygons.
402
403  Given that the polygon is tangential, the radius of the inscribed circle is
404  the [apothem of the polygon](https://en.wikipedia.org/wiki/Apothem) given by
405  2 x Area / Perimeter. We apply a parallel offset of this size to two sides of
406  the polygon and find their intersection to determine the centre of the circle
407  givng the incentre of the polygon.
408
409  Args:
410    shape (geom.Polygon): the polygon.
411
412  Returns:
413    geom.Point: the incentre of the polygon.
414
415  """
416  if is_regular_polygon(shape):
417    return shape.centroid
418  return polylabel.polylabel(shape)

Get a polygon incentre.

See https://en.wikipedia.org/wiki/Incenter.

This method relies on the polygon being tangential, i.e. there is an inscribed circle to which all sides of the polygon are tangent. It will work on all the polygons encountered in the Laves tilings, but is not guaranteed to work on all polygons.

Given that the polygon is tangential, the radius of the inscribed circle is the apothem of the polygon given by 2 x Area / Perimeter. We apply a parallel offset of this size to two sides of the polygon and find their intersection to determine the centre of the circle givng the incentre of the polygon.

Args: shape (geom.Polygon): the polygon.

Returns: geom.Point: the incentre of the polygon.

def get_incircle( shape: shapely.geometry.polygon.Polygon) -> shapely.geometry.polygon.Polygon:
421def get_incircle(shape:geom.Polygon) -> geom.Polygon:
422  """Return incircle of supplied polygon.
423
424  Args:
425    shape(geom.Polygon): polygon for which incircle is required.
426
427  Returns:
428    geom.Polygon: a polygon representation of the incircle
429
430  """
431  c = get_incentre(shape)
432  r = min([c.distance(e) for e in get_sides(shape)])
433  return c.buffer(r)

Return incircle of supplied polygon.

Args: shape(geom.Polygon): polygon for which incircle is required.

Returns: geom.Polygon: a polygon representation of the incircle

def get_apothem(shape: shapely.geometry.polygon.Polygon) -> float:
436def get_apothem(shape:geom.Polygon) -> float:
437  """Return length of the apothem of supplied polygon.
438
439  Args:
440    shape(geom.Polygon): polygon for which apothem length is required.
441
442  Returns:
443    float: an estimate of the apothem length.
444
445  """
446  if is_regular_polygon(shape):
447    return 2 * shape.area / geom.LineString(shape.exterior.coords).length
448  c = polylabel.polylabel(shape, 1)
449  return min([c.distance(e) for e in get_sides(shape)])

Return length of the apothem of supplied polygon.

Args: shape(geom.Polygon): polygon for which apothem length is required.

Returns: float: an estimate of the apothem length.

def get_angle_bisector( shape: shapely.geometry.polygon.Polygon, v=0) -> shapely.geometry.linestring.LineString:
452def get_angle_bisector(shape:geom.Polygon, v = 0) -> geom.LineString:
453  """Return angle bisector of specified corner of supplied polygon.
454
455  Args:
456    shape (geom.Polygon): the polygon
457    v (int, optional): index of the corner whose bisector is required.
458    Defaults to 0.
459
460  Returns:
461    geom.LineString: line which bisects the specified corner.
462
463  """
464  pts = get_corners(shape, repeat_first = False)
465  n = len(pts)
466  p0 = pts[v % n]
467  p1 = pts[(v + 1) % n]
468  p2 = pts[(v - 1) % n]
469  d01 = p0.distance(p1)
470  d02 = p0.distance(p2)
471  if d01 < d02:
472    p2 = geom.LineString([p0, p2]).interpolate(d01)
473  else:
474    p1 = geom.LineString([p0, p1]).interpolate(d02)
475  m = geom.Point((p1.x + p2.x) / 2, (p1.y + p2.y) / 2)
476  # we have to scale this to much larger in case of an angle near 180
477  return affine.scale(geom.LineString([p0, m]), 10, 10, origin = p0)

Return angle bisector of specified corner of supplied polygon.

Args: shape (geom.Polygon): the polygon v (int, optional): index of the corner whose bisector is required. Defaults to 0.

Returns: geom.LineString: line which bisects the specified corner.

def gridify( gs: geopandas.geoseries.GeoSeries | geopandas.geodataframe.GeoDataFrame | shapely.lib.Geometry) -> geopandas.geoseries.GeoSeries | geopandas.geodataframe.GeoDataFrame | shapely.lib.Geometry:
500def gridify(
501    gs:gpd.GeoSeries | gpd.GeoDataFrame | shapely.Geometry,
502  ) -> gpd.GeoSeries | gpd.GeoDataFrame | shapely.Geometry:
503  """Return supplied GeoSeries rounded to tiling_utils.RESOLUTION.
504
505  Args:
506    gs (gpd.GeoSeries): geometries to gridify.
507    precision (int, optional): digits of precision. Defaults to 6.
508
509  Returns:
510    gpd.GeoSeries: the rounded geometries.
511
512  """
513  if isinstance(gs, shapely.Geometry) :
514    return shapely.set_precision(gs, grid_size = RESOLUTION)
515  if isinstance(gs, gpd.GeoSeries):
516    return gpd.GeoSeries(
517      [shapely.set_precision(g, grid_size = RESOLUTION) for g in list(gs)],
518      crs = gs.crs)
519  if isinstance(gs, gpd.GeoDataFrame):
520    gs.geometry = gpd.GeoSeries(
521      [shapely.set_precision(g, grid_size = RESOLUTION)
522       for g in list(gs.geometry)], crs = gs.crs)
523    return gs
524  return None

Return supplied GeoSeries rounded to tiling_utils.RESOLUTION.

Args: gs (gpd.GeoSeries): geometries to gridify. precision (int, optional): digits of precision. Defaults to 6.

Returns: gpd.GeoSeries: the rounded geometries.

def get_dual_tile_unit(unit) -> geopandas.geodataframe.GeoDataFrame:
527def get_dual_tile_unit(unit) -> gpd.GeoDataFrame:
528  """Convert supplied TileUnit to candidate GeoDataFrame of its dual TileUnit.
529
530  NOTE: this is complicated and not remotely guaranteed to work!
531  a particular issue is that where to place the vertices of the faces
532  of the dual with respect to the tiles in the original is ill-defined.
533  This is because the dual process is topologically not metrically defined,
534  so that exact vertex locations are ambiguous. Tiling duality is defined in
535  Section 4.2 of Grunbaum B, Shephard G C, 1987 _Tilings and Patterns_ (W. H.
536  Freeman and Company, New York)
537
538  NOTE: In general, this method will work only if all supplied tiles are
539  regular polygons. A known exception is if the only non-regular polygons are
540  triangles.
541
542  NOTE: 'clean' polygons are required. If supplied polygons have messy
543  vertices with multiple points where there is only one proper point, bad
544  things are likely to happen! Consider using `clean_polygon()` on the
545  tile geometries.
546
547  Because of the above limitations, we only return a GeoDataFrame
548  for inspection. However some `weavingspace.tile_unit.TileUnit` setup
549  methods in `weavingspace.tiling_geometries` use this method, where we are
550  confident the returned dual is valid.
551
552  Args:
553    unit (TileUnit): the tiling for which the dual is required.
554
555  Returns:
556    gpd.GeoDataFrame: GeoDataFrame that could be the tiles attribute for
557      a TileUnit of the dual tiling.
558
559  """
560  # get a local patch of this Tiling
561  local_patch:gpd.GeoDataFrame = unit.get_local_patch(r = 1, include_0 = True)
562  # Find the interior points of these tiles - these will be guaranteed
563  # to have a sequence of surrounding tiles incident on them
564  interior_pts = _get_interior_vertices(local_patch)
565  # Compile a list of the polygons incident on the interior points
566  cycles = [{poly_id for poly_id, p in enumerate(local_patch.geometry)
567             if pt.distance(p) < RESOLUTION * 2} for pt in interior_pts]
568  dual_faces = []
569  for cycle in cycles:
570    ids, pts = [], []
571    for poly_id in cycle:
572      ids.append(local_patch.tile_id[poly_id])
573      poly = local_patch.geometry[poly_id]
574      pts.append(get_incentre(poly))
575    # sort them into CW order so they are well formed
576    sorted_coords = _sort_cw([(pt.x, pt.y, ID)
577                              for pt, ID in zip(pts, ids, strict = True)])
578    dual_faces.append(
579      (geom.Polygon([(pt_id[0], pt_id[1]) for pt_id in sorted_coords]),
580       "".join([pt_id[2] for pt_id in sorted_coords])))
581  # ensure the resulting face centroids are inside the original tile
582  # displaced a little to avoid uncertainties at corners/edges
583  # This is a guess at something that _could_ work, which turned out well!
584  dual_faces = [(f, ID) for f, ID in dual_faces
585                if affine.translate(
586                  unit.get_prototile_from_vectors().loc[0, "geometry"],
587                  RESOLUTION * 10, RESOLUTION * 10).contains(f.centroid)]
588  gdf = gpd.GeoDataFrame(
589    data = {"tile_id": [f[1] for f in dual_faces]}, crs = unit.crs,
590    geometry = gpd.GeoSeries([f[0] for f in dual_faces]))
591  # ensure no duplicates
592  gdf = gdf.dissolve(by = "tile_id", as_index = False).explode(
593    index_parts = False, ignore_index = True)
594  gdf.tile_id = _relabel(gdf.tile_id)
595  return gdf

Convert supplied TileUnit to candidate GeoDataFrame of its dual TileUnit.

NOTE: this is complicated and not remotely guaranteed to work! a particular issue is that where to place the vertices of the faces of the dual with respect to the tiles in the original is ill-defined. This is because the dual process is topologically not metrically defined, so that exact vertex locations are ambiguous. Tiling duality is defined in Section 4.2 of Grunbaum B, Shephard G C, 1987 _Tilings and Patterns_ (W. H. Freeman and Company, New York)

NOTE: In general, this method will work only if all supplied tiles are regular polygons. A known exception is if the only non-regular polygons are triangles.

NOTE: 'clean' polygons are required. If supplied polygons have messy vertices with multiple points where there is only one proper point, bad things are likely to happen! Consider using clean_polygon() on the tile geometries.

Because of the above limitations, we only return a GeoDataFrame for inspection. However some weavingspace.tile_unit.TileUnit setup methods in weavingspace.tiling_geometries use this method, where we are confident the returned dual is valid.

Args: unit (TileUnit): the tiling for which the dual is required.

Returns: gpd.GeoDataFrame: GeoDataFrame that could be the tiles attribute for a TileUnit of the dual tiling.

def write_map_to_layers( gdf: geopandas.geodataframe.GeoDataFrame, fname: str = 'output.gpkg', tile_var: str = 'tile_id') -> None:
640def write_map_to_layers(gdf:gpd.GeoDataFrame,
641                        fname:str = "output.gpkg",
642                        tile_var:str = "tile_id") -> None:
643  """Write supplied GeoDataFrame to a GPKG file.
644
645  Geopackage file layers will be based on the `tile_var` attribute.
646
647  Args:
648    gdf (gpd.GeoDataFrame): the GeoDataFrame.
649    fname (str, optional): filename to write.
650    tile_var (str, optional): the attribute to use to separate
651      output file into layers. Defaults to "tile_id".
652
653  """
654  grouped = gdf.groupby(tile_var, as_index = False)
655  for e in pd.Series.unique(gdf[tile_var]):
656    grouped.get_group(e).to_file(fname, layer = e, driver = "GPKG")

Write supplied GeoDataFrame to a GPKG file.

Geopackage file layers will be based on the tile_var attribute.

Args: gdf (gpd.GeoDataFrame): the GeoDataFrame. fname (str, optional): filename to write. tile_var (str, optional): the attribute to use to separate output file into layers. Defaults to "tile_id".

def get_collapse_distance(geometry: shapely.geometry.polygon.Polygon) -> float:
659def get_collapse_distance(geometry:geom.Polygon) -> float:
660  """Return distance under which polygon will disappear if negatively buffered.
661
662  Performs a binary search between an upper bound based on the radius of circle
663  of equal area to the polygon, and 0.
664
665  Args:
666    geometry (geom.Polygon): the polygon.
667
668  Returns:
669    float: its collapse distance.
670
671  """
672  if is_regular_polygon(geometry):
673    return get_apothem(geometry)
674  radius = np.sqrt(geometry.area / np.pi)
675  lower = 0
676  upper = radius
677  delta = 1e12
678  stop_delta = radius / 1000
679  while delta > stop_delta:
680    new_r = (lower + upper) / 2
681    if geometry.buffer(-new_r).area > 0:
682      lower = new_r
683      delta = upper - new_r
684    else:
685      upper = new_r
686      delta = new_r - lower
687  return new_r

Return distance under which polygon will disappear if negatively buffered.

Performs a binary search between an upper bound based on the radius of circle of equal area to the polygon, and 0.

Args: geometry (geom.Polygon): the polygon.

Returns: float: its collapse distance.

def get_largest_polygon(polygons: geopandas.geoseries.GeoSeries) -> geopandas.geoseries.GeoSeries:
690def get_largest_polygon(polygons:gpd.GeoSeries) -> gpd.GeoSeries:
691  """Return largest polygon in a GeoSeries as a GeoSeries of one polygon.
692
693  NOT CURRENTLY USED ANYWHERE IN MODULE.
694
695  Args:
696    polygons (gpd.GeoSeries): the set of polygons to pick from.
697
698  Returns:
699    gpd.GeoSeries: the largest polygon.
700
701  """
702  actual_polygons = [p for p in polygons.geometry
703                     if isinstance(p, (geom.Polygon, geom.MultiPolygon))]
704  areas = sorted(actual_polygons, key = lambda x: x.area, reverse = True)
705  return gpd.GeoSeries(areas[:1])

Return largest polygon in a GeoSeries as a GeoSeries of one polygon.

NOT CURRENTLY USED ANYWHERE IN MODULE.

Args: polygons (gpd.GeoSeries): the set of polygons to pick from.

Returns: gpd.GeoSeries: the largest polygon.

def touch_along_an_edge( p1: shapely.geometry.polygon.Polygon, p2: shapely.geometry.polygon.Polygon) -> bool:
708def touch_along_an_edge(p1:geom.Polygon, p2:geom.Polygon) -> bool:
709  """Test if two polygons touch along an edge.
710
711  Checks that the intersection area of the two polygons buffered by
712  a small amount is large enough to indicate that they neighbour at more
713  than a corner.
714
715  Args:
716    p1 (geom.Polygon): First polygon
717    p2 (geom.Polygon): Second polygon
718
719  Returns:
720    bool: True if they neighbour along an edge
721
722  """
723  return (
724    p1.buffer(RESOLUTION, join_style = "mitre", cap_style = "square")
725      .intersection(p2.buffer(RESOLUTION, join_style = "mitre",
726                                          cap_style = "square"))
727      .area > 16 * RESOLUTION ** 2)

Test if two polygons touch along an edge.

Checks that the intersection area of the two polygons buffered by a small amount is large enough to indicate that they neighbour at more than a corner.

Args: p1 (geom.Polygon): First polygon p2 (geom.Polygon): Second polygon

Returns: bool: True if they neighbour along an edge

def get_width_height_left_bottom(gs: geopandas.geoseries.GeoSeries) -> tuple[float, float, float, float]:
730def get_width_height_left_bottom(
731    gs:gpd.GeoSeries,
732  ) -> tuple[float,float,float,float]:
733  """Return width, height, left and bottom limits of a GeoSeries.
734
735  Args:
736    gs (geopandas.GeoSeries): GeoSeries for which limits are required.
737
738  Returns:
739    tuple: four float values of width, height, left and bottom of gs.
740
741  """
742  bb = gs.total_bounds
743  return (bb[2] - bb[0], bb[3] - bb[1], bb[0], bb[1])

Return width, height, left and bottom limits of a GeoSeries.

Args: gs (geopandas.GeoSeries): GeoSeries for which limits are required.

Returns: tuple: four float values of width, height, left and bottom of gs.

def get_bounding_ellipse( shapes: geopandas.geoseries.GeoSeries, mag: float = 1.0) -> geopandas.geoseries.GeoSeries:
746def get_bounding_ellipse(
747    shapes:gpd.GeoSeries,
748    mag:float = 1.0,
749  ) -> gpd.GeoSeries:
750  """Return an ellipse containing the supplied shapes.
751
752  The method used is to calculate the size of square that would contain
753  the shapes, if they had an aspect ratio 1, then stretch the circle in
754  the x, y directions according to the actual aspect ratio of the shapes.
755
756  Args:
757    shapes (gpd.GeoSeries): the shapes to be contained.
758    mag (float, optional): optionally increase the size of the returned
759      ellipse by this scale factor. Defaults to 1.0.
760
761  Returns:
762    gpd.GeoSeries: the set of shapes.
763
764  """
765  width, height, left, bottom = get_width_height_left_bottom(shapes)
766  c = geom.Point(left + width / 2, bottom + height / 2)
767  r = min(width, height) * np.sqrt(2)
768  circle = [c.buffer(r)]
769  return gpd.GeoSeries(circle, crs = shapes.crs).scale(
770    width / r * mag / np.sqrt(2), height / r * mag / np.sqrt(2), origin = c)

Return an ellipse containing the supplied shapes.

The method used is to calculate the size of square that would contain the shapes, if they had an aspect ratio 1, then stretch the circle in the x, y directions according to the actual aspect ratio of the shapes.

Args: shapes (gpd.GeoSeries): the shapes to be contained. mag (float, optional): optionally increase the size of the returned ellipse by this scale factor. Defaults to 1.0.

Returns: gpd.GeoSeries: the set of shapes.

def get_tiling_edges(tiles: geopandas.geoseries.GeoSeries) -> geopandas.geoseries.GeoSeries:
773def get_tiling_edges(tiles:gpd.GeoSeries) -> gpd.GeoSeries:
774  """Return linestring GeoSeries from supplied polygon GeoSeries.
775
776  This is used to allow display of edges of tiles in legend when they are
777  masked by an ellipse (if we instead clip polygons then the ellipse edge
778  will also show in the result.)
779
780  Args:
781    tiles (gpd.GeoSeries): Polygons to convert.
782
783  Returns:
784    gpd.GeoSeries: LineStrings from the supplied Polygons.
785
786  """
787  tiles = tiles.explode(ignore_index = True)
788  edges = [geom.LineString(p.exterior.coords) for p in tiles]
789  return gpd.GeoSeries(edges, crs = tiles.crs)

Return linestring GeoSeries from supplied polygon GeoSeries.

This is used to allow display of edges of tiles in legend when they are masked by an ellipse (if we instead clip polygons then the ellipse edge will also show in the result.)

Args: tiles (gpd.GeoSeries): Polygons to convert.

Returns: gpd.GeoSeries: LineStrings from the supplied Polygons.

def get_polygon_sector( shape: shapely.geometry.polygon.Polygon, start: float = 0.0, end: float = 1.0) -> shapely.geometry.polygon.Polygon:
792def get_polygon_sector(
793    shape:geom.Polygon,
794    start:float = 0.0,
795    end:float = 1.0,
796  ) -> geom.Polygon:
797  """Return a sector of the provided Polygon.
798
799  The returned sector is a section of the polygon boundary between the
800  normalized start and end positions, and including the polygon centroid.
801  Should (probably) only be applied to convex polygons.
802
803  Args:
804    shape (geom.Polygon): the Polygon.
805    start (float): normalized start position along the boundary. Defaults to
806      0.
807    end (float): normalized start position along the boundary. Defaults to
808      1.
809
810  Returns:
811    geom.Polygon: the requested polygon sector.
812
813  """
814  if start == end:
815    # must return a null polygon since the calling context
816    # expects to get something back... which most likely
817    # is needed to align with other data
818    return geom.Polygon()
819  if start * end < 0:
820    # either side of 0/1 so assume required sector includes 0
821    e1, e2 = min(start, end), max(start, end)
822    arc1 = shapely.ops.substring(geom.LineString(shape.exterior.coords),
823                                 np.mod(e1, 1), 1, normalized = True)
824    arc2 = shapely.ops.substring(geom.LineString(shape.exterior.coords),
825                                 0, e2, normalized = True)
826    sector = geom.Polygon(
827      [shape.centroid] + list(arc1.coords) + list(arc2.coords)[1:])
828  else:
829    arc = shapely.ops.substring(geom.LineString(shape.exterior.coords),
830                                start, end, normalized = True)
831    sector = geom.Polygon([shape.centroid, *list(arc.coords)])
832  return sector

Return a sector of the provided Polygon.

The returned sector is a section of the polygon boundary between the normalized start and end positions, and including the polygon centroid. Should (probably) only be applied to convex polygons.

Args: shape (geom.Polygon): the Polygon. start (float): normalized start position along the boundary. Defaults to 0. end (float): normalized start position along the boundary. Defaults to 1.

Returns: geom.Polygon: the requested polygon sector.

def repair_polygon( polygon: shapely.geometry.polygon.Polygon | shapely.geometry.multipolygon.MultiPolygon | geopandas.geoseries.GeoSeries, shrink_then_grow: bool = True) -> shapely.geometry.polygon.Polygon | geopandas.geoseries.GeoSeries:
835def repair_polygon(
836    polygon:geom.Polygon|geom.MultiPolygon|gpd.GeoSeries,
837    shrink_then_grow:bool = True,
838  ) -> geom.Polygon|gpd.GeoSeries:
839  """Repair a polyon or GeoSeries by double buffering.
840
841  This is method is often unofficially recommended (on stackexchange etc.)
842  even in the shapely docs, to resolve topology issues and extraneous
843  additional vertices appearing when spatial operations are repeatedly
844  applied.
845
846  Optionally the buffer may be applied in the opposite order (i.e. grow then
847  shrink). This operation may also convert a MultiPolygon that has some 'stray'
848  parts to a Polygon.
849
850  Args:
851    polygon(geom.Polygon|gpd.GeoSeries): Polygon or GeoSeries to clean.
852    res (float, optional): buffer size to use. Defaults to 1e-3.
853    shrink_then_grow (bool, optional): if True the negative buffer is
854      applied first, otherwise the buffer operations are applied in
855      reverse. Defaults to True.
856
857  Returns:
858    geom.Polygon|gpd.GeoSeries: the cleaned Polygon or GeoSeries.
859
860  """
861  if type(polygon) is gpd.GeoSeries:
862    return gpd.GeoSeries([repair_polygon(p, shrink_then_grow)
863                          for p in polygon]).set_crs(polygon.crs)
864  if polygon is None:
865    return None
866  if shrink_then_grow:
867    return polygon.buffer(
868      -RESOLUTION * 10, join_style = "mitre", cap_style = "square").buffer(
869       RESOLUTION * 10, join_style = "mitre", cap_style = "square")
870  return polygon.buffer(
871     RESOLUTION * 10, join_style = "mitre", cap_style = "square").buffer(
872    -RESOLUTION * 10, join_style = "mitre", cap_style = "square")

Repair a polyon or GeoSeries by double buffering.

This is method is often unofficially recommended (on stackexchange etc.) even in the shapely docs, to resolve topology issues and extraneous additional vertices appearing when spatial operations are repeatedly applied.

Optionally the buffer may be applied in the opposite order (i.e. grow then shrink). This operation may also convert a MultiPolygon that has some 'stray' parts to a Polygon.

Args: polygon(geom.Polygon|gpd.GeoSeries): Polygon or GeoSeries to clean. res (float, optional): buffer size to use. Defaults to 1e-3. shrink_then_grow (bool, optional): if True the negative buffer is applied first, otherwise the buffer operations are applied in reverse. Defaults to True.

Returns: geom.Polygon|gpd.GeoSeries: the cleaned Polygon or GeoSeries.

def safe_union( gs: geopandas.geoseries.GeoSeries, as_polygon: bool = False) -> geopandas.geoseries.GeoSeries | shapely.geometry.polygon.Polygon:
875def safe_union(
876    gs:gpd.GeoSeries,
877    as_polygon:bool = False,
878  ) -> gpd.GeoSeries|geom.Polygon:
879  """Union supplied GeoSeries buffering to avoid gaps and internal edges.
880
881  Optionally returns a Polygon or a GeoSeries.
882
883  NOTE: APPEARS NOT TO BE VERY SAFE... find workarounds for the few occasions
884  on which it is used.
885
886  Frequently when unioning polygons that are ostensibly adjacent 'rogue'
887  internal boundaries remain in the result. We can avoid this by buffering the
888  polygons before unioning them, then reversing the buffer on the unioned
889  shape.
890
891  Args:
892    gs (gpd.GeoSeries): the Polygons to union.
893    res (float, optional): size of the buffer to use. Defaults to 1e-3.
894    as_polygon (bool, optional): if True returns a Polygon, otherwise
895      returns a one Polygon GeoSeries. Defaults to False.
896
897  Returns:
898    gpd.GeoSeries|geom.Polygon: the resulting union of supplied
899      polygons.
900
901  """
902  r = RESOLUTION * 10
903  union = gpd.GeoSeries(
904    [p.buffer(r, join_style = "mitre", cap_style = "square") for p in gs],
905    crs = gs.crs).union_all().buffer(-r, join_style = "mitre",
906                                         cap_style = "square")
907  if as_polygon:
908    return union
909  return gpd.GeoSeries([union], crs = gs.crs)

Union supplied GeoSeries buffering to avoid gaps and internal edges.

Optionally returns a Polygon or a GeoSeries.

NOTE: APPEARS NOT TO BE VERY SAFE... find workarounds for the few occasions on which it is used.

Frequently when unioning polygons that are ostensibly adjacent 'rogue' internal boundaries remain in the result. We can avoid this by buffering the polygons before unioning them, then reversing the buffer on the unioned shape.

Args: gs (gpd.GeoSeries): the Polygons to union. res (float, optional): size of the buffer to use. Defaults to 1e-3. as_polygon (bool, optional): if True returns a Polygon, otherwise returns a one Polygon GeoSeries. Defaults to False.

Returns: gpd.GeoSeries|geom.Polygon: the resulting union of supplied polygons.

def get_translation_transform(dx: float, dy: float) -> tuple[float, ...]:
912def get_translation_transform(dx:float, dy:float) -> tuple[float,...]:
913  """Return shapely affine transform tuple for a translation.
914
915  Args:
916      dx (float): translation distance in x direction.
917      dy (float): translation distance in y direction.
918
919  Returns:
920    list[float]: a six item list of floats, per the shapely.affinity.
921    affine_transform method, see
922      https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
923
924  """
925  return (1, 0, 0, 1, dx, dy)

Return shapely affine transform tuple for a translation.

Args: dx (float): translation distance in x direction. dy (float): translation distance in y direction.

Returns: list[float]: a six item list of floats, per the shapely.affinity. affine_transform method, see https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations

def get_rotation_transform( angle: float, centre: tuple[float, ...] | None = None) -> tuple[float, ...]:
928def get_rotation_transform(
929    angle:float, centre:tuple[float,...]|None = None) -> tuple[float,...]:
930  """Return the shapely affine transform tuple for a rotation.
931
932  Rotation is optionally about a supplied centre point.
933
934  Args:
935    angle (float): the angle of rotation (in degrees).
936    centre (tuple[float], optional): An option centre location. Defaults to
937    None, which will in turn be converted to (0, 0).
938
939  Returns:
940    list[float]: a six item list of floats, per the shapely.affinity.
941      affine_transform method, see
942        https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
943
944  """
945  if centre is None or np.allclose((0, 0), centre, atol = RESOLUTION):
946    a = np.radians(angle)
947    return (np.cos(a), -np.sin(a), np.sin(a), np.cos(a), 0, 0)
948  dx, dy = centre
949  t1 = get_translation_transform(-dx, -dy)
950  r = get_rotation_transform(angle)
951  t2 = get_translation_transform(dx, dy)
952  return combine_transforms([t1, r, t2])

Return the shapely affine transform tuple for a rotation.

Rotation is optionally about a supplied centre point.

Args: angle (float): the angle of rotation (in degrees). centre (tuple[float], optional): An option centre location. Defaults to None, which will in turn be converted to (0, 0).

Returns: list[float]: a six item list of floats, per the shapely.affinity. affine_transform method, see https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations

def get_reflection_transform( angle: float, centre: tuple[float, ...] | None = None) -> tuple[float, ...]:
955def get_reflection_transform(
956    angle:float, centre:tuple[float,...]|None = None) -> tuple[float,...]:
957  """Return a shapely affine reflection transform.
958
959  Reflection is in a line at the specified angle, optionally through a
960  specified centre point.
961
962  Args:
963    angle (float): angle to the x-axis of the line of reflection.
964    centre (tuple[float], optional): point through which the line of
965      reflection passes. Defaults to None, which
966      will in turn be converted to (0, 0).
967
968  Returns:
969    list[float]: a six item list of floats, per the shapely.affinity.
970      affine_transform method, see
971        https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
972
973  """
974  if centre is None or np.allclose((0, 0), centre, atol = RESOLUTION):
975    A = 2 * np.radians(angle)
976    return (np.cos(A), np.sin(A), np.sin(A), -np.cos(A), 0, 0)
977  dx, dy = centre
978  t1 = get_translation_transform(-dx, -dy)
979  r = get_reflection_transform(angle)
980  t2 = get_translation_transform(dx, dy)
981  return combine_transforms([t1, r, t2])

Return a shapely affine reflection transform.

Reflection is in a line at the specified angle, optionally through a specified centre point.

Args: angle (float): angle to the x-axis of the line of reflection. centre (tuple[float], optional): point through which the line of reflection passes. Defaults to None, which will in turn be converted to (0, 0).

Returns: list[float]: a six item list of floats, per the shapely.affinity. affine_transform method, see https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations

def combine_transforms(transforms: list[tuple[float, ...]]) -> tuple[float, ...]:
 984def combine_transforms(transforms:list[tuple[float,...]]) -> tuple[float,...]:
 985  """Return shapely affine transform that combines supplied list of transforms.
 986
 987  Transforms are applied in the order of the supplied list
 988
 989  Args:
 990    transforms (list[list[float]]): sequence of transforms to combine.
 991
 992  Returns:
 993    list[float]: a transform tuple combining the supplied transforms applied
 994      in order, see
 995        https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
 996
 997  """
 998  result = np.identity(3)
 999  for t in transforms:
1000    result = as_numpy_matrix(t) @ result
1001  return as_shapely_transform(result)

Return shapely affine transform that combines supplied list of transforms.

Transforms are applied in the order of the supplied list

Args: transforms (list[list[float]]): sequence of transforms to combine.

Returns: list[float]: a transform tuple combining the supplied transforms applied in order, see https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations

def reverse_transform(transform: tuple[float, ...]) -> tuple[float, ...]:
1004def reverse_transform(transform:tuple[float,...]) -> tuple[float,...]:
1005  """Return inverse shapely affine transform of the supplied transform.
1006
1007  Args:
1008    transform (list[float]): the transform for which the inverse is desired.
1009
1010  Returns:
1011    list[float]: shapely affine transform tuple that will invert the supplied
1012      transform.
1013
1014  """
1015  return as_shapely_transform(
1016    np.linalg.inv(as_numpy_matrix(transform)))

Return inverse shapely affine transform of the supplied transform.

Args: transform (list[float]): the transform for which the inverse is desired.

Returns: list[float]: shapely affine transform tuple that will invert the supplied transform.

def as_shapely_transform(arr: numpy.ndarray) -> tuple[float, ...]:
1019def as_shapely_transform(arr:np.ndarray) -> tuple[float,...]:
1020  """Return shapely affine transform equivalent to supplied numpy matrix.
1021
1022  The numpy matrix is a conventional augmented (i.e. 3x3) affine transform
1023  matrix.
1024
1025  Args:
1026    arr (np.array): augmented affine transform matrix of the desired
1027      transform.
1028
1029  Returns:
1030    list[float]: desired shapely affine transform list of floats.
1031
1032  """
1033  return (arr[0][0], arr[0][1], arr[1][0], arr[1][1], arr[0][2], arr[1][2])

Return shapely affine transform equivalent to supplied numpy matrix.

The numpy matrix is a conventional augmented (i.e. 3x3) affine transform matrix.

Args: arr (np.array): augmented affine transform matrix of the desired transform.

Returns: list[float]: desired shapely affine transform list of floats.

def as_numpy_matrix(transform: tuple[float, ...]) -> numpy.ndarray:
1036def as_numpy_matrix(transform:tuple[float,...]) -> np.ndarray:
1037  """Convert shapely affine transform to augmented affine transform matrix.
1038
1039  Args:
1040    transform (list[float]): the transform in shapely format.
1041
1042  Returns:
1043    np.array: the transform in numpy matrix format.
1044
1045  """
1046  return np.array([[transform[0], transform[1], transform[4]],
1047                   [transform[2], transform[3], transform[5]],
1048                   [           0,            0,            1]])

Convert shapely affine transform to augmented affine transform matrix.

Args: transform (list[float]): the transform in shapely format.

Returns: np.array: the transform in numpy matrix format.

class StraightLine(typing.NamedTuple):
1051class StraightLine(NamedTuple):
1052  """Straight line in canonical Ax+By+C=0 form."""
1053
1054  A: float
1055  B: float
1056  C: float

Straight line in canonical Ax+By+C=0 form.

StraightLine(A: float, B: float, C: float)

Create new instance of StraightLine(A, B, C)

A: float

Alias for field number 0

B: float

Alias for field number 1

C: float

Alias for field number 2

def get_straight_line( p1: shapely.geometry.point.Point, p2: shapely.geometry.point.Point, perpendicular: bool = False) -> StraightLine:
1059def get_straight_line(
1060    p1:geom.Point, p2:geom.Point,
1061    perpendicular:bool = False,
1062  ) -> StraightLine:
1063  """Return StraightLine p1-p2, optionally perpendicular through the midpoint.
1064
1065  Args:
1066    p1 (geom.Point): First point.
1067    p2 (geom.Point): Second point
1068    perpendicular (bool, optional): If True the perpendicular bisector is
1069      returned. Defaults to False.
1070
1071  Returns:
1072    StraightLine: _description_
1073
1074  """
1075  if perpendicular:
1076    ls = affine.rotate(geom.LineString([p1, p2]), 90)
1077    pts = list(ls.coords)
1078    x1, y1 = pts[0]
1079    x2, y2 = pts[1]
1080  else:
1081    x1, y1 = p1.x, p1.y
1082    x2, y2 = p2.x, p2.y
1083  return StraightLine(y1 - y2, x2 - x1, x1 * y2 - x2 * y1)

Return StraightLine p1-p2, optionally perpendicular through the midpoint.

Args: p1 (geom.Point): First point. p2 (geom.Point): Second point perpendicular (bool, optional): If True the perpendicular bisector is returned. Defaults to False.

Returns: StraightLine: _description_

def get_intersection( line1: StraightLine, line2: StraightLine) -> shapely.geometry.point.Point | None:
1086def get_intersection(line1:StraightLine,
1087                     line2:StraightLine) -> geom.Point|None:
1088  """Return point of intersection of straight lines.
1089
1090  Args:
1091    line1 (StraightLine): First straight line.
1092    line2 (StraightLine): Second straight line.
1093
1094  Returns:
1095    geom.Point|None: Point is returned if the lines intersect, otherwise None.
1096
1097  """
1098  x_set, y_set = False, False
1099  denominator = line1.A * line2.B - line2.A * line1.B
1100  if np.isclose(line1.A, 0, atol = 1e-4, rtol = 1e-4):
1101    y = -line1.C / line1.B
1102    y_set = True
1103  elif np.isclose(line2.A, 0, atol = 1e-4, rtol = 1e-4):
1104    y = -line2.C / line2.B
1105    y_set = True
1106  if np.isclose(line1.B, 0, atol = 1e-4, rtol = 1e-4):
1107    x = -line1.C / line1.A
1108    x_set = True
1109  elif np.isclose(line2.B, 0, atol = 1e-4, rtol = 1e-4):
1110    x = -line2.C / line2.A
1111    x_set = True
1112  if np.isclose(denominator, 0, atol = 1e-4, rtol = 1e-4):
1113    return None
1114  x = x if x_set else (line1.B * line2.C - line2.B * line1.C) / denominator
1115  y = y if y_set else (line1.C * line2.A - line2.C * line1.A) / denominator
1116  return geom.Point(x, y)

Return point of intersection of straight lines.

Args: line1 (StraightLine): First straight line. line2 (StraightLine): Second straight line.

Returns: geom.Point|None: Point is returned if the lines intersect, otherwise None.