From afe640086be9944d2f9d235581607263748f2618 Mon Sep 17 00:00:00 2001 From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:36:18 +0200 Subject: [PATCH 1/6] refactor!: unify area shapes on shapely and drop matplotlib Every Shape now carries a shapely geometry in (lat, lon) space and containment is one vectorized contains_xy call in the base class. Circles become geographic N-gons via kwikpos, with the vertex count sized to keep the border within 0.05 NM of the true circle. --- packages/minisky/minisky/tools/areafilter.py | 77 ++++++++++---------- packages/minisky/pyproject.toml | 2 +- uv.lock | 64 +++++++++++++++- 3 files changed, 101 insertions(+), 42 deletions(-) diff --git a/packages/minisky/minisky/tools/areafilter.py b/packages/minisky/minisky/tools/areafilter.py index 771df9e..cadbdae 100644 --- a/packages/minisky/minisky/tools/areafilter.py +++ b/packages/minisky/minisky/tools/areafilter.py @@ -11,11 +11,11 @@ from __future__ import annotations import numpy as np -from matplotlib.path import Path +import shapely from minisky.command import AltM, Keyword, LatLonDeg, LatLonDegrees, command from minisky.result import Err, Ok, Result -from minisky.tools.geo import kwikdist +from minisky.tools.geo import kwikpos class AreaFilter: @@ -174,18 +174,22 @@ 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. + Derived classes build a shapely geometry (`self.geom`) in (lat, lon) + coordinate space; containment is tested against it here. Attributes: name: Area name. coordinates: Flat list of lat/lon coordinates in deg defining the shape (plus radius in nm for circles). + geom: Shapely geometry of the shape in (lat, lon) space. top: Upper altitude bound [m]. bottom: Lower altitude bound [m]. raw: Dictionary with the raw shape definition (name, kind, coordinates). """ + geom: shapely.Geometry + 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 @@ -194,13 +198,9 @@ def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e 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) + """Return whether points (lat [deg], lon [deg], alt [m]) lie inside + this shape's geometry and altitude bounds.""" + return shapely.contains_xy(self.geom, lat, lon) & (self.bottom <= alt) & (alt <= self.top) def _str_vrange(self) -> str: if self.top < 9e8: @@ -228,11 +228,16 @@ def kind(cls) -> str: class Line(Shape): """A line shape between two lat/lon positions [deg]. - Purely graphical: the inherited checkInside() always returns False. + Purely graphical: checkInside() always returns False. """ def __init__(self, name: str, coordinates) -> None: super().__init__(name, coordinates) + self.geom = shapely.LineString(np.reshape(coordinates, (-1, 2))) + + def checkInside(self, lat: np.ndarray, lon: np.ndarray, alt: np.ndarray) -> np.ndarray: + """A line has zero area: nothing is ever inside it.""" + return np.zeros(len(lat), dtype=bool) def __str__(self) -> str: return ( @@ -256,14 +261,7 @@ def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e 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)) - ) + self.geom = shapely.box(self.lat0, self.lon0, self.lat1, self.lon1) class Circle(Shape): @@ -273,18 +271,30 @@ class Circle(Shape): altitude bounds [m]. """ + # Maximum distance [nm] between the polygon border and the true circle + TOLERANCE_NM = 0.05 + MIN_VERTICES = 36 + MAX_VERTICES = 720 + 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 + # An N-gon inscribed in a circle of radius r falls short of the true + # border by r*(1 - cos(pi/N)); pick N so that stays within tolerance + num_vertices = int( + np.clip( + np.ceil(np.pi / np.arccos(max(-1.0, 1.0 - self.TOLERANCE_NM / self.r))), + self.MIN_VERTICES, + self.MAX_VERTICES, + ) + ) + # Place the vertices geographically so the cos(lat) longitude + # scaling keeps this a circle rather than an ellipse in degrees + bearings = np.linspace(0.0, 360.0, num_vertices, endpoint=False) + vlat, vlon = kwikpos(self.clat, self.clon, bearings, self.r) + self.geom = shapely.Polygon(np.column_stack((vlat, vlon))) def __str__(self) -> str: return ( @@ -298,20 +308,9 @@ 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. + bounds [m]. """ 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 + self.geom = shapely.Polygon(np.reshape(coordinates, (-1, 2))) 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/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" From d22b9679eceddb8d8aab3e335617ed4ff5f678e7 Mon Sep 17 00:00:00 2001 From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:08:12 +0200 Subject: [PATCH 2/6] refactor!: keep shapely for polygon containment only --- packages/minisky/minisky/tools/areafilter.py | 209 +++++++++--------- .../minisky/tests/unit/test_areafilter.py | 24 ++ 2 files changed, 133 insertions(+), 100 deletions(-) diff --git a/packages/minisky/minisky/tools/areafilter.py b/packages/minisky/minisky/tools/areafilter.py index cadbdae..16b800b 100644 --- a/packages/minisky/minisky/tools/areafilter.py +++ b/packages/minisky/minisky/tools/areafilter.py @@ -6,16 +6,23 @@ 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. + +Boxes and circles are tested directly in geographic coordinates; polygons +use a planar shapely geometry internally, which is only valid for polygons +that do not cross the antimeridian or enclose a pole - those are rejected +at definition time. """ from __future__ import annotations +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 kwikpos +from minisky.tools.geo import kwikdist class AreaFilter: @@ -23,7 +30,7 @@ class AreaFilter: def __init__(self) -> None: # Dictionary of all basic shapes (The shape classes defined in this file) by name - self.basic_shapes: dict[str, Shape] = {} + self.basic_shapes: dict[str, HasArea | Line] = {} def has_area(self, areaname: str) -> bool: """Check if area with name 'areaname' exists.""" @@ -59,16 +66,19 @@ def define_area( 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}") + try: + 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}") + except ValueError as e: + return Err(str(e)) self.basic_shapes[areaname] = shape return Ok(f"Created {areatype} {areaname}") @@ -147,11 +157,11 @@ def checkInside( Returns: Array of booleans, True == Inside. All False when no area with - the given name exists. + the given name exists, or when the named shape is a line. """ - if areaname not in self.basic_shapes: + area = self.basic_shapes.get(areaname) + if area is None or isinstance(area, Line): return np.zeros(len(lat), dtype=bool) - area = self.basic_shapes[areaname] return area.checkInside(lat, lon, alt) def reset(self) -> None: @@ -169,75 +179,40 @@ def deleteArea(self, name: str) -> Result[str, str]: 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 build a shapely geometry (`self.geom`) in (lat, lon) - coordinate space; containment is tested against it here. - - Attributes: - name: Area name. - coordinates: Flat list of lat/lon coordinates in deg defining the - shape (plus radius in nm for circles). - geom: Shapely geometry of the shape in (lat, lon) space. - top: Upper altitude bound [m]. - bottom: Lower altitude bound [m]. - raw: Dictionary with the raw shape definition (name, kind, - coordinates). - """ +def _vrange_str(top: float, bottom: float) -> str: + """Describe an altitude range [m] for shape __str__ output.""" + if top < 9e8: + if bottom > -9e8: + return f" with altitude between {bottom} and {top}" + else: + return f" with altitude below {top}" + if bottom > -9e8: + return f" with altitude above {bottom}" + return "" - geom: shapely.Geometry - 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) +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 checkInside(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.""" - return shapely.contains_xy(self.geom, lat, lon) & (self.bottom <= alt) & (alt <= self.top) + ... - 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): +class Line: """A line shape between two lat/lon positions [deg]. - Purely graphical: checkInside() always returns False. + Purely graphical: a line has no inside, and no checkInside(). """ def __init__(self, name: str, coordinates) -> None: - super().__init__(name, coordinates) - self.geom = shapely.LineString(np.reshape(coordinates, (-1, 2))) - - def checkInside(self, lat: np.ndarray, lon: np.ndarray, alt: np.ndarray) -> np.ndarray: - """A line has zero area: nothing is ever inside it.""" - return np.zeros(len(lat), dtype=bool) + self.name = name + self.coordinates = coordinates def __str__(self) -> str: return ( @@ -247,7 +222,7 @@ def __str__(self) -> str: ) -class Box(Shape): +class Box(HasArea): """A lat/lon-aligned box shape. Defined by two opposite corner points [deg] (sorted at construction) @@ -255,62 +230,96 @@ class Box(Shape): """ def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e9) -> None: - super().__init__(name, coordinates, top, bottom) + self.name = name + self.coordinates = coordinates + self.top = np.maximum(bottom, top) + self.bottom = np.minimum(bottom, top) # 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]) - self.geom = shapely.box(self.lat0, self.lon0, self.lat1, self.lon1) + + def checkInside(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)) + ) + + def __str__(self) -> str: + return ( + f"{self.name} is a BOX with coordinates " + + ", ".join(str(c) for c in self.coordinates) + + _vrange_str(self.top, self.bottom) + ) -class Circle(Shape): +class Circle(HasArea): """A circle shape. Defined by a center position [deg], a radius [nm], and optional altitude bounds [m]. """ - # Maximum distance [nm] between the polygon border and the true circle - TOLERANCE_NM = 0.05 - MIN_VERTICES = 36 - MAX_VERTICES = 720 - def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e9) -> None: - super().__init__(name, coordinates, top, bottom) + self.name = name + self.coordinates = coordinates + self.top = np.maximum(bottom, top) + self.bottom = np.minimum(bottom, top) self.clat = coordinates[0] self.clon = coordinates[1] self.r = coordinates[2] - # An N-gon inscribed in a circle of radius r falls short of the true - # border by r*(1 - cos(pi/N)); pick N so that stays within tolerance - num_vertices = int( - np.clip( - np.ceil(np.pi / np.arccos(max(-1.0, 1.0 - self.TOLERANCE_NM / self.r))), - self.MIN_VERTICES, - self.MAX_VERTICES, - ) - ) - # Place the vertices geographically so the cos(lat) longitude - # scaling keeps this a circle rather than an ellipse in degrees - bearings = np.linspace(0.0, 360.0, num_vertices, endpoint=False) - vlat, vlon = kwikpos(self.clat, self.clon, bearings, self.r) - self.geom = shapely.Polygon(np.column_stack((vlat, vlon))) + + def checkInside(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.clat, self.clon, lat, lon) # [NM] + return (distance <= self.r) & (self.bottom <= alt) & (alt <= self.top) 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() + f"and radius {self.r}." + _vrange_str(self.top, self.bottom) ) -class Poly(Shape): +class Poly(HasArea): """A polygon shape. Defined by a sequence of lat/lon vertices [deg] and optional altitude - bounds [m]. + bounds [m]. Containment is tested against a planar shapely polygon in + (lat, lon) space, which cannot represent polygons that cross the + antimeridian or enclose a pole; those are rejected with ValueError. """ def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e9) -> None: - super().__init__(name, coordinates, top, bottom) - self.geom = shapely.Polygon(np.reshape(coordinates, (-1, 2))) + self.name = name + self.coordinates = coordinates + self.top = np.maximum(bottom, top) + self.bottom = np.minimum(bottom, top) + vertices = np.reshape(coordinates, (-1, 2)) + # A ring with an edge spanning more than 180 deg of longitude either + # crosses the antimeridian or winds around a pole; both are invalid + # in the planar (lat, lon) space the containment test runs in. + lons = np.append(vertices[:, 1], vertices[0, 1]) + if np.any(np.abs(np.diff(lons)) > 180.0): + raise ValueError( + f"Polygon {name} crosses the antimeridian or encloses a pole; " + "split it into separate polygons." + ) + self._geom = shapely.Polygon(vertices) + + def checkInside(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.""" + return shapely.contains_xy(self._geom, lat, lon) & (self.bottom <= alt) & (alt <= self.top) + + def __str__(self) -> str: + return ( + f"{self.name} is a POLY with coordinates " + + ", ".join(str(c) for c in self.coordinates) + + _vrange_str(self.top, self.bottom) + ) diff --git a/packages/minisky/tests/unit/test_areafilter.py b/packages/minisky/tests/unit/test_areafilter.py index 69b025e..90d1fe5 100644 --- a/packages/minisky/tests/unit/test_areafilter.py +++ b/packages/minisky/tests/unit/test_areafilter.py @@ -137,6 +137,14 @@ def test_center_inside_far_point_outside(self, area_filter: AreaFilter) -> None: # 2 deg lat is about 120 NM: outside assert not check_single(area_filter, "C", 54.0, 4.0) + def test_circle_near_pole(self, area_filter: AreaFilter) -> None: + # 100 NM radius centred close to the north pole must remain valid + result = area_filter.define_area("C", "CIRCLE", [89.9, 0.0, 100.0]) + assert result.is_ok() + assert check_single(area_filter, "C", 89.9, 0.0) + # 2 deg of latitude south is about 120 NM: outside + assert not check_single(area_filter, "C", 87.9, 0.0) + class TestPoly: def test_triangle_centroid_inside(self, area_filter: AreaFilter) -> None: @@ -144,3 +152,19 @@ def test_triangle_centroid_inside(self, area_filter: AreaFilter) -> None: 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) + + def test_antimeridian_polygon_rejected(self, area_filter: AreaFilter) -> None: + # Quad spanning lon 170 to -170 across the antimeridian + result = area_filter.define_area( + "P", "POLY", [10.0, 170.0, 10.0, -170.0, -10.0, -170.0, -10.0, 170.0] + ) + assert result.is_err() + assert not area_filter.has_area("P") + + +class TestLine: + def test_line_has_no_inside(self, area_filter: AreaFilter) -> None: + area_filter.define_area("L", "LINE", [52.0, 4.0, 53.0, 5.0]) + assert area_filter.has_area("L") + # A point exactly on the line is still not "inside" it + assert not check_single(area_filter, "L", 52.5, 4.5) From 5e82c465eac55f36a3bb811ac8288df8d9900bc6 Mon Sep 17 00:00:00 2001 From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:23:22 +0200 Subject: [PATCH 3/6] refactor!: rename checkInside to contains --- packages/minisky/minisky/tools/areafilter.py | 14 +++++++------- packages/minisky/minisky/traffic/trafficgroups.py | 2 +- packages/minisky/tests/unit/test_areafilter.py | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/minisky/minisky/tools/areafilter.py b/packages/minisky/minisky/tools/areafilter.py index 16b800b..37be8f8 100644 --- a/packages/minisky/minisky/tools/areafilter.py +++ b/packages/minisky/minisky/tools/areafilter.py @@ -144,7 +144,7 @@ def define_polyline_area( """Draw a multi-segment line through position vertices.""" return self.define_area(name, "LINE", self._coordinates((first, *additional))) - def checkInside( + def contains( 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'. @@ -162,7 +162,7 @@ def checkInside( area = self.basic_shapes.get(areaname) if area is None or isinstance(area, Line): return np.zeros(len(lat), dtype=bool) - return area.checkInside(lat, lon, alt) + return area.contains(lat, lon, alt) def reset(self) -> None: """Clear all data.""" @@ -198,7 +198,7 @@ class HasArea(Protocol): so asking whether an aircraft is inside one is a category error. """ - def checkInside(self, lat: np.ndarray, lon: np.ndarray, alt: np.ndarray) -> np.ndarray: + 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.""" ... @@ -207,7 +207,7 @@ def checkInside(self, lat: np.ndarray, lon: np.ndarray, alt: np.ndarray) -> np.n class Line: """A line shape between two lat/lon positions [deg]. - Purely graphical: a line has no inside, and no checkInside(). + Purely graphical: a line has no inside, and no contains(). """ def __init__(self, name: str, coordinates) -> None: @@ -240,7 +240,7 @@ def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e 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) -> np.ndarray: + 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)) @@ -272,7 +272,7 @@ def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e self.clon = coordinates[1] self.r = coordinates[2] - def checkInside(self, lat: np.ndarray, lon: np.ndarray, alt: np.ndarray) -> np.ndarray: + 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.clat, self.clon, lat, lon) # [NM] @@ -312,7 +312,7 @@ def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e ) self._geom = shapely.Polygon(vertices) - def checkInside(self, lat: np.ndarray, lon: np.ndarray, alt: np.ndarray) -> np.ndarray: + 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.""" return shapely.contains_xy(self._geom, lat, lon) & (self.bottom <= alt) & (alt <= self.top) diff --git a/packages/minisky/minisky/traffic/trafficgroups.py b/packages/minisky/minisky/traffic/trafficgroups.py index 149db80..b538fe5 100644 --- a/packages/minisky/minisky/traffic/trafficgroups.py +++ b/packages/minisky/minisky/traffic/trafficgroups.py @@ -133,7 +133,7 @@ def add_to_group( 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( + inside = self.areas.contains( sole_member, self.traffic.lat, self.traffic.lon, self.traffic.alt ) indices = np.flatnonzero(inside) diff --git a/packages/minisky/tests/unit/test_areafilter.py b/packages/minisky/tests/unit/test_areafilter.py index 90d1fe5..1310ff0 100644 --- a/packages/minisky/tests/unit/test_areafilter.py +++ b/packages/minisky/tests/unit/test_areafilter.py @@ -17,7 +17,7 @@ def area_filter() -> 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]) + return bool(area_filter.contains(name, np.array([lat]), np.array([lon]), np.array([alt]))[0]) class TestDefineArea: @@ -30,7 +30,7 @@ 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])) + result = area_filter.contains("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: @@ -123,7 +123,7 @@ def test_array_input(self, area_filter: AreaFilter) -> None: 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) + inside = area_filter.contains("B", lat, lon, alt) assert inside.tolist() == [True, False, True] From ad5b8991e02d4052b1d5221970903f93c75f151a Mon Sep 17 00:00:00 2001 From: Abraham Cheung <58929011+abc8747@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:25:18 +0800 Subject: [PATCH 4/6] refactor(areafilter)!: rename to Shapes and separate areas/lines - remove bluesky-era define_area, which is unnecessary indirection - aggressively slim down tests since we guarantee invalid states to not be representable --- packages/minisky/minisky/runtime.py | 12 +- .../minisky/minisky/simulation/simulation.py | 8 +- packages/minisky/minisky/stack/__init__.py | 15 +- packages/minisky/minisky/tools/areafilter.py | 201 ++++++---------- packages/minisky/minisky/traffic/traffic.py | 8 +- .../minisky/minisky/traffic/trafficgroups.py | 21 +- .../minisky/tests/integration/test_stack.py | 7 +- .../minisky/tests/unit/test_areafilter.py | 225 +++++------------- 8 files changed, 175 insertions(+), 322 deletions(-) diff --git a/packages/minisky/minisky/runtime.py b/packages/minisky/minisky/runtime.py index 1447945..1ca68fa 100644 --- a/packages/minisky/minisky/runtime.py +++ b/packages/minisky/minisky/runtime.py @@ -14,7 +14,7 @@ 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.areafilter import Shapes from minisky.tools.geo_commands import GeoCommands from minisky.tools.navdata import Navdatabase from minisky.traffic import Traffic @@ -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..ae5e7ae 100644 --- a/packages/minisky/minisky/simulation/simulation.py +++ b/packages/minisky/minisky/simulation/simulation.py @@ -36,7 +36,7 @@ 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.areafilter import Shapes from minisky.tools.navdata import Navdatabase 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..dd0d63e 100644 --- a/packages/minisky/minisky/stack/__init__.py +++ b/packages/minisky/minisky/stack/__init__.py @@ -62,7 +62,7 @@ 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.areafilter import Shapes from minisky.tools.navdata import Navdatabase 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 index 37be8f8..43df09f 100644 --- a/packages/minisky/minisky/tools/areafilter.py +++ b/packages/minisky/minisky/tools/areafilter.py @@ -1,20 +1,9 @@ -"""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. - -Boxes and circles are tested directly in geographic coordinates; polygons -use a planar shapely geometry internally, which is only valid for polygons -that do not cross the antimeridian or enclose a pole - those are rejected -at definition time. -""" +"""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 @@ -25,68 +14,29 @@ from minisky.tools.geo import kwikdist -class AreaFilter: - """Named geometric shapes for a MiniSky runtime.""" +class Shapes: + """Own named containable areas and graphical lines.""" def __init__(self) -> None: - # Dictionary of all basic shapes (The shape classes defined in this file) by name - self.basic_shapes: dict[str, HasArea | Line] = {} - - 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}") - - try: - 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}") - except ValueError as e: - return Err(str(e)) - - self.basic_shapes[areaname] = shape - return Ok(f"Created {areatype} {areaname}") + 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) @staticmethod def _coordinates(points: tuple[LatLonDegrees, ...]) -> tuple[float, ...]: return tuple(value for point in points for value in (point.lat, point.lon)) + # 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_area( self, @@ -97,7 +47,8 @@ def define_box_area( 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) + self._store_area(name, Box(name, self._coordinates((first, second)), top, bottom)) + return Ok(f"Created BOX {name}") @command(name="CIRCLE") def define_circle_area( @@ -109,19 +60,25 @@ def define_circle_area( 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 - ) + coordinates = (*self._coordinates((center,)), radius) + self._store_area(name, Circle(name, coordinates, top, bottom)) + return Ok(f"Created CIRCLE {name}") @command(name="LINE") - def define_line_area(self, name: Keyword, start: LatLonDeg, end: LatLonDeg) -> Result[str, str]: + def define_line(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))) + self._store_line(name, Line(name, self._coordinates((start, end)))) + return Ok(f"Created LINE {name}") @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)) + try: + area = Poly(name, self._coordinates(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_area( @@ -133,50 +90,31 @@ def define_polyalt_area( *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 - ) + try: + area = Poly(name, self._coordinates((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_area( + def define_polyline( 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 contains( - 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, or when the named shape is a line. - """ - area = self.basic_shapes.get(areaname) - if area is None or isinstance(area, Line): - return np.zeros(len(lat), dtype=bool) - return area.contains(lat, lon, alt) + self._store_line(name, Line(name, self._coordinates((first, *additional)))) + return Ok(f"Created POLYLINE {name}") def reset(self) -> None: - """Clear all data.""" - self.basic_shapes.clear() + self._areas.clear() + self._lines.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: + def delete(self, name: str) -> Result[str, str]: + if self._areas.pop(name, None) is not None: return Ok(f"Area {name} deleted.") - return Err(f"No area found with name {name}.") + if self._lines.pop(name, None) is not None: + return Ok(f"Line {name} deleted.") + return Err(f"No shape found with name {name}.") def _vrange_str(top: float, bottom: float) -> str: @@ -222,6 +160,9 @@ def __str__(self) -> str: ) +# TODO(abraham): stop using sentinels to indicate the lack of top / bottom. +# see issue #40 + class Box(HasArea): """A lat/lon-aligned box shape. @@ -290,9 +231,8 @@ class Poly(HasArea): """A polygon shape. Defined by a sequence of lat/lon vertices [deg] and optional altitude - bounds [m]. Containment is tested against a planar shapely polygon in - (lat, lon) space, which cannot represent polygons that cross the - antimeridian or enclose a pole; those are rejected with ValueError. + bounds [m]. Longitudes are unwrapped before planar Shapely containment; + polygons that touch or enclose a pole are rejected. """ def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e9) -> None: @@ -300,22 +240,31 @@ def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e self.coordinates = coordinates self.top = np.maximum(bottom, top) self.bottom = np.minimum(bottom, top) - vertices = np.reshape(coordinates, (-1, 2)) - # A ring with an edge spanning more than 180 deg of longitude either - # crosses the antimeridian or winds around a pole; both are invalid - # in the planar (lat, lon) space the containment test runs in. - lons = np.append(vertices[:, 1], vertices[0, 1]) - if np.any(np.abs(np.diff(lons)) > 180.0): - raise ValueError( - f"Polygon {name} crosses the antimeridian or encloses a pole; " - "split it into separate polygons." - ) - self._geom = shapely.Polygon(vertices) + vertices = np.asarray(coordinates, dtype=float).reshape((-1, 2)) + 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.""" - return shapely.contains_xy(self._geom, lat, lon) & (self.bottom <= alt) & (alt <= self.top) + 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) + ) def __str__(self) -> str: return ( diff --git a/packages/minisky/minisky/traffic/traffic.py b/packages/minisky/minisky/traffic/traffic.py index 1babc68..c4ef476 100644 --- a/packages/minisky/minisky/traffic/traffic.py +++ b/packages/minisky/minisky/traffic/traffic.py @@ -61,7 +61,7 @@ vatmos, vcasormach, ) -from minisky.tools.areafilter import AreaFilter +from minisky.tools.areafilter import Shapes from minisky.tools.convert import latlon2txt from minisky.traffic.asas import ConflictDetection, ConflictResolution @@ -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 b538fe5..dac77c6 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.areafilter 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.contains( - 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/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 index 1310ff0..edb9c7d 100644 --- a/packages/minisky/tests/unit/test_areafilter.py +++ b/packages/minisky/tests/unit/test_areafilter.py @@ -1,170 +1,61 @@ -"""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). -""" +"""Unit tests for geographic shape behavior.""" 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.contains(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.contains("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.contains("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) - - def test_circle_near_pole(self, area_filter: AreaFilter) -> None: - # 100 NM radius centred close to the north pole must remain valid - result = area_filter.define_area("C", "CIRCLE", [89.9, 0.0, 100.0]) - assert result.is_ok() - assert check_single(area_filter, "C", 89.9, 0.0) - # 2 deg of latitude south is about 120 NM: outside - assert not check_single(area_filter, "C", 87.9, 0.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) - - def test_antimeridian_polygon_rejected(self, area_filter: AreaFilter) -> None: - # Quad spanning lon 170 to -170 across the antimeridian - result = area_filter.define_area( - "P", "POLY", [10.0, 170.0, 10.0, -170.0, -10.0, -170.0, -10.0, 170.0] - ) - assert result.is_err() - assert not area_filter.has_area("P") - - -class TestLine: - def test_line_has_no_inside(self, area_filter: AreaFilter) -> None: - area_filter.define_area("L", "LINE", [52.0, 4.0, 53.0, 5.0]) - assert area_filter.has_area("L") - # A point exactly on the line is still not "inside" it - assert not check_single(area_filter, "L", 52.5, 4.5) +from minisky.command import LatLonDegrees +from minisky.tools.areafilter 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_area("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_area("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("B", [52.0, 4.0, 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("C", [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("P", [10.0, 170.0, 10.0, -170.0, -10.0, -170.0, -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("P", [80.0, -135.0, 80.0, -45.0, 80.0, 45.0, 80.0, 135.0]) From 058e3c6676b65249e7243c3cb80db7d430f8f913 Mon Sep 17 00:00:00 2001 From: Abraham Cheung <58929011+abc8747@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:17:26 +0800 Subject: [PATCH 5/6] chore: rename area filter to shapes --- docs/api/tools.md | 6 +++--- docs/architecture.md | 2 +- packages/minisky/minisky/runtime.py | 2 +- packages/minisky/minisky/simulation/simulation.py | 2 +- packages/minisky/minisky/stack/__init__.py | 2 +- packages/minisky/minisky/tools/{areafilter.py => shapes.py} | 1 + packages/minisky/minisky/traffic/traffic.py | 2 +- packages/minisky/minisky/traffic/trafficgroups.py | 2 +- .../tests/unit/{test_areafilter.py => test_shapes.py} | 4 +++- 9 files changed, 13 insertions(+), 10 deletions(-) rename packages/minisky/minisky/tools/{areafilter.py => shapes.py} (99%) rename packages/minisky/tests/unit/{test_areafilter.py => test_shapes.py} (96%) 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 1ca68fa..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 Shapes 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 diff --git a/packages/minisky/minisky/simulation/simulation.py b/packages/minisky/minisky/simulation/simulation.py index ae5e7ae..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 Shapes from minisky.tools.navdata import Navdatabase + from minisky.tools.shapes import Shapes from minisky.traffic import Traffic diff --git a/packages/minisky/minisky/stack/__init__.py b/packages/minisky/minisky/stack/__init__.py index dd0d63e..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 Shapes from minisky.tools.navdata import Navdatabase + from minisky.tools.shapes import Shapes from minisky.traffic import Traffic diff --git a/packages/minisky/minisky/tools/areafilter.py b/packages/minisky/minisky/tools/shapes.py similarity index 99% rename from packages/minisky/minisky/tools/areafilter.py rename to packages/minisky/minisky/tools/shapes.py index 43df09f..464f7a8 100644 --- a/packages/minisky/minisky/tools/areafilter.py +++ b/packages/minisky/minisky/tools/shapes.py @@ -163,6 +163,7 @@ def __str__(self) -> str: # TODO(abraham): stop using sentinels to indicate the lack of top / bottom. # see issue #40 + class Box(HasArea): """A lat/lon-aligned box shape. diff --git a/packages/minisky/minisky/traffic/traffic.py b/packages/minisky/minisky/traffic/traffic.py index c4ef476..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 Shapes from minisky.tools.convert import latlon2txt +from minisky.tools.shapes import Shapes from minisky.traffic.asas import ConflictDetection, ConflictResolution from .activewpdata import ActiveWaypoint diff --git a/packages/minisky/minisky/traffic/trafficgroups.py b/packages/minisky/minisky/traffic/trafficgroups.py index dac77c6..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 Shapes + from minisky.tools.shapes import Shapes from minisky.traffic.traffic import Traffic diff --git a/packages/minisky/tests/unit/test_areafilter.py b/packages/minisky/tests/unit/test_shapes.py similarity index 96% rename from packages/minisky/tests/unit/test_areafilter.py rename to packages/minisky/tests/unit/test_shapes.py index edb9c7d..83cb810 100644 --- a/packages/minisky/tests/unit/test_areafilter.py +++ b/packages/minisky/tests/unit/test_shapes.py @@ -3,7 +3,7 @@ import numpy as np import pytest from minisky.command import LatLonDegrees -from minisky.tools.areafilter import Box, Circle, HasArea, Poly, Shapes +from minisky.tools.shapes import Box, Circle, HasArea, Poly, Shapes def contains(shape: HasArea, lat: float, lon: float, alt: float = 0.0) -> bool: @@ -29,10 +29,12 @@ def test_mutually_exclusive_shape_names() -> None: 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("B", [52.0, 4.0, 53.0, 5.0], top=3000.0, bottom=1000.0) From 532710235c217db612e0f8929f6243188ec1328e Mon Sep 17 00:00:00 2001 From: Abraham Cheung <58929011+abc8747@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:28:53 +0800 Subject: [PATCH 6/6] refactor(shapes)!: make shape constructors take typed LatLonDegrees - remove dead _coordinates flattening, __str__, __vrange_str --- packages/minisky/minisky/tools/shapes.py | 118 ++++++--------------- packages/minisky/tests/unit/test_shapes.py | 26 +++-- 2 files changed, 54 insertions(+), 90 deletions(-) diff --git a/packages/minisky/minisky/tools/shapes.py b/packages/minisky/minisky/tools/shapes.py index 464f7a8..da24300 100644 --- a/packages/minisky/minisky/tools/shapes.py +++ b/packages/minisky/minisky/tools/shapes.py @@ -23,10 +23,6 @@ def __init__(self) -> None: self.areas: Mapping[str, HasArea] = MappingProxyType(self._areas) self.lines: Mapping[str, Line] = MappingProxyType(self._lines) - @staticmethod - def _coordinates(points: tuple[LatLonDegrees, ...]) -> tuple[float, ...]: - return tuple(value for point in points for value in (point.lat, point.lon)) - # NOTE: we want area and line names to be mutually exclusive. def _store_area(self, name: str, area: HasArea) -> None: @@ -38,7 +34,7 @@ def _store_line(self, name: str, line: Line) -> None: self._lines[name] = line @command(name="BOX") - def define_box_area( + def define_box( self, name: Keyword, first: LatLonDeg, @@ -47,11 +43,11 @@ def define_box_area( bottom: AltM = -1e9, ) -> Result[str, str]: """Define a box-shaped area from two opposite corners.""" - self._store_area(name, Box(name, self._coordinates((first, second)), top, bottom)) + self._store_area(name, Box(first, second, top, bottom)) return Ok(f"Created BOX {name}") @command(name="CIRCLE") - def define_circle_area( + def define_circle( self, name: Keyword, center: LatLonDeg, @@ -60,28 +56,27 @@ def define_circle_area( bottom: AltM = -1e9, ) -> Result[str, str]: """Define a circular area from a center and radius in nautical miles.""" - coordinates = (*self._coordinates((center,)), radius) - self._store_area(name, Circle(name, coordinates, top, bottom)) + 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(name, self._coordinates((start, end)))) + self._store_line(name, Line((start, end))) return Ok(f"Created LINE {name}") @command(name="POLY", aliases=("POLYGON",)) - def define_poly_area(self, name: Keyword, *points: LatLonDeg) -> Result[str, str]: + def define_poly(self, name: Keyword, *points: LatLonDeg) -> Result[str, str]: """Define a polygon from position vertices.""" try: - area = Poly(name, self._coordinates(points)) + 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_area( + def define_polyalt( self, name: Keyword, top: AltM, @@ -91,7 +86,7 @@ def define_polyalt_area( ) -> Result[str, str]: """Define a polygon between top and bottom altitudes.""" try: - area = Poly(name, self._coordinates((first, *additional)), top, bottom) + area = Poly((first, *additional), top, bottom) except ValueError as error: return Err(str(error)) self._store_area(name, area) @@ -102,7 +97,7 @@ 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(name, self._coordinates((first, *additional)))) + self._store_line(name, Line((first, *additional))) return Ok(f"Created POLYLINE {name}") def reset(self) -> None: @@ -117,18 +112,6 @@ def delete(self, name: str) -> Result[str, str]: return Err(f"No shape found with name {name}.") -def _vrange_str(top: float, bottom: float) -> str: - """Describe an altitude range [m] for shape __str__ output.""" - if top < 9e8: - if bottom > -9e8: - return f" with altitude between {bottom} and {top}" - else: - return f" with altitude below {top}" - if bottom > -9e8: - return f" with altitude above {bottom}" - return "" - - class HasArea(Protocol): """An area shape that supports point-inside tests. @@ -142,22 +125,13 @@ def contains(self, lat: np.ndarray, lon: np.ndarray, alt: np.ndarray) -> np.ndar ... +# 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 line shape between two lat/lon positions [deg]. + """A graphical line through latitude/longitude positions.""" - Purely graphical: a line has no inside, and no contains(). - """ - - def __init__(self, name: str, coordinates) -> None: - self.name = name - self.coordinates = 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]})." - ) + def __init__(self, points: tuple[LatLonDegrees, ...]) -> None: + self.points = points # TODO(abraham): stop using sentinels to indicate the lack of top / bottom. @@ -167,20 +141,18 @@ def __str__(self) -> str: class Box(HasArea): """A lat/lon-aligned box shape. - Defined by two opposite corner points [deg] (sorted at construction) - and optional altitude bounds [m]. + Defined by two opposite corner points [deg] and optional altitude bounds [m]. """ - def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e9) -> None: - self.name = name - self.coordinates = coordinates + 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) - # 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]) + 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.""" @@ -190,13 +162,6 @@ def contains(self, lat: np.ndarray, lon: np.ndarray, alt: np.ndarray) -> np.ndar & ((self.bottom <= alt) & (alt <= self.top)) ) - def __str__(self) -> str: - return ( - f"{self.name} is a BOX with coordinates " - + ", ".join(str(c) for c in self.coordinates) - + _vrange_str(self.top, self.bottom) - ) - class Circle(HasArea): """A circle shape. @@ -205,27 +170,19 @@ class Circle(HasArea): altitude bounds [m]. """ - def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e9) -> None: - self.name = name - self.coordinates = coordinates + 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) - self.clat = coordinates[0] - self.clon = coordinates[1] - self.r = coordinates[2] 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.clat, self.clon, lat, lon) # [NM] - return (distance <= self.r) & (self.bottom <= alt) & (alt <= self.top) - - def __str__(self) -> str: - return ( - f"{self.name} is a CIRCLE with " - f"center ({self.clat}, {self.clon}) " - f"and radius {self.r}." + _vrange_str(self.top, self.bottom) - ) + distance = kwikdist(self.center.lat, self.center.lon, lat, lon) # [NM] + return (distance <= self.radius) & (self.bottom <= alt) & (alt <= self.top) class Poly(HasArea): @@ -236,12 +193,12 @@ class Poly(HasArea): polygons that touch or enclose a pole are rejected. """ - def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e9) -> None: - self.name = name - self.coordinates = coordinates + 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(coordinates, dtype=float).reshape((-1, 2)) + 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)): @@ -266,10 +223,3 @@ def contains(self, lat: np.ndarray, lon: np.ndarray, alt: np.ndarray) -> np.ndar & (self.bottom <= alt) & (alt <= self.top) ) - - def __str__(self) -> str: - return ( - f"{self.name} is a POLY with coordinates " - + ", ".join(str(c) for c in self.coordinates) - + _vrange_str(self.top, self.bottom) - ) diff --git a/packages/minisky/tests/unit/test_shapes.py b/packages/minisky/tests/unit/test_shapes.py index 83cb810..e5e6660 100644 --- a/packages/minisky/tests/unit/test_shapes.py +++ b/packages/minisky/tests/unit/test_shapes.py @@ -17,7 +17,7 @@ def test_mutually_exclusive_shape_names() -> None: first = LatLonDegrees(52.0, 4.0) second = LatLonDegrees(53.0, 5.0) - shapes.define_box_area("A", first, second) + shapes.define_box("A", first, second) assert "A" in shapes.areas assert "A" not in shapes.lines @@ -25,7 +25,7 @@ def test_mutually_exclusive_shape_names() -> None: assert "A" not in shapes.areas assert "A" in shapes.lines - shapes.define_box_area("A", first, second) + shapes.define_box("A", first, second) assert "A" in shapes.areas assert "A" not in shapes.lines @@ -36,7 +36,7 @@ def test_mutually_exclusive_shape_names() -> None: def test_box_containment_includes_altitude_bounds() -> None: - box = Box("B", [52.0, 4.0, 53.0, 5.0], top=3000.0, bottom=1000.0) + 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) @@ -44,14 +44,21 @@ def test_box_containment_includes_altitude_bounds() -> None: def test_circle_near_pole_uses_geographic_distance() -> None: - circle = Circle("C", [89.9, 0.0, 100.0]) + 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("P", [10.0, 170.0, 10.0, -170.0, -10.0, -170.0, -10.0, 170.0]) + 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) @@ -60,4 +67,11 @@ def test_polygon_contains_points_across_antimeridian() -> None: def test_polygon_enclosing_pole_is_rejected() -> None: with pytest.raises(ValueError, match="Polygon must not enclose a pole"): - Poly("P", [80.0, -135.0, 80.0, -45.0, 80.0, 45.0, 80.0, 135.0]) + Poly( + ( + LatLonDegrees(80.0, -135.0), + LatLonDegrees(80.0, -45.0), + LatLonDegrees(80.0, 45.0), + LatLonDegrees(80.0, 135.0), + ) + )