diff --git a/packages/minisky/minisky/tools/areafilter.py b/packages/minisky/minisky/tools/areafilter.py index 61b331f..b6e08c5 100644 --- a/packages/minisky/minisky/tools/areafilter.py +++ b/packages/minisky/minisky/tools/areafilter.py @@ -5,86 +5,25 @@ 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 and -indexes them in an R-tree for fast geospatial queries. +inside an area. Each `AreaFilter` stores its defined shapes by name. """ from __future__ import annotations -from contextlib import suppress -from weakref import WeakValueDictionary - import numpy as np from matplotlib.path import Path from minisky.result import Err, Ok, Result - -try: - from rtree.index import Index # type: ignore[assignment] -except (ImportError, OSError): - print( - "Warning: RTree could not be loaded. areafilter get_intersecting and get_knearest won't work" - ) - - class Index: - """Dummy index class for installations where rtree is missing - or doesn't work. - """ - - @staticmethod - def intersection(*args, **kwargs): - return [] - - @staticmethod - def nearest(*args, **kwargs): - return [] - - @staticmethod - def insert(*args, **kwargs): - return - - @staticmethod - def delete(*args, **kwargs): - return - - from minisky.tools.geo import kwikdist class AreaFilter: - """Named geometric shapes and spatial index for a MiniSky runtime.""" + """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] = {} - # Counter to keep track of used shape ids - self.max_area_id = 0 - - # Weak-value dictionary of all Shape-derived objects by name, and id - self.areas_by_id: WeakValueDictionary[int, Shape] = WeakValueDictionary() - self.areas_by_name: WeakValueDictionary[str, Shape] = WeakValueDictionary() - - # RTree of all areas for efficient geospatial searching - self.areatree = Index() - - def _register(self, shape: Shape) -> None: - # Owner-local weak reference and tree storage - shape.area_id = self.max_area_id - self.max_area_id += 1 - self.areas_by_id[shape.area_id] = shape - self.areas_by_name[shape.name] = shape - self.areatree.insert(shape.area_id, shape.bbox) - shape._registered = True - - def _unregister(self, shape: Shape) -> None: - if not shape._registered: - return - self.areatree.delete(shape.area_id, shape.bbox) - self.areas_by_id.pop(shape.area_id, None) - self.areas_by_name.pop(shape.name, None) - shape._registered = False - def has_area(self, areaname: str) -> bool: """Check if area with name 'areaname' exists.""" return areaname in self.basic_shapes @@ -119,18 +58,14 @@ def define_area( else: return Err(f"Unknown shape: {areaname}") - old_shape = self.basic_shapes.get(areaname) - if old_shape is not None: - self._unregister(old_shape) - if areatype == "BOX": - shape = Box(self, areaname, coordinates, top, bottom) + shape = Box(areaname, coordinates, top, bottom) elif areatype == "CIRCLE": - shape = Circle(self, areaname, coordinates, top, bottom) + shape = Circle(areaname, coordinates, top, bottom) elif areatype[:4] == "POLY": - shape = Poly(self, areaname, coordinates, top, bottom) + shape = Poly(areaname, coordinates, top, bottom) elif areatype == "LINE": - shape = Line(self, areaname, coordinates) + shape = Line(areaname, coordinates) else: return Err(f"Unknown shape type: {areatype}") @@ -219,13 +154,7 @@ def checkInside( def reset(self) -> None: """Clear all data.""" - for shape in list(self.basic_shapes.values()): - self._unregister(shape) self.basic_shapes.clear() - self.areas_by_id.clear() - self.areas_by_name.clear() - self.areatree = Index() - self.max_area_id = 0 def deleteArea(self, name: str) -> Result[str, str]: """Delete a previously defined area by name. @@ -233,43 +162,17 @@ def deleteArea(self, name: str) -> Result[str, str]: Args: name: Name of the area shape to remove. """ - shape = self.basic_shapes.pop(name, None) - if shape is not None: - self._unregister(shape) + 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}.") - def get_intersecting(self, lat0: float, lon0: float, lat1: float, lon1: float) -> list[Shape]: - """Return all shapes that intersect with a specified rectangular area. - - Arguments: - - lat0/1, lon0/1: Coordinates of the top-left and bottom-right corner - of the intersection area. - """ - ids = self.areatree.intersection((lat0, lon0, lat1, lon1)) - return [self.areas_by_id[area_id] for area_id in ids if area_id in self.areas_by_id] - - def get_knearest( - self, lat0: float, lon0: float, lat1: float, lon1: float, k: int = 1 - ) -> list[Shape]: - """Return the k nearest shapes to a specified rectangular area. - - Arguments: - - lat0/1, lon0/1: Coordinates of the top-left and bottom-right corner - of the relevant area. - - k: The (maximum) number of results to return. - """ - ids = self.areatree.nearest((lat0, lon0, lat1, lon1), k) - return [self.areas_by_id[area_id] for area_id in ids if area_id in self.areas_by_id] - class Shape: """ Base class of BlueSky shapes - Handles the naming, altitude bounds, bounding box, and R-tree - registration common to all shape types. Derived classes implement - checkInside() for their specific geometry. + Handles the naming and altitude bounds common to all shape types. + Derived classes implement checkInside() for their specific geometry. Attributes: name: Area name. @@ -277,36 +180,16 @@ class Shape: shape (plus radius in nm for circles). top: Upper altitude bound [m]. bottom: Lower altitude bound [m]. - bbox: Bounding box (latmin, lonmin, latmax, lonmax) in deg. - area_id: Unique numeric id of this shape in the R-tree. raw: Dictionary with the raw shape definition (name, kind, coordinates). """ - area_id: int - - def __init__( - self, owner: AreaFilter, name: str, coordinates, top: float = 1e9, bottom: float = -1e9 - ) -> None: - self.owner = owner - self._registered = False + 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) - lat = coordinates[::2] - lon = coordinates[1::2] - self.bbox = [min(lat), min(lon), max(lat), max(lon)] - - # Owner-local weak reference and tree storage - owner._register(self) - - def __del__(self) -> None: - # Objects are removed automatically from the weak-value dicts, - # but need to be manually removed from the rtree - with suppress(Exception): - self.owner._unregister(self) 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 @@ -346,8 +229,8 @@ class Line(Shape): Purely graphical: the inherited checkInside() always returns False. """ - def __init__(self, owner: AreaFilter, name: str, coordinates) -> None: - super().__init__(owner, name, coordinates) + def __init__(self, name: str, coordinates) -> None: + super().__init__(name, coordinates) def __str__(self) -> str: return ( @@ -364,10 +247,8 @@ class Box(Shape): and optional altitude bounds [m]. """ - def __init__( - self, owner: AreaFilter, name: str, coordinates, top: float = 1e9, bottom: float = -1e9 - ) -> None: - super().__init__(owner, name, coordinates, top, bottom) + 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]) @@ -390,10 +271,8 @@ class Circle(Shape): altitude bounds [m]. """ - def __init__( - self, owner: AreaFilter, name: str, coordinates, top: float = 1e9, bottom: float = -1e9 - ) -> None: - super().__init__(owner, name, coordinates, top, bottom) + 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] @@ -421,10 +300,8 @@ class Poly(Shape): point-in-polygon tests. """ - def __init__( - self, owner: AreaFilter, name: str, coordinates, top: float = 1e9, bottom: float = -1e9 - ) -> None: - super().__init__(owner, name, coordinates, top, bottom) + 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): diff --git a/packages/minisky/pyproject.toml b/packages/minisky/pyproject.toml index 9b39842..f2cfd0c 100644 --- a/packages/minisky/pyproject.toml +++ b/packages/minisky/pyproject.toml @@ -16,7 +16,6 @@ dependencies = [ "prompt-toolkit>=3.0.50", "pyarrow>=19.0.1", "requests>=2.32.3", - "rtree>=1.3.0", "scipy>=1.15.1", ] diff --git a/packages/minisky/tests/unit/test_areafilter.py b/packages/minisky/tests/unit/test_areafilter.py index 332d9d5..69b025e 100644 --- a/packages/minisky/tests/unit/test_areafilter.py +++ b/packages/minisky/tests/unit/test_areafilter.py @@ -38,6 +38,72 @@ def test_reset_clears_areas(self, area_filter: AreaFilter) -> None: 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: diff --git a/uv.lock b/uv.lock index 031c6e0..e0fe682 100644 --- a/uv.lock +++ b/uv.lock @@ -1266,7 +1266,6 @@ dependencies = [ { name = "prompt-toolkit" }, { name = "pyarrow" }, { name = "requests" }, - { name = "rtree" }, { 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 = "typer" }, @@ -1284,7 +1283,6 @@ requires-dist = [ { name = "prompt-toolkit", specifier = ">=3.0.50" }, { name = "pyarrow", specifier = ">=19.0.1" }, { name = "requests", specifier = ">=2.32.3" }, - { name = "rtree", specifier = ">=1.3.0" }, { name = "scipy", specifier = ">=1.15.1" }, { name = "typer", specifier = ">=0.15.0" }, ] @@ -2499,22 +2497,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/8b/a1299085b28a2f6135e30370b126e3c5055b61908622f2488ade67641479/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:d8955b57e42f2a5434670d5aa7b75eaf6e74602ccd8955dddf7045379cd762fb", size = 1129444, upload-time = "2025-11-05T21:41:17.906Z" }, ] -[[package]] -name = "rtree" -version = "1.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/09/7302695875a019514de9a5dd17b8320e7a19d6e7bc8f85dcfb79a4ce2da3/rtree-1.4.1.tar.gz", hash = "sha256:c6b1b3550881e57ebe530cc6cffefc87cd9bf49c30b37b894065a9f810875e46", size = 52425, upload-time = "2025-08-13T19:32:01.413Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/d9/108cd989a4c0954e60b3cdc86fd2826407702b5375f6dfdab2802e5fed98/rtree-1.4.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:d672184298527522d4914d8ae53bf76982b86ca420b0acde9298a7a87d81d4a4", size = 468484, upload-time = "2025-08-13T19:31:50.593Z" }, - { url = "https://files.pythonhosted.org/packages/f3/cf/2710b6fd6b07ea0aef317b29f335790ba6adf06a28ac236078ed9bd8a91d/rtree-1.4.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a7e48d805e12011c2cf739a29d6a60ae852fb1de9fc84220bbcef67e6e595d7d", size = 436325, upload-time = "2025-08-13T19:31:52.367Z" }, - { url = "https://files.pythonhosted.org/packages/55/e1/4d075268a46e68db3cac51846eb6a3ab96ed481c585c5a1ad411b3c23aad/rtree-1.4.1-py3-none-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa8c4496e31e9ad58ff6c7df89abceac7022d906cb64a3e18e4fceae6b77f65", size = 459789, upload-time = "2025-08-13T19:31:53.926Z" }, - { url = "https://files.pythonhosted.org/packages/d1/75/e5d44be90525cd28503e7f836d077ae6663ec0687a13ba7810b4114b3668/rtree-1.4.1-py3-none-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12de4578f1b3381a93a655846900be4e3d5f4cd5e306b8b00aa77c1121dc7e8c", size = 507644, upload-time = "2025-08-13T19:31:55.164Z" }, - { url = "https://files.pythonhosted.org/packages/fd/85/b8684f769a142163b52859a38a486493b05bafb4f2fb71d4f945de28ebf9/rtree-1.4.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b558edda52eca3e6d1ee629042192c65e6b7f2c150d6d6cd207ce82f85be3967", size = 1454478, upload-time = "2025-08-13T19:31:56.808Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a4/c2292b95246b9165cc43a0c3757e80995d58bc9b43da5cb47ad6e3535213/rtree-1.4.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f155bc8d6bac9dcd383481dee8c130947a4866db1d16cb6dff442329a038a0dc", size = 1555140, upload-time = "2025-08-13T19:31:58.031Z" }, - { url = "https://files.pythonhosted.org/packages/74/25/5282c8270bfcd620d3e73beb35b40ac4ab00f0a898d98ebeb41ef0989ec8/rtree-1.4.1-py3-none-win_amd64.whl", hash = "sha256:efe125f416fd27150197ab8521158662943a40f87acab8028a1aac4ad667a489", size = 389358, upload-time = "2025-08-13T19:31:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/3f/50/0a9e7e7afe7339bd5e36911f0ceb15fed51945836ed803ae5afd661057fd/rtree-1.4.1-py3-none-win_arm64.whl", hash = "sha256:3d46f55729b28138e897ffef32f7ce93ac335cb67f9120125ad3742a220800f0", size = 355253, upload-time = "2025-08-13T19:32:00.296Z" }, -] - [[package]] name = "ruff" version = "0.16.1"