diff --git a/docs/api/tools.md b/docs/api/tools.md index 094af92..76e22a8 100644 --- a/docs/api/tools.md +++ b/docs/api/tools.md @@ -16,11 +16,11 @@ Great-circle distances, bearings, and coordinate calculations. ::: minisky.tools.geo -## Area filter +## Shapes -Named geographic areas (boxes, circles, polygons) used by area-based commands. +Named geographic areas (boxes, circles, polygons) and graphical lines. -::: minisky.tools.areafilter +::: minisky.tools.shapes ## Navigation database diff --git a/docs/architecture.md b/docs/architecture.md index 9da8e4b..0909b55 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,7 +16,7 @@ Constructing `MiniSky` creates an independent object graph: | [`runtime.navigation`][minisky.tools.navdata.Navdatabase] | [`Navdatabase`][minisky.tools.navdata.Navdatabase] | Waypoints, airports, and airways | | [`runtime.commands`][minisky.stack.CommandStack] | [`CommandStack`][minisky.stack.CommandStack] | Command registry, queue, and scenario state | | [`runtime.plugins`][minisky.plugin.plugin.PluginManager] | [`PluginManager`][minisky.plugin.plugin.PluginManager] | Plugin declarations, hooks, and lifespans | -| `runtime.areas` | [`AreaFilter`][minisky.tools.areafilter.AreaFilter] | Named geographic areas | +| `runtime.shapes` | [`Shapes`][minisky.tools.shapes.Shapes] | Named geographic areas and graphical lines | | `runtime.variables` | [`VariableExplorer`][minisky.core.varexplorer.VariableExplorer] | Runtime data inspection | | `runtime.streaming` | `StreamHub` | Rate-capped snapshot fan-out | diff --git a/packages/minisky/minisky/runtime.py b/packages/minisky/minisky/runtime.py index 1447945..11c0a78 100644 --- a/packages/minisky/minisky/runtime.py +++ b/packages/minisky/minisky/runtime.py @@ -14,9 +14,9 @@ from minisky.simulation import ConsoleIO, Runner, Simulation, SimulationState from minisky.stack import CommandStack from minisky.streaming import StreamHub, build_snapshot -from minisky.tools.areafilter import AreaFilter from minisky.tools.geo_commands import GeoCommands from minisky.tools.navdata import Navdatabase +from minisky.tools.shapes import Shapes from minisky.traffic import Traffic from minisky.traffic.activewpdata import ActiveWaypoint from minisky.traffic.aporasas import APorASAS @@ -51,13 +51,13 @@ def __init__( self.numpy_random = np.random.RandomState() self.console = ConsoleIO(lambda: self.simulation.state == SimulationState.OP) self.navigation = Navdatabase(data("navigation"), self.console) - self.areas = AreaFilter() + self.shapes = Shapes() self.variables = VariableExplorer() self.traffic = Traffic( config=config, python_random=self.python_random, numpy_random=self.numpy_random, - areas=self.areas, + shapes=self.shapes, navigation=self.navigation, console=self.console, get_simulation=lambda: self.simulation, @@ -89,7 +89,7 @@ def __init__( traffic=self.traffic, navigation=self.navigation, console=self.console, - areas=self.areas, + shapes=self.shapes, variables=self.variables, plugins=self.plugins, replaceables=self.replaceables, @@ -106,7 +106,7 @@ def __init__( numpy_random=self.numpy_random, console=self.console, command_stack=self.commands, - areas=self.areas, + shapes=self.shapes, plugins=self.plugins, replaceables=self.replaceables, stop_runner=self._stop_runner, @@ -120,7 +120,7 @@ def __init__( ( self.console, self.navigation, - self.areas, + self.shapes, self.variables, self.traffic, self.traffic.cond, diff --git a/packages/minisky/minisky/simulation/simulation.py b/packages/minisky/minisky/simulation/simulation.py index 04f4b16..247da20 100644 --- a/packages/minisky/minisky/simulation/simulation.py +++ b/packages/minisky/minisky/simulation/simulation.py @@ -36,8 +36,8 @@ from minisky.plugin import PluginManager from minisky.simulation.console import ConsoleIO from minisky.stack import CommandStack - from minisky.tools.areafilter import AreaFilter from minisky.tools.navdata import Navdatabase + from minisky.tools.shapes import Shapes from minisky.traffic import Traffic @@ -124,7 +124,7 @@ def __init__( numpy_random: np.random.RandomState, console: ConsoleIO, command_stack: CommandStack, - areas: AreaFilter, + shapes: Shapes, plugins: PluginManager, replaceables: ReplaceableManager, stop_runner: Callable[[], None], @@ -136,7 +136,7 @@ def __init__( self.numpy_random = numpy_random self.console = console self.commands = command_stack - self.areas = areas + self.shapes = shapes self.plugins = plugins self.replaceables = replaceables self.stop_runner = stop_runner @@ -267,7 +267,7 @@ def reset(self) -> None: self.navigation.reset() self.traffic.reset() self.commands.reset() - self.areas.reset() + self.shapes.reset() self.console.reset() # Reset replaceables (Autopilot, PerfBase, etc.) to defaults self.replaceables.reset() diff --git a/packages/minisky/minisky/stack/__init__.py b/packages/minisky/minisky/stack/__init__.py index 2a00914..e092f10 100644 --- a/packages/minisky/minisky/stack/__init__.py +++ b/packages/minisky/minisky/stack/__init__.py @@ -62,8 +62,8 @@ from minisky.core.varexplorer import VariableExplorer from minisky.plugin import PluginManager from minisky.simulation import ConsoleIO, Runner, Simulation - from minisky.tools.areafilter import AreaFilter from minisky.tools.navdata import Navdatabase + from minisky.tools.shapes import Shapes from minisky.traffic import Traffic @@ -260,7 +260,7 @@ def __init__( traffic: Traffic, navigation: Navdatabase, console: ConsoleIO, - areas: AreaFilter, + shapes: Shapes, variables: VariableExplorer, plugins: PluginManager, replaceables: ReplaceableManager, @@ -271,7 +271,7 @@ def __init__( self.traffic = traffic self.navigation = navigation self.console = console - self.areas = areas + self.shapes = shapes self.variables = variables self.plugins = plugins self.replaceables = replaceables @@ -500,10 +500,15 @@ def delete_element(self, target: Keyword, *additional_targets: Keyword) -> Resul self.traffic.wind.clear() return Ok("") - if not stored_group and not exact_aircraft and self.areas.has_area(first): + if not stored_group and not exact_aircraft and first in self.shapes.areas: if len(targets) != 1: return Err("An area cannot be combined with other DEL targets") - return self.areas.deleteArea(first) + return self.shapes.delete(first) + + if not stored_group and not exact_aircraft and first in self.shapes.lines: + if len(targets) != 1: + return Err("A line cannot be combined with other DEL targets") + return self.shapes.delete(first) indices: list[int] = [] for target_name in targets: diff --git a/packages/minisky/minisky/tools/areafilter.py b/packages/minisky/minisky/tools/areafilter.py deleted file mode 100644 index 771df9e..0000000 --- a/packages/minisky/minisky/tools/areafilter.py +++ /dev/null @@ -1,317 +0,0 @@ -"""Area filter module - -Defines named geometric shapes - boxes, circles, polygons, and lines - on -the map, optionally bounded by a top and bottom altitude, and provides -point-inside-shape tests for (vectors of) aircraft positions. This backs -the BOX, CIRCLE, POLY, POLYALT, LINE, and POLYLINE stack commands, and is -used by plugins and traffic logic that need to know which aircraft are -inside an area. Each `AreaFilter` stores its defined shapes by name. -""" - -from __future__ import annotations - -import numpy as np -from matplotlib.path import Path - -from minisky.command import AltM, Keyword, LatLonDeg, LatLonDegrees, command -from minisky.result import Err, Ok, Result -from minisky.tools.geo import kwikdist - - -class AreaFilter: - """Named geometric shapes for a MiniSky runtime.""" - - def __init__(self) -> None: - # Dictionary of all basic shapes (The shape classes defined in this file) by name - self.basic_shapes: dict[str, Shape] = {} - - def has_area(self, areaname: str) -> bool: - """Check if area with name 'areaname' exists.""" - return areaname in self.basic_shapes - - def define_area( - self, - areaname: str, - areatype: str, - coordinates: tuple[float, ...] | list[float], - top: float = 1e9, - bottom: float = -1e9, - ) -> Result[str, str]: - """Define a new area, or list/inspect existing areas. - - Args: - areaname: Name of the area, or "LIST" to list all defined shapes. - areatype: Shape type: "BOX", "CIRCLE", "POLY"/"POLYALT", or "LINE". - coordinates: Flat sequence of lat/lon pairs [deg]; for a circle: - (lat [deg], lon [deg], radius [nm]). When empty, information - about the existing area with the given name is returned. - top: Top altitude bound [m] (default: effectively unbounded). - bottom: Bottom altitude bound [m] (default: effectively unbounded). - """ - if areaname == "LIST": - if not self.basic_shapes: - return Ok("No shapes are currently defined.") - else: - return Ok("Currently defined shapes:\n" + ", ".join(self.basic_shapes)) - if not coordinates: - if areaname in self.basic_shapes: - return Ok(str(self.basic_shapes[areaname])) - else: - return Err(f"Unknown shape: {areaname}") - - if areatype == "BOX": - shape = Box(areaname, coordinates, top, bottom) - elif areatype == "CIRCLE": - shape = Circle(areaname, coordinates, top, bottom) - elif areatype[:4] == "POLY": - shape = Poly(areaname, coordinates, top, bottom) - elif areatype == "LINE": - shape = Line(areaname, coordinates) - else: - return Err(f"Unknown shape type: {areatype}") - - self.basic_shapes[areaname] = shape - return Ok(f"Created {areatype} {areaname}") - - @staticmethod - def _coordinates(points: tuple[LatLonDegrees, ...]) -> tuple[float, ...]: - return tuple(value for point in points for value in (point.lat, point.lon)) - - @command(name="BOX") - def define_box_area( - self, - name: Keyword, - first: LatLonDeg, - second: LatLonDeg, - top: AltM = 1e9, - bottom: AltM = -1e9, - ) -> Result[str, str]: - """Define a box-shaped area from two opposite corners.""" - return self.define_area(name, "BOX", self._coordinates((first, second)), top, bottom) - - @command(name="CIRCLE") - def define_circle_area( - self, - name: Keyword, - center: LatLonDeg, - radius: float, - top: AltM = 1e9, - bottom: AltM = -1e9, - ) -> Result[str, str]: - """Define a circular area from a center and radius in nautical miles.""" - return self.define_area( - name, "CIRCLE", (*self._coordinates((center,)), radius), top, bottom - ) - - @command(name="LINE") - def define_line_area(self, name: Keyword, start: LatLonDeg, end: LatLonDeg) -> Result[str, str]: - """Draw a line between two positions.""" - return self.define_area(name, "LINE", self._coordinates((start, end))) - - @command(name="POLY", aliases=("POLYGON",)) - def define_poly_area(self, name: Keyword, *points: LatLonDeg) -> Result[str, str]: - """Define a polygon from position vertices.""" - return self.define_area(name, "POLY", self._coordinates(points)) - - @command(name="POLYALT") - def define_polyalt_area( - self, - name: Keyword, - top: AltM, - bottom: AltM, - first: LatLonDeg, - *additional: LatLonDeg, - ) -> Result[str, str]: - """Define a polygon between top and bottom altitudes.""" - return self.define_area( - name, "POLYALT", self._coordinates((first, *additional)), top, bottom - ) - - @command(name="POLYLINE", aliases=("LINES", "POLYLINES")) - def define_polyline_area( - self, name: Keyword, first: LatLonDeg, *additional: LatLonDeg - ) -> Result[str, str]: - """Draw a multi-segment line through position vertices.""" - return self.define_area(name, "LINE", self._coordinates((first, *additional))) - - def checkInside( - self, areaname: str, lat: np.ndarray, lon: np.ndarray, alt: np.ndarray - ) -> np.ndarray: - """Check if points with coordinates lat, lon, alt are inside area with name 'areaname'. - - Args: - areaname: Name of the area to test against. - lat: Latitude(s) [deg]. - lon: Longitude(s) [deg]. - alt: Altitude(s) [m]. - - Returns: - Array of booleans, True == Inside. All False when no area with - the given name exists. - """ - if areaname not in self.basic_shapes: - return np.zeros(len(lat), dtype=bool) - area = self.basic_shapes[areaname] - return area.checkInside(lat, lon, alt) - - def reset(self) -> None: - """Clear all data.""" - self.basic_shapes.clear() - - def deleteArea(self, name: str) -> Result[str, str]: - """Delete a previously defined area by name. - - Args: - name: Name of the area shape to remove. - """ - if self.basic_shapes.pop(name, None) is not None: - return Ok(f"Area {name} deleted.") - return Err(f"No area found with name {name}.") - - -class Shape: - """ - Base class of BlueSky shapes - - Handles the naming and altitude bounds common to all shape types. - Derived classes implement checkInside() for their specific geometry. - - Attributes: - name: Area name. - coordinates: Flat list of lat/lon coordinates in deg defining the - shape (plus radius in nm for circles). - top: Upper altitude bound [m]. - bottom: Lower altitude bound [m]. - raw: Dictionary with the raw shape definition (name, kind, - coordinates). - """ - - def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e9) -> None: - self.raw = {"name": name, "shape": self.kind(), "coordinates": coordinates} - self.name = name - self.coordinates = coordinates - self.top = np.maximum(bottom, top) - self.bottom = np.minimum(bottom, top) - - def checkInside(self, lat: np.ndarray, lon: np.ndarray, alt: np.ndarray) -> np.ndarray: - """Returns True (or boolean array) if coordinate lat, lon, alt lies - within this shape. - - Reimplement this function in the derived shape classes for this to - work. - """ - return np.zeros(len(lat), dtype=bool) - - def _str_vrange(self) -> str: - if self.top < 9e8: - if self.bottom > -9e8: - return f" with altitude between {self.bottom} and {self.top}" - else: - return f" with altitude below {self.top}" - if self.bottom > -9e8: - return f" with altitude above {self.bottom}" - return "" - - def __str__(self) -> str: - return ( - f"{self.name} is a {self.raw['shape']} with coordinates " - + ", ".join(str(c) for c in self.coordinates) - + self._str_vrange() - ) - - @classmethod - def kind(cls) -> str: - """Return a string describing what kind of shape this is.""" - return cls.__name__.upper() - - -class Line(Shape): - """A line shape between two lat/lon positions [deg]. - - Purely graphical: the inherited checkInside() always returns False. - """ - - def __init__(self, name: str, coordinates) -> None: - super().__init__(name, coordinates) - - def __str__(self) -> str: - return ( - f"{self.name} is a LINE with " - f"start point ({self.coordinates[0]}, {self.coordinates[1]}), " - f"and end point ({self.coordinates[2]}, {self.coordinates[3]})." - ) - - -class Box(Shape): - """A lat/lon-aligned box shape. - - Defined by two opposite corner points [deg] (sorted at construction) - and optional altitude bounds [m]. - """ - - def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e9) -> None: - super().__init__(name, coordinates, top, bottom) - # Sort the order of the corner points - self.lat0 = min(coordinates[0], coordinates[2]) - self.lon0 = min(coordinates[1], coordinates[3]) - self.lat1 = max(coordinates[0], coordinates[2]) - self.lon1 = max(coordinates[1], coordinates[3]) - - def checkInside(self, lat: np.ndarray, lon: np.ndarray, alt: np.ndarray): - """Return whether points (lat [deg], lon [deg], alt [m]) lie inside this box.""" - return ( - ((self.lat0 <= lat) & (lat <= self.lat1)) - & ((self.lon0 <= lon) & (lon <= self.lon1)) - & ((self.bottom <= alt) & (alt <= self.top)) - ) - - -class Circle(Shape): - """A circle shape. - - Defined by a center position [deg], a radius [nm], and optional - altitude bounds [m]. - """ - - def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e9) -> None: - super().__init__(name, coordinates, top, bottom) - self.clat = coordinates[0] - self.clon = coordinates[1] - self.r = coordinates[2] - - def checkInside(self, lat: np.ndarray, lon: np.ndarray, alt: np.ndarray): - """Return whether points (lat [deg], lon [deg], alt [m]) lie within - the circle radius [nm] and altitude bounds.""" - distance = kwikdist(self.clat, self.clon, lat, lon) # [NM] - inside = (distance <= self.r) & (self.bottom <= alt) & (alt <= self.top) - return inside - - def __str__(self) -> str: - return ( - f"{self.name} is a CIRCLE with " - f"center ({self.clat}, {self.clon}) " - f"and radius {self.r}." + self._str_vrange() - ) - - -class Poly(Shape): - """A polygon shape. - - Defined by a sequence of lat/lon vertices [deg] and optional altitude - bounds [m]; the border is stored as a matplotlib Path for fast - point-in-polygon tests. - """ - - def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e9) -> None: - super().__init__(name, coordinates, top, bottom) - self.border = Path(np.reshape(coordinates, (len(coordinates) // 2, 2))) - - def checkInside(self, lat: np.ndarray, lon: np.ndarray, alt: np.ndarray): - """Return whether points (lat [deg], lon [deg], alt [m]) lie inside - the polygon border and altitude bounds.""" - points = np.vstack((lat, lon)).T - inside = np.all( - (self.border.contains_points(points), self.bottom <= alt, alt <= self.top), - axis=0, - ) - return inside diff --git a/packages/minisky/minisky/tools/shapes.py b/packages/minisky/minisky/tools/shapes.py new file mode 100644 index 0000000..da24300 --- /dev/null +++ b/packages/minisky/minisky/tools/shapes.py @@ -0,0 +1,225 @@ +"""Named geographic areas and graphical lines.""" + +from __future__ import annotations + +from collections.abc import Mapping +from types import MappingProxyType +from typing import Protocol + +import numpy as np +import shapely + +from minisky.command import AltM, Keyword, LatLonDeg, LatLonDegrees, command +from minisky.result import Err, Ok, Result +from minisky.tools.geo import kwikdist + + +class Shapes: + """Own named containable areas and graphical lines.""" + + def __init__(self) -> None: + self._areas: dict[str, HasArea] = {} + self._lines: dict[str, Line] = {} + self.areas: Mapping[str, HasArea] = MappingProxyType(self._areas) + self.lines: Mapping[str, Line] = MappingProxyType(self._lines) + + # NOTE: we want area and line names to be mutually exclusive. + + def _store_area(self, name: str, area: HasArea) -> None: + self._lines.pop(name, None) + self._areas[name] = area + + def _store_line(self, name: str, line: Line) -> None: + self._areas.pop(name, None) + self._lines[name] = line + + @command(name="BOX") + def define_box( + self, + name: Keyword, + first: LatLonDeg, + second: LatLonDeg, + top: AltM = 1e9, + bottom: AltM = -1e9, + ) -> Result[str, str]: + """Define a box-shaped area from two opposite corners.""" + self._store_area(name, Box(first, second, top, bottom)) + return Ok(f"Created BOX {name}") + + @command(name="CIRCLE") + def define_circle( + self, + name: Keyword, + center: LatLonDeg, + radius: float, + top: AltM = 1e9, + bottom: AltM = -1e9, + ) -> Result[str, str]: + """Define a circular area from a center and radius in nautical miles.""" + self._store_area(name, Circle(center, radius, top, bottom)) + return Ok(f"Created CIRCLE {name}") + + @command(name="LINE") + def define_line(self, name: Keyword, start: LatLonDeg, end: LatLonDeg) -> Result[str, str]: + """Draw a line between two positions.""" + self._store_line(name, Line((start, end))) + return Ok(f"Created LINE {name}") + + @command(name="POLY", aliases=("POLYGON",)) + def define_poly(self, name: Keyword, *points: LatLonDeg) -> Result[str, str]: + """Define a polygon from position vertices.""" + try: + area = Poly(points) + except ValueError as error: + return Err(str(error)) + self._store_area(name, area) + return Ok(f"Created POLY {name}") + + @command(name="POLYALT") + def define_polyalt( + self, + name: Keyword, + top: AltM, + bottom: AltM, + first: LatLonDeg, + *additional: LatLonDeg, + ) -> Result[str, str]: + """Define a polygon between top and bottom altitudes.""" + try: + area = Poly((first, *additional), top, bottom) + except ValueError as error: + return Err(str(error)) + self._store_area(name, area) + return Ok(f"Created POLYALT {name}") + + @command(name="POLYLINE", aliases=("LINES", "POLYLINES")) + def define_polyline( + self, name: Keyword, first: LatLonDeg, *additional: LatLonDeg + ) -> Result[str, str]: + """Draw a multi-segment line through position vertices.""" + self._store_line(name, Line((first, *additional))) + return Ok(f"Created POLYLINE {name}") + + def reset(self) -> None: + self._areas.clear() + self._lines.clear() + + def delete(self, name: str) -> Result[str, str]: + if self._areas.pop(name, None) is not None: + return Ok(f"Area {name} deleted.") + if self._lines.pop(name, None) is not None: + return Ok(f"Line {name} deleted.") + return Err(f"No shape found with name {name}.") + + +class HasArea(Protocol): + """An area shape that supports point-inside tests. + + Lines are deliberately not part of this protocol: a line has zero area, + so asking whether an aircraft is inside one is a category error. + """ + + def contains(self, lat: np.ndarray, lon: np.ndarray, alt: np.ndarray) -> np.ndarray: + """Return whether points (lat [deg], lon [deg], alt [m]) lie inside + this shape's geometry and altitude bounds.""" + ... + + +# NOTE(abraham): it seems like no one is using line so we should consider +# removing it. maybe it came from the old GUI that we removed? +class Line: + """A graphical line through latitude/longitude positions.""" + + def __init__(self, points: tuple[LatLonDegrees, ...]) -> None: + self.points = points + + +# TODO(abraham): stop using sentinels to indicate the lack of top / bottom. +# see issue #40 + + +class Box(HasArea): + """A lat/lon-aligned box shape. + + Defined by two opposite corner points [deg] and optional altitude bounds [m]. + """ + + def __init__( + self, first: LatLonDegrees, second: LatLonDegrees, top: float = 1e9, bottom: float = -1e9 + ) -> None: + self.top = np.maximum(bottom, top) + self.bottom = np.minimum(bottom, top) + self.lat0 = min(first.lat, second.lat) + self.lon0 = min(first.lon, second.lon) + self.lat1 = max(first.lat, second.lat) + self.lon1 = max(first.lon, second.lon) + + def contains(self, lat: np.ndarray, lon: np.ndarray, alt: np.ndarray) -> np.ndarray: + """Return whether points (lat [deg], lon [deg], alt [m]) lie inside this box.""" + return ( + ((self.lat0 <= lat) & (lat <= self.lat1)) + & ((self.lon0 <= lon) & (lon <= self.lon1)) + & ((self.bottom <= alt) & (alt <= self.top)) + ) + + +class Circle(HasArea): + """A circle shape. + + Defined by a center position [deg], a radius [nm], and optional + altitude bounds [m]. + """ + + def __init__( + self, center: LatLonDegrees, radius: float, top: float = 1e9, bottom: float = -1e9 + ) -> None: + self.center = center + self.radius = radius + self.top = np.maximum(bottom, top) + self.bottom = np.minimum(bottom, top) + + def contains(self, lat: np.ndarray, lon: np.ndarray, alt: np.ndarray) -> np.ndarray: + """Return whether points (lat [deg], lon [deg], alt [m]) lie within + the circle radius [nm] and altitude bounds.""" + distance = kwikdist(self.center.lat, self.center.lon, lat, lon) # [NM] + return (distance <= self.radius) & (self.bottom <= alt) & (alt <= self.top) + + +class Poly(HasArea): + """A polygon shape. + + Defined by a sequence of lat/lon vertices [deg] and optional altitude + bounds [m]. Longitudes are unwrapped before planar Shapely containment; + polygons that touch or enclose a pole are rejected. + """ + + def __init__( + self, points: tuple[LatLonDegrees, ...], top: float = 1e9, bottom: float = -1e9 + ) -> None: + self.top = np.maximum(bottom, top) + self.bottom = np.minimum(bottom, top) + vertices = np.asarray([(point.lat, point.lon) for point in points], dtype=float) + if len(vertices) < 3: + raise ValueError("Polygon requires at least three vertices") + if np.any(np.isclose(np.abs(vertices[:, 0]), 90.0)): + raise ValueError("Polygon must not touch a pole") + + lon = vertices[:, 1] + unwrapped_ring = np.unwrap(np.append(lon, lon[0]), period=360.0) + if not np.isclose(unwrapped_ring[-1], unwrapped_ring[0]): + raise ValueError("Polygon must not enclose a pole") + unwrapped_lon = unwrapped_ring[:-1] + self._reference_lon = float((unwrapped_lon.min() + unwrapped_lon.max()) * 0.5) + self._geom = shapely.Polygon(np.column_stack((vertices[:, 0], unwrapped_lon))) + if not shapely.is_valid(self._geom): + raise ValueError(f"Invalid polygon: {shapely.is_valid_reason(self._geom)}") + + def contains(self, lat: np.ndarray, lon: np.ndarray, alt: np.ndarray) -> np.ndarray: + """Return whether points (lat [deg], lon [deg], alt [m]) lie inside + the polygon border and altitude bounds.""" + unwrapped_lon = lon + 360.0 * np.floor((self._reference_lon - lon + 180.0) / 360.0) + return ( + shapely.contains_xy(self._geom, lat, unwrapped_lon) + & (self.bottom <= alt) + & (alt <= self.top) + ) diff --git a/packages/minisky/minisky/traffic/traffic.py b/packages/minisky/minisky/traffic/traffic.py index 1babc68..16fe6b7 100644 --- a/packages/minisky/minisky/traffic/traffic.py +++ b/packages/minisky/minisky/traffic/traffic.py @@ -61,8 +61,8 @@ vatmos, vcasormach, ) -from minisky.tools.areafilter import AreaFilter from minisky.tools.convert import latlon2txt +from minisky.tools.shapes import Shapes from minisky.traffic.asas import ConflictDetection, ConflictResolution from .activewpdata import ActiveWaypoint @@ -178,7 +178,7 @@ def __init__( config: MiniSkyConfig, python_random: Random, numpy_random: np.random.RandomState, - areas: AreaFilter, + shapes: Shapes, navigation: Navdatabase, console: ConsoleIO, get_simulation: Callable[[], Simulation], @@ -189,7 +189,7 @@ def __init__( self.config = config self.python_random = python_random self.numpy_random = numpy_random - self.areas = areas + self.shapes = shapes self.navigation = navigation self.console = console self._get_simulation = get_simulation @@ -263,7 +263,7 @@ def __init__( self.kinematics = Kinematics(self, get_simulation) # Group Logic - self.groups = TrafficGroups(self, areas) + self.groups = TrafficGroups(self, shapes) # Traffic autothrottle settings self.swats = np.array( diff --git a/packages/minisky/minisky/traffic/trafficgroups.py b/packages/minisky/minisky/traffic/trafficgroups.py index 149db80..24aecf8 100644 --- a/packages/minisky/minisky/traffic/trafficgroups.py +++ b/packages/minisky/minisky/traffic/trafficgroups.py @@ -30,7 +30,7 @@ from minisky.result import Err, Ok, Result if TYPE_CHECKING: - from minisky.tools.areafilter import AreaFilter + from minisky.tools.shapes import Shapes from minisky.traffic.traffic import Traffic @@ -44,10 +44,10 @@ class TrafficGroups(TrafficArrays): _ALL_NAMES = frozenset({"*", "ALL"}) - def __init__(self, traffic: Traffic, areas: AreaFilter) -> None: + def __init__(self, traffic: Traffic, shapes: Shapes) -> None: super().__init__(traffic) self.traffic = traffic - self.areas = areas + self.shapes = shapes self.groups: dict[str, int] = {} self.allmasks = 0 with self.settrafarrays(): @@ -55,7 +55,7 @@ def __init__(self, traffic: Traffic, areas: AreaFilter) -> None: def new_implementation(self, implementation: Callable[..., TrafficArrays]) -> TrafficArrays: """Construct a replacement with this runtime's traffic and area store.""" - return implementation(self.traffic, self.areas) + return implementation(self.traffic, self.shapes) def __contains__(self, groupname: str) -> bool: """Return whether a stored or virtual group exists.""" @@ -100,7 +100,7 @@ def _member_indices(self, members: tuple[str, ...]) -> Result[np.ndarray, str]: case Ok(selection): indices.extend(int(index) for index in selection) case Err(error): - if self.areas.has_area(member): + if member in self.shapes.areas: return Err("Area names cannot be combined with aircraft or groups") return Err(error) return Ok(np.unique(np.asarray(indices, dtype=int))) @@ -132,10 +132,13 @@ def add_to_group( sole_is_selection = sole_member is not None and ( self.traffic.idx(sole_member) >= 0 or sole_member in self ) - if sole_member is not None and not sole_is_selection and self.areas.has_area(sole_member): - inside = self.areas.checkInside( - sole_member, self.traffic.lat, self.traffic.lon, self.traffic.alt - ) + area = ( + self.shapes.areas.get(sole_member) + if sole_member is not None and not sole_is_selection + else None + ) + if area is not None: + inside = area.contains(self.traffic.lat, self.traffic.lon, self.traffic.alt) indices = np.flatnonzero(inside) else: match self._member_indices(members): diff --git a/packages/minisky/pyproject.toml b/packages/minisky/pyproject.toml index f2cfd0c..25628f9 100644 --- a/packages/minisky/pyproject.toml +++ b/packages/minisky/pyproject.toml @@ -8,7 +8,6 @@ dependencies = [ "typer>=0.15.0", "colorama>=0.4.6", "fastapi[standard]>=0.115.7", - "matplotlib>=3.10.0", "numpy>=2.2.2", "openap>=2.4", "pandas>=2.2.3", @@ -17,6 +16,7 @@ dependencies = [ "pyarrow>=19.0.1", "requests>=2.32.3", "scipy>=1.15.1", + "shapely>=2.0", ] [project.scripts] diff --git a/packages/minisky/tests/integration/test_stack.py b/packages/minisky/tests/integration/test_stack.py index f87f272..4e01c3f 100644 --- a/packages/minisky/tests/integration/test_stack.py +++ b/packages/minisky/tests/integration/test_stack.py @@ -193,7 +193,12 @@ def test_area_commands_keep_multi_token_aviation_values(self, runtime: MiniSky) result = runtime.commands.cmddict["BOX"]("TEST N52 E004 N53 E005 FL100 FL200") assert isinstance(result, (Ok, Err)) assert result.is_ok(), result.err() - assert runtime.areas.has_area("TEST") + assert "TEST" in runtime.shapes.areas + + result = runtime.commands.cmddict["LINE"]("TRACK N52 E004 N53 E005") + assert isinstance(result, (Ok, Err)) + assert result.is_ok(), result.err() + assert "TRACK" in runtime.shapes.lines def test_group_selection_is_kept_for_compatible_commands( self, runtime: MiniSky, run_cmd: RunCommand diff --git a/packages/minisky/tests/unit/test_areafilter.py b/packages/minisky/tests/unit/test_areafilter.py deleted file mode 100644 index 69b025e..0000000 --- a/packages/minisky/tests/unit/test_areafilter.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Unit tests for minisky.tools.areafilter (geometric areas). - -Coordinates are flat [lat, lon, ...] lists; circle radius is in NM; -altitudes in meters (default vertical range is unbounded). -""" - -import numpy as np -import pytest -from minisky.tools.areafilter import AreaFilter - - -@pytest.fixture -def area_filter() -> AreaFilter: - return AreaFilter() - - -def check_single( - area_filter: AreaFilter, name: str, lat: float, lon: float, alt: float = 0.0 -) -> bool: - return bool(area_filter.checkInside(name, np.array([lat]), np.array([lon]), np.array([alt]))[0]) - - -class TestDefineArea: - def test_define_box_and_has_area(self, area_filter: AreaFilter) -> None: - result = area_filter.define_area("BOX1", "BOX", [52.0, 4.0, 53.0, 5.0]) - assert result.is_ok() - assert area_filter.has_area("BOX1") - - def test_unknown_area_absent(self, area_filter: AreaFilter) -> None: - assert not area_filter.has_area("NOPE") - - def test_checkinside_unknown_area_returns_false(self, area_filter: AreaFilter) -> None: - result = area_filter.checkInside("NOPE", np.array([52.0]), np.array([4.0]), np.array([0.0])) - assert not result.any() - - def test_reset_clears_areas(self, area_filter: AreaFilter) -> None: - area_filter.define_area("TMP", "BOX", [52.0, 4.0, 53.0, 5.0]) - area_filter.reset() - assert not area_filter.has_area("TMP") - - def test_unknown_shape_type_is_err(self, area_filter: AreaFilter) -> None: - result = area_filter.define_area("X", "BLOB", [52.0, 4.0, 53.0, 5.0]) - assert result.is_err() - assert not area_filter.has_area("X") - - -class TestTracking: - def test_each_shape_type_tracked(self, area_filter: AreaFilter) -> None: - area_filter.define_area("B", "BOX", [52.0, 4.0, 53.0, 5.0]) - area_filter.define_area("C", "CIRCLE", [52.0, 4.0, 50.0]) - area_filter.define_area("P", "POLY", [52.0, 4.0, 53.0, 4.0, 52.5, 5.0]) - area_filter.define_area("L", "LINE", [52.0, 4.0, 53.0, 5.0]) - for name in ("B", "C", "P", "L"): - assert area_filter.has_area(name) - - def test_list_reports_defined_shapes(self, area_filter: AreaFilter) -> None: - result = area_filter.define_area("LIST", "BOX", []) - assert result.is_ok() - assert "No shapes" in result.unwrap() - - area_filter.define_area("B1", "BOX", [52.0, 4.0, 53.0, 5.0]) - area_filter.define_area("C1", "CIRCLE", [52.0, 4.0, 50.0]) - listing = area_filter.define_area("LIST", "BOX", []).unwrap() - assert "B1" in listing - assert "C1" in listing - - def test_inspect_shape_by_name(self, area_filter: AreaFilter) -> None: - area_filter.define_area("C1", "CIRCLE", [52.0, 4.0, 50.0]) - result = area_filter.define_area("C1", "CIRCLE", []) - assert result.is_ok() - assert "CIRCLE" in result.unwrap() - - assert area_filter.define_area("NOPE", "BOX", []).is_err() - - def test_delete_area(self, area_filter: AreaFilter) -> None: - area_filter.define_area("TMP", "BOX", [52.0, 4.0, 53.0, 5.0]) - result = area_filter.deleteArea("TMP") - assert result.is_ok() - assert not area_filter.has_area("TMP") - assert not check_single(area_filter, "TMP", 52.5, 4.5) - - def test_delete_unknown_area_is_err(self, area_filter: AreaFilter) -> None: - assert area_filter.deleteArea("NOPE").is_err() - - def test_redefine_replaces_shape(self, area_filter: AreaFilter) -> None: - area_filter.define_area("B", "BOX", [52.0, 4.0, 53.0, 5.0]) - assert check_single(area_filter, "B", 52.5, 4.5) - - # Redefine the same name elsewhere; the old geometry must be gone - area_filter.define_area("B", "BOX", [10.0, 10.0, 11.0, 11.0]) - assert not check_single(area_filter, "B", 52.5, 4.5) - assert check_single(area_filter, "B", 10.5, 10.5) - - def test_redefine_can_change_shape_type(self, area_filter: AreaFilter) -> None: - area_filter.define_area("A", "BOX", [52.0, 4.0, 53.0, 5.0]) - area_filter.define_area("A", "CIRCLE", [52.0, 4.0, 50.0]) - assert "CIRCLE" in str(area_filter.basic_shapes["A"]) - - def test_delete_leaves_other_shapes(self, area_filter: AreaFilter) -> None: - area_filter.define_area("B1", "BOX", [52.0, 4.0, 53.0, 5.0]) - area_filter.define_area("B2", "BOX", [10.0, 10.0, 11.0, 11.0]) - area_filter.deleteArea("B1") - assert not area_filter.has_area("B1") - assert area_filter.has_area("B2") - assert check_single(area_filter, "B2", 10.5, 10.5) - - -class TestBox: - def test_inside_and_outside(self, area_filter: AreaFilter) -> None: - area_filter.define_area("B", "BOX", [52.0, 4.0, 53.0, 5.0]) - assert check_single(area_filter, "B", 52.5, 4.5) - assert not check_single(area_filter, "B", 51.0, 4.5) - assert not check_single(area_filter, "B", 52.5, 6.0) - - def test_altitude_bounds(self, area_filter: AreaFilter) -> None: - area_filter.define_area("B", "BOX", [52.0, 4.0, 53.0, 5.0], top=3000.0, bottom=1000.0) - assert check_single(area_filter, "B", 52.5, 4.5, alt=2000.0) - assert not check_single(area_filter, "B", 52.5, 4.5, alt=500.0) - assert not check_single(area_filter, "B", 52.5, 4.5, alt=5000.0) - - def test_array_input(self, area_filter: AreaFilter) -> None: - area_filter.define_area("B", "BOX", [52.0, 4.0, 53.0, 5.0]) - lat = np.array([52.5, 51.0, 52.9]) - lon = np.array([4.5, 4.5, 4.1]) - alt = np.zeros(3) - inside = area_filter.checkInside("B", lat, lon, alt) - assert inside.tolist() == [True, False, True] - - -class TestCircle: - def test_center_inside_far_point_outside(self, area_filter: AreaFilter) -> None: - # 50 NM radius around (52, 4) - area_filter.define_area("C", "CIRCLE", [52.0, 4.0, 50.0]) - assert check_single(area_filter, "C", 52.0, 4.0) - # ~0.5 deg lat is about 30 NM: inside - assert check_single(area_filter, "C", 52.5, 4.0) - # 2 deg lat is about 120 NM: outside - assert not check_single(area_filter, "C", 54.0, 4.0) - - -class TestPoly: - def test_triangle_centroid_inside(self, area_filter: AreaFilter) -> None: - # Triangle (52,4) (53,4) (52.5,5) - area_filter.define_area("P", "POLY", [52.0, 4.0, 53.0, 4.0, 52.5, 5.0]) - assert check_single(area_filter, "P", 52.5, 4.3) - assert not check_single(area_filter, "P", 52.5, 5.5) diff --git a/packages/minisky/tests/unit/test_shapes.py b/packages/minisky/tests/unit/test_shapes.py new file mode 100644 index 0000000..e5e6660 --- /dev/null +++ b/packages/minisky/tests/unit/test_shapes.py @@ -0,0 +1,77 @@ +"""Unit tests for geographic shape behavior.""" + +import numpy as np +import pytest +from minisky.command import LatLonDegrees +from minisky.tools.shapes import Box, Circle, HasArea, Poly, Shapes + + +def contains(shape: HasArea, lat: float, lon: float, alt: float = 0.0) -> bool: + return bool(shape.contains(np.array([lat]), np.array([lon]), np.array([alt]))[0]) + + +def test_mutually_exclusive_shape_names() -> None: + # the same name should not exist simultaneously in + # shapes.areas and shapes.lines + shapes = Shapes() + first = LatLonDegrees(52.0, 4.0) + second = LatLonDegrees(53.0, 5.0) + + shapes.define_box("A", first, second) + assert "A" in shapes.areas + assert "A" not in shapes.lines + + shapes.define_line("A", first, second) + assert "A" not in shapes.areas + assert "A" in shapes.lines + + shapes.define_box("A", first, second) + assert "A" in shapes.areas + assert "A" not in shapes.lines + + +# +# see issue #32 / PR #42 +# + + +def test_box_containment_includes_altitude_bounds() -> None: + box = Box(LatLonDegrees(52.0, 4.0), LatLonDegrees(53.0, 5.0), top=3000.0, bottom=1000.0) + + assert contains(box, 52.5, 4.5, 2000.0) + assert not contains(box, 51.0, 4.5, 2000.0) + assert not contains(box, 52.5, 4.5, 5000.0) + + +def test_circle_near_pole_uses_geographic_distance() -> None: + circle = Circle(LatLonDegrees(89.9, 0.0), 100.0) + + assert contains(circle, 89.9, 0.0) + assert not contains(circle, 87.9, 0.0) + + +def test_polygon_contains_points_across_antimeridian() -> None: + polygon = Poly( + ( + LatLonDegrees(10.0, 170.0), + LatLonDegrees(10.0, -170.0), + LatLonDegrees(-10.0, -170.0), + LatLonDegrees(-10.0, 170.0), + ) + ) + + assert contains(polygon, 0.0, 179.0) + assert contains(polygon, 0.0, -179.0) + assert not contains(polygon, 0.0, 0.0) + + +def test_polygon_enclosing_pole_is_rejected() -> None: + with pytest.raises(ValueError, match="Polygon must not enclose a pole"): + Poly( + ( + LatLonDegrees(80.0, -135.0), + LatLonDegrees(80.0, -45.0), + LatLonDegrees(80.0, 45.0), + LatLonDegrees(80.0, 135.0), + ) + ) diff --git a/uv.lock b/uv.lock index 0ed819c..48600b3 100644 --- a/uv.lock +++ b/uv.lock @@ -1258,7 +1258,6 @@ source = { editable = "packages/minisky" } dependencies = [ { name = "colorama" }, { name = "fastapi", extra = ["standard"] }, - { name = "matplotlib" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "openap" }, @@ -1269,6 +1268,7 @@ dependencies = [ { name = "requests" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "shapely" }, { name = "typer" }, ] @@ -1276,7 +1276,6 @@ dependencies = [ requires-dist = [ { name = "colorama", specifier = ">=0.4.6" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.7" }, - { name = "matplotlib", specifier = ">=3.10.0" }, { name = "numpy", specifier = ">=2.2.2" }, { name = "openap", specifier = ">=2.4" }, { name = "pandas", specifier = ">=2.2.3" }, @@ -1285,6 +1284,7 @@ requires-dist = [ { name = "pyarrow", specifier = ">=19.0.1" }, { name = "requests", specifier = ">=2.32.3" }, { name = "scipy", specifier = ">=1.15.1" }, + { name = "shapely", specifier = ">=2.0" }, { name = "typer", specifier = ">=0.15.0" }, ] @@ -2690,6 +2690,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/a8/3fb9a4319efa3b26f5be0e90e6d8918df43fa7c7e977d26390f589501d82/sentry_sdk-2.64.0-py3-none-any.whl", hash = "sha256:715ea91ca860a819e8d8a50a7bde3a80d0df3b4ed7b6660a20fb9a2d084188f1", size = 498901, upload-time = "2026-06-30T08:13:45.566Z" }, ] +[[package]] +name = "shapely" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/8d/1ff672dea9ec6a7b5d422eb6d095ed886e2e523733329f75fdcb14ee1149/shapely-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91121757b0a36c9aac3427a651a7e6567110a4a67c97edf04f8d55d4765f6618", size = 1820038, upload-time = "2025-09-24T13:50:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ce/28fab8c772ce5db23a0d86bf0adaee0c4c79d5ad1db766055fa3dab442e2/shapely-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16a9c722ba774cf50b5d4541242b4cce05aafd44a015290c82ba8a16931ff63d", size = 1626039, upload-time = "2025-09-24T13:50:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/70/8b/868b7e3f4982f5006e9395c1e12343c66a8155c0374fdc07c0e6a1ab547d/shapely-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cc4f7397459b12c0b196c9efe1f9d7e92463cbba142632b4cc6d8bbbbd3e2b09", size = 3001519, upload-time = "2025-09-24T13:50:18.606Z" }, + { url = "https://files.pythonhosted.org/packages/13/02/58b0b8d9c17c93ab6340edd8b7308c0c5a5b81f94ce65705819b7416dba5/shapely-2.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136ab87b17e733e22f0961504d05e77e7be8c9b5a8184f685b4a91a84efe3c26", size = 3110842, upload-time = "2025-09-24T13:50:21.77Z" }, + { url = "https://files.pythonhosted.org/packages/af/61/8e389c97994d5f331dcffb25e2fa761aeedfb52b3ad9bcdd7b8671f4810a/shapely-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:16c5d0fc45d3aa0a69074979f4f1928ca2734fb2e0dde8af9611e134e46774e7", size = 4021316, upload-time = "2025-09-24T13:50:23.626Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d4/9b2a9fe6039f9e42ccf2cb3e84f219fd8364b0c3b8e7bbc857b5fbe9c14c/shapely-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ddc759f72b5b2b0f54a7e7cde44acef680a55019eb52ac63a7af2cf17cb9cd2", size = 4178586, upload-time = "2025-09-24T13:50:25.443Z" }, + { url = "https://files.pythonhosted.org/packages/16/f6/9840f6963ed4decf76b08fd6d7fed14f8779fb7a62cb45c5617fa8ac6eab/shapely-2.1.2-cp311-cp311-win32.whl", hash = "sha256:2fa78b49485391224755a856ed3b3bd91c8455f6121fee0db0e71cefb07d0ef6", size = 1543961, upload-time = "2025-09-24T13:50:26.968Z" }, + { url = "https://files.pythonhosted.org/packages/38/1e/3f8ea46353c2a33c1669eb7327f9665103aa3a8dfe7f2e4ef714c210b2c2/shapely-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:c64d5c97b2f47e3cd9b712eaced3b061f2b71234b3fc263e0fcf7d889c6559dc", size = 1722856, upload-time = "2025-09-24T13:50:28.497Z" }, + { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, + { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, + { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" }, + { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" }, + { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" }, + { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" }, + { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" }, + { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" }, + { url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" }, + { url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" }, + { url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" }, + { url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" }, + { url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" }, + { url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" }, +] + [[package]] name = "shellingham" version = "1.5.4"