From 74fc79528547799357bd65e824925de14fea8ccf Mon Sep 17 00:00:00 2001 From: Abraham Cheung <58929011+abc8747@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:34:43 +0800 Subject: [PATCH 01/16] refactor: pydantic settings and compat --- minisky/core/settings.py | 79 ++++++++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 31 deletions(-) diff --git a/minisky/core/settings.py b/minisky/core/settings.py index d3e37a7..7185231 100644 --- a/minisky/core/settings.py +++ b/minisky/core/settings.py @@ -1,43 +1,60 @@ -"""MiniSky settings loader. +"""MiniSky configuration.""" -Reads settings.toml from the project root at import time and exposes every -key/value pair as a module-level attribute (e.g., -``minisky.core.settings.prefer_compiled``). Nested tables (e.g. ``[tangram]``) -are exposed as the corresponding ``dict``. Also provides the data() helper -that resolves paths inside the package data directory. -""" +from __future__ import annotations -# %% import tomllib from pathlib import Path +from typing import Annotated, Any -filename_settings = Path(__file__).parent.parent.parent / "settings.toml" +import annotated_types +from pydantic import BaseModel, ConfigDict, Field -with open(filename_settings, "rb") as file: - config = tomllib.load(file) -for key, value in config.items(): - globals()[key] = value +class MiniSkySettings(BaseModel): + """Validated, immutable settings for the MiniSky runtime.""" -# Explicit type declarations for pyright (set dynamically above via globals()) -prefer_compiled: bool -asas_dtlookahead: float -asas_pzr: float -asas_pzh: float -asas_marh: float -asas_marv: float -plugin_path: str -enabled_plugins: list[str] + model_config = ConfigDict(frozen=True, extra="allow") + prefer_compiled: bool = True + asas_dtlookahead: Annotated[float, Field(), annotated_types.Ge(0)] = 300.0 + asas_pzr: Annotated[float, Field(), annotated_types.Gt(0)] = 5.0 + asas_pzh: Annotated[float, Field(), annotated_types.Gt(0)] = 1000.0 + asas_marh: Annotated[float, Field(), annotated_types.Gt(0)] = 1.05 + asas_marv: Annotated[float, Field(), annotated_types.Gt(0)] = 1.05 + plugin_path: Annotated[str, Field(), annotated_types.MinLen(1)] = "plugins" + # TODO(abraham): remove when we implement out-of-tree plugins + enabled_plugins: tuple[str, ...] = () -def data(path: str) -> Path: - """Return the absolute path of a file or folder in the package data directory. + @classmethod + def from_file(cls, path: str | Path) -> MiniSkySettings: + """Load and validate settings from a TOML file.""" + with Path(path).expanduser().open("rb") as file: + return cls.model_validate(tomllib.load(file)) + + +# +# compat +# + +DEFAULT_SETTINGS_FILE = Path(__file__).parent.parent.parent / "settings.toml" +PACKAGE_DATA_DIR = Path(__file__).parent.parent / "data" - Args: - path: Path relative to the minisky/data directory - (e.g., "navigation"). +filename_settings = DEFAULT_SETTINGS_FILE +default_settings = MiniSkySettings.from_file(filename_settings) - Returns: - Path: Absolute path to minisky/data/. - """ - return Path(__file__).parent.parent / "data" / path +config: dict[str, Any] = default_settings.model_dump() +prefer_compiled = default_settings.prefer_compiled +asas_dtlookahead = default_settings.asas_dtlookahead +asas_pzr = default_settings.asas_pzr +asas_pzh = default_settings.asas_pzh +asas_marh = default_settings.asas_marh +asas_marv = default_settings.asas_marv +plugin_path = default_settings.plugin_path +enabled_plugins = list(default_settings.enabled_plugins) + + +def data(path: str) -> Path: + """Return an absolute path inside the package data directory.""" + # NOTE(abraham): in the case where we need to distribute as a wheel this + # should be removed. + return PACKAGE_DATA_DIR / path From 2c0e9564e28ffd8314dbd7ed4e1ae31157fc159e Mon Sep 17 00:00:00 2001 From: Abraham Cheung <58929011+abc8747@users.noreply.github.com> Date: Mon, 27 Jul 2026 06:05:18 +0800 Subject: [PATCH 02/16] refactor: add explicit runtime as owner of nav, traffic, sim, console, runner - added await runtime.run(); - injected settings directly into conflict detection and resolution --- example_plugins/tangram.py | 3 +- minisky/__init__.py | 102 ++++++++++------------------- minisky/core/settings.py | 4 +- minisky/core/trafficarrays.py | 20 ++++-- minisky/runtime.py | 42 ++++++++++++ minisky/traffic/asas/detection.py | 42 +++++++----- minisky/traffic/asas/mvp.py | 19 +++--- minisky/traffic/asas/resolution.py | 61 +++++++++-------- minisky/traffic/traffic.py | 41 ++++++------ 9 files changed, 182 insertions(+), 152 deletions(-) create mode 100644 minisky/runtime.py diff --git a/example_plugins/tangram.py b/example_plugins/tangram.py index f2780a9..a30552a 100644 --- a/example_plugins/tangram.py +++ b/example_plugins/tangram.py @@ -407,7 +407,8 @@ def init_plugin() -> dict[str, Any]: """Create the bridge and register its simulation hooks.""" global bridge - cfg = TangramPluginSettings.model_validate(settings.config).tangram + # TODO(abraham): we should namespace it under settings.plugins.tangram. + cfg = TangramPluginSettings.model_validate(settings.default_settings).tangram bridge = TangramBridge( redis_url=cfg.redis_url, channel=cfg.channel, diff --git a/minisky/__init__.py b/minisky/__init__.py index 9d8b4a9..2d41628 100644 --- a/minisky/__init__.py +++ b/minisky/__init__.py @@ -1,34 +1,20 @@ -"""MiniSky: a minimal fork of BlueSky, the open-source ATM simulator. +"""MiniSky air traffic simulator. -This package is the top-level entry point of the simulator. It exposes the -main singleton objects that together make up a running simulation: - -- ``traf``: the :class:`~minisky.traffic.Traffic` object holding all aircraft states -- ``sim``: the :class:`~minisky.simulation.Simulation` object controlling sim time and state -- ``scr``: the :class:`~minisky.simulation.ConsoleIO` object buffering console output -- ``runner``: the :class:`~minisky.simulation.Runner` driving the asyncio simulation loop -- ``navdb``: the :class:`~minisky.tools.navdata.Navdatabase` with navaids, airports, and airways - -It also defines the shared return codes for stack commands (``BS_OK``, -``BS_ARGERR``, ``BS_FUNERR``, ``BS_CMDERR``) and the simulation state -constants (``INIT``, ``HOLD``, ``OP``, ``END``). - -Call :func:`init` once to construct these singletons, then optionally -:func:`load_plugins` to activate the plugins enabled in the settings. +`MiniSky` is the explicit owner of a simulator +runtime. The module-level `traf`, `sim`, `scr`, `runner`, and `navdb` names are +temporary compatibility aliases for the active runtime. """ +from __future__ import annotations + from minisky import core, plugin, stack, tools -from minisky.core import varexplorer -from minisky.core.settings import data +from minisky.core.settings import MiniSkySettings, data, filename_settings from minisky.simulation import ConsoleIO, Runner, Simulation from minisky.tools.navdata import Navdatabase # isort: split -# NOTE: ``traffic`` is imported last, and separately from the ``minisky import`` -# block above, on purpose: importing it pulls in the performance model, whose -# module-level code calls ``minisky.data`` (defined by the settings import -# above). Importing it earlier would trigger a circular import. The explicit -# subpackage import also lets pyright resolve ``minisky.traffic.*`` attributes. +# traffic remains last because importing the performance model reads +# `minisky.data` during module initialization. from minisky import traffic from minisky.traffic import Traffic @@ -41,9 +27,7 @@ # simulation states INIT, HOLD, OP, END = (0, 1, 2, 3) -# Main singleton objects in BlueSky. They are None until init() constructs them, -# but are annotated with their concrete types so downstream code type-checks -# against the real objects (init() must be called before any of them are used). +_current: MiniSky | None = None runner: Runner = None # type: ignore[assignment] traf: Traffic = None # type: ignore[assignment] navdb: Navdatabase = None # type: ignore[assignment] @@ -51,58 +35,40 @@ scr: ConsoleIO = None # type: ignore[assignment] -def init(scenario: str | None = None) -> None: - """Initialize all MiniSky modules and singletons. - - Constructs the navigation database, traffic, simulation, console I/O and - runner singletons, initializes the tools and variable explorer, and - discovers available plugins (via AST parsing, without importing them). - Must be called once before the simulation is stepped or run. +def _activate(instance: MiniSky) -> None: + """Point the compatibility aliases at an active runtime.""" + global _current, runner, traf, navdb, sim, scr - If a scenario filename is given it is loaded onto the command stack with - the ``IC`` command; otherwise the runner is configured to stay alive even - when a ``QUIT``/``STOP`` command is issued, so an idle simulator keeps - accepting commands. + _current = instance + runner = instance.runner + traf = instance.traffic + navdb = instance.navigation + sim = instance.simulation + scr = instance.console - Args: - scenario: Optional path to a scenario (.scn) file to load at startup. - When omitted, the simulator starts empty and shutdown is prevented. - """ - global traf, sim, scr, runner - global navdb - - # Initialise tools - tools.init() - navdb = Navdatabase() +from minisky.runtime import MiniSky # noqa: E402 - # Initialize singletons - traf = Traffic() - sim = Simulation() - scr = ConsoleIO() - runner = Runner() - # Initialize remaining modules - varexplorer.init() +def init( + scenario: str | None = None, + settings: MiniSkySettings | None = None, +) -> MiniSky: + """Construct and activate a MiniSky runtime. - if scenario: - stack.stack(f"IC {scenario}") - else: - # without scenario, sim shall be up - runner.prevent_shutdown() + This function is a compatibility adapter. New code should construct + `MiniSky` directly with explicit settings. + """ + if settings is None: + settings = MiniSkySettings.from_file(filename_settings) - stack.init() + instance = MiniSky(settings, scenario) - # Discover available plugins (AST parsing only, no imports) + # plugin discovery remains part of the legacy startup path for now. plugin.discover() + return instance def load_plugins() -> None: - """Load the plugins enabled in the settings. - - Imports and initializes every plugin listed under ``enabled_plugins`` in - the settings, registering their timed functions and stack commands. - Must be called after :func:`init`, since plugins may rely on the traffic - and simulation singletons during initialization. - """ + """Load plugins enabled by the compatibility settings module.""" plugin.load_enabled() diff --git a/minisky/core/settings.py b/minisky/core/settings.py index 7185231..feb4ff9 100644 --- a/minisky/core/settings.py +++ b/minisky/core/settings.py @@ -4,7 +4,7 @@ import tomllib from pathlib import Path -from typing import Annotated, Any +from typing import Annotated import annotated_types from pydantic import BaseModel, ConfigDict, Field @@ -41,8 +41,6 @@ def from_file(cls, path: str | Path) -> MiniSkySettings: filename_settings = DEFAULT_SETTINGS_FILE default_settings = MiniSkySettings.from_file(filename_settings) - -config: dict[str, Any] = default_settings.model_dump() prefer_compiled = default_settings.prefer_compiled asas_dtlookahead = default_settings.asas_dtlookahead asas_pzr = default_settings.asas_pzr diff --git a/minisky/core/trafficarrays.py b/minisky/core/trafficarrays.py index 7b4ee03..f6fabc3 100644 --- a/minisky/core/trafficarrays.py +++ b/minisky/core/trafficarrays.py @@ -7,15 +7,15 @@ MiniSky stores aircraft state as parallel numpy arrays and lists, where index i in every array belongs to the same aircraft. Per-aircraft parameters are registered by assigning them inside a -``with self.settrafarrays():`` block (implemented by +`with self.settrafarrays():` block (implemented by RegisterElementParameters): every list or numpy array created inside the block is recorded in _LstVars or _ArrVars, and every nested TrafficArrays instance is re-parented to form a tree rooted at the traffic object. -When aircraft are created, ``create(n)`` appends n default-valued elements +When aircraft are created, `create(n)` appends n default-valued elements to every registered list and array; when aircraft are deleted, -``delete(idx)`` removes the corresponding elements from all of them, and -``reset()`` empties everything back to zero aircraft. Each of these +`delete(idx)` removes the corresponding elements from all of them, and +`reset()` empties everything back to zero aircraft. Each of these operations recurses through the tree of children, so all per-aircraft data in the simulation grows and shrinks in lockstep. """ @@ -88,7 +88,7 @@ def _replace_instance_on_traf(base: type["TrafficArrays"], impl: type["TrafficAr for attr_name, attr_value in minisky.traf.__dict__.items(): if isinstance(attr_value, base): # Create new instance of selected implementation - new_instance = impl() + new_instance = attr_value.new_implementation(impl) # Copy over any per-aircraft array data from old instance (if they exist) for arr_var in getattr(attr_value, "_ArrVars", []): if hasattr(new_instance, arr_var): @@ -96,8 +96,10 @@ def _replace_instance_on_traf(base: type["TrafficArrays"], impl: type["TrafficAr for lst_var in getattr(attr_value, "_LstVars", []): if hasattr(new_instance, lst_var): setattr(new_instance, lst_var, getattr(attr_value, lst_var)) - # Replace on traf + # Replace on traf and detach the old child from the traffic tree. setattr(minisky.traf, attr_name, new_instance) + if attr_value._parent is not None: + attr_value._parent._children.remove(attr_value) # Stack commands registered as bound methods of the old instance # would silently mutate the orphaned object; rebind them _rebind_stack_commands(attr_value, new_instance) @@ -281,6 +283,12 @@ def __init__(self) -> None: self._ArrVars = [] self._LstVars = [] + def new_implementation( + self, implementation: type["TrafficArrays"] + ) -> "TrafficArrays": + """Construct a selected replacement implementation.""" + return implementation() + def reparent(self, newparent: "TrafficArrays") -> None: """Give TrafficArrays object a new parent.""" # Remove myself from the parent list of children, and add to new parent diff --git a/minisky/runtime.py b/minisky/runtime.py new file mode 100644 index 0000000..bf25218 --- /dev/null +++ b/minisky/runtime.py @@ -0,0 +1,42 @@ +"""Explicit ownership root for one MiniSky runtime.""" + +from __future__ import annotations + +from minisky import stack, tools +from minisky.core import varexplorer +from minisky.core.settings import MiniSkySettings +from minisky.simulation import ConsoleIO, Runner, Simulation +from minisky.tools.navdata import Navdatabase +from minisky.traffic import Traffic + + +class MiniSky: + """Own the primary objects that make up one simulator runtime.""" + + def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> None: + self.settings = settings + tools.init() + + self.navigation = Navdatabase() + self.traffic = Traffic(settings) + self.simulation = Simulation() + self.console = ConsoleIO() + self.runner = Runner() + + # compatibility facade must be active before modules register + # commands and variable-explorer parents against the runtime. + import minisky + + minisky._activate(self) + varexplorer.init() + + if scenario: + stack.stack(f"IC {scenario}") + else: + self.runner.prevent_shutdown() + + stack.init() + + async def run(self) -> None: + """Run the simulation until its runner stops.""" + await self.runner.run() diff --git a/minisky/traffic/asas/detection.py b/minisky/traffic/asas/detection.py index ddd2afc..1e03e0a 100644 --- a/minisky/traffic/asas/detection.py +++ b/minisky/traffic/asas/detection.py @@ -4,8 +4,8 @@ current position and velocity of each aircraft (the ownship) is linearly extrapolated and compared against every other aircraft (the intruder). A conflict is flagged when the extrapolated trajectories penetrate each other's -cylindrical protected zone (radius ``rpz``, half-height ``hpz``) within the -lookahead time ``dtlookahead``. A loss of separation (LoS) is flagged when the +cylindrical protected zone (radius `rpz`, half-height `hpz`) within the +lookahead time `dtlookahead`. A loss of separation (LoS) is flagged when the protected zone is already penetrated at the current time. Rather than evaluating all N^2 aircraft pairs, detection first selects @@ -23,6 +23,7 @@ from scipy.spatial import KDTree import minisky +from minisky.core.settings import MiniSkySettings from minisky.core.trafficarrays import TrafficArrays from minisky.stack.argparser import Time, Txt from minisky.tools.aero import ft, nm @@ -58,9 +59,9 @@ class ConflictDetection(TrafficArrays): protected zone disk overlaps in time with the horizontal intrusion, and the conflict starts within the lookahead time. - The result of each update is stored both as pairwise lists (``confpairs``, - ``lospairs`` and the per-conflict geometry arrays) and as per-aircraft - arrays (``inconf``, ``tcpamax``). Separation minima and lookahead time can + The result of each update is stored both as pairwise lists (`confpairs`, + `lospairs` and the per-conflict geometry arrays) and as per-aircraft + arrays (`inconf`, `tcpamax`). Separation minima and lookahead time can be set globally or per aircraft. Attributes: @@ -90,17 +91,18 @@ class ConflictDetection(TrafficArrays): dtnolook (ndarray): Per-aircraft detection hold-off interval [s]. """ - def __init__(self) -> None: + def __init__(self, settings: MiniSkySettings) -> None: super().__init__() + self.settings = settings ## Default values # [m] Horizontal separation minimum for detection - self.rpz_def = minisky.core.settings.asas_pzr * nm + self.rpz_def = self.settings.asas_pzr * nm self.global_rpz = True # [m] Vertical separation minimum for detection - self.hpz_def = minisky.core.settings.asas_pzh * ft + self.hpz_def = self.settings.asas_pzh * ft self.global_hpz = True # [s] lookahead time - self.dtlookahead_def = minisky.core.settings.asas_dtlookahead + self.dtlookahead_def = self.settings.asas_dtlookahead self.global_dtlook = True self.dtnolook_def = 0.0 self.global_dtnolook = True @@ -135,12 +137,16 @@ def __init__(self) -> None: self.dtlookahead = np.array([]) self.dtnolook = np.array([]) + def new_implementation(self, implementation: type[TrafficArrays]) -> TrafficArrays: + """Construct a replacement with this runtime's settings.""" + return implementation(self.settings) + def clearconfdb(self) -> None: """Clear the conflict database. Empties the pairwise conflict/LoS lists and geometry arrays of the current timestep and resets the per-aircraft conflict flags. The - historic lists (``confpairs_all``, ``lospairs_all``) are kept. + historic lists (`confpairs_all`, `lospairs_all`) are kept. """ self.confpairs_unique.clear() self.lospairs_unique.clear() @@ -159,7 +165,7 @@ def create(self, n: int = 1) -> None: """Initialise per-aircraft detection parameters for new aircraft. Called by the traffic object when aircraft are created. Extends all - per-aircraft arrays and fills the last ``n`` elements with the current + per-aircraft arrays and fills the last `n` elements with the current default separation minima and lookahead times. Args: @@ -183,9 +189,9 @@ def reset(self) -> None: self.clearconfdb() self.confpairs_all.clear() self.lospairs_all.clear() - self.rpz_def = minisky.core.settings.asas_pzr * nm - self.hpz_def = minisky.core.settings.asas_pzh * ft - self.dtlookahead_def = minisky.core.settings.asas_dtlookahead + self.rpz_def = self.settings.asas_pzr * nm + self.hpz_def = self.settings.asas_pzh * ft + self.dtlookahead_def = self.settings.asas_dtlookahead self.dtnolook_def = 0.0 self.global_rpz = self.global_hpz = True self.global_dtlook = self.global_dtnolook = True @@ -202,7 +208,7 @@ def switch(self, name: Txt = "ON") -> "tuple | None": tuple: (success (bool), message (str)) for the command stack. Raises: - AssertionError: If ``name`` is not "ON" or "OFF". + AssertionError: If `name` is not "ON" or "OFF". """ assert name in ["ON", "OFF"], f"Invalid CD method: {name}" @@ -347,10 +353,10 @@ def setdtnolook(self, time: Time = -1.0, *acidx: int) -> tuple: def update(self, ownship: Any, intruder: Any) -> None: """Perform an update step of the Conflict Detection implementation. - Runs :meth:`detect` on the current traffic states and stores its + Runs [`ConflictDetection.detect`][minisky.traffic.asas.detection.ConflictDetection.detect] on the current traffic states and stores its results. Also maintains the sets of unique conflict/LoS pairs (where (a, b) and (b, a) count as one pair) and appends newly appearing - pairs to the cumulative ``confpairs_all``/``lospairs_all`` lists. + pairs to the cumulative `confpairs_all`/`lospairs_all` lists. Args: ownship: Traffic object with the states of the ownship aircraft. @@ -397,7 +403,7 @@ def detect( State-based detection with spatial candidate pruning: a KD-tree on flat-earth-projected positions selects the pairs within horizontal - reach (``max(rpz) + 2 * max(gs) * max(dtlookahead)``), pairs that are + reach (`max(rpz) + 2 * max(gs) * max(dtlookahead)`), pairs that are vertically out of reach within the lookahead are dropped, and the CPA geometry is evaluated only for the remaining candidates. For every candidate pair, the time to the horizontal closest point of approach diff --git a/minisky/traffic/asas/mvp.py b/minisky/traffic/asas/mvp.py index 81e93bd..cb2d441 100644 --- a/minisky/traffic/asas/mvp.py +++ b/minisky/traffic/asas/mvp.py @@ -18,6 +18,7 @@ import numpy as np +from minisky.core.settings import MiniSkySettings from minisky.stack.argparser import Txt from minisky.traffic.asas import ConflictResolution @@ -25,15 +26,15 @@ class MVP(ConflictResolution): """Conflict resolution using the Modified Voltage Potential Method. - For each detected conflict pair, :meth:`MVP` computes a repulsive + For each detected conflict pair, [`MVP.MVP`][minisky.traffic.asas.mvp.MVP.MVP] computes a repulsive velocity-change vector that pushes the closest point of approach out of - the resolution zone (the protected zone scaled by ``resofach`` and - ``resofacv``). :meth:`resolve` accumulates these vectors for all + the resolution zone (the protected zone scaled by `resofach` and + `resofacv`). [`MVP.resolve`][minisky.traffic.asas.mvp.MVP.resolve] accumulates these vectors for all conflicts of each aircraft, adds them to the current velocity, and converts the result into track, ground speed, vertical speed, and altitude advisories, capped to the aircraft performance envelope. - Selected via the stack command ``RESO MVP``. Resolution manoeuvres can be + Selected via the stack command `RESO MVP`. Resolution manoeuvres can be restricted with RMETHH (horizontal: heading and/or speed) and RMETHV (vertical speed only). @@ -44,8 +45,8 @@ class MVP(ConflictResolution): swresovert (bool): Limit resolutions to the vertical direction. """ - def __init__(self) -> None: - super().__init__() + def __init__(self, settings: MiniSkySettings) -> None: + super().__init__(settings) # [-] switch to limit resolution to the horizontal direction self.swresohoriz = True # [-] switch to use only speed resolutions (works with swresohoriz = True) @@ -59,7 +60,7 @@ def setprio(self, flag=None, priocode="") -> "bool | tuple": """Set the prio switch and the type of prio. Implements the PRIORULES stack command for MVP. Validates the - priority code against the codes supported by :meth:`applyprio`. + priority code against the codes supported by [`MVP.applyprio`][minisky.traffic.asas.mvp.MVP.applyprio]. Args: flag (bool): True to enable priority rules, False to disable. @@ -278,7 +279,7 @@ def resolve(self, conf: Any, ownship: Any, intruder: Any) -> tuple: """Resolve all current conflicts. Loops over all detected conflict pairs, computes the MVP resolution - vector for each with :meth:`MVP`, and accumulates the vectors per + vector for each with [`MVP.MVP`][minisky.traffic.asas.mvp.MVP.MVP], and accumulates the vectors per aircraft (applying priority rules and the NORESO/RESOOFF opt-outs). The summed velocity change is added to the current velocity vector and converted back to advisories, honouring the horizontal/vertical @@ -423,7 +424,7 @@ def MVP( Computes the velocity change that displaces the predicted closest point of approach (CPA) of one conflict pair to the edge of the - resolution zone (protected zone scaled by ``resofach``/``resofacv``). + resolution zone (protected zone scaled by `resofach`/`resofacv`). Horizontally, the intrusion at CPA is divided by the time to CPA to obtain the required speed change along the CPA displacement direction; a geometric correction is applied when the intruder is diff --git a/minisky/traffic/asas/resolution.py b/minisky/traffic/asas/resolution.py index 97f7283..f7376b6 100644 --- a/minisky/traffic/asas/resolution.py +++ b/minisky/traffic/asas/resolution.py @@ -1,16 +1,16 @@ """Conflict resolution base class. -This module provides :class:`ConflictResolution`, the base class for all +This module provides [`ConflictResolution`][minisky.traffic.asas.resolution.ConflictResolution], the base class for all conflict resolution (CR) implementations in MiniSky. It manages the shared resolution machinery: per-aircraft resolution advisories (heading, speed, vertical speed, altitude), resolution zone margins relative to the detection protected zone, priority rules, per-aircraft opt-outs (NORESO/RESOOFF), and the logic that decides when an aircraft may resume normal navigation after a -conflict has been resolved (:meth:`ConflictResolution.resumenav`). +conflict has been resolved ([`ConflictResolution.resumenav`][minisky.traffic.asas.resolution.ConflictResolution.resumenav]). Actual resolution algorithms (e.g. the Modified Voltage Potential method in -``minisky.traffic.asas.mvp``) subclass this class and override -:meth:`ConflictResolution.resolve`. +`minisky.traffic.asas.mvp`) subclass this class and override +[`ConflictResolution.resolve`][minisky.traffic.asas.resolution.ConflictResolution.resolve]. """ from typing import Any @@ -18,7 +18,8 @@ import numpy as np import minisky -from minisky.core.trafficarrays import TrafficArrays +from minisky.core.settings import MiniSkySettings +from minisky.core.trafficarrays import TrafficArrays, select_implementation from minisky.stack.argparser import Txt from minisky.tools.aero import ft, nm @@ -27,14 +28,14 @@ class ConflictResolution(TrafficArrays): """Base class for Conflict Resolution implementations. Each update step, when resolution is active and conflicts are detected, - :meth:`resolve` is called to compute resolution advisories for all + [`ConflictResolution.resolve`][minisky.traffic.asas.resolution.ConflictResolution.resolve] is called to compute resolution advisories for all aircraft. These advisories are stored in the per-aircraft arrays below and - are followed by the autopilot for aircraft whose ``active`` flag is True. - :meth:`resumenav` then decides per aircraft whether to keep following the + are followed by the autopilot for aircraft whose `active` flag is True. + [`ConflictResolution.resumenav`][minisky.traffic.asas.resolution.ConflictResolution.resumenav] then decides per aircraft whether to keep following the resolution or to resume the flight plan (after the conflict pair has passed its closest point of approach). - The base class itself performs no avoidance: its :meth:`resolve` simply + The base class itself performs no avoidance: its [`ConflictResolution.resolve`][minisky.traffic.asas.resolution.ConflictResolution.resolve] simply returns the autopilot values. Subclasses implement an actual algorithm. Attributes: @@ -60,8 +61,9 @@ class ConflictResolution(TrafficArrays): vs (ndarray): Resolution vertical speed advisory [m/s]. """ - def __init__(self) -> None: + def __init__(self, settings: MiniSkySettings) -> None: super().__init__() + self.settings = settings self.activate = False # [-] switch to activate priority rules for conflict resolution @@ -72,8 +74,8 @@ def __init__(self) -> None: # Resolution factors: # set < 1 to maneuver only a fraction of the resolution # set > 1 to add a margin to separation values - self.resofach = minisky.core.settings.asas_marh - self.resofacv = minisky.core.settings.asas_marv + self.resofach = self.settings.asas_marh + self.resofacv = self.settings.asas_marv # Switches to guarantee last reso zone commands keep valid if cd zone changes self.resodhrelative = ( @@ -91,6 +93,10 @@ def __init__(self) -> None: self.alt = np.array([]) # alt provided by the ASAS [m] self.vs = np.array([]) # vspeed provided by the ASAS [m/s] + def new_implementation(self, implementation: type[TrafficArrays]) -> TrafficArrays: + """Construct a replacement with this runtime's settings.""" + return implementation(self.settings) + def switch(self, flag: bool | None = None) -> None: """Turn conflict resolution on or off. @@ -110,8 +116,8 @@ def reset(self) -> None: self.swprio = False self.priocode = "" self.resopairs.clear() - self.resofach = minisky.core.settings.asas_marh - self.resofacv = minisky.core.settings.asas_marv + self.resofach = self.settings.asas_marh + self.resofacv = self.settings.asas_marv self.resodhrelative = True self.resorrelative = True @@ -177,8 +183,8 @@ def update(self, conf: Any, ownship: Any, intruder: Any) -> None: """Perform an update step of the Conflict Resolution implementation. When resolution is active, computes new resolution advisories with - :meth:`resolve` if there are current conflicts, and updates which - aircraft should keep following the resolution with :meth:`resumenav`. + [`ConflictResolution.resolve`][minisky.traffic.asas.resolution.ConflictResolution.resolve] if there are current conflicts, and updates which + aircraft should keep following the resolution with [`ConflictResolution.resumenav`][minisky.traffic.asas.resolution.ConflictResolution.resumenav]. Args: conf: The ConflictDetection instance with the current conflicts. @@ -308,7 +314,7 @@ def setprio(self, flag: bool | None = None, priocode="") -> "bool | tuple": Implements the PRIORULES stack command. The base class only stores the settings; interpretation of the priority code is up to the - resolution algorithm (see e.g. ``MVP.applyprio``). + resolution algorithm (see e.g. `MVP.applyprio`). Args: flag (bool): True to enable priority rules, False to disable. @@ -334,7 +340,7 @@ def setnoreso(self, *idx: int) -> "bool | tuple": """ADD or Remove aircraft that nobody will avoid. Multiple aircraft can be sent to this function at once. - Implements the NORESO stack command: toggles the ``noresoac`` flag + Implements the NORESO stack command: toggles the `noresoac` flag for the given aircraft. Flagged aircraft still avoid others, but other aircraft will not avoid them. @@ -360,7 +366,7 @@ def setresooff(self, *idx: int) -> "bool | tuple": """ADD or Remove aircraft that will not avoid anybody else. Multiple aircraft can be sent to this function at once. - Implements the RESOOFF stack command: toggles the ``resooffac`` flag + Implements the RESOOFF stack command: toggles the `resooffac` flag for the given aircraft. Flagged aircraft perform no resolution manoeuvres themselves, but others may still avoid them. @@ -388,7 +394,7 @@ def setresofach(self, factor: float | None = None) -> tuple: (to maneuver only a fraction of a resolution vector). Implements the RFACH stack command. The horizontal resolution zone - radius is ``resofach`` times the detection protected zone radius: + radius is `resofach` times the detection protected zone radius: values below 1 manoeuvre only a fraction of the resolution, values above 1 add a separation margin. @@ -415,7 +421,7 @@ def setresofacv(self, factor: float | None = None) -> tuple: """Set resolution factor vertical (to maneuver only a fraction of a resolution vector). Implements the RFACV stack command. The vertical resolution zone - height is ``resofacv`` times the detection protected zone height. + height is `resofacv` times the detection protected zone height. Args: factor (float): Vertical resolution factor [-]. When None, the @@ -439,7 +445,7 @@ def setresozoner(self, zoner: float | None = None) -> tuple: (to maneuver only a fraction of a resolution vector). Implements the RSZONER stack command: sets the horizontal resolution - zone as an absolute radius, from which ``resofach`` is derived. Only + zone as an absolute radius, from which `resofach` is derived. Only available when all aircraft share the same (global) protected zone radius. @@ -475,7 +481,7 @@ def setresozonedh(self, zonedh: float | None = None) -> tuple: resolution vector), but then with absolute value. Implements the RSZONEDH stack command: sets the vertical resolution - zone as an absolute height, from which ``resofacv`` is derived. Only + zone as an absolute height, from which `resofacv` is derived. Only available when all aircraft share the same (global) protected zone height. @@ -511,7 +517,7 @@ def setmethod(name: Txt = "") -> tuple: """Select a Conflict Resolution method. Implements the RESO stack command. Selecting "MVP" replaces the - traffic object's resolution instance (``minisky.traf.cr``) with a new + traffic object's resolution instance (`minisky.traf.cr`) with a new MVP instance and activates it. Args: @@ -535,10 +541,9 @@ def setmethod(name: Txt = "") -> tuple: return True, "Conflict Resolution turned off." if name == "MVP": - from minisky.traffic.asas.mvp import MVP - - # Replace the current conflict resolution instance with MVP - minisky.traf.cr = MVP() + success, message = select_implementation("CONFLICTRESOLUTION", name) + if not success: + return success, message minisky.traf.cr.switch(True) return True, "Selected MVP as Conflict Resolution method." diff --git a/minisky/traffic/traffic.py b/minisky/traffic/traffic.py index f5a525b..c502ca2 100644 --- a/minisky/traffic/traffic.py +++ b/minisky/traffic/traffic.py @@ -1,14 +1,14 @@ """BlueSky traffic implementation. -Defines the :class:`Traffic` class, the top-level traffic database of the -simulator. It holds all per-aircraft state (position, attitude, speeds, +Defines the [`Traffic`][minisky.traffic.traffic.Traffic] class, the top-level +traffic database of the simulator. It holds all per-aircraft state (position, attitude, speeds, atmosphere, autopilot selections) as numpy arrays, owns the sub-models (autopilot, performance, conflict detection/resolution, wind, turbulence, trails, groups), and performs the numerical integration of the aircraft states each simulation time step. A single instance is created at simulator start-up and made available as -``minisky.traf``. Several methods double as stack-command implementations +`minisky.traf`. Several methods double as stack-command implementations (CRE, MCRE, CRECONFS, MOVE, POS, BANK, THR, NOISE, CRECMD, ...). """ @@ -19,6 +19,7 @@ import numpy as np import minisky +from minisky.core.settings import MiniSkySettings from minisky.core.trafficarrays import TrafficArrays from minisky.tools import geo from minisky.tools.aero import ( @@ -54,13 +55,16 @@ class Traffic(TrafficArrays): """Central traffic database holding the state of all simulated aircraft. - Traffic is the top-level :class:`TrafficArrays` object: all per-aircraft + Traffic is the top-level + [`TrafficArrays`][minisky.core.trafficarrays.TrafficArrays] object: all per-aircraft arrays registered by its child entities (autopilot, active waypoint data, performance model, conflict detection/resolution, etc.) grow and shrink together when aircraft are created or deleted. A single instance is - available at runtime as ``minisky.traf``. + available at runtime as `minisky.traf`. - Every simulation step, :meth:`update` refreshes the atmosphere, runs the + Every simulation step, + [`Traffic.update`][minisky.traffic.traffic.Traffic.update] refreshes the atmosphere, + runs the autopilot and separation-assurance logic, applies performance limits, and numerically integrates airspeed, heading, vertical speed and position of all aircraft. All internal state is kept in SI units; stack commands use @@ -118,8 +122,9 @@ class Traffic(TrafficArrays): Created by: Jacco M. Hoekstra """ - def __init__(self) -> None: + def __init__(self, settings: MiniSkySettings) -> None: super().__init__() + self.settings = settings # Traffic is the toplevel trafficarrays object self.setroot(self) @@ -181,8 +186,8 @@ def __init__(self) -> None: self.swvnavspd = np.array([], dtype=bool) # Flight Models - self.cd = ConflictDetection() - self.cr = ConflictResolution() + self.cd = ConflictDetection(settings) + self.cr = ConflictResolution(settings) self.ap = Autopilot() self.aporasas = APorASAS() self.noise = SurveillanceUncertainty() @@ -333,9 +338,7 @@ def mcre( acalt_ = np.full(n, acalt) if acalt is not None else np.random.randint(2000, 39000, n) * ft acspd_ = np.full(n, acspd) if acspd is not None else np.random.randint(250, 450, n) * kts - self.__create_aircraft( - np.array(callsign), actype_, aclat, aclon, achdg, acalt_, acspd_ - ) + self.__create_aircraft(np.array(callsign), actype_, aclat, aclon, achdg, acalt_, acspd_) return True, f"{n} aircraft created" @@ -490,8 +493,8 @@ def creconfs( tasref = self.tas[targetidx] # m/s vsref = self.vs[targetidx] # m/s cpa = dcpa * nm - pzr = minisky.core.settings.asas_pzr * nm - pzh = minisky.core.settings.asas_pzh * ft + pzr = self.settings.asas_pzr * nm + pzh = self.settings.asas_pzh * ft trk = trkref + np.radians(dpsi) if dH is None: @@ -537,9 +540,7 @@ def creconfs( achdg = np.degrees(np.atan2(tase, tasn)) # Create and, when necessary, set vertical speed - self.cre( - callsign, actype, float(aclat), float(aclon), float(achdg), acalt, float(acspd) - ) + self.cre(callsign, actype, float(aclat), float(aclon), float(achdg), acalt, float(acspd)) self.ap.selaltcmd(len(self.lat) - 1, altref, acvs) self.vs[-1] = acvs @@ -852,8 +853,10 @@ def position(self, id_or_name: int | str) -> tuple[bool, str]: """Show information on an aircraft, airport, waypoint or navaid. Implements the POS stack command. Dispatches to - :meth:`position_aircraft` when an aircraft index is given, and to - :meth:`position_by_name` for a name lookup. + [`Traffic.position_aircraft`][minisky.traffic.traffic.Traffic.position_aircraft] + when an aircraft index is given, and to + [`Traffic.position_by_name`][minisky.traffic.traffic.Traffic.position_by_name] + for a name lookup. Args: id_or_name: Aircraft index (int) or the name of an aircraft, From 203664685a4312ca11b6504564c130ebb2c15b01 Mon Sep 17 00:00:00 2001 From: Abraham Cheung <58929011+abc8747@users.noreply.github.com> Date: Mon, 27 Jul 2026 06:25:08 +0800 Subject: [PATCH 03/16] refactor: make `Runner` own `simulation` and `console` refs - also make `Simulation` own explicit traffic, navigation, and console refs --- minisky/__init__.py | 4 +- minisky/core/trafficarrays.py | 27 +++--- minisky/runtime.py | 25 ++++-- minisky/simulation/console.py | 39 +++++---- minisky/simulation/runner.py | 63 ++++++++------ minisky/simulation/simulation.py | 145 +++++++++++++++++-------------- minisky/tools/navdata.py | 52 +++++++---- 7 files changed, 206 insertions(+), 149 deletions(-) diff --git a/minisky/__init__.py b/minisky/__init__.py index 2d41628..213ba46 100644 --- a/minisky/__init__.py +++ b/minisky/__init__.py @@ -10,6 +10,7 @@ from minisky import core, plugin, stack, tools from minisky.core.settings import MiniSkySettings, data, filename_settings from minisky.simulation import ConsoleIO, Runner, Simulation +from minisky.simulation.simulation import END, HOLD, INIT, OP from minisky.tools.navdata import Navdatabase # isort: split @@ -24,9 +25,6 @@ BS_FUNERR = 2 BS_CMDERR = 4 -# simulation states -INIT, HOLD, OP, END = (0, 1, 2, 3) - _current: MiniSky | None = None runner: Runner = None # type: ignore[assignment] traf: Traffic = None # type: ignore[assignment] diff --git a/minisky/core/trafficarrays.py b/minisky/core/trafficarrays.py index f6fabc3..7a63f78 100644 --- a/minisky/core/trafficarrays.py +++ b/minisky/core/trafficarrays.py @@ -30,12 +30,12 @@ replaceables: dict[str, type["TrafficArrays"]] = {} -def reset_replaceables() -> None: +def reset_replaceables(traffic: "TrafficArrays") -> None: """Reset all replaceables to their default implementation and reinstantiate on traf.""" for base in replaceables.values(): base.selectdefault() # Reinstantiate on traf with default implementation - _replace_instance_on_traf(base, base._generator) + _replace_instance_on_traf(base, base._generator, traffic) def select_implementation(basename: str = "", implname: str = "") -> tuple[bool, str]: @@ -68,24 +68,25 @@ def select_implementation(basename: str = "", implname: str = "") -> tuple[bool, impl.select() - # Replace existing instance on traf if it exists - _replace_instance_on_traf(base, impl) + # The stack command still targets the active compatibility runtime. + import minisky + + _replace_instance_on_traf(base, impl, minisky.traf) return True, f"Selected {implname} for {basename}" -def _replace_instance_on_traf(base: type["TrafficArrays"], impl: type["TrafficArrays"]) -> None: +def _replace_instance_on_traf( + base: type["TrafficArrays"], + impl: type["TrafficArrays"], + traffic: "TrafficArrays", +) -> None: """Replace existing instance of base class on traf with new impl instance. This ensures SELECTIMPL takes effect immediately, not just for future instantiations. """ - import minisky - - if minisky.traf is None: - return - - # Find attribute on traf that is an instance of the base class - for attr_name, attr_value in minisky.traf.__dict__.items(): + # Find attribute on traffic that is an instance of the base class + for attr_name, attr_value in traffic.__dict__.items(): if isinstance(attr_value, base): # Create new instance of selected implementation new_instance = attr_value.new_implementation(impl) @@ -97,7 +98,7 @@ def _replace_instance_on_traf(base: type["TrafficArrays"], impl: type["TrafficAr if hasattr(new_instance, lst_var): setattr(new_instance, lst_var, getattr(attr_value, lst_var)) # Replace on traf and detach the old child from the traffic tree. - setattr(minisky.traf, attr_name, new_instance) + setattr(traffic, attr_name, new_instance) if attr_value._parent is not None: attr_value._parent._children.remove(attr_value) # Stack commands registered as bound methods of the old instance diff --git a/minisky/runtime.py b/minisky/runtime.py index bf25218..69450d6 100644 --- a/minisky/runtime.py +++ b/minisky/runtime.py @@ -4,8 +4,9 @@ from minisky import stack, tools from minisky.core import varexplorer -from minisky.core.settings import MiniSkySettings +from minisky.core.settings import MiniSkySettings, data from minisky.simulation import ConsoleIO, Runner, Simulation +from minisky.simulation.simulation import OP from minisky.tools.navdata import Navdatabase from minisky.traffic import Traffic @@ -17,14 +18,19 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No self.settings = settings tools.init() - self.navigation = Navdatabase() + self.console = ConsoleIO(lambda: self.simulation.state == OP) + self.navigation = Navdatabase(data("navigation"), self.console) self.traffic = Traffic(settings) - self.simulation = Simulation() - self.console = ConsoleIO() - self.runner = Runner() - - # compatibility facade must be active before modules register - # commands and variable-explorer parents against the runtime. + self.simulation = Simulation( + traffic=self.traffic, + navigation=self.navigation, + console=self.console, + stop_runner=self._stop_runner, + ) + self.runner = Runner(self.simulation, self.console) + + # the compatibility facade must be active before commands and variable + # explorer parents are registered against this runtime. import minisky minisky._activate(self) @@ -37,6 +43,9 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No stack.init() + def _stop_runner(self) -> None: + self.runner.stop() + async def run(self) -> None: """Run the simulation until its runner stops.""" await self.runner.run() diff --git a/minisky/simulation/console.py b/minisky/simulation/console.py index 03f2ef9..bac9092 100644 --- a/minisky/simulation/console.py +++ b/minisky/simulation/console.py @@ -1,30 +1,31 @@ """Console I/O for the MiniSky simulation. -Defines :class:`ConsoleIO`, the text output channel of the simulator. Stack -commands and simulation state changes report back through its :meth:`echo` +Defines `ConsoleIO`, the text output channel of the simulator. Stack +commands and simulation state changes report back through its `echo` method, which prints to stdout and stores the message in a buffer that remote -clients (such as the HTTP API served by ``minisky server``) can read asynchronously. -A single instance is created by :func:`minisky.init` and available as -``minisky.scr``. +clients (such as the HTTP API served by `minisky server`) can read asynchronously. +A single instance is owned by `MiniSky` and temporarily available as +`minisky.scr` through the compatibility facade. """ +from __future__ import annotations + import asyncio import io import sys +from collections.abc import Callable from colorama import Fore, Style -import minisky - class ConsoleIO: """Class within sim task which sends/receives data to/from GUI task. - Acts as the simulator's screen/console object (``minisky.scr``). Output - produced with :meth:`echo` is printed to stdout and kept in an in-memory - buffer; an :class:`asyncio.Event` is set on every echo so that awaiting - consumers (e.g. the HTTP API's ``/stack`` endpoint) know new output is - available and can collect it with :meth:`read_output_buffer`. + Acts as the simulator's screen/console object (`minisky.scr`). Output + produced with `echo` is printed to stdout and kept in an in-memory + buffer; an `asyncio.Event` is set on every echo so that awaiting + consumers (e.g. the HTTP API's `/stack` endpoint) know new output is + available and can collect it with `read_output_buffer`. Attributes: siminfo_rate: Update rate of simulation info messages [Hz]. @@ -32,8 +33,8 @@ class ConsoleIO: prevtime: Simulation time of the previous info update [s]. samplecount: Number of simulation samples counted while operating. prevcount: Sample count at the previous info update. - output_buffer: ``StringIO`` buffer holding the latest echoed text. - event: ``asyncio.Event`` set whenever new output has been echoed. + output_buffer: `StringIO` buffer holding the latest echoed text. + event: `asyncio.Event` set whenever new output has been echoed. """ # Prefix for the stdout copy of echoed text, aligned with uvicorn's @@ -47,7 +48,9 @@ class ConsoleIO: # Update rate of aircraft update messages [Hz] acupdate_rate: int = 5 - def __init__(self) -> None: + def __init__(self, is_operating: Callable[[], bool]) -> None: + self.is_operating = is_operating + # Timing bookkeeping counters self.prevtime: float = 0.0 self.samplecount: int = 0 @@ -60,9 +63,9 @@ def update(self) -> None: """Count one simulation sample while the simulation is operating. Increments the sample counter only when the simulation state is - ``OP``; used for bookkeeping of the effective update rate. + `OP`; used for bookkeeping of the effective update rate. """ - if minisky.sim.state == minisky.OP: + if self.is_operating(): self.samplecount += 1 def reset(self) -> None: @@ -75,7 +78,7 @@ def echo(self, text: str = "", flag: int = 0) -> None: """Print a message and store it in the output buffer. The previous buffer contents are discarded, the text is written both - to stdout (each line prefixed with :attr:`prefix`) and to the buffer + to stdout (each line prefixed with `prefix`) and to the buffer (verbatim), and the output event is set to wake up any consumer awaiting new output. diff --git a/minisky/simulation/runner.py b/minisky/simulation/runner.py index ba1e6fe..c5c5f84 100644 --- a/minisky/simulation/runner.py +++ b/minisky/simulation/runner.py @@ -1,18 +1,22 @@ """Node encapsulates the sim process, and manages process I/O. -Defines the :class:`Runner`, the asyncio-based main loop of MiniSky. It calls -``minisky.sim.step()`` repeatedly at an interval derived from the requested +Defines the `Runner`, the asyncio-based main loop of MiniSky. It calls +`self.simulation.step()` repeatedly at an interval derived from the requested simulation speed, and supports fast-forward jumps where the sleep interval is reduced to a minimum until a target simulation time is reached. A single -instance is created by :func:`minisky.init` and available as -``minisky.runner``. +instance is owned by `MiniSky` and temporarily available as `minisky.runner` +through the compatibility facade. """ +from __future__ import annotations + import asyncio import os -from typing import Any +from typing import TYPE_CHECKING -import minisky +if TYPE_CHECKING: + from minisky.simulation.console import ConsoleIO + from minisky.simulation.simulation import Simulation MIN_UPDATE_INTERVAL = 0.0001 @@ -20,37 +24,40 @@ class Runner: """Asyncio loop that drives the simulation at a configurable speed. - Each loop iteration performs one call to ``minisky.sim.step()`` (which - advances simulation time by one ``simdt``) and then sleeps so that steps - occur every ``1 / speed`` wall-clock seconds. During a fast-forward jump - (see :meth:`forward`) the sleep is shortened to the minimum interval so + Each loop iteration performs one call to `self.simulation.step()` (which + advances simulation time by one `simdt`) and then sleeps so that steps + occur every `1 / speed` wall-clock seconds. During a fast-forward jump + (see `forward`) the sleep is shortened to the minimum interval so the target simulation time is reached as fast as possible. Attributes: node_id: Random 5-byte identifier for this simulation node. host_id: Identifier of the host this node belongs to (empty by default). running: True while the run loop is active. - allow_shutdown: If False, :meth:`stop` is ignored and the loop keeps + allow_shutdown: If False, `stop` is ignored and the loop keeps running (used when the simulator should idle without a scenario). speed: Simulation speed factor relative to real time; the loop targets - one simulation step every ``1 / speed`` wall-clock seconds. + one simulation step every `1 / speed` wall-clock seconds. jump: Remaining fast-forward request [s of simulation time]; 0 when no jump is active. jump_to: Target simulation time of the active fast-forward jump [s]. """ - def __init__(self, **kwargs: Any) -> None: + def __init__(self, simulation: Simulation, console: ConsoleIO, speed: float = 1) -> None: """Initialize the runner. Args: - **kwargs: Optional settings. Supports ``speed`` (simulation speed - factor relative to real time, default 1). + simulation: Simulation stepped by the run loop. + console: Output channel used for lifecycle messages. + speed: Simulation speed factor relative to real time, default 1. """ + self.simulation = simulation + self.console = console self.node_id: bytes = b"\x00" + os.urandom(4) self.host_id: bytes = b"" self.running: bool = False self.allow_shutdown: bool = True - self.speed = kwargs.get("speed", 1) + self.speed = speed self.jump: float = 0 self.jump_to: float = 0 @@ -64,16 +71,16 @@ def forward(self, seconds: float) -> None: Args: seconds: Amount of simulation time to jump forward [s]. """ - self.jump_to = minisky.sim.simt + seconds - 2 # -2 for the action margin + self.jump_to = self.simulation.simt + seconds - 2 # -2 for the action margin self.jump = seconds def setspeed(self, mult: float) -> tuple[bool, str]: """Set the simulation speed multiplier (stack DTMULT command). - The loop targets one simulation step every ``1 / speed`` wall-clock + The loop targets one simulation step every `1 / speed` wall-clock seconds, so a larger multiplier makes simulated time advance faster relative to the wall clock. This is the wall-clock-pacing equivalent of - BlueSky's ``DTMULT``. + BlueSky's `DTMULT`. Args: mult: Simulation speed factor relative to real time; must be @@ -89,7 +96,7 @@ def setspeed(self, mult: float) -> tuple[bool, str]: return True, f"Simulation speed set to {mult}x" def prevent_shutdown(self) -> None: - """Disable shutdown so that :meth:`stop` requests are ignored. + """Disable shutdown so that `stop` requests are ignored. Used when the simulator runs without a scenario (e.g. behind the HTTP API) and should keep accepting commands even after a scenario ends or @@ -101,12 +108,12 @@ async def run(self) -> None: """Run the main simulation loop until stopped. Repeatedly steps the simulation, sleeping between steps so that steps - occur every ``1 / speed`` wall-clock seconds. While a fast-forward + occur every `1 / speed` wall-clock seconds. While a fast-forward jump is active the sleep interval is reduced to the minimum until the target simulation time is reached. The loop exits when - :meth:`stop` sets ``running`` to False (and shutdown is allowed). + `stop` sets `running` to False (and shutdown is allowed). """ - minisky.scr.echo("Starting simulation") + self.console.echo("Starting simulation") self.running = True while self.running: @@ -115,7 +122,7 @@ async def run(self) -> None: update_interval = MIN_UPDATE_INTERVAL # Check if jump is completed - if self.jump_to <= minisky.sim.simt: + if self.jump_to <= self.simulation.simt: self.jump = 0 self.jump_to = 0 else: @@ -123,7 +130,7 @@ async def run(self) -> None: next_time = asyncio.get_event_loop().time() + update_interval - minisky.sim.step() + self.simulation.step() current_time = asyncio.get_event_loop().time() @@ -131,16 +138,16 @@ async def run(self) -> None: await asyncio.sleep(sleep_time) - minisky.scr.echo("Simulation completed") + self.console.echo("Simulation completed") def stop(self) -> None: """Request the run loop to stop. Has no effect when shutdown has been disabled with - :meth:`prevent_shutdown`; in that case a message is printed and the + `prevent_shutdown`; in that case a message is printed and the loop keeps running. """ if self.allow_shutdown: self.running = False else: - minisky.scr.echo("Shutdown is prevented") + self.console.echo("Shutdown is prevented") diff --git a/minisky/simulation/simulation.py b/minisky/simulation/simulation.py index 32762e9..f254baf 100644 --- a/minisky/simulation/simulation.py +++ b/minisky/simulation/simulation.py @@ -1,25 +1,35 @@ """BlueSky simulation control object. -Defines the :class:`Simulation` class, the central clock and state machine of +Defines the `Simulation` class, the central clock and state machine of the simulator. It advances simulation time, processes the command stack, triggers plugin pre-/post-update hooks, and updates all aircraft in the traffic object once per timestep. A single instance is created by -:func:`minisky.init` and made available as ``minisky.sim``. +`minisky.init` and made available as `minisky.sim`. """ +from __future__ import annotations + import datetime import time +from collections.abc import Callable from random import seed -from typing import Any +from typing import TYPE_CHECKING, Any import numpy as np -# Local imports -import minisky +from minisky import stack from minisky.core.trafficarrays import reset_replaceables from minisky.plugin import PluginManager from minisky.tools import areafilter +if TYPE_CHECKING: + from minisky.simulation.console import ConsoleIO + from minisky.tools.navdata import Navdatabase + from minisky.traffic import Traffic + +# Simulation states +INIT, HOLD, OP, END = (0, 1, 2, 3) # TODO(abraham): use IntEnum. + # Minimum sleep interval MINSLEEP = 1e-3 @@ -28,28 +38,38 @@ class Simulation: """The simulation object: clock, state machine, and per-step update driver. Holds simulation time and state, and advances the simulation one timestep - at a time. Each :meth:`step` processes pending stack commands and, while + at a time. Each `step` processes pending stack commands and, while operating, increments simulation time, triggers plugin hooks and updates - the traffic. State transitions are driven by the ``OP``/``HOLD``/``RESET`` - and ``QUIT`` stack commands, which map onto :meth:`op`, :meth:`hold`, - :meth:`reset` and :meth:`stop`. + the traffic. State transitions are driven by the `OP`/`HOLD`/`RESET` + and `QUIT` stack commands, which map onto `op`, `hold`, + `reset` and `stop`. Attributes: - state: Current simulation state, one of ``minisky.INIT``, - ``minisky.HOLD``, ``minisky.OP`` or ``minisky.END``. + state: Current simulation state, one of `minisky.INIT`, + `minisky.HOLD`, `minisky.OP` or `minisky.END`. prevstate: Previous simulation state (unused placeholder). simt: Elapsed simulation time [s]. simdt: Simulation timestep [s]. syst: System (wall-clock) time reference [s]. - utc: Simulated UTC clock time as a ``datetime``; settable with - :meth:`setutc`. + utc: Simulated UTC clock time as a `datetime`; settable with + `setutc`. rtmode: Flag indicating whether the timestep may be varied to keep the simulation running in real time. clients: Set of known client identifiers connected to this simulation. """ - def __init__(self) -> None: - self.state = minisky.INIT + def __init__( + self, + traffic: Traffic, + navigation: Navdatabase, + console: ConsoleIO, + stop_runner: Callable[[], None], + ) -> None: + self.traffic = traffic + self.navigation = navigation + self.console = console + self.stop_runner = stop_runner + self.state = INIT self.prevstate = None # Simulation time [seconds] @@ -80,23 +100,21 @@ def step(self) -> None: A step consists of: - 1. Auto-start: while in ``INIT``, switch to ``OP`` as soon as there is + 1. Auto-start: while in `INIT`, switch to `OP` as soon as there is traffic or there are pending scenario commands. 2. Process the command stack (always, in every state). - 3. While in ``OP``: advance ``simt`` and the simulated UTC clock by - ``simdt`` seconds, run plugin ``preupdate`` hooks (including - timers), update all aircraft, then run plugin ``update`` hooks. + 3. While in `OP`: advance `simt` and the simulated UTC clock by + `simdt` seconds, run plugin `preupdate` hooks (including + timers), update all aircraft, then run plugin `update` hooks. """ # Simulation starts as soon as there is traffic, or pending commands - if self.state == minisky.INIT and ( - minisky.traf.ntraf > 0 or len(minisky.stack.get_scendata()[0]) > 0 - ): + if self.state == INIT and (self.traffic.ntraf > 0 or len(stack.get_scendata()[0]) > 0): self.op() # Always update stack - minisky.stack.process() + stack.process() - if self.state == minisky.OP: + if self.state == OP: self.simt += self.simdt # Update UTC time @@ -105,7 +123,7 @@ def step(self) -> None: # Plugin pre-update (timers + preupdate hooks) PluginManager.preupdate() - minisky.traf.update() + self.traffic.update() # Plugin post-update hooks PluginManager.update() @@ -120,36 +138,36 @@ def step(self) -> None: def stop(self) -> None: """Stop the simulation (stack STOP/QUIT command). - Sets the simulation state to ``END`` and asks the runner to exit its + Sets the simulation state to `END` and asks the runner to exit its loop. If the runner was configured with - :meth:`~minisky.simulation.runner.Runner.prevent_shutdown`, the loop + `minisky.simulation.runner.Runner.prevent_shutdown`, the loop keeps running and only the state changes. """ - self.state = minisky.END - minisky.runner.stop() + self.state = END + self.stop_runner() def op(self) -> None: """Set simulation state to OPERATE (stack OP command). Resumes (or starts) advancing simulation time. Also re-anchors the - system time reference ``syst`` to the current wall-clock time plus one + system time reference `syst` to the current wall-clock time plus one timestep [s]. """ self.syst = time.time() + self.simdt - self.state = minisky.OP - minisky.scr.echo("Simulation running") + self.state = OP + self.console.echo("Simulation running") def hold(self) -> None: """Set simulation state to HOLD (stack HOLD command). - Pauses the advance of simulation time and triggers the plugin ``hold`` + Pauses the advance of simulation time and triggers the plugin `hold` hooks. Stack commands are still processed while holding, so the - simulation can be resumed with the ``OP`` command. + simulation can be resumed with the `OP` command. """ self.syst = time.time() + self.simdt - self.state = minisky.HOLD + self.state = HOLD PluginManager.hold() - minisky.scr.echo("Simulation paused") + self.console.echo("Simulation paused") def reset(self) -> None: """Reset all simulation objects (stack RESET command). @@ -160,23 +178,23 @@ def reset(self) -> None: console output, replaceable entities (autopilot, performance models, etc.) and plugin timers/hooks reset to their defaults. """ - self.state = minisky.INIT + self.state = INIT self.syst = 0 self.simt = 0 self.simdt = 1 self.utc = datetime.datetime.now(datetime.UTC).replace( hour=0, minute=0, second=0, microsecond=0 ) - minisky.navdb.reset() - minisky.traf.reset() - minisky.stack.reset() + self.navigation.reset() + self.traffic.reset() + stack.reset() areafilter.reset() - minisky.scr.reset() + self.console.reset() # Reset replaceables (Autopilot, PerfBase, etc.) to defaults - reset_replaceables() + reset_replaceables(self.traffic) # Reset plugins (timers + reset hooks) PluginManager.reset() - minisky.scr.echo("Simulation reset") + self.console.echo("Simulation reset") def realtime(self, flag: bool | None = None) -> tuple[bool, str]: """Get or set realtime mode (stack REALTIME command). @@ -185,8 +203,8 @@ def realtime(self, flag: bool | None = None) -> tuple[bool, str]: synchronized with the wall clock. Args: - flag: ``True``/``False`` to enable or disable realtime mode, or - ``None`` to only report the current setting. + flag: `True`/`False` to enable or disable realtime mode, or + `None` to only report the current setting. Returns: Tuple of (success flag, message stating whether realtime mode is @@ -200,17 +218,17 @@ def realtime(self, flag: bool | None = None) -> tuple[bool, str]: def event(self, eventname: bytes, eventdata: Any, sender_rte: Any) -> bool: """Handle events coming from the network. - Supports two event types: ``b"STACK"``, which appends a single stack - command line to the command stack, and ``b"BATCH"``, which resets the + Supports two event types: `b"STACK"`, which appends a single stack + command line to the command stack, and `b"BATCH"`, which resets the simulation, installs a full scenario (times + commands) on the stack, and immediately starts operating. Args: - eventname: Event type identifier as bytes (``b"STACK"`` or - ``b"BATCH"``). - eventdata: Event payload; the command string for ``STACK``, or a - dict with ``scentime`` (command times [s]) and ``scencmd`` - (command strings) for ``BATCH``. + eventname: Event type identifier as bytes (`b"STACK"` or + `b"BATCH"`). + eventdata: Event payload; the command string for `STACK`, or a + dict with `scentime` (command times [s]) and `scencmd` + (command strings) for `BATCH`. sender_rte: Route/identifier of the sending client, passed on as the stack command's sender id. @@ -222,13 +240,13 @@ def event(self, eventname: bytes, eventdata: Any, sender_rte: Any) -> bool: if eventname == b"STACK": # We received a single stack command. Add it to the existing stack - minisky.stack.stack(eventdata, sender_id=sender_rte) + stack.stack(eventdata, sender_id=sender_rte) event_processed = True elif eventname == b"BATCH": # We are in a batch simulation, and received an entire scenario. Assign it to the stack. self.reset() - minisky.stack.set_scendata(eventdata["scentime"], eventdata["scencmd"]) + stack.set_scendata(eventdata["scentime"], eventdata["scencmd"]) self.op() event_processed = True @@ -242,12 +260,12 @@ def setutc(self, *args: str) -> tuple[bool, str]: Accepted argument forms: - no arguments: leave the clock unchanged (the new value is reported). - - ``RUN``: today's date at 00:00:00 UTC. - - ``REAL``: current local date and time. - - ``UTC``: current UTC date and time. - - a time string ``HH:MM:SS`` or ``HH:MM:SS.ff``: set the clock time. - - ``day month year``: set the date (three integers). - - ``day month year timestring``: set both date and time. + - `RUN`: today's date at 00:00:00 UTC. + - `REAL`: current local date and time. + - `UTC`: current UTC date and time. + - a time string `HH:MM:SS` or `HH:MM:SS.ff`: set the clock time. + - `day month year`: set the date (three integers). + - `day month year timestring`: set both date and time. Args: *args: Zero, one, three, or four arguments as described above. @@ -299,11 +317,10 @@ def setutc(self, *args: str) -> tuple[bool, str]: return True, "Simulation UTC " + str(self.utc) - @staticmethod - def setseed(value: int) -> None: + def setseed(self, value: int) -> None: """Set the random seed for this simulation (stack SEED command). - Seeds both Python's :mod:`random` module and NumPy's random generator + Seeds both Python's `random` module and NumPy's random generator so that stochastic scenario elements are reproducible. Args: @@ -311,4 +328,4 @@ def setseed(value: int) -> None: """ seed(value) np.random.seed(value) - minisky.scr.echo("random seed set") + self.console.echo("random seed set") diff --git a/minisky/tools/navdata.py b/minisky/tools/navdata.py index 0087ea9..461e323 100644 --- a/minisky/tools/navdata.py +++ b/minisky/tools/navdata.py @@ -3,27 +3,32 @@ Loads waypoint, airport, airway, FIR, and country data from the package data directory and provides lookup functions to find navaids and airports by identifier or position. The global Navdatabase instance is available -as ``minisky.navdb``; it backs the DEFWPT stack command and every position +as `minisky.navdb`; it backs the DEFWPT stack command and every position argument that references a navaid, airport, or runway. """ +from __future__ import annotations + import json -from typing import Any +from pathlib import Path +from typing import TYPE_CHECKING, Any import numpy as np import pandas as pd -import minisky from minisky.tools import geo from minisky.tools.aero import nm +if TYPE_CHECKING: + from minisky.simulation.console import ConsoleIO + def _tolist(column: Any) -> list: """Return a pandas column as a plain Python list. - Wrapper around ``Series.to_list()`` that gives a concrete ``list`` - return type (the pandas ``__getitem__`` overloads otherwise widen the - result to include ``str``). + Wrapper around `Series.to_list()` that gives a concrete `list` + return type (the pandas `__getitem__` overloads otherwise widen the + result to include `str`). """ return column.to_list() @@ -100,10 +105,11 @@ class Navdatabase: Created by : Jacco M. Hoekstra (TU Delft) """ - def __init__(self) -> None: + def __init__(self, data_path: Path, console: ConsoleIO) -> None: """The navigation database: Contains waypoint, airport, airway, and sector data, but also geographical graphics data.""" - # Variables are initialized in reset() + self.data_path = data_path + self.console = console self.reset() def reset(self) -> None: @@ -111,7 +117,7 @@ def reset(self) -> None: # print("Loading global navigation database...") # wptdata, aptdata, awydata, firdata, codata, rwythresholds = load_navdata() - nav_data_path = minisky.data("navigation") + nav_data_path = self.data_path wptdata = pd.read_parquet(nav_data_path / "waypoint.parquet") aptdata = pd.read_parquet(nav_data_path / "airport.parquet") @@ -171,7 +177,13 @@ def reset(self) -> None: self.rwythresholds = rwythresholds - def defwpt(self, name: str | None = None, lat: float | None = None, lon: float | None = None, wptype: str | None = None) -> tuple[bool, str]: + def defwpt( + self, + name: str | None = None, + lat: float | None = None, + lon: float | None = None, + wptype: str | None = None, + ) -> tuple[bool, str]: """DEFWPT: Define, inspect, or delete a scenario-specific waypoint. Without lat/lon, information about the existing waypoint is @@ -203,7 +215,7 @@ def defwpt(self, name: str | None = None, lat: float | None = None, lon: float | # No data: give info on waypoint elif lat == None or lon == None: - reflat, reflon = minisky.scr.getviewctr() + reflat, reflon = self.console.getviewctr() if self.wpid.count(name.upper()) > 0: i = self.getwpidx(name.upper(), reflat, reflon) txt = self.wpid[i] + " : " + str(self.wplat[i]) + "," + str(self.wplon[i]) @@ -231,7 +243,7 @@ def defwpt(self, name: str | None = None, lat: float | None = None, lon: float | self.wpdesc.append("Custom waypoint") # description # Update screen info - minisky.scr.addnavwpt(name.upper(), lat, lon) + self.console.addnavwpt(name.upper(), lat, lon) return True, name.upper() + " added to navdb." @@ -266,7 +278,7 @@ def delwpt(self, name: str | None = None) -> tuple[bool, str]: del self.wpdesc[idx] # description # Update screen info 9delete necessary there?) - minisky.scr.removenavwpt(name.upper()) + self.console.removenavwpt(name.upper()) return True, name.upper() + " deleted from navdb." @@ -385,7 +397,9 @@ def getaptidx(self, txt: str) -> int: except ValueError: return -1 - def getinear(self, wlat: "np.ndarray | list", wlon: "np.ndarray | list", lat: float, lon: float) -> int: # lat,lon in degrees + def getinear( + self, wlat: np.ndarray | list, wlon: np.ndarray | list, lat: float, lon: float + ) -> int: # lat,lon in degrees """Get the index of the entry nearest to a given position. Uses a fast flat-earth squared-distance comparison. @@ -419,7 +433,15 @@ def getapinear(self, lat: float, lon: float): # lat,lon in degrees """Get the index of the airport closest to position (lat, lon) [deg].""" return self.getinear(self.aptlat, self.aptlon, lat, lon) - def getinside(self, wlat: "np.ndarray | list", wlon: "np.ndarray | list", lat0: float, lat1: float, lon0: float, lon1: float) -> list: + def getinside( + self, + wlat: np.ndarray | list, + wlon: np.ndarray | list, + lat0: float, + lat1: float, + lon0: float, + lon1: float, + ) -> list: """Get indices of positions inside the given lat/lon box. Args: From 2eb520d46a441852f53d055d20595a777c238e67 Mon Sep 17 00:00:00 2001 From: Abraham Cheung <58929011+abc8747@users.noreply.github.com> Date: Mon, 27 Jul 2026 06:50:32 +0800 Subject: [PATCH 04/16] refactor: make `Minisky` own command stack, registry, queue - current command and sender state, scenario name, timestamps and commands are also now owned by `Minisky`. --- minisky/__init__.py | 1 + minisky/core/trafficarrays.py | 36 +- minisky/plugin/plugin_decorators.py | 61 +- minisky/runtime.py | 16 +- minisky/simulation/simulation.py | 20 +- minisky/stack/__init__.py | 1081 +++++++++++++++++---------- minisky/stack/commands.py | 163 ++-- 7 files changed, 875 insertions(+), 503 deletions(-) diff --git a/minisky/__init__.py b/minisky/__init__.py index 213ba46..347ac4f 100644 --- a/minisky/__init__.py +++ b/minisky/__init__.py @@ -43,6 +43,7 @@ def _activate(instance: MiniSky) -> None: navdb = instance.navigation sim = instance.simulation scr = instance.console + stack._activate(instance.commands) from minisky.runtime import MiniSky # noqa: E402 diff --git a/minisky/core/trafficarrays.py b/minisky/core/trafficarrays.py index 7a63f78..defb31c 100644 --- a/minisky/core/trafficarrays.py +++ b/minisky/core/trafficarrays.py @@ -20,7 +20,7 @@ in the simulation grows and shrinks in lockstep. """ -from typing import ClassVar +from typing import Any, ClassVar import numpy as np @@ -30,15 +30,20 @@ replaceables: dict[str, type["TrafficArrays"]] = {} -def reset_replaceables(traffic: "TrafficArrays") -> None: +def reset_replaceables(traffic: "TrafficArrays", cmddict: dict[str, Any]) -> None: """Reset all replaceables to their default implementation and reinstantiate on traf.""" for base in replaceables.values(): base.selectdefault() # Reinstantiate on traf with default implementation - _replace_instance_on_traf(base, base._generator, traffic) + _replace_instance_on_traf(base, base._generator, traffic, cmddict) -def select_implementation(basename: str = "", implname: str = "") -> tuple[bool, str]: +def select_implementation( + basename: str = "", + implname: str = "", + traffic: "TrafficArrays | None" = None, + cmddict: "dict[str, Any] | None" = None, +) -> tuple[bool, str]: """Select an implementation for a replaceable class. Arguments: @@ -68,10 +73,14 @@ def select_implementation(basename: str = "", implname: str = "") -> tuple[bool, impl.select() - # The stack command still targets the active compatibility runtime. - import minisky + if traffic is None or cmddict is None: + import minisky + from minisky.stack import Command - _replace_instance_on_traf(base, impl, minisky.traf) + traffic = minisky.traf + cmddict = Command.cmddict + + _replace_instance_on_traf(base, impl, traffic, cmddict) return True, f"Selected {implname} for {basename}" @@ -80,6 +89,7 @@ def _replace_instance_on_traf( base: type["TrafficArrays"], impl: type["TrafficArrays"], traffic: "TrafficArrays", + cmddict: dict[str, Any], ) -> None: """Replace existing instance of base class on traf with new impl instance. @@ -103,17 +113,19 @@ def _replace_instance_on_traf( attr_value._parent._children.remove(attr_value) # Stack commands registered as bound methods of the old instance # would silently mutate the orphaned object; rebind them - _rebind_stack_commands(attr_value, new_instance) + _rebind_stack_commands(attr_value, new_instance, cmddict) break -def _rebind_stack_commands(old_instance: "TrafficArrays", new_instance: "TrafficArrays") -> None: +def _rebind_stack_commands( + old_instance: "TrafficArrays", + new_instance: "TrafficArrays", + cmddict: dict[str, Any], +) -> None: """Rebind stack command callbacks from old_instance to new_instance.""" import inspect - from minisky.stack import Command - - for cmdobj in set(Command.cmddict.values()): + for cmdobj in set(cmddict.values()): callback = cmdobj.callback if inspect.ismethod(callback) and callback.__self__ is old_instance: cmdobj.callback = getattr(new_instance, callback.__func__.__name__, callback) diff --git a/minisky/plugin/plugin_decorators.py b/minisky/plugin/plugin_decorators.py index 755a863..dc26323 100644 --- a/minisky/plugin/plugin_decorators.py +++ b/minisky/plugin/plugin_decorators.py @@ -1,9 +1,12 @@ -"""Stack command decorators for MiniSky plugins. +"""Stack command declarations for MiniSky plugins. -Provides the @command decorator for registering stack commands. +The `@command` decorator stores command metadata on a function. It registers +immediately when a runtime is active; otherwise the declaration is collected +by `CommandStack.init()` when the runtime is constructed. """ import inspect +import sys from collections.abc import Callable from typing import Any @@ -44,34 +47,46 @@ def my_command(arg: str): """ def deco(func): - # Import here to avoid circular import - from minisky.stack import Command - - # Get the underlying function if decorated with staticmethod/classmethod actual_func = func.__func__ if isinstance(func, (staticmethod, classmethod)) else func - - # Determine command name - cmd_name = name or actual_func.__name__ - - # Use function docstring as help if not provided - cmd_help = help or inspect.cleandoc(inspect.getdoc(actual_func) or "") - - # Register the command - Command.addcommand( - actual_func, - name=cmd_name, - aliases=aliases, - brief=brief, - help=cmd_help, - arguments=arguments, - ) - + declaration = { + "name": name or actual_func.__name__, + "aliases": aliases, + "brief": brief, + "help": help or inspect.cleandoc(inspect.getdoc(actual_func) or ""), + "arguments": arguments, + } + actual_func.__stack_command__ = declaration # type: ignore[reportFunctionMemberAccess] + + try: + from minisky.stack import Command, current + + current() + except (ImportError, RuntimeError): + return func + + Command.addcommand(actual_func, **declaration) return func # Allow both @command and @command(args) return deco(func) if func else deco +def register_declared_commands() -> None: + """Register command declarations from modules imported before runtime startup.""" + from minisky.stack import Command + + for module in tuple(sys.modules.values()): + if module is None: + continue + for value in vars(module).values(): + actual_func = ( + value.__func__ if isinstance(value, (staticmethod, classmethod)) else value + ) + declaration = getattr(actual_func, "__stack_command__", None) + if declaration is not None: + Command.addcommand(actual_func, **declaration) + + def append_commands(newcommands: dict, syndict: dict | None = None) -> None: """Append additional functions to the stack command dictionary. diff --git a/minisky/runtime.py b/minisky/runtime.py index 69450d6..c3645d5 100644 --- a/minisky/runtime.py +++ b/minisky/runtime.py @@ -2,11 +2,12 @@ from __future__ import annotations -from minisky import stack, tools +from minisky import tools from minisky.core import varexplorer from minisky.core.settings import MiniSkySettings, data from minisky.simulation import ConsoleIO, Runner, Simulation from minisky.simulation.simulation import OP +from minisky.stack import CommandStack from minisky.tools.navdata import Navdatabase from minisky.traffic import Traffic @@ -21,10 +22,18 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No self.console = ConsoleIO(lambda: self.simulation.state == OP) self.navigation = Navdatabase(data("navigation"), self.console) self.traffic = Traffic(settings) + self.commands = CommandStack( + traffic=self.traffic, + navigation=self.navigation, + console=self.console, + get_simulation=lambda: self.simulation, + get_runner=lambda: self.runner, + ) self.simulation = Simulation( traffic=self.traffic, navigation=self.navigation, console=self.console, + command_stack=self.commands, stop_runner=self._stop_runner, ) self.runner = Runner(self.simulation, self.console) @@ -35,14 +44,13 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No minisky._activate(self) varexplorer.init() + self.commands.init() if scenario: - stack.stack(f"IC {scenario}") + self.commands.stack(f"IC {scenario}") else: self.runner.prevent_shutdown() - stack.init() - def _stop_runner(self) -> None: self.runner.stop() diff --git a/minisky/simulation/simulation.py b/minisky/simulation/simulation.py index f254baf..e491162 100644 --- a/minisky/simulation/simulation.py +++ b/minisky/simulation/simulation.py @@ -17,18 +17,18 @@ import numpy as np -from minisky import stack from minisky.core.trafficarrays import reset_replaceables from minisky.plugin import PluginManager from minisky.tools import areafilter if TYPE_CHECKING: from minisky.simulation.console import ConsoleIO + from minisky.stack import CommandStack from minisky.tools.navdata import Navdatabase from minisky.traffic import Traffic # Simulation states -INIT, HOLD, OP, END = (0, 1, 2, 3) # TODO(abraham): use IntEnum. +INIT, HOLD, OP, END = (0, 1, 2, 3) # TODO(abraham): use IntEnum. # Minimum sleep interval MINSLEEP = 1e-3 @@ -63,11 +63,13 @@ def __init__( traffic: Traffic, navigation: Navdatabase, console: ConsoleIO, + command_stack: CommandStack, stop_runner: Callable[[], None], ) -> None: self.traffic = traffic self.navigation = navigation self.console = console + self.commands = command_stack self.stop_runner = stop_runner self.state = INIT self.prevstate = None @@ -108,11 +110,13 @@ def step(self) -> None: timers), update all aircraft, then run plugin `update` hooks. """ # Simulation starts as soon as there is traffic, or pending commands - if self.state == INIT and (self.traffic.ntraf > 0 or len(stack.get_scendata()[0]) > 0): + if self.state == INIT and ( + self.traffic.ntraf > 0 or len(self.commands.get_scendata()[0]) > 0 + ): self.op() # Always update stack - stack.process() + self.commands.process() if self.state == OP: self.simt += self.simdt @@ -187,11 +191,11 @@ def reset(self) -> None: ) self.navigation.reset() self.traffic.reset() - stack.reset() + self.commands.reset() areafilter.reset() self.console.reset() # Reset replaceables (Autopilot, PerfBase, etc.) to defaults - reset_replaceables(self.traffic) + reset_replaceables(self.traffic, self.commands.cmddict) # Reset plugins (timers + reset hooks) PluginManager.reset() self.console.echo("Simulation reset") @@ -240,13 +244,13 @@ def event(self, eventname: bytes, eventdata: Any, sender_rte: Any) -> bool: if eventname == b"STACK": # We received a single stack command. Add it to the existing stack - stack.stack(eventdata, sender_id=sender_rte) + self.commands.stack(eventdata, sender_id=sender_rte) event_processed = True elif eventname == b"BATCH": # We are in a batch simulation, and received an entire scenario. Assign it to the stack. self.reset() - stack.set_scendata(eventdata["scentime"], eventdata["scencmd"]) + self.commands.set_scendata(eventdata["scentime"], eventdata["scencmd"]) self.op() event_processed = True diff --git a/minisky/stack/__init__.py b/minisky/stack/__init__.py index 037b456..35cf22c 100644 --- a/minisky/stack/__init__.py +++ b/minisky/stack/__init__.py @@ -1,55 +1,48 @@ """The stack parses all text-based commands in the simulation. The stack is MiniSky's text-command interpreter. Every instruction to the -simulator - typed by a user, read from a scenario (.scn) file, or issued by -a plugin - enters as a line of text such as ``CRE KL204 B744 52.0 4.0 90 -FL300 250``. Command lines are queued with :func:`stack` and executed once -per simulation step by :func:`process`. +simulator—typed by a user, read from a scenario (`.scn`) file, or issued by +a plugin—enters as a line of text such as +`CRE KL204 B744 52.0 4.0 90 FL300 250`. Command lines are queued with +[stack][minisky.stack.stack] and executed once per simulation step by [process][minisky.stack.process]. -Each available command is represented by a :class:`Command` object, which +Each available command is represented by a [Command][minisky.stack.Command] object, which couples the command name to the Python function that implements it and to -the argument parsers that convert argument text into typed values (see -:mod:`minisky.stack.argparser`). The base command set is defined in -:mod:`minisky.stack.commands` and registered in :func:`init`. +the argument parsers that convert argument text into typed values. The base +command set is defined in `minisky.stack.commands` and registered by +`init()`. -This module also implements scenario handling: :func:`ic` loads a scenario +Each `CommandStack` owns one runtime's command registry, pending command +queue, scenario buffer, and sender state. The module-level functions and +`Command.cmddict` remain compatibility aliases for the active runtime. + +This module also implements scenario handling: [ic][minisky.stack.ic] loads a scenario file, whose timestamped command lines are buffered and moved onto the stack -by :func:`checkscen` when the simulation time passes their timestamps. +by `checkscen()` when the simulation time passes their timestamps. """ +from __future__ import annotations + import inspect import os import traceback from collections.abc import Callable, Iterator from io import StringIO from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any import numpy as np -import minisky -from minisky.plugin.plugin_decorators import append_commands, command +from minisky.core import trafficarrays +from minisky.plugin.plugin_decorators import append_commands, command, register_declared_commands from minisky.stack import argparser, commands from minisky.stack.argparser import ArgumentError, Parameter, String, Time, Txt, getnextarg +from minisky.tools import areafilter - -def init() -> None: - """Initialise BlueSky base stack commands.""" - - cmddict, synonyms = commands.get_commands() - - # register command - for name, values in cmddict.items(): - function, arguments, brief, help_text = values - - Command.addcommand( - function, - name=name, - arguments=arguments, - brief=brief, - help=help_text, - aliases=synonyms.get(name, []), - ) +if TYPE_CHECKING: + from minisky.simulation import ConsoleIO, Runner, Simulation + from minisky.tools.navdata import Navdatabase + from minisky.traffic import Traffic class Command: @@ -62,8 +55,8 @@ class Command: the callback. Calling a Command instance with an argument string parses the arguments and executes the callback. - All commands are stored in the class-level ``cmddict`` dictionary, - which maps command names (and aliases) to Command instances. + `cmddict` is a compatibility alias for the active runtime registry and + maps command names and aliases to Command instances. Attributes: name: Command name in upper case (e.g., "CRE"). @@ -76,56 +69,31 @@ class Command: """ # Dictionary with all command objects - cmddict: dict[str, "Command"] = {} + cmddict: dict[str, Command] = {} @classmethod def addcommand( - cls, func: Callable, parent: "Command | None" = None, name: str = "", **kwargs: Any + cls, func: Callable, parent: Command | None = None, name: str = "", **kwargs: Any ) -> None: - """Add 'func' as a stack command. + """Add `func` as a stack command. - Creates a Command object for the given function and registers it - (and its aliases) in Command.cmddict. When a command with the same - name already exists, the existing Command object is kept. + Delegates registration to the active runtime's `CommandStack`, + which creates a [Command][minisky.stack.Command] object for the function and registers + its name and aliases. When a command with the same name already + exists, the existing command object is kept. Args: - func: Function (or static/class method) implementing the command. + func: Function, static method, or class method implementing the + command. parent: Optional parent command when this is a subcommand. - name: Command name. Defaults to the function name, upper-cased. - **kwargs: Command options: ``arguments`` (argument type - specification string, e.g. "callsign,alt,[vspd]"), - ``brief``, ``help``, and ``aliases``. + name: Command name. Defaults to the function name in upper case. + **kwargs: Command options: `arguments` (an argument type + specification such as `callsign,alt,[vspd]`), `brief`, `help`, + and `aliases`. """ - # Get function object if it's decorated as static or classmethod - func = func.__func__ if isinstance(func, (staticmethod, classmethod)) else func - # Stack command name - name = (name or func.__name__).upper() - - # When a parent is passed this function is a subcommand - target = Command.cmddict + current().addcommand(func, parent=parent, name=name, command_type=cls, **kwargs) - # Check if this command already exists - cmdobj = target.get(name) - if not cmdobj: - cmdobj = cls(func, parent, name, **kwargs) - target[name] = cmdobj - for alias in cmdobj.aliases: - target[alias] = cmdobj - else: - # for subclasses reimplementing stack functions we keep only one - # Command object - print(f"Attempt to reimplement {name} from {cmdobj.callback} to {func}") - if not isinstance(cmdobj, cls): - raise TypeError( - f"Error reimplementing {name}: " - f"A {type(cmdobj).__name__} cannot be " - f"reimplemented as a {cls.__name__}" - ) - # Store reference to command object for function - if not inspect.ismethod(func): - func.__stack_cmd__ = cmdobj # type: ignore[reportFunctionMemberAccess] - - def __init__(self, func, parent: "Command | None" = None, name: str = "", **kwargs) -> None: + def __init__(self, func, parent: Command | None = None, name: str = "", **kwargs) -> None: self.name = name self.help = inspect.cleandoc(kwargs.get("help", "")) self.brief = kwargs.get("brief", "") @@ -307,445 +275,802 @@ def _get_arguments(self, arguments) -> tuple: return tuple(argtypes) -class Stack: - """Stack static-only namespace. +class CommandStack: + """Command registry, queue, and scenario state for one runtime. - Holds the queue of pending command lines, as well as the commands and - timestamps loaded from a scenario file. This class is never - instantiated; all state is kept in class attributes. + Holds the available command objects, the queue of pending command lines, + and the commands and timestamps loaded from a scenario file. Each + `MiniSky` runtime owns one instance, so command and + scenario state is not shared between runtimes. Attributes: + cmddict: Mapping of command names and aliases to [Command][minisky.stack.Command] objects. current: Command line currently being processed. - cmdstack: List of (cmdline, sender route) tuples awaiting processing. + cmdstack: List of `(cmdline, sender route)` tuples awaiting processing. scenname: Name of the currently loaded scenario. scentime: Execution times [s] of the buffered scenario commands. scencmd: Buffered scenario command lines. sender_rte: Network route to the sender of the current command. """ - # Stack data - current = "" - cmdstack = [] # The actual stack: Current commands to be processed + def __init__( + self, + traffic: Traffic, + navigation: Navdatabase, + console: ConsoleIO, + get_simulation: Callable[[], Simulation], + get_runner: Callable[[], Runner], + scenario_root: Path | None = None, + ) -> None: + self.traffic = traffic + self.navigation = navigation + self.console = console + self._get_simulation = get_simulation + self._get_runner = get_runner + self.scenario_root = scenario_root or Path(__file__).parent.parent.parent + self.cmddict: dict[str, Command] = {} + self._reset_state() + + @property + def simulation(self) -> Simulation: + return self._get_simulation() + + @property + def runner(self) -> Runner: + return self._get_runner() + + def addcommand( + self, + func: Callable, + parent: Command | None = None, + name: str = "", + command_type: type[Command] = Command, + **kwargs: Any, + ) -> None: + """Add `func` as a stack command in this runtime. + + Creates a command object for the given function and registers it and + its aliases in this command stack's `cmddict`. When a command with the + same name already exists, the existing command object is kept. + + Args: + func: Function, static method, or class method implementing the + command. + parent: Optional parent command when this is a subcommand. + name: Command name. Defaults to the function name in upper case. + command_type: Command class used to wrap the callback. + **kwargs: Command options: `arguments` (an argument type + specification such as `callsign,alt,[vspd]`), `brief`, `help`, + and `aliases`. + """ + func = func.__func__ if isinstance(func, (staticmethod, classmethod)) else func + name = (name or func.__name__).upper() + + cmdobj = self.cmddict.get(name) + if not cmdobj: + cmdobj = command_type(func, parent, name, **kwargs) + self.cmddict[name] = cmdobj + for alias in cmdobj.aliases: + self.cmddict[alias] = cmdobj + else: + if cmdobj.callback is func: + return + print(f"Attempt to reimplement {name} from {cmdobj.callback} to {func}") + if not isinstance(cmdobj, command_type): + raise TypeError( + f"Error reimplementing {name}: " + f"A {type(cmdobj).__name__} cannot be " + f"reimplemented as a {command_type.__name__}" + ) + + if not inspect.ismethod(func): + func.__stack_cmd__ = cmdobj # type: ignore[reportFunctionMemberAccess] + + def _reset_state(self) -> None: + """Reset the runtime-owned command queue and scenario state.""" + # Stack data + self.current = "" + self.cmdstack: list[tuple[str, bytes | None]] = [] + + # Scenario details + self.scenname = "" + self.scentime: list[float] = [] + self.scencmd: list[str] = [] + + # Current command details + self.sender_rte: bytes | None = None + + def commands(self) -> Iterator[str]: + """Iterate over the command lines pending for this simulation step. + + Detaches the pending command list before iterating so that a + [stack][minisky.stack.stack] call from another thread, such as a plugin I/O thread, + cannot race with processing: a command lands either on the detached + list processed in this step or on the fresh list processed next step. + """ + pending, self.cmdstack = self.cmdstack, [] + # Assign to instance attributes so current and sender_rte track the loop. + for self.current, self.sender_rte in pending: + yield self.current + + def select_implementation(self, basename: str = "", implname: str = "") -> tuple[bool, str]: + """Select a replaceable implementation on this runtime's traffic tree.""" + return trafficarrays.select_implementation(basename, implname, self.traffic, self.cmddict) + + def init(self) -> None: + """Initialise BlueSky base stack commands.""" + + cmddict, synonyms = commands.get_commands(self) + + # register command + for name, values in cmddict.items(): + function, arguments, brief, help_text = values + + self.addcommand( + function, + name=name, + arguments=arguments, + brief=brief, + help=help_text, + aliases=synonyms.get(name, []), + ) + + register_declared_commands() + + def delete_element(self, *arg): + """DEL: Delete an element (aircraft, wind field, area shape, or group). + + Dispatches based on the first argument: the string "WIND" clears the + wind field, any other string deletes the area with that name, a traffic + group object deletes that group, and anything else is treated as + aircraft indices to delete. + + Args: + *arg: Element(s) to delete: "WIND", an area name, a traffic group, + or one or more aircraft indices. + + Returns: + The result of the dispatched delete function. + """ + if isinstance(arg[0], str) and arg[0] == "WIND": + return self.traffic.wind.clear() + elif isinstance(arg[0], str): + return areafilter.deleteArea(arg[0]) + elif hasattr(arg[0], "groupname"): + return self.traffic.groups.delgroup(arg[0]) + else: + return self.traffic.delete(np.array(arg)) + + def reset(self) -> None: + """Reset the stack. + + Clears the command queue and buffered scenario data, and resets the + argument-parser reference data (position, heading, speed). + """ + self._reset_state() + argparser.reset() + + def process(self) -> None: + """Sim-side stack processing; called once per simulation step. + + First moves due scenario commands onto the stack (see checkscen), then + parses and executes every queued command line: the first word is looked + up in self.cmddict (an aircraft callsign may also be used as prefix, + in which case the second word is the command, defaulting to POS), the + remaining text is passed to the Command object for argument parsing and + execution, and any resulting message is echoed to the screen. The + pending commands are detached from the stack up front (see + Stack.commands), so commands stacked while processing runs — including + from other threads — are kept for the next step instead of being lost. + """ + # First check for commands in scenario file + self.checkscen() + + # Process stack of commands + for cmdline in self.commands(): + success = True + echotext = "" + + # Get first argument from command line and check if it's a command + cmd, argstring = argparser.getnextarg(cmdline) + cmdu = cmd.upper() + cmdobj = self.cmddict.get(cmdu) + + # If no function is found for 'cmd', check if cmd is actually an aircraft id + if not cmdobj and cmdu in self.traffic.callsign: + cmd, argstring = argparser.getnextarg(argstring) + argstring = cmdu + " " + argstring + # When no other args are parsed, command is POS + cmdu = cmd.upper() if cmd else "POS" + cmdobj = self.cmddict.get(cmdu) + + # Proceed if a command object was found + if cmdobj: + try: + # Call the command, passing the argument string + success, echotext = cmdobj(argstring) + if not success: + if not argstring: + echotext = echotext or cmdobj.brieftext() + else: + echotext = f"Error: {echotext or cmdobj.brieftext()}" + + except argparser.ArgumentError as e: + success = False + header = "" if not argstring else e.args[0] if e.args else "Argument error." + echotext = f"{header}\nUsage:\n{cmdobj.brieftext()}" + except Exception as e: + header = "" if not argstring else e.args[0] if e.args else "Function error." + echotext = ( + f"Error calling function implementation of {cmdu}: {header}\n" + + "Traceback printed to terminal." + ) + traceback.print_exc() + + # Command not found + else: + success = False + if not argstring: + echotext = f"error: unknown command or aircraft: {cmd}" + else: + echotext = f"error: unknown command: {cmd}" + + if echotext: + self.console.echo(echotext) + + def readscn(self, scn: str | Path | StringIO) -> Iterator[tuple[float, str]]: + """Read a scenario file and yield its timestamped commands. + + Parses lines of the form `HH:MM:SS.hh>CMDLINE`, skipping comments + (lines starting with "#") and empty lines, and supporting line + continuation with a trailing backslash. + + Args: + scn: Scenario source: path to a .scn file (str or Path; the .scn + suffix is added when missing), or a StringIO object. + + Yields: + tuple: (command time [s] (float), command line (str)). + + Raises: + TypeError: When scn is neither a path nor a StringIO object. + """ + if isinstance(scn, (str, Path)): + # ensure .scn suffix if necessary + scn_path = Path(scn).with_suffix(".scn") + + with open(scn_path) as fscen: + scn_input = StringIO(fscen.read()) + elif isinstance(scn, StringIO): + scn_input = scn + else: + raise TypeError("scn must be a string or StringIO") + + prevline = "" + for line in scn_input: + line = line.strip() + # Skip emtpy lines and comments + if not line or line[0] == "#": + continue + line = prevline + line + + # Check for line continuation + if line[-1] == "\\": + prevline = f"{line[:-1].strip()} " + continue + prevline = "" + + # Try reading timestamp and command + try: + icmdline = line.index(">") + tstamp = line[:icmdline] + ttxt = tstamp.strip().split(":") + ihr = int(ttxt[0]) * 3600.0 + imin = int(ttxt[1]) * 60.0 + xsec = float(ttxt[2]) + cmdtime = ihr + imin + xsec + + yield (cmdtime, line[icmdline + 1 :].strip("\n")) + except (ValueError, IndexError): + # nice try, we will just ignore this syntax error + if not (len(line.strip()) > 0 and line.strip()[0] == "#"): + self.console.echo(f"Skipping invalid scenario line: {line.strip()}") + + def ic(self, scn: str) -> tuple[bool, str]: + """IC: Load a scenario file. + + Resets the simulation, reads the scenario file, and buffers its + timestamped commands for execution when the simulation time passes + their timestamps (see checkscen). + + Args: + scn: The filename of the scenario, relative to the project root. + + Returns: + tuple: (success (bool), message (str)). + """ + + self.simulation.reset() + + scn_path = self.scenario_root / scn + if not scn_path.exists(): + return False, f"IC: File not found: {scn_path}" + + lines = self.readscn(scn_path) + + for cmdtime, cmd in lines: + self.scentime.append(cmdtime) + self.scencmd.append(cmd) + self.scenname = scn_path.stem - # Scenario details - scenname = "" # Currently used scenario name (for reading) - scentime = [] # Times of the commands from the read scenario file - scencmd = [] # Commands from the scenario file + return True, f"scenario {scn_path} loaded." - # Current command details - sender_rte = None # bs net route to sender + def ic_StringIO(self, scn: StringIO, scn_name: str | None = None) -> tuple[bool, str]: + """IC: Load a scenario from a StringIO object. + + Resets the simulation, reads scenario lines from the StringIO object, + and buffers the timestamped commands for execution (see checkscen). + + Args: + scn: StringIO object containing scenario lines. + scn_name: The name of the scenario (optional). + + Returns: + tuple: (success (bool), message (str)). + """ + + # reset sim always + self.simulation.reset() + + lines = self.readscn(scn) + + for cmdtime, cmd in lines: + self.scentime.append(cmdtime) + self.scencmd.append(cmd) + self.scenname = scn_name or "" + + return True, f"scenario {scn_name} loaded." + + def scenario(self, name: String) -> tuple[bool, str]: + """SCENARIO: Set the scenario name for the current simulation. + + Args: + name: The name to give the scenario. + + Returns: + tuple: (True, confirmation message). + """ + self.scenname = name + return True, "Starting scenario " + name + + def schedule(self, time: Time, cmdline: String) -> bool: + """SCHEDULE: Schedule a stack command at a specific simulation time. + + The command is inserted into the scenario buffer, keeping the buffer + sorted by execution time. + + Args: + time: Absolute simulation time [s] at which the command should + be executed. + cmdline: The command line to be executed. + + Returns: + bool: True (the command is always scheduled). + """ + # Get index of first scentime greater than 'time' as insert position + idx = next((i for i, t in enumerate(self.scentime) if t > time), len(self.scentime)) + self.scentime.insert(idx, time) + self.scencmd.insert(idx, cmdline) + return True + + def delay(self, time: Time, cmdline: String) -> bool: + """DELAY: Delay a stack command by a time interval. + + Like schedule(), but the given time is relative to the current + simulation time. + + Args: + time: Time interval [s] by which the command should be delayed. + cmdline: The command line to be executed after the delay. + + Returns: + bool: True (the command is always scheduled). + """ + # Get index of first scentime greater than 'time' as insert position + time += self.simulation.simt + idx = next((i for i, t in enumerate(self.scentime) if t > time), len(self.scentime)) + self.scentime.insert(idx, time) + self.scencmd.insert(idx, cmdline) + return True + + def showhelp(self, cmd: Txt = "", subcmd: Txt = "") -> tuple[bool, str]: + """HELP: Display general help text or help text for a specific command, + or dump command reference in file when command is >filename. + + Args: + cmd: Command name to display help for, or ">filename" to write a + tab-delimited command reference for all commands to a file + in the docs directory. + subcmd: Optional subcommand to display help for. + + Returns: + tuple: (success (bool), help text or status message (str)). + """ + + # Check if help is asked for a specific command + cmdobj = self.cmddict.get(cmd or "HELP") + if cmdobj: + return True, cmdobj.helptext(subcmd) + + # Write command reference to tab-delimited text file + if cmd[0] == ">": + # Get filename + fname = "./docs/" + cmd[1:] if len(cmd) > 1 else "./docs/minisky-commands.txt" + + # Get unique set of commands + cmdobjs = set(self.cmddict.values()) + table = [] # for alphabetical sort use a table + + # Get info for all commands + for obj in cmdobjs: + funcname = obj.callback.__name__.replace("<", "").replace(">", "") + args = ",".join(str(p) for p in obj.params) + syn = ",".join(obj.aliases) + line = f"{obj.name}\t{obj.help}\t{obj.brief}\t{args}\t{funcname}\t{syn}" + table.append(line) + + # Sort & write table + table.sort() + with open(fname, "w") as f: + # Header of first table + f.write("Command\tDescription\tUsage\tArgument types\tFunction\tSynonyms\n") + f.write("\n".join(table)) + return True, "Writing command reference in " + fname + + return False, "HELP: Unknown command: " + cmd + + def checkscen(self) -> None: + """Check if commands from the scenario buffer need to be stacked. + + All buffered scenario commands with a timestamp at or before the + current simulation time are moved onto the command stack and removed + from the scenario buffer. + """ + if self.scencmd: + # Find index of first timestamp exceeding self.simulation.simt + idx = next((i for i, t in enumerate(self.scentime) if t > self.simulation.simt), None) + # Stack all commands before that time, and remove from scenario + self.stack(*self.scencmd[:idx]) + del self.scencmd[:idx] + del self.scentime[:idx] + + def stack(self, *cmdlines: str, sender_id: bytes | None = None) -> None: + """Stack one or more commands separated by ";". + + The queued commands are executed on the next call to process(). + + Args: + *cmdlines: Command line strings; each may contain multiple + commands separated by ";". + sender_id: Optional network route/id of the command sender. + """ + for cmdline in cmdlines: + cmdline = cmdline.strip() + if cmdline: + for line in cmdline.split(";"): + self.cmdstack.append((line, sender_id)) + + def sender(self): + """Return the sender of the currently executed stack command. + If there is no sender id (e.g., when the command originates + from a scenario file), None is returned.""" + return self.sender_rte[-1] if self.sender_rte else None + + def routetosender(self): + """Return the route to the sender of the currently executed stack command. + If there is no sender id (e.g., when the command originates + from a scenario file), None is returned.""" + return self.sender_rte + + def get_scenname(self) -> str: + """Return the name of the current scenario. + This is either the name defined by the SCEN command, + or otherwise the filename of the scenario.""" + return self.scenname + + def get_scendata(self) -> tuple: + """Return the scenario data that was loaded from a scenario file. + + Returns: + tuple: (scentime, scencmd), the lists of command times [s] and + command lines still buffered for execution. + """ + return self.scentime, self.scencmd + + def set_scendata(self, newtime, newcmd) -> None: + """Set the scenario data. This is used by the batch logic.""" + self.scentime = newtime + self.scencmd = newcmd + + +_active_stack: CommandStack | None = None + + +def _activate(command_stack: CommandStack) -> None: + """Activate a runtime command stack for compatibility APIs.""" + global _active_stack + _active_stack = command_stack + Command.cmddict = command_stack.cmddict + + +def current() -> CommandStack: + """Return the active runtime command stack.""" + if _active_stack is None: + raise RuntimeError("MiniSky command stack is not initialized") + return _active_stack + + +class Stack: + """Compatibility namespace for the former static stack class. + + The command queue and scenario state now belong to the active runtime's + `CommandStack`. This class preserves the former `Stack.reset()` and + `Stack.commands()` entry points by delegating to that active instance. + """ @classmethod def reset(cls) -> None: - """Reset stack variables.""" - cls.cmdstack = [] - cls.scenname = "" - cls.scentime = [] - cls.scencmd = [] - cls.sender_rte = None + """Reset the active runtime's stack variables.""" + current()._reset_state() @classmethod def commands(cls) -> Iterator[str]: - """Generator function to iterate over stack commands. + """Iterate over the active runtime's pending command lines. - Detaches the pending command list before iterating so that a - stack() call from another thread (e.g. a plugin I/O thread) can - never race with processing: a command lands either on the old - list (processed this step) or on the fresh one (next step). + The pending list is detached before iteration so commands added while + processing are retained for the next simulation step. """ - pending, cls.cmdstack = cls.cmdstack, [] - # (assigning to cls attributes on purpose, so cls.current tracks the loop) - for cls.current, cls.sender_rte in pending: # noqa: B020 - yield cls.current + return current().commands() + + +def init() -> None: + """Initialise the base stack commands for the active runtime.""" + current().init() def delete_element(*arg): """DEL: Delete an element (aircraft, wind field, area shape, or group). - Dispatches based on the first argument: the string "WIND" clears the - wind field, any other string deletes the area with that name, a traffic - group object deletes that group, and anything else is treated as - aircraft indices to delete. + Dispatches based on the first argument: the string `WIND` clears the wind + field, any other string deletes the area with that name, a traffic group + object deletes that group, and anything else is treated as aircraft + indices to delete. Args: - *arg: Element(s) to delete: "WIND", an area name, a traffic group, - or one or more aircraft indices. + *arg: Element or elements to delete: `WIND`, an area name, a traffic + group, or one or more aircraft indices. Returns: The result of the dispatched delete function. """ - if isinstance(arg[0], str) and arg[0] == "WIND": - return minisky.traf.wind.clear() - elif isinstance(arg[0], str): - return minisky.tools.areafilter.deleteArea(arg[0]) - elif hasattr(arg[0], "groupname"): - return minisky.traf.groups.delgroup(arg[0]) - else: - return minisky.traf.delete(np.array(arg)) + return current().delete_element(*arg) def reset() -> None: """Reset the stack. Clears the command queue and buffered scenario data, and resets the - argument-parser reference data (position, heading, speed). + argument-parser reference data for position, heading, and speed. """ - Stack.reset() - argparser.reset() + current().reset() def process() -> None: - """Sim-side stack processing; called once per simulation step. - - First moves due scenario commands onto the stack (see checkscen), then - parses and executes every queued command line: the first word is looked - up in Command.cmddict (an aircraft callsign may also be used as prefix, - in which case the second word is the command, defaulting to POS), the - remaining text is passed to the Command object for argument parsing and - execution, and any resulting message is echoed to the screen. The - pending commands are detached from the stack up front (see - Stack.commands), so commands stacked while processing runs — including - from other threads — are kept for the next step instead of being lost. - """ - # First check for commands in scenario file - checkscen() - - # Process stack of commands - for cmdline in Stack.commands(): - success = True - echotext = "" - - # Get first argument from command line and check if it's a command - cmd, argstring = argparser.getnextarg(cmdline) - cmdu = cmd.upper() - cmdobj = Command.cmddict.get(cmdu) - - # If no function is found for 'cmd', check if cmd is actually an aircraft id - if not cmdobj and cmdu in minisky.traf.callsign: - cmd, argstring = argparser.getnextarg(argstring) - argstring = cmdu + " " + argstring - # When no other args are parsed, command is POS - cmdu = cmd.upper() if cmd else "POS" - cmdobj = Command.cmddict.get(cmdu) - - # Proceed if a command object was found - if cmdobj: - try: - # Call the command, passing the argument string - success, echotext = cmdobj(argstring) - if not success: - if not argstring: - echotext = echotext or cmdobj.brieftext() - else: - echotext = f"Error: {echotext or cmdobj.brieftext()}" - - except argparser.ArgumentError as e: - success = False - header = "" if not argstring else e.args[0] if e.args else "Argument error." - echotext = f"{header}\nUsage:\n{cmdobj.brieftext()}" - except Exception as e: - header = "" if not argstring else e.args[0] if e.args else "Function error." - echotext = ( - f"Error calling function implementation of {cmdu}: {header}\n" - + "Traceback printed to terminal." - ) - traceback.print_exc() + """Process the active runtime's command stack once. - # Command not found - else: - success = False - if not argstring: - echotext = f"error: unknown command or aircraft: {cmd}" - else: - echotext = f"error: unknown command: {cmd}" + First moves due scenario commands onto the stack, then parses and executes + every queued command line. The first word is looked up in + `Command.cmddict`; an aircraft callsign may also be used as a prefix, in + which case the second word is the command and defaults to `POS`. Remaining + text is parsed into typed arguments and passed to the command callback. - if echotext: - minisky.scr.echo(echotext) + The pending commands are detached before processing, so commands stacked + during processing, including from other threads, are retained for the next + simulation step. + """ + current().process() -def readscn(scn: "str | Path | StringIO") -> Iterator[tuple[float, str]]: +def readscn(scn: str | Path | StringIO) -> Iterator[tuple[float, str]]: """Read a scenario file and yield its timestamped commands. - Parses lines of the form ``HH:MM:SS.hh>CMDLINE``, skipping comments - (lines starting with "#") and empty lines, and supporting line - continuation with a trailing backslash. + Parses lines of the form `HH:MM:SS.hh>CMDLINE`, skipping comments and empty + lines and supporting line continuation with a trailing backslash. Args: - scn: Scenario source: path to a .scn file (str or Path; the .scn - suffix is added when missing), or a StringIO object. + scn: Scenario source: a path to a `.scn` file, or a `StringIO` object. + The `.scn` suffix is added to paths when missing. Yields: - tuple: (command time [s] (float), command line (str)). + A `(command time [s], command line)` tuple for each valid line. Raises: - TypeError: When scn is neither a path nor a StringIO object. + TypeError: When `scn` is neither a path nor a `StringIO` object. """ - if isinstance(scn, (str, Path)): - # ensure .scn suffix if necessary - scn_path = Path(scn).with_suffix(".scn") - - with open(scn_path) as fscen: - scn_input = StringIO(fscen.read()) - elif isinstance(scn, StringIO): - scn_input = scn - else: - raise TypeError("scn must be a string or StringIO") - - prevline = "" - for line in scn_input: - line = line.strip() - # Skip emtpy lines and comments - if not line or line[0] == "#": - continue - line = prevline + line - - # Check for line continuation - if line[-1] == "\\": - prevline = f"{line[:-1].strip()} " - continue - prevline = "" - - # Try reading timestamp and command - try: - icmdline = line.index(">") - tstamp = line[:icmdline] - ttxt = tstamp.strip().split(":") - ihr = int(ttxt[0]) * 3600.0 - imin = int(ttxt[1]) * 60.0 - xsec = float(ttxt[2]) - cmdtime = ihr + imin + xsec - - yield (cmdtime, line[icmdline + 1 :].strip("\n")) - except (ValueError, IndexError): - # nice try, we will just ignore this syntax error - if not (len(line.strip()) > 0 and line.strip()[0] == "#"): - minisky.scr.echo(f"Skipping invalid scenario line: {line.strip()}") + return current().readscn(scn) def ic(scn: str) -> tuple[bool, str]: """IC: Load a scenario file. - Resets the simulation, reads the scenario file, and buffers its - timestamped commands for execution when the simulation time passes - their timestamps (see checkscen). + Resets the simulation, reads the scenario file, and buffers its timestamped + commands for execution when simulation time passes their timestamps. Args: - scn: The filename of the scenario, relative to the project root. + scn: Scenario filename relative to the project root. Returns: - tuple: (success (bool), message (str)). + A `(success, message)` tuple. """ - - minisky.sim.reset() - - scn_path = Path(__file__).parent.parent.parent / scn - if not scn_path.exists(): - return False, f"IC: File not found: {scn_path}" - - lines = readscn(scn_path) - - for cmdtime, cmd in lines: - Stack.scentime.append(cmdtime) - Stack.scencmd.append(cmd) - Stack.scenname = scn_path.stem - - return True, f"scenario {scn_path} loaded." + return current().ic(scn) def ic_StringIO(scn: StringIO, scn_name: str | None = None) -> tuple[bool, str]: - """IC: Load a scenario from a StringIO object. + """IC: Load a scenario from a `StringIO` object. - Resets the simulation, reads scenario lines from the StringIO object, - and buffers the timestamped commands for execution (see checkscen). + Resets the simulation, reads scenario lines from the object, and buffers + the timestamped commands for execution. Args: - scn: StringIO object containing scenario lines. - scn_name: The name of the scenario (optional). + scn: Object containing scenario lines. + scn_name: Optional scenario name. Returns: - tuple: (success (bool), message (str)). + A `(success, message)` tuple. """ - - # reset sim always - minisky.sim.reset() - - lines = readscn(scn) - - for cmdtime, cmd in lines: - Stack.scentime.append(cmdtime) - Stack.scencmd.append(cmd) - Stack.scenname = scn_name or "" - - return True, f"scenario {scn_name} loaded." + return current().ic_StringIO(scn, scn_name) def scenario(name: String) -> tuple[bool, str]: """SCENARIO: Set the scenario name for the current simulation. Args: - name: The name to give the scenario. + name: Name to give the scenario. Returns: - tuple: (True, confirmation message). + A `(True, confirmation message)` tuple. """ - Stack.scenname = name - return True, "Starting scenario " + name + return current().scenario(name) def schedule(time: Time, cmdline: String) -> bool: - """SCHEDULE: Schedule a stack command at a specific simulation time. + """SCHEDULE: Schedule a command at a specific simulation time. - The command is inserted into the scenario buffer, keeping the buffer - sorted by execution time. + The command is inserted into the scenario buffer while preserving its + execution-time ordering. Args: - time: Absolute simulation time [s] at which the command should - be executed. - cmdline: The command line to be executed. + time: Absolute simulation time [s] at which to execute the command. + cmdline: Command line to execute. Returns: - bool: True (the command is always scheduled). + `True`; the command is always scheduled. """ - # Get index of first scentime greater than 'time' as insert position - idx = next((i for i, t in enumerate(Stack.scentime) if t > time), len(Stack.scentime)) - Stack.scentime.insert(idx, time) - Stack.scencmd.insert(idx, cmdline) - return True + return current().schedule(time, cmdline) def delay(time: Time, cmdline: String) -> bool: - """DELAY: Delay a stack command by a time interval. + """DELAY: Delay a command by a time interval. - Like schedule(), but the given time is relative to the current - simulation time. + Like [schedule][minisky.stack.schedule], but `time` is relative to the current simulation time. Args: - time: Time interval [s] by which the command should be delayed. - cmdline: The command line to be executed after the delay. + time: Time interval [s] by which to delay the command. + cmdline: Command line to execute after the delay. Returns: - bool: True (the command is always scheduled). + `True`; the command is always scheduled. """ - # Get index of first scentime greater than 'time' as insert position - time += minisky.sim.simt - idx = next((i for i, t in enumerate(Stack.scentime) if t > time), len(Stack.scentime)) - Stack.scentime.insert(idx, time) - Stack.scencmd.insert(idx, cmdline) - return True + return current().delay(time, cmdline) def showhelp(cmd: Txt = "", subcmd: Txt = "") -> tuple[bool, str]: - """HELP: Display general help text or help text for a specific command, - or dump command reference in file when command is >filename. + """HELP: Display command help or write a command reference file. Args: - cmd: Command name to display help for, or ">filename" to write a - tab-delimited command reference for all commands to a file - in the docs directory. - subcmd: Optional subcommand to display help for. + cmd: Command name to display, or `>filename` to write a tab-delimited + command reference in the documentation directory. + subcmd: Optional subcommand to display. Returns: - tuple: (success (bool), help text or status message (str)). + A `(success, help text or status message)` tuple. """ - - # Check if help is asked for a specific command - cmdobj = Command.cmddict.get(cmd or "HELP") - if cmdobj: - return True, cmdobj.helptext(subcmd) - - # Write command reference to tab-delimited text file - if cmd[0] == ">": - # Get filename - fname = "./docs/" + cmd[1:] if len(cmd) > 1 else "./docs/minisky-commands.txt" - - # Get unique set of commands - cmdobjs = set(Command.cmddict.values()) - table = [] # for alphabetical sort use a table - - # Get info for all commands - for obj in cmdobjs: - funcname = obj.callback.__name__.replace("<", "").replace(">", "") - args = ",".join(str(p) for p in obj.params) - syn = ",".join(obj.aliases) - line = f"{obj.name}\t{obj.help}\t{obj.brief}\t{args}\t{funcname}\t{syn}" - table.append(line) - - # Sort & write table - table.sort() - with open(fname, "w") as f: - # Header of first table - f.write("Command\tDescription\tUsage\tArgument types\tFunction\tSynonyms\n") - f.write("\n".join(table)) - return True, "Writing command reference in " + fname - - return False, "HELP: Unknown command: " + cmd + return current().showhelp(cmd, subcmd) def checkscen() -> None: - """Check if commands from the scenario buffer need to be stacked. + """Move due scenario commands onto the active runtime's command queue. - All buffered scenario commands with a timestamp at or before the - current simulation time are moved onto the command stack and removed - from the scenario buffer. + All buffered scenario commands with a timestamp at or before the current + simulation time are removed from the scenario buffer and queued for + execution. """ - if Stack.scencmd: - # Find index of first timestamp exceeding minisky.sim.simt - idx = next((i for i, t in enumerate(Stack.scentime) if t > minisky.sim.simt), None) - # Stack all commands before that time, and remove from scenario - stack(*Stack.scencmd[:idx]) - del Stack.scencmd[:idx] - del Stack.scentime[:idx] + current().checkscen() def stack(*cmdlines: str, sender_id: bytes | None = None) -> None: - """Stack one or more commands separated by ";". + """Stack one or more commands separated by semicolons. - The queued commands are executed on the next call to process(). + Queued commands are executed on the next call to [process][minisky.stack.process]. Args: - *cmdlines: Command line strings; each may contain multiple - commands separated by ";". - sender_id: Optional network route/id of the command sender. + *cmdlines: Command line strings. Each may contain multiple commands + separated by semicolons. + sender_id: Optional network route or identifier of the sender. """ - for cmdline in cmdlines: - cmdline = cmdline.strip() - if cmdline: - for line in cmdline.split(";"): - Stack.cmdstack.append((line, sender_id)) + current().stack(*cmdlines, sender_id=sender_id) def sender(): - """Return the sender of the currently executed stack command. - If there is no sender id (e.g., when the command originates - from a scenario file), None is returned.""" - return Stack.sender_rte[-1] if Stack.sender_rte else None + """Return the sender of the command currently being executed. + + Returns `None` when the command has no sender identifier, such as a command + originating from a scenario file. + """ + return current().sender() def routetosender(): - """Return the route to the sender of the currently executed stack command. - If there is no sender id (e.g., when the command originates - from a scenario file), None is returned.""" - return Stack.sender_rte + """Return the route to the sender of the current command. + + Returns `None` when the command has no sender identifier, such as a command + originating from a scenario file. + """ + return current().routetosender() def get_scenname() -> str: - """Return the name of the current scenario. - This is either the name defined by the SCEN command, - or otherwise the filename of the scenario.""" - return Stack.scenname + """Return the current scenario name. + + This is the name defined by the `SCENARIO` command or, when no explicit + name was set, the scenario filename. + """ + return current().get_scenname() -def get_scendata() -> tuple: - """Return the scenario data that was loaded from a scenario file. +def get_scendata() -> tuple[list[float], list[str]]: + """Return the buffered scenario data. Returns: - tuple: (scentime, scencmd), the lists of command times [s] and - command lines still buffered for execution. + A `(scentime, scencmd)` tuple containing command times [s] and command + lines still buffered for execution. """ - return Stack.scentime, Stack.scencmd + return current().get_scendata() def set_scendata(newtime, newcmd) -> None: - """Set the scenario data. This is used by the batch logic.""" - Stack.scentime = newtime - Stack.scencmd = newcmd + """Replace the buffered scenario data used by batch execution.""" + current().set_scendata(newtime, newcmd) + + +for _name in ( + "init", + "delete_element", + "reset", + "process", + "readscn", + "ic", + "ic_StringIO", + "scenario", + "schedule", + "delay", + "showhelp", + "checkscen", + "stack", + "sender", + "routetosender", + "get_scenname", + "get_scendata", + "set_scendata", +): + globals()[_name].__doc__ = getattr(CommandStack, _name).__doc__ diff --git a/minisky/stack/commands.py b/minisky/stack/commands.py index 10cc59f..e08c91b 100644 --- a/minisky/stack/commands.py +++ b/minisky/stack/commands.py @@ -45,102 +45,109 @@ command of the simulator (e.g., CRE, ALT, HDG) to the Python function that implements it, its argument type specification, and its usage and help texts, plus a dictionary of command synonyms. Both dictionaries are -registered with the command interpreter in minisky.stack.init(). +registered with the command interpreter in `CommandStack.init()`. The strings in the command dictionary are the in-simulator help texts shown by the HELP command. """ +from __future__ import annotations -def get_commands() -> tuple: +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from minisky.stack import CommandStack + + +def get_commands(command_stack: CommandStack) -> tuple: """Assemble the base command and synonym dictionaries of the simulator. - Imports minisky at call time so that command callbacks can be bound to - the fully initialised simulation objects (traf, sim, navdb, scr, ...). + Binds callbacks to the objects owned by the provided runtime command stack. Returns: tuple: (cmddict, synonyms). cmddict maps a command name to a list of [function, argument type string, brief usage text, help text]; synonyms maps a command name to a list of alias names. """ - import minisky + from minisky import core, plugin, tools + from minisky.traffic import route from minisky.traffic.asas import resolution as asasresolution cmddict = { "ADDWPT": [ - minisky.traffic.route.addwpt, + route.addwpt, "callsign,wpt,[alt,spd,wpt,wpt]", "ADDWPT callsign, wpt, [alt, spd, wpt, wpt]", "Add a waypoint to the route.", ], "ADDWPTMODE": [ - minisky.traffic.route.change_wpt_mode, + route.change_wpt_mode, "callsign, [wpt,alt]", "ADDWPTMODE callsign, [wpt,alt]", "Changes the mode of the ADDWPT command to add waypoints of type 'mode'.", ], "AFTER": [ - minisky.traffic.route.addwpt_after, + route.addwpt_after, "callsign,wpt,txt,wpt,[alt,spd]", "AFTER callsign, wpt, addwpt, waypoint, [alt, spd]", "Add a waypoint after another waypoint in the route.", ], "ALT": [ - minisky.traf.ap.selaltcmd, + command_stack.traffic.ap.selaltcmd, "callsign,alt,[vspd]", "ALT callsign, alt, [vspd]", "Select autopilot altitude command.", ], "ASAS": [ - minisky.traf.cd.switch, + command_stack.traffic.cd.switch, "[txt]", "ASAS [ON/OFF]", "Select a Conflict Detection method.", ], "AT": [ - minisky.traffic.route.at_wpt, + route.at_wpt, "callsign,wpt,[txt,...]", "AT callsign, wpt, [DEL] ALT/SPD/DO alt/spd/stack command", "Set or show altitude and/or speed constraints at a waypoint.", ], "ATALT": [ - minisky.traf.cond.ataltcmd, + command_stack.traffic.cond.ataltcmd, "callsign,alt,string", "callsign ATALT alt cmd ", "When aircraft at given altitude , execute the command", ], "ATDIST": [ - minisky.traf.cond.atdistcmd, + command_stack.traffic.cond.atdistcmd, "callsign,latlon,float,string", "callsign ATDIST pos dist cmd ", "When aircraft passing this distance (in nm) to position, execute the command", ], "ATSPD": [ - minisky.traf.cond.atspdcmd, + command_stack.traffic.cond.atspdcmd, "callsign,spd,string", "callsign ATSPD spd cmd ", "When aircraft reaches given speed, execute the command", ], "BANK": [ - minisky.traf.setbanklim, + command_stack.traffic.setbanklim, "callsign,[float]", "BANK callsign bankangle[deg]", "Set or show bank limit for this vehicle", ], "BEFORE": [ - minisky.traffic.route.addwpt_before, + route.addwpt_before, "callsign,wpt,txt,wpt,[alt,spd]", "BEFORE callsign, wpt, addwpt, waypoint, [alt, spd]", "Add a waypoint before another waypoint in the route.", ], "BOX": [ - minisky.tools.areafilter.define_box_area, + tools.areafilter.define_box_area, "txt,latlon,latlon,[alt,alt]", "BOX name,lat,lon,lat,lon,[top,bottom]", "Define a box-shaped area", ], "CASMACHTHR": [ - minisky.tools.aero.casmachthr, + tools.aero.casmachthr, "float", "CASMACHTHR threshold", """Set a threshold below which speeds should be considered as Mach numbers @@ -148,115 +155,115 @@ def get_commands() -> tuple: never be considered as Mach number(e.g., when simulating drones).""", ], "CIRCLE": [ - minisky.tools.areafilter.define_circle_area, + tools.areafilter.define_circle_area, "txt,latlon,float,[alt,alt]", "CIRCLE name,lat,lon,radius,[top,bottom]", "Define a circle-shaped area", ], "CLRCRECMD": [ - minisky.traf.clrcrecmd, + command_stack.traffic.clrcrecmd, "", "CLRCRECMD", "CLRCRECMD will clear CRECMD list of commands aircraft creation", ], "CRE": [ - minisky.traf.cre, + command_stack.traffic.cre, "txt,txt,float,float,[hdg,alt,spd]", "CRE callsign,type,lat,lon,hdg,alt,spd", "Create an aircraft", ], "CRECMD": [ - minisky.traf.crecmd, + command_stack.traffic.crecmd, "string", "CRECMD cmdline (to be added after aircraft id )", "Add a command for each aircraft to be issued after creation of aircraft", ], "CRECONFS": [ - minisky.traf.creconfs, + command_stack.traffic.creconfs, "txt,txt,callsign,hdg,float,time,[alt,time,spd]", "CRECONFS id, type, targetid, dpsi, cpa, tlos_hor, dH, tlos_ver, spd", "Create an aircraft that is in conflict with 'targetid'", ], "DATE": [ - minisky.sim.setutc, + command_stack.simulation.setutc, "[int,int,int,txt]", "DATE [day,month,year,HH:MM:SS.hh]", "Set simulation date", ], "DEFWPT": [ - minisky.navdb.defwpt, + command_stack.navigation.defwpt, "txt,latlon,[txt]", "DEFWPT wpname,lat,lon,[DELETE/FIX/VOR/DME/NDB/DEL]", "Define (or delete) a waypoint only for this scenario/run", ], "DEL": [ - minisky.stack.delete_element, + command_stack.delete_element, "callsign/txt,...", "DEL callsign/ALL/WIND/shape", "Delete command (aircraft, wind, area)", ], "DELAY": [ - minisky.stack.delay, + command_stack.delay, "time, string", "DELAY time, cmdline", "Delay a stack command until a specific simulation time.", ], "DELRTE": [ - minisky.traffic.route.delrte, + route.delrte, "callsign", "DELRTE callsign", "Delete the complete route for an aircraft.", ], "DELWPT": [ - minisky.traffic.route.delwpt, + route.delwpt, "callsign,wpt", "DELWPT callsign,wpt", "Delete a waypoint from a route.", ], "DEST": [ - minisky.traf.ap.setdest, + command_stack.traffic.ap.setdest, "callsign,wpt,[spd]", "DEST callsign, latlon/airport, casmach (= CASkts/Mach)", "Set destination of aircraft, aircraft will fly to this airport.", ], "DIRECT": [ - minisky.traffic.route.direct, + route.direct, "callsign, wpt", "DIRECT callsign, wpt", "Go direct to a specified waypoint in the route.", ], "DTMULT": [ - minisky.runner.setspeed, + command_stack.runner.setspeed, "float", "DTMULT multiplier", "Set the simulation speed multiplier (wall-clock pacing, DTMULT equivalent).", ], "DTLOOK": [ - minisky.traf.cd.setdtlook, + command_stack.traffic.cd.setdtlook, "[time,callsign,...]", "DTLOOK [time, callsign...]", "Set the lookahead time (in [hh:mm:]sec) for conflict detection.", ], "DTNOLOOK": [ - minisky.traf.cd.setdtnolook, + command_stack.traffic.cd.setdtnolook, "[time,callsign,...]", "DTNOLOOK [time, callsign...]", "Set the interval (in [hh:mm:]sec) in which conflict detection is skipped after a conflict resolution.", ], "ECHO": [ - minisky.scr.echo, + command_stack.console.echo, "string", "ECHO txt", "Show a text in command window for user to read", ], "GETWIND": [ - minisky.traf.wind.get, + command_stack.traffic.wind.get, "lat, lon, [alt]", "GETWIND lat, lon, [alt]", "Get wind at a specified position (and optionally at altitude).", ], "GROUP": [ - minisky.traf.groups.group, + command_stack.traffic.groups.group, "[txt,callsign/txt,...]", "GROUP [grname, (areaname OR callsign,...) ]", "Add aircraft to a group. OR all aircraft in given area.\n" @@ -265,73 +272,73 @@ def get_commands() -> tuple: + "A group is created when a group with the given name doesn't exist yet.", ], "HDG": [ - minisky.traf.ap.selhdgcmd, + command_stack.traffic.ap.selhdgcmd, "callsign,hdg", "HDG callsign,hdg (deg,True or Magnetic)", "Autopilot select heading command.", ], "HELP": [ - minisky.stack.showhelp, + command_stack.showhelp, "[txt,txt]", "HELP [cmd, subcmd]", "Display general help text or help text for a specific command.", ], "HOLD": [ - minisky.sim.hold, + command_stack.simulation.hold, "", "HOLD", "Pause(hold) simulation", ], "IC": [ - minisky.stack.ic, + command_stack.ic, "string", "IC scenario_filename", "Load a scenario filename.", ], "LINE": [ - minisky.tools.areafilter.define_line_area, + tools.areafilter.define_line_area, "txt,latlon,latlon", "LINE name,lat,lon,lat,lon", "Draw a line on the radar screen", ], "LISTRTE": [ - minisky.traffic.route.listrte, + route.listrte, "callsign,[txt]", "LISTRTE callsign, [pagenr]", "Show list of route in window per page of 5 waypoints.", ], "LNAV": [ - minisky.traf.ap.setLNAV, + command_stack.traffic.ap.setLNAV, "callsign,[bool]", "LNAV callsign,[ON/OFF]", "LNAV (lateral FMS mode) switch for autopilot.", ], "LSVAR": [ - minisky.core.varexplorer.lsvar, + core.varexplorer.lsvar, "[word]", "LSVAR path.to.variable", "Inspect any variable in a simulation", ], "MAGVAR": [ - minisky.tools.geo.magdeccmd, + tools.geo.magdeccmd, "lat,lon", "MAGVAR lat,lon", "Show magnetic variation/declination at position", ], "MCRE": [ - minisky.traf.mcre, + command_stack.traffic.mcre, "int,[float,float,float,float,txt,alt,spd]", "MCRE n,[lat,lon,lat,lon,type,alt,spd]", "Multiple random create of n aircraft in current view", ], "MOVE": [ - minisky.traf.move, + command_stack.traffic.move, "callsign,latlon,[alt,hdg,spd,vspd]", "MOVE callsign,lat,lon,[alt,hdg,spd,vspd]", "Move an aircraft to a new position", ], "NOISE": [ - minisky.traf.setnoise, + command_stack.traffic.setnoise, "[onoff]", "NOISE [ON/OFF]", "Turbulence/noise switch", @@ -343,49 +350,49 @@ def get_commands() -> tuple: "ADD or Remove aircraft that nobody will avoid.", ], "OP": [ - minisky.sim.op, + command_stack.simulation.op, "", "OP", "Start/Run simulation or continue after hold", ], "PERFSTATS": [ - minisky.traf.perf.show_performance, + command_stack.traffic.perf.show_performance, "callsign", "PERFSTATS callsign", "Show the performace information of an aircraft.", ], "ORIG": [ - minisky.traf.ap.setorig, + command_stack.traffic.ap.setorig, "callsign,wpt", "ORIG callsign, latlon/airport", "Set origin of aircraft.", ], "PLUGINS": [ - minisky.plugin.manage_plugins, + plugin.manage_plugins, "[txt,txt]", "PLUGINS [LIST/LOAD, plugin_name]", "List available plugins or load a plugin", ], "POLY": [ - minisky.tools.areafilter.define_poly_area, + tools.areafilter.define_poly_area, "txt,[latlon,...]", "POLY name,[lat,lon,lat,lon, ...]", "Define a polygon-shaped area", ], "POLYALT": [ - minisky.tools.areafilter.define_polyalt_area, + tools.areafilter.define_polyalt_area, "txt,alt,alt,latlon,...", "POLYALT name,top,bottom,lat,lon,lat,lon, ...", "Define a polygon-shaped area in 3D: between two altitudes", ], "POLYLINE": [ - minisky.tools.areafilter.define_polyline_area, + tools.areafilter.define_polyline_area, "txt,latlon,...", "POLYLINE name,lat,lon,lat,lon,...", "Draw a multi-segment line on the radar screen", ], "POS": [ - minisky.traf.position, + command_stack.traffic.position, "callsign/wpt", "POS callsign/waypoint", "Get info on aircraft, airport or waypoint", @@ -397,25 +404,25 @@ def get_commands() -> tuple: "Define priority rules (right of way) for conflict resolution.", ], "QUIT": [ - minisky.sim.stop, + command_stack.simulation.stop, "", "QUIT", "Quit program/Stop simulation", ], "REALTIME": [ - minisky.sim.realtime, + command_stack.simulation.realtime, "[bool]", "REALTIME [ON/OFF]", "En-/disable realtime running allowing a variable timestep.", ], "RESET": [ - minisky.sim.reset, + command_stack.simulation.reset, "", "RESET", "Reset simulation", ], "RESO": [ - minisky.traf.cr.setmethod, + command_stack.traffic.cr.setmethod, "[txt]", "RESO [name]", "Select a Conflict Resolution method.", @@ -451,7 +458,7 @@ def get_commands() -> tuple: "Set resolution factor vertical.", ], "RTA": [ - minisky.traffic.route.set_rta, + route.set_rta, "callsign, wpt, time", "RTA callsign, wpt, time", "Add RTA to waypoint record.", @@ -469,97 +476,97 @@ def get_commands() -> tuple: "Set resolution factor horizontal, but then with absolute value.", ], "SCHEDULE": [ - minisky.stack.schedule, + command_stack.schedule, "time,string", "SCHEDULE a stack command at a specific simulation time.", "Schedule a stack command at a specific simulation time.", ], "SCENARIO": [ - minisky.stack.scenario, + command_stack.scenario, "string", "SCENARIO name", "Sets the scenario name for the current simulation.", ], "SEED": [ - minisky.sim.setseed, + command_stack.simulation.setseed, "int", "SEED value", "Set seed for all functions using a randomizer (e.g.mcre,noise)", ], "SELECTIMPL": [ - minisky.core.trafficarrays.select_implementation, + command_stack.select_implementation, "[txt,txt]", "SELECTIMPL [classname, implname]", "Select implementation for a replaceable class (e.g., SELECTIMPL AUTOPILOT MYAUTOPILOT)", ], "SPD": [ - minisky.traf.ap.selspdcmd, + command_stack.traffic.ap.selspdcmd, "callsign,spd", "SPD callsign,casmach (= CASkts/Mach)", "Select autopilot speed.", ], "SWTOC": [ - minisky.traf.ap.setswtoc, + command_stack.traffic.ap.setswtoc, "callsign,[bool]", "SWTOC callsign,[ON/OFF]", "Switch ToC logic (=climb early) on/off.", ], "SWTOD": [ - minisky.traf.ap.setswtod, + command_stack.traffic.ap.setswtod, "callsign,[bool]", "SWTOD callsign,[ON/OFF]", "Switch ToD logic (=climb early) on/off.", ], "THR": [ - minisky.traf.setthrottle, + command_stack.traffic.setthrottle, "callsign[,txt]", "THR callsign, IDLE/0.0/throttlesetting/1.0/AUTO(default)", "Set throttle or autotothrottle(default)", ], "TIME": [ - minisky.sim.setutc, + command_stack.simulation.setutc, "[txt]", "TIME RUN(default) / HH:MM:SS.hh / REAL / UTC ", "Set simulated clock time", ], "TRAIL": [ - minisky.traf.trails.setTrails, + command_stack.traffic.trails.setTrails, "[callsign/bool],[float/txt]", "TRAIL ON/OFF, [dt] OR TRAIL callsign colour", "Toggle aircraft trails on/off", ], "UNGROUP": [ - minisky.traf.groups.ungroup, + command_stack.traffic.groups.ungroup, "txt,callsign,...", "UNGROUP grname, callsign", "Remove aircraft from a group", ], "VNAV": [ - minisky.traf.ap.setVNAV, + command_stack.traffic.ap.setVNAV, "callsign,[bool]", "VNAV callsign,[ON/OFF]", "Switch on/off VNAV mode, the vertical FMS mode (autopilot).", ], "VS": [ - minisky.traf.ap.selvspdcmd, + command_stack.traffic.ap.selvspdcmd, "callsign,vspd", "VS callsign,vspd (ft/min)", "Vertical speed command (autopilot).", ], "WIND": [ - minisky.traf.wind.add, + command_stack.traffic.wind.add, "latlon,[float/txt,float,float]...", "WIND lat,lon,[alt],dir,spd[,alt,dir,spd,...] or WIND lat,lon,DEL", "Define a wind vector as part of the 2D or 3D wind field.", ], "ZONEDH": [ - minisky.traf.cd.sethpz, + command_stack.traffic.cd.sethpz, "[float,callsign,...]", "ZONEDH [height, callsign...]", "Set the vertical separation distance (i.e., half of the protected zone height) in feet.", ], "ZONER": [ - minisky.traf.cd.setrpz, + command_stack.traffic.cd.setrpz, "[float,callsign,...]", "ZONER [radius, callsign...]", "Set the horizontal separation distance (i.e., the radius of the protected zone) in nautical miles.", From 0954e6c3d540c13bd382a245d27a2188ab57ed51 Mon Sep 17 00:00:00 2001 From: Abraham Cheung <58929011+abc8747@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:21:20 +0800 Subject: [PATCH 05/16] refactor: make `Minisky` own area filter and variable explorer --- minisky/__init__.py | 2 + minisky/core/varexplorer.py | 258 ++++++++++--------- minisky/runtime.py | 12 +- minisky/simulation/simulation.py | 6 +- minisky/stack/__init__.py | 9 +- minisky/stack/commands.py | 16 +- minisky/tools/areafilter.py | 428 ++++++++++++++++++------------- minisky/traffic/traffic.py | 6 +- minisky/traffic/trafficgroups.py | 46 ++-- 9 files changed, 453 insertions(+), 330 deletions(-) diff --git a/minisky/__init__.py b/minisky/__init__.py index 347ac4f..21c7481 100644 --- a/minisky/__init__.py +++ b/minisky/__init__.py @@ -44,6 +44,8 @@ def _activate(instance: MiniSky) -> None: sim = instance.simulation scr = instance.console stack._activate(instance.commands) + core.varexplorer._activate(instance.variables) + tools.areafilter._activate(instance.areas) from minisky.runtime import MiniSky # noqa: E402 diff --git a/minisky/core/varexplorer.py b/minisky/core/varexplorer.py index 3cc0363..f678ca1 100644 --- a/minisky/core/varexplorer.py +++ b/minisky/core/varexplorer.py @@ -3,142 +3,130 @@ Provide flexible access to simulation data in BlueSky. Data sources (by default the simulation and traffic objects) are -registered in a module-level variable list, after which any of their +registered in a runtime-owned variable list, after which any of their attributes can be inspected by name or dotted path (optionally with an -index, e.g. "traf.lat[0]"). This backs the LSVAR stack command, which +index, e.g. `traf.lat[0]`). This backs the LSVAR stack command, which prints variable type, size, and parent information to the console. """ +from __future__ import annotations + +import re from collections import OrderedDict +from collections.abc import Collection from numbers import Number from typing import Any -try: - from collections.abc import Collection -except ImportError: - # In python <3.3 collections.abc doesn't exist - from collections.abc import Collection - -import re - import numpy as np -import minisky from minisky.core import TrafficArrays -# Globals -# The variable lists and their corresponding sources -varlist = OrderedDict() - -def init() -> None: - """Variable explorer initialization function. - Is called in minisky.init()""" - # Add the default sources to the variable explorer - varlist.update( - [ - ("sim", (minisky.sim, getvarsfromobj(minisky.sim))), - ("traf", (minisky.traf, getvarsfromobj(minisky.traf))), - ] - ) - - -def register_data_parent(obj: Any, name: str) -> None: - """Register an object as a searchable data source of the variable explorer. - - Args: - obj: The object whose attributes should become inspectable. - name: Top-level name under which the object is registered. - """ - varlist[name] = (obj, getvarsfromobj(obj)) +class VariableExplorer: + """Searchable simulation data sources owned by one MiniSky runtime.""" + def __init__(self) -> None: + # The variable lists and their corresponding sources + self.varlist: OrderedDict[str, tuple[Any, list[str] | None]] = OrderedDict() -def getvarsfromobj(obj: Any) -> list[str] | None: - """Return a list with the names of the variables of the passed object.""" - try: - # Return attribute names, but exclude private attributes - return [name for name in vars(obj) if name[0] != "_"] - except TypeError: - return None + def init(self, simulation: Any, traffic: Any) -> None: + """Variable explorer initialization function. + Registers the default simulation and traffic data sources. + """ + # Add the default sources to the variable explorer + self.varlist.update( + [ + ("sim", (simulation, getvarsfromobj(simulation))), + ("traf", (traffic, getvarsfromobj(traffic))), + ] + ) -def lsvar(varname: str = "") -> tuple[bool, str]: - """Stack function to list information on simulation variables in the - BlueSky console.""" - if not varname: - # When no argument is passed, show a list of parent objects for which - # variables can be accessed - return True, "\n" + str.join(", ", list(varlist)) - - # Find the variable in the variable list - v = findvar(varname) - if v: - thevar = v.get() # reference to the actual variable - # When the variable is an object, get child attributes - attrs = getvarsfromobj(thevar) - vartype = v.get_type() # Type of the variable - if isinstance(v.parent, TrafficArrays) and v.parent.istrafarray(v.varname): - vartype += " (TrafficArray)" - txt = f"Variable: {v.varname}\n" + f"Type: {vartype}\n" - if isinstance(thevar, Collection): - txt += f"Size: {len(thevar)}\n" - txt += f"Parent: {v.parentname}" - if attrs: - txt += "\nAttributes: " + str.join(", ", attrs) + "\n" - return True, "\n" + txt - return False, f"Variable {varname} not found" - - -def findvar(varname: str) -> "Variable | None": - """Find a variable and its parent object in the registered varlist set, based - on varname, as passed by the stack. - Variables can be searched in two ways: - By name only: e.g., varname lat returns (traf, lat) - By object: e.g., varname traf.lat returns (traf, lat) - - An optional integer index may be appended, e.g. "traf.lat[0]". - - Args: - varname: Variable name or dotted object path, with optional index. - - Returns: - Variable: A Variable wrapper object, or None when not found. - """ - try: - # Find a string matching 'a.b.c[d]', where everything except a is optional - varset = re.findall(r"(\w+)(?<=.)*(?:\[(\w+)\])?", varname) - # The actual variable is always the last - name, index = varset[-1] - # is a parent object passed? (e.g., traf.lat instead of just lat) - if len(varset) > 1: - obj = None - # The first object should be in the varlist of Plot - # As either a top-level object: - if varset[0][0] in varlist: - result = varlist.get(varset[0][0]) - obj = result[0] if result is not None else None + def register_data_parent(self, obj: Any, name: str) -> None: + """Register an object as a searchable data source of the variable explorer. + + Args: + obj: The object whose attributes should become inspectable. + name: Top-level name under which the object is registered. + """ + self.varlist[name] = (obj, getvarsfromobj(obj)) + + def lsvar(self, varname: str = "") -> tuple[bool, str]: + """Stack function to list information on simulation variables in the + BlueSky console.""" + if not varname: + # When no argument is passed, show a list of parent objects for which + # variables can be accessed + return True, "\n" + str.join(", ", list(self.varlist)) + + # Find the variable in the variable list + v = self.findvar(varname) + if v: + thevar = v.get() # reference to the actual variable + # When the variable is an object, get child attributes + attrs = getvarsfromobj(thevar) + vartype = v.get_type() # Type of the variable + if isinstance(v.parent, TrafficArrays) and v.parent.istrafarray(v.varname): + vartype += " (TrafficArray)" + txt = f"Variable: {v.varname}\n" + f"Type: {vartype}\n" + if isinstance(thevar, Collection): + txt += f"Size: {len(thevar)}\n" + txt += f"Parent: {v.parentname}" + if attrs: + txt += "\nAttributes: " + str.join(", ", attrs) + "\n" + return True, "\n" + txt + return False, f"Variable {varname} not found" + + def findvar(self, varname: str) -> Variable | None: + """Find a variable and its parent object in the registered varlist set, based + on varname, as passed by the stack. + Variables can be searched in two ways: + By name only: e.g., varname lat returns (traf, lat) + By object: e.g., varname traf.lat returns (traf, lat) + + An optional integer index may be appended, e.g. `traf.lat[0]`. + + Args: + varname: Variable name or dotted object path, with optional index. + + Returns: + Variable: A Variable wrapper object, or None when not found. + """ + try: + # Find a string matching 'a.b.c[d]', where everything except a is optional + varset = re.findall(r"(\w+)(?<=.)*(?:\[(\w+)\])?", varname) + # The actual variable is always the last + name, index = varset[-1] + # is a parent object passed? (e.g., traf.lat instead of just lat) + if len(varset) > 1: + obj = None + # The first object should be in the varlist of Plot + # As either a top-level object: + if varset[0][0] in self.varlist: + result = self.varlist.get(varset[0][0]) + obj = result[0] if result is not None else None + else: + for objset in self.varlist.values(): + if objset[1] is not None and varset[0][0] in objset[1]: + obj = getattr(objset[0], varset[0][0]) + + # Iterate over objectname,index pairs in varset + for pair in varset[1:-1]: + if obj is None: + break + obj = getattr(obj, pair[0], None) + + if obj and hasattr(obj, name): + return Variable(obj, varset[-2][0], name, index) else: - for objset in varlist.values(): - if varset[0][0] in objset[1]: - obj = getattr(objset[0], varset[0][0]) - - # Iterate over objectname,index pairs in varset - for pair in varset[1:-1]: - if obj is None: - break - obj = getattr(obj, pair[0], None) - - if obj and hasattr(obj, name): - return Variable(obj, varset[-2][0], name, index) - else: - # A parent object is not passed, we only have a variable name - # this name should exist in Plot.vlist - for objname, objset in varlist.items(): - if name in objset[1]: - return Variable(objset[0], objname, name, index) - except Exception: - pass - return None + # A parent object is not passed, we only have a variable name + # this name should exist in Plot.vlist + for objname, objset in self.varlist.items(): + if objset[1] is not None and name in objset[1]: + return Variable(objset[0], objname, name, index) + except Exception: + pass + return None class Variable: @@ -185,3 +173,37 @@ def get(self): v = getattr(self.parent, self.varname) return [v[i] for i in self.index] return getattr(self.parent, self.varname) + + +def getvarsfromobj(obj: Any) -> list[str] | None: + """Return a list with the names of the variables of the passed object.""" + try: + # Return attribute names, but exclude private attributes + return [name for name in vars(obj) if name[0] != "_"] + except TypeError: + return None + + +_active: VariableExplorer | None = None + + +def _activate(explorer: VariableExplorer) -> None: + """Activate a runtime variable explorer for temporary compatibility calls.""" + global _active + _active = explorer + + +def _current() -> VariableExplorer: + if _active is None: + raise RuntimeError("MiniSky variable explorer is not initialized") + return _active + + +def register_data_parent(obj: Any, name: str) -> None: + """Register a data source on the active runtime's variable explorer.""" + _current().register_data_parent(obj, name) + + +def findvar(varname: str) -> Variable | None: + """Find a variable on the active runtime's variable explorer.""" + return _current().findvar(varname) diff --git a/minisky/runtime.py b/minisky/runtime.py index c3645d5..94c5128 100644 --- a/minisky/runtime.py +++ b/minisky/runtime.py @@ -3,11 +3,12 @@ from __future__ import annotations from minisky import tools -from minisky.core import varexplorer from minisky.core.settings import MiniSkySettings, data +from minisky.core.varexplorer import VariableExplorer from minisky.simulation import ConsoleIO, Runner, Simulation from minisky.simulation.simulation import OP from minisky.stack import CommandStack +from minisky.tools.areafilter import AreaFilter from minisky.tools.navdata import Navdatabase from minisky.traffic import Traffic @@ -21,11 +22,15 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No self.console = ConsoleIO(lambda: self.simulation.state == OP) self.navigation = Navdatabase(data("navigation"), self.console) - self.traffic = Traffic(settings) + self.areas = AreaFilter() + self.variables = VariableExplorer() + self.traffic = Traffic(settings, self.areas) self.commands = CommandStack( traffic=self.traffic, navigation=self.navigation, console=self.console, + areas=self.areas, + variables=self.variables, get_simulation=lambda: self.simulation, get_runner=lambda: self.runner, ) @@ -34,16 +39,17 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No navigation=self.navigation, console=self.console, command_stack=self.commands, + areas=self.areas, stop_runner=self._stop_runner, ) self.runner = Runner(self.simulation, self.console) + self.variables.init(self.simulation, self.traffic) # the compatibility facade must be active before commands and variable # explorer parents are registered against this runtime. import minisky minisky._activate(self) - varexplorer.init() self.commands.init() if scenario: diff --git a/minisky/simulation/simulation.py b/minisky/simulation/simulation.py index e491162..bafc9da 100644 --- a/minisky/simulation/simulation.py +++ b/minisky/simulation/simulation.py @@ -19,11 +19,11 @@ from minisky.core.trafficarrays import reset_replaceables from minisky.plugin import PluginManager -from minisky.tools import areafilter if TYPE_CHECKING: from minisky.simulation.console import ConsoleIO from minisky.stack import CommandStack + from minisky.tools.areafilter import AreaFilter from minisky.tools.navdata import Navdatabase from minisky.traffic import Traffic @@ -64,12 +64,14 @@ def __init__( navigation: Navdatabase, console: ConsoleIO, command_stack: CommandStack, + areas: AreaFilter, stop_runner: Callable[[], None], ) -> None: self.traffic = traffic self.navigation = navigation self.console = console self.commands = command_stack + self.areas = areas self.stop_runner = stop_runner self.state = INIT self.prevstate = None @@ -192,7 +194,7 @@ def reset(self) -> None: self.navigation.reset() self.traffic.reset() self.commands.reset() - areafilter.reset() + self.areas.reset() self.console.reset() # Reset replaceables (Autopilot, PerfBase, etc.) to defaults reset_replaceables(self.traffic, self.commands.cmddict) diff --git a/minisky/stack/__init__.py b/minisky/stack/__init__.py index 35cf22c..6b8bed2 100644 --- a/minisky/stack/__init__.py +++ b/minisky/stack/__init__.py @@ -37,10 +37,11 @@ from minisky.plugin.plugin_decorators import append_commands, command, register_declared_commands from minisky.stack import argparser, commands from minisky.stack.argparser import ArgumentError, Parameter, String, Time, Txt, getnextarg -from minisky.tools import areafilter if TYPE_CHECKING: + from minisky.core.varexplorer import VariableExplorer from minisky.simulation import ConsoleIO, Runner, Simulation + from minisky.tools.areafilter import AreaFilter from minisky.tools.navdata import Navdatabase from minisky.traffic import Traffic @@ -298,6 +299,8 @@ def __init__( traffic: Traffic, navigation: Navdatabase, console: ConsoleIO, + areas: AreaFilter, + variables: VariableExplorer, get_simulation: Callable[[], Simulation], get_runner: Callable[[], Runner], scenario_root: Path | None = None, @@ -305,6 +308,8 @@ def __init__( self.traffic = traffic self.navigation = navigation self.console = console + self.areas = areas + self.variables = variables self._get_simulation = get_simulation self._get_runner = get_runner self.scenario_root = scenario_root or Path(__file__).parent.parent.parent @@ -435,7 +440,7 @@ def delete_element(self, *arg): if isinstance(arg[0], str) and arg[0] == "WIND": return self.traffic.wind.clear() elif isinstance(arg[0], str): - return areafilter.deleteArea(arg[0]) + return self.areas.deleteArea(arg[0]) elif hasattr(arg[0], "groupname"): return self.traffic.groups.delgroup(arg[0]) else: diff --git a/minisky/stack/commands.py b/minisky/stack/commands.py index e08c91b..aee2a9e 100644 --- a/minisky/stack/commands.py +++ b/minisky/stack/commands.py @@ -69,7 +69,7 @@ def get_commands(command_stack: CommandStack) -> tuple: of [function, argument type string, brief usage text, help text]; synonyms maps a command name to a list of alias names. """ - from minisky import core, plugin, tools + from minisky import plugin, tools from minisky.traffic import route from minisky.traffic.asas import resolution as asasresolution @@ -141,7 +141,7 @@ def get_commands(command_stack: CommandStack) -> tuple: "Add a waypoint before another waypoint in the route.", ], "BOX": [ - tools.areafilter.define_box_area, + command_stack.areas.define_box_area, "txt,latlon,latlon,[alt,alt]", "BOX name,lat,lon,lat,lon,[top,bottom]", "Define a box-shaped area", @@ -155,7 +155,7 @@ def get_commands(command_stack: CommandStack) -> tuple: never be considered as Mach number(e.g., when simulating drones).""", ], "CIRCLE": [ - tools.areafilter.define_circle_area, + command_stack.areas.define_circle_area, "txt,latlon,float,[alt,alt]", "CIRCLE name,lat,lon,radius,[top,bottom]", "Define a circle-shaped area", @@ -296,7 +296,7 @@ def get_commands(command_stack: CommandStack) -> tuple: "Load a scenario filename.", ], "LINE": [ - tools.areafilter.define_line_area, + command_stack.areas.define_line_area, "txt,latlon,latlon", "LINE name,lat,lon,lat,lon", "Draw a line on the radar screen", @@ -314,7 +314,7 @@ def get_commands(command_stack: CommandStack) -> tuple: "LNAV (lateral FMS mode) switch for autopilot.", ], "LSVAR": [ - core.varexplorer.lsvar, + command_stack.variables.lsvar, "[word]", "LSVAR path.to.variable", "Inspect any variable in a simulation", @@ -374,19 +374,19 @@ def get_commands(command_stack: CommandStack) -> tuple: "List available plugins or load a plugin", ], "POLY": [ - tools.areafilter.define_poly_area, + command_stack.areas.define_poly_area, "txt,[latlon,...]", "POLY name,[lat,lon,lat,lon, ...]", "Define a polygon-shaped area", ], "POLYALT": [ - tools.areafilter.define_polyalt_area, + command_stack.areas.define_polyalt_area, "txt,alt,alt,latlon,...", "POLYALT name,top,bottom,lat,lon,lat,lon, ...", "Define a polygon-shaped area in 3D: between two altitudes", ], "POLYLINE": [ - tools.areafilter.define_polyline_area, + command_stack.areas.define_polyline_area, "txt,latlon,...", "POLYLINE name,lat,lon,lat,lon,...", "Draw a multi-segment line on the radar screen", diff --git a/minisky/tools/areafilter.py b/minisky/tools/areafilter.py index 280b7f1..b3d9bfe 100644 --- a/minisky/tools/areafilter.py +++ b/minisky/tools/areafilter.py @@ -5,10 +5,13 @@ 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. All defined shapes are stored by name in ``basic_shapes`` -and indexed in an R-tree for fast geospatial queries. +inside an area. Each `AreaFilter` stores its defined shapes by name and +indexes them in an R-tree for fast geospatial queries. """ +from __future__ import annotations + +from contextlib import suppress from weakref import WeakValueDictionary import numpy as np @@ -45,179 +48,258 @@ def delete(*args, **kwargs): from minisky.tools.geo import kwikdist -# Dictionary of all basic shapes (The shape classes defined in this file) by name -basic_shapes = {} - -def has_area(areaname: str) -> bool: - """Check if area with name 'areaname' exists.""" - return areaname in basic_shapes +class AreaFilter: + """Named geometric shapes and spatial index for one MiniSky runtime.""" + def __init__(self) -> None: + # Dictionary of all basic shapes (The shape classes defined in this file) by name + self.basic_shapes: dict[str, Shape] = {} -def define_area( - areaname: str, areatype: str, coordinates: tuple[float, ...], top: float = 1e9, bottom: float = -1e9 -) -> tuple[bool, 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). - - Returns: - tuple: (success (bool), message (str)). - """ - if areaname == "LIST": - if not basic_shapes: - return True, "No shapes are currently defined." - else: - return True, "Currently defined shapes:\n" + ", ".join(basic_shapes) - if not coordinates: - if areaname in basic_shapes: - return True, str(basic_shapes[areaname]) - else: - return False, f"Unknown shape: {areaname}" - if areatype == "BOX": - basic_shapes[areaname] = Box(areaname, coordinates, top, bottom) - elif areatype == "CIRCLE": - basic_shapes[areaname] = Circle(areaname, coordinates, top, bottom) - elif areatype[:4] == "POLY": - basic_shapes[areaname] = Poly(areaname, coordinates, top, bottom) - elif areatype == "LINE": - basic_shapes[areaname] = Line(areaname, coordinates) + # Counter to keep track of used shape ids + self.max_area_id = 0 - return True, f"Created {areatype} {areaname}" + # 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 define_box_area(name: str, *coords: float) -> tuple[bool, str]: - """BOX: Define a box-shaped area. - - Args: - name: Area name. - *coords: lat1, lon1, lat2, lon2 [deg] of two opposite corners, - optionally followed by top and bottom altitude [m]. - """ - return define_area(name, "BOX", coords[:4], *coords[4:]) - - -def define_circle_area(name: str, *coords: float) -> tuple[bool, str]: - """CIRCLE: Define a circle-shaped area. - - Args: - name: Area name. - *coords: lat, lon [deg] of the center and radius [nm], optionally - followed by top and bottom altitude [m]. - """ - return define_area(name, "CIRCLE", coords[:3], *coords[3:]) - - -def define_line_area(name: str, *coords: float) -> tuple[bool, str]: - """LINE: Draw a line between two positions on the radar screen. - - Args: - name: Line name. - *coords: lat1, lon1, lat2, lon2 [deg] of the two end points. - """ - return define_area(name, "LINE", coords) + 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 + + def define_area( + self, + areaname: str, + areatype: str, + coordinates: tuple[float, ...] | list[float], + top: float = 1e9, + bottom: float = -1e9, + ) -> tuple[bool, 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). + + Returns: + tuple: (success (bool), message (str)). + """ + if areaname == "LIST": + if not self.basic_shapes: + return True, "No shapes are currently defined." + else: + return True, "Currently defined shapes:\n" + ", ".join(self.basic_shapes) + if not coordinates: + if areaname in self.basic_shapes: + return True, str(self.basic_shapes[areaname]) + else: + return False, 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) + elif areatype == "CIRCLE": + shape = Circle(self, areaname, coordinates, top, bottom) + elif areatype[:4] == "POLY": + shape = Poly(self, areaname, coordinates, top, bottom) + elif areatype == "LINE": + shape = Line(self, areaname, coordinates) + else: + return False, f"Unknown shape type: {areatype}" -def define_poly_area(name: str, *coords: float) -> tuple[bool, str]: - """POLY: Define a polygon-shaped area. + self.basic_shapes[areaname] = shape + return True, f"Created {areatype} {areaname}" - Args: - name: Area name. - *coords: lat, lon pairs [deg] of the polygon vertices. - """ - return define_area(name, "POLY", coords) + def define_box_area(self, name: str, *coords: float) -> tuple[bool, str]: + """BOX: Define a box-shaped area. + Args: + name: Area name. + *coords: lat1, lon1, lat2, lon2 [deg] of two opposite corners, + optionally followed by top and bottom altitude [m]. + """ + return self.define_area(name, "BOX", coords[:4], *coords[4:]) -def define_polyalt_area(name: str, top: float, bottom: float, *coords: float) -> tuple[bool, str]: - """POLYALT: Define a polygon-shaped area in 3D, between two altitudes. - - Args: - name: Area name. - top: Top altitude bound [m]. - bottom: Bottom altitude bound [m]. - *coords: lat, lon pairs [deg] of the polygon vertices. - """ - return define_area(name, "POLYALT", coords, top, bottom) + def define_circle_area(self, name: str, *coords: float) -> tuple[bool, str]: + """CIRCLE: Define a circle-shaped area. + Args: + name: Area name. + *coords: lat, lon [deg] of the center and radius [nm], optionally + followed by top and bottom altitude [m]. + """ + return self.define_area(name, "CIRCLE", coords[:3], *coords[3:]) -def define_polyline_area(name: str, *coords: float) -> tuple[bool, str]: - """POLYLINE: Draw a multi-segment line on the radar screen. + def define_line_area(self, name: str, *coords: float) -> tuple[bool, str]: + """LINE: Draw a line between two positions on the radar screen. - Args: - name: Line name. - *coords: lat, lon pairs [deg] of the line points. - """ - return define_area(name, "LINE", coords) + Args: + name: Line name. + *coords: lat1, lon1, lat2, lon2 [deg] of the two end points. + """ + return self.define_area(name, "LINE", coords) + def define_poly_area(self, name: str, *coords: float) -> tuple[bool, str]: + """POLY: Define a polygon-shaped area. -def checkInside(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: + name: Area name. + *coords: lat, lon pairs [deg] of the polygon vertices. + """ + return self.define_area(name, "POLY", coords) + + def define_polyalt_area( + self, name: str, top: float, bottom: float, *coords: float + ) -> tuple[bool, str]: + """POLYALT: Define a polygon-shaped area in 3D, between two altitudes. + + Args: + name: Area name. + top: Top altitude bound [m]. + bottom: Bottom altitude bound [m]. + *coords: lat, lon pairs [deg] of the polygon vertices. + """ + return self.define_area(name, "POLYALT", coords, top, bottom) - Args: - areaname: Name of the area to test against. - lat: Latitude(s) [deg]. - lon: Longitude(s) [deg]. - alt: Altitude(s) [m]. + def define_polyline_area(self, name: str, *coords: float) -> tuple[bool, str]: + """POLYLINE: Draw a multi-segment line on the radar screen. - Returns: - Array of booleans, True == Inside. All False when no area with - the given name exists. - """ - if areaname not in basic_shapes: - return np.zeros(len(lat), dtype=bool) - area = basic_shapes[areaname] - return area.checkInside(lat, lon, alt) + Args: + name: Line name. + *coords: lat, lon pairs [deg] of the line points. + """ + return self.define_area(name, "LINE", coords) + + def checkInside( + self, areaname: str, lat: np.ndarray, lon: np.ndarray, alt: np.ndarray + ) -> np.ndarray: + """Check if points with coordinates lat, lon, alt are inside area with name 'areaname'. + + Args: + areaname: Name of the area to test against. + lat: Latitude(s) [deg]. + lon: Longitude(s) [deg]. + alt: Altitude(s) [m]. + + Returns: + Array of booleans, True == Inside. All False when no area with + the given name exists. + """ + if areaname not in self.basic_shapes: + return np.zeros(len(lat), dtype=bool) + area = self.basic_shapes[areaname] + return area.checkInside(lat, lon, alt) + + def reset(self) -> None: + """Clear all data.""" + 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) -> tuple[bool, str]: + """Delete a previously defined area by name. + + Args: + name: Name of the area shape to remove. + + Returns: + tuple: (success (bool), message (str)). + """ + shape = self.basic_shapes.pop(name, None) + if shape is not None: + self._unregister(shape) + return True, f"Area {name} deleted." + return False, 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] -def reset() -> None: - """Clear all data.""" - basic_shapes.clear() - Shape.reset() +_active = AreaFilter() -def deleteArea(name: str) -> tuple[bool, str]: - """Delete a previously defined area by name. +def _activate(area_filter: AreaFilter) -> None: + """Activate an area filter for temporary compatibility calls.""" + global _active + _active = area_filter - Args: - name: Name of the area shape to remove. - Returns: - tuple: (success (bool), message (str)). - """ - if name in basic_shapes: - del basic_shapes[name] - return True, f"Area {name} deleted." - return False, f"No area found with name {name}." +def has_area(areaname: str) -> bool: + """Compatibility escape hatch for `AreaFilter.has_area`.""" + return _active.has_area(areaname) -def get_intersecting(lat0: float, lon0: float, lat1: float, lon1: float) -> list: - """Return all shapes that intersect with a specified rectangular area. +def define_area( + areaname: str, + areatype: str, + coordinates: tuple[float, ...] | list[float], + top: float = 1e9, + bottom: float = -1e9, +) -> tuple[bool, str]: + """Compatibility escape hatch for `AreaFilter.define_area`.""" + return _active.define_area(areaname, areatype, coordinates, top, bottom) - Arguments: - - lat0/1, lon0/1: Coordinates of the top-left and bottom-right corner - of the intersection area. - """ - items = Shape.areatree.intersection((lat0, lon0, lat1, lon1)) - return [Shape.areas_by_id[i.id] for i in items] +def checkInside(areaname: str, lat: np.ndarray, lon: np.ndarray, alt: np.ndarray) -> np.ndarray: + """Compatibility escape hatch for `AreaFilter.checkInside`.""" + return _active.checkInside(areaname, lat, lon, alt) -def get_knearest(lat0: float, lon0: float, lat1: float, lon1: float, k: int = 1) -> list: - """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. - """ - items = Shape.areatree.nearest((lat0, lon0, lat1, lon1), k) - return [Shape.areas_by_id[i.id] for i in items] +def reset() -> None: + """Compatibility escape hatch for `AreaFilter.reset`.""" + _active.reset() class Shape: @@ -240,24 +322,13 @@ class Shape: coordinates). """ - # Global counter to keep track of used shape ids - max_area_id = 0 - - # Weak-value dictionary of all Shape-derived objects by name, and id - areas_by_id = WeakValueDictionary() - areas_by_name = WeakValueDictionary() - - # RTree of all areas for efficient geospatial searching - areatree = Index() - - @classmethod - def reset(cls) -> None: - """Reset shape data when simulation is reset.""" - # Weak dicts and areatree should be cleared automatically - # Reset max area id - cls.max_area_id = 0 + area_id: int - def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e9) -> None: + def __init__( + self, owner: AreaFilter, name: str, coordinates, top: float = 1e9, bottom: float = -1e9 + ) -> None: + self.owner = owner + self._registered = False self.raw = {"name": name, "shape": self.kind(), "coordinates": coordinates} self.name = name self.coordinates = coordinates @@ -267,17 +338,14 @@ def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e lon = coordinates[1::2] self.bbox = [min(lat), min(lon), max(lat), max(lon)] - # Global weak reference and tree storage - self.area_id = Shape.max_area_id - Shape.max_area_id += 1 - Shape.areas_by_id[self.area_id] = self - Shape.areas_by_name[self.name] = self - Shape.areatree.insert(self.area_id, self.bbox) + # 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 - Shape.areatree.delete(self.area_id, self.bbox) + 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 @@ -317,8 +385,8 @@ class Line(Shape): Purely graphical: the inherited checkInside() always returns False. """ - def __init__(self, name: str, coordinates) -> None: - super().__init__(name, coordinates) + def __init__(self, owner: AreaFilter, name: str, coordinates) -> None: + super().__init__(owner, name, coordinates) def __str__(self) -> str: return ( @@ -335,8 +403,10 @@ class Box(Shape): and optional altitude bounds [m]. """ - def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e9) -> None: - super().__init__(name, coordinates, top, bottom) + def __init__( + self, owner: AreaFilter, name: str, coordinates, top: float = 1e9, bottom: float = -1e9 + ) -> None: + super().__init__(owner, 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]) @@ -359,8 +429,10 @@ class Circle(Shape): altitude bounds [m]. """ - def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e9) -> None: - super().__init__(name, coordinates, top, bottom) + def __init__( + self, owner: AreaFilter, name: str, coordinates, top: float = 1e9, bottom: float = -1e9 + ) -> None: + super().__init__(owner, name, coordinates, top, bottom) self.clat = coordinates[0] self.clon = coordinates[1] self.r = coordinates[2] @@ -388,8 +460,10 @@ class Poly(Shape): point-in-polygon tests. """ - def __init__(self, name: str, coordinates, top: float = 1e9, bottom: float = -1e9) -> None: - super().__init__(name, coordinates, top, bottom) + def __init__( + self, owner: AreaFilter, name: str, coordinates, top: float = 1e9, bottom: float = -1e9 + ) -> None: + super().__init__(owner, 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/minisky/traffic/traffic.py b/minisky/traffic/traffic.py index c502ca2..961b381 100644 --- a/minisky/traffic/traffic.py +++ b/minisky/traffic/traffic.py @@ -37,6 +37,7 @@ vtas2cas, vtas2mach, ) +from minisky.tools.areafilter import AreaFilter from minisky.tools.convert import latlon2txt from minisky.traffic.asas import ConflictDetection, ConflictResolution @@ -122,9 +123,10 @@ class Traffic(TrafficArrays): Created by: Jacco M. Hoekstra """ - def __init__(self, settings: MiniSkySettings) -> None: + def __init__(self, settings: MiniSkySettings, areas: AreaFilter) -> None: super().__init__() self.settings = settings + self.areas = areas # Traffic is the toplevel trafficarrays object self.setroot(self) @@ -196,7 +198,7 @@ def __init__(self, settings: MiniSkySettings) -> None: self.perf = OpenAP() # Group Logic - self.groups = TrafficGroups() + self.groups = TrafficGroups(self, areas) # Traffic autopilot data self.swhdgsel = np.array([], dtype=bool) # determines whether aircraft is turning diff --git a/minisky/traffic/trafficgroups.py b/minisky/traffic/trafficgroups.py index c3b0599..4786776 100644 --- a/minisky/traffic/trafficgroups.py +++ b/minisky/traffic/trafficgroups.py @@ -9,25 +9,29 @@ the DEL command. """ -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, Any import numpy as np -import minisky from minisky.core import TrafficArrays -from minisky.tools import areafilter + +if TYPE_CHECKING: + from minisky.tools.areafilter import AreaFilter + from minisky.traffic.traffic import Traffic class GroupArray(np.ndarray): """Numpy index array that carries the name of the group it represents. - Returned by TrafficGroups.listgroup(); the extra ``groupname`` + Returned by TrafficGroups.listgroup(); the extra `groupname` attribute allows commands that receive a group argument (such as DEL) to know which group the aircraft indices belong to. """ # Similar to normal numpy arrays, but with the attribute of a groupname - def __new__(cls, *args, groupname: str = "", **kwargs) -> "GroupArray": + def __new__(cls, *args, groupname: str = "", **kwargs) -> GroupArray: ret = np.array(*args, **kwargs).view(cls) ret.groupname = groupname return ret @@ -37,9 +41,9 @@ class TrafficGroups(TrafficArrays): """Administration of aircraft groups using per-aircraft bitmasks. Each group is assigned one bit of a 64-bit mask; an aircraft's - ``ingroup`` value is the OR of the masks of all groups it belongs to. - Available at runtime as ``minisky.traf.groups``. The special group - name ``*`` refers to all aircraft in the simulation. + `ingroup` value is the OR of the masks of all groups it belongs to. + Available at runtime as `minisky.traf.groups`. The special group + name `*` refers to all aircraft in the simulation. Attributes: groups (dict): Mapping of group name to its bitmask (int). @@ -47,14 +51,20 @@ class TrafficGroups(TrafficArrays): ingroup (ndarray): Per-aircraft group-membership bitmask (int64). """ - def __init__(self) -> None: + def __init__(self, traffic: Traffic, areas: AreaFilter) -> None: # Initialize the groups structure super().__init__() + self.traffic = traffic + self.areas = areas self.groups = {} self.allmasks = 0 with self.settrafarrays(): self.ingroup = np.array([], dtype=np.int64) + def new_implementation(self, implementation: type[TrafficArrays]) -> TrafficArrays: + """Construct a replacement with this runtime's traffic and area store.""" + return implementation(self.traffic, self.areas) + def __contains__(self, groupname: str) -> bool: """Check whether a group with the given name exists ("*" always does).""" # Check if a group with a name exists @@ -96,20 +106,20 @@ def group(self, groupname: str = "", *args) -> tuple[bool, str]: break elif not args: - acnames = np.array(minisky.traf.callsign)[self.listgroup(groupname)] + acnames = np.array(self.traffic.callsign)[self.listgroup(groupname)] return True, "Aircraft in group {}:\n{}".format(groupname, ", ".join(acnames)) # Add aircraft to group - if areafilter.has_area(args[0]): - inside = areafilter.checkInside( - args[0], minisky.traf.lat, minisky.traf.lon, minisky.traf.alt + if self.areas.has_area(args[0]): + inside = self.areas.checkInside( + args[0], self.traffic.lat, self.traffic.lon, self.traffic.alt ) self.ingroup[inside] |= self.groups[groupname] - acnames = np.array(minisky.traf.callsign)[inside] + acnames = np.array(self.traffic.callsign)[inside] else: idx = list(args) self.ingroup[idx] |= self.groups[groupname] - acnames = np.array(minisky.traf.callsign)[idx] + acnames = np.array(self.traffic.callsign)[idx] return True, "Aircraft added to group {}:\n{}".format(groupname, ", ".join(acnames)) def delgroup(self, grouparray: Any) -> None: @@ -124,13 +134,13 @@ def delgroup(self, grouparray: Any) -> None: as returned by listgroup(). """ # Delete all aircraft in the respective group - minisky.traf.delete(grouparray) + self.traffic.delete(grouparray) # Remove the group from the group list if grouparray.groupname != "*": self.allmasks ^= self.groups.pop(grouparray.groupname) - def ungroup(self, groupname: str, *args) -> "tuple[bool, str] | None": + def ungroup(self, groupname: str, *args) -> tuple[bool, str] | None: """Remove members from a group by aircraft index. Implements the UNGROUP stack command. @@ -163,7 +173,7 @@ def listgroup(self, groupname: str) -> Any: exist. """ if groupname == "*": - return GroupArray(range(minisky.traf.ntraf), groupname="*") + return GroupArray(range(self.traffic.ntraf), groupname="*") groupmask = self.groups.get(groupname, None) if groupmask is None: return False, f"Group {groupname} doesn't exist" From fbf4bda57b14a840a99115f854069c3c8ba04422 Mon Sep 17 00:00:00 2001 From: Abraham Cheung <58929011+abc8747@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:40:50 +0800 Subject: [PATCH 06/16] refactor: make `CommandStack` own `ArgumentParser` --- minisky/stack/__init__.py | 32 ++++- minisky/stack/argparser.py | 204 ++++++++++++++++++++------------ tests/integration/test_stack.py | 4 +- 3 files changed, 160 insertions(+), 80 deletions(-) diff --git a/minisky/stack/__init__.py b/minisky/stack/__init__.py index 6b8bed2..e834f8b 100644 --- a/minisky/stack/__init__.py +++ b/minisky/stack/__init__.py @@ -65,6 +65,7 @@ class Command: brief: Brief usage text (command name plus argument list). aliases: Tuple of alternative names for this command. callback: The function that implements this command. + argument_parser: Runtime-owned argument parser used by the command. params: List of Parameter objects used to parse arguments. valid: False when the callback is an unbound class/instance method. """ @@ -94,7 +95,16 @@ def addcommand( """ current().addcommand(func, parent=parent, name=name, command_type=cls, **kwargs) - def __init__(self, func, parent: Command | None = None, name: str = "", **kwargs) -> None: + def __init__( + self, + func, + parent: Command | None = None, + name: str = "", + *, + argument_parser: argparser.ArgumentParser, + **kwargs, + ) -> None: + self.argument_parser = argument_parser self.name = name self.help = inspect.cleandoc(kwargs.get("help", "")) self.brief = kwargs.get("brief", "") @@ -211,7 +221,7 @@ def callback(self, function): self.params[-1].gobble = True break - param = Parameter(paramspecs[pos], annot, isopt) + param = self.argument_parser.parameter(paramspecs[pos], annot, isopt) if param: pos = min(pos + param.size(), len(paramspecs) - 1) self.params.append(param) @@ -224,7 +234,11 @@ def callback(self, function): f"{self.callback.__name__} has arguments." ) else: - self.params = [p for p in map(Parameter, paramspecs) if p] + self.params = [ + parameter + for spec in paramspecs + if (parameter := self.argument_parser.parameter(spec)) + ] def helptext(self, subcmd: str = "") -> str: """Return complete help text.""" @@ -292,6 +306,7 @@ class CommandStack: scentime: Execution times [s] of the buffered scenario commands. scencmd: Buffered scenario command lines. sender_rte: Network route to the sender of the current command. + argument_parser: Runtime-owned parser registry and reference data. """ def __init__( @@ -310,6 +325,7 @@ def __init__( self.console = console self.areas = areas self.variables = variables + self.argument_parser = argparser.ArgumentParser(traffic, navigation, console) self._get_simulation = get_simulation self._get_runner = get_runner self.scenario_root = scenario_root or Path(__file__).parent.parent.parent @@ -353,7 +369,13 @@ def addcommand( cmdobj = self.cmddict.get(name) if not cmdobj: - cmdobj = command_type(func, parent, name, **kwargs) + cmdobj = command_type( + func, + parent, + name, + argument_parser=self.argument_parser, + **kwargs, + ) self.cmddict[name] = cmdobj for alias in cmdobj.aliases: self.cmddict[alias] = cmdobj @@ -453,7 +475,7 @@ def reset(self) -> None: argument-parser reference data (position, heading, speed). """ self._reset_state() - argparser.reset() + self.argument_parser.reset() def process(self) -> None: """Sim-side stack processing; called once per simulation step. diff --git a/minisky/stack/argparser.py b/minisky/stack/argparser.py index fae0430..1081932 100644 --- a/minisky/stack/argparser.py +++ b/minisky/stack/argparser.py @@ -2,26 +2,27 @@ Converts the text arguments of stack commands into typed Python values. Every argument type that can appear in a command's argument specification -(e.g., "alt", "spd", "latlon", "callsign") maps to a Parser object in the -module-level ``argparsers`` dictionary. For each function parameter of a -stack command a Parameter object is created, which selects the applicable -parsers based on the command's annotation string; when multiple types are -allowed (separated by "/"), each parser is tried in turn. - -The module-level ``refdata`` namespace stores reference data (position, -aircraft index, heading, speed) taken from previously parsed arguments, so -that context-dependent arguments - such as a bare waypoint name resolved to -the closest occurrence, or a magnetic heading - can be interpreted relative -to the last parsed position or aircraft. +(e.g., "alt", "spd", "latlon", "callsign") maps to a `Parser` object in +the runtime-owned `ArgumentParser.parsers` dictionary. For each function +parameter of a stack command a `Parameter` object is created, which selects +the applicable parsers based on the command's annotation string; when +multiple types are allowed (separated by "/"), each parser is tried in turn. + +`ArgumentParser.refdata` stores reference data (position, aircraft index, +heading, speed) taken from previously parsed arguments, so that +context-dependent arguments - such as a bare waypoint name resolved to the +closest occurrence, or a magnetic heading - can be interpreted relative to +the last parsed position or aircraft. """ +from __future__ import annotations + import inspect import re from collections.abc import Callable from types import SimpleNamespace, UnionType -from typing import Annotated, Any, Union, get_args, get_origin +from typing import TYPE_CHECKING, Annotated, Any, Union, get_args, get_origin -import minisky from minisky.tools.convert import ( txt2alt, txt2bool, @@ -34,6 +35,11 @@ ) from minisky.tools.position import Position, islat +if TYPE_CHECKING: + from minisky.simulation.console import ConsoleIO + from minisky.tools.navdata import Navdatabase + from minisky.traffic import Traffic + # Regular expression for argument parser # Reading the regular expression: # [\'"]? : skip potential opening quote @@ -50,10 +56,6 @@ def _match_groups(argstring: str) -> tuple[str, str]: return m.groups() # type: ignore[return-value] -# Stack reference data namespace -refdata = SimpleNamespace(lat=None, lon=None, alt=None, acidx=-1, hdg=None, cas=None) - - def getnextarg(cmdstring: str) -> tuple: """Return first argument and remainder of command string from cmdstring. @@ -69,20 +71,6 @@ def getnextarg(cmdstring: str) -> tuple: return _match_groups(cmdstring) -def reset() -> None: - """Reset reference data. - - Clears the stored reference position, aircraft index, heading, and - speed used to resolve context-dependent arguments. - """ - refdata.lat = None - refdata.lon = None - refdata.alt = None - refdata.acidx = -1 - refdata.hdg = None - refdata.cas = None - - class Parameter: """Wrapper class for stack function parameters. @@ -104,9 +92,14 @@ class Parameter: """ def __init__( - self, param: inspect.Parameter, annotation: str = "", isopt: "bool | None" = None + self, + param: inspect.Parameter, + parsers: dict[str, Parser | None], + annotation: str = "", + isopt: bool | None = None, ) -> None: self.name = param.name + self.parser_registry = parsers self.default = param.default self.optional = ( (self.hasdefault() or param.kind == param.VAR_POSITIONAL) if isopt is None else isopt @@ -122,13 +115,13 @@ def __init__( self.parsers = [Parser(str)] self.annotation = "word" elif isinstance(self.annotation, str): - # If the annotation is a string we get our parsers from the argparsers dict - pfuns = [argparsers.get(a) for a in self.annotation.split("/")] + # If the annotation is a string we get our parsers from the runtime registry + pfuns = [self.parser_registry.get(a) for a in self.annotation.split("/")] self.parsers = [p for p in pfuns if p is not None] elif argkeys: - # Annotated type aliases (e.g. Alt, Spd) carry the argparsers key + # Annotated type aliases (e.g. Alt, Spd) carry the parser-registry key # as metadata; unions of them (or with None) are also accepted - pfuns = [argparsers.get(a) for a in argkeys] + pfuns = [self.parser_registry.get(a) for a in argkeys] self.parsers = [p for p in pfuns if p is not None] self.annotation = "/".join(argkeys) elif isinstance(param.annotation, type) and issubclass(param.annotation, Parser): @@ -224,7 +217,7 @@ class Parser: # Output size of this parser size = 1 - def __init__(self, parsefun: "Callable[..., Any] | None" = None) -> None: + def __init__(self, parsefun: Callable[..., Any] | None = None) -> None: self.parsefun = parsefun def parse(self, argstring: str) -> tuple: @@ -252,6 +245,10 @@ def parse(self, argstring: str) -> tuple: class CallsignArg(Parser): """Argument parser for aircraft callsigns and group ids.""" + def __init__(self, argument_parser: ArgumentParser) -> None: + super().__init__() + self.argument_parser = argument_parser + def parse(self, argstring: str) -> tuple: """Parse a callsign or group name into traffic index/indices. @@ -264,17 +261,18 @@ def parse(self, argstring: str) -> tuple: """ arg, argstring = _match_groups(argstring) callsign = arg.upper() - if callsign in minisky.traf.groups: - idx = minisky.traf.groups.listgroup(callsign) + traffic = self.argument_parser.traffic + if callsign in traffic.groups: + idx = traffic.groups.listgroup(callsign) else: - idx = minisky.traf.idx(callsign) + idx = traffic.idx(callsign) if idx < 0: raise ArgumentError(f"Aircraft with callsign {callsign} not found") # Update ref position for navdb lookup - refdata.lat = minisky.traf.lat[idx] - refdata.lon = minisky.traf.lon[idx] - refdata.acidx = idx + self.argument_parser.refdata.lat = traffic.lat[idx] + self.argument_parser.refdata.lon = traffic.lon[idx] + self.argument_parser.refdata.acidx = idx return idx, argstring @@ -290,6 +288,10 @@ class WptArg(Parser): Default values """ + def __init__(self, argument_parser: ArgumentParser) -> None: + super().__init__() + self.argument_parser = argument_parser + def parse(self, argstring: str) -> tuple: """Combine one or two arguments into a single waypoint position text. @@ -300,9 +302,10 @@ def parse(self, argstring: str) -> tuple: name = arg.upper() # Try aircraft first: translate a/c id into a valid position text with a lat,lon - idx = minisky.traf.idx(name) + traffic = self.argument_parser.traffic + idx = traffic.idx(name) if idx >= 0: - name = f"{minisky.traf.lat[idx]},{minisky.traf.lon[idx]}" + name = f"{traffic.lat[idx]},{traffic.lon[idx]}" # Check if lat/lon combination elif islat(name): @@ -311,7 +314,7 @@ def parse(self, argstring: str) -> tuple: name = name + "," + arg # apt,runway ? Combine into one string with a slash as separator - elif argstring[:2].upper() == "RW" and name in minisky.navdb.aptid: + elif argstring[:2].upper() == "RW" and name in self.argument_parser.navigation.aptid: arg, argstring = _match_groups(argstring) name = name + "/" + arg.upper() @@ -333,6 +336,10 @@ class PosArg(Parser): # This parser's output size is 2 (lat, lon) size = 2 + def __init__(self, argument_parser: ArgumentParser) -> None: + super().__init__() + self.argument_parser = argument_parser + def parse(self, argstring: str) -> tuple: """Parse one or two arguments into a lat/lon position. @@ -349,9 +356,11 @@ def parse(self, argstring: str) -> tuple: argu = arg.upper() # Try aircraft first: translate a/c id into a valid position text with a lat,lon - idx = minisky.traf.idx(argu) + traffic = self.argument_parser.traffic + refdata = self.argument_parser.refdata + idx = traffic.idx(argu) if idx >= 0: - return minisky.traf.lat[idx], minisky.traf.lon[idx], argstring + return traffic.lat[idx], traffic.lon[idx], argstring # Check if lat/lon combination if islat(argu): @@ -361,12 +370,12 @@ def parse(self, argstring: str) -> tuple: return txt2lat(argu), txt2lon(nextarg), argstring # apt,runway ? Combine into one string with a slash as separator - if argstring[:2].upper() == "RW" and argu in minisky.navdb.aptid: + if argstring[:2].upper() == "RW" and argu in self.argument_parser.navigation.aptid: arg, argstring = _match_groups(argstring) argu = argu + "/" + arg.upper() if refdata.lat is None: - refdata.lat, refdata.lon = minisky.scr.getviewctr() + refdata.lat, refdata.lon = self.argument_parser.console.getviewctr() posobj = Position(argu, refdata.lat, refdata.lon) if posobj.error: @@ -396,31 +405,80 @@ def parse(self, argstring: str) -> tuple: return pandir, argstring -argparsers = { - "*": None, - "txt": Parser(str.upper), - "word": Parser(str), - "string": StringArg(), - "float": Parser(float), - "int": Parser(int), - "onoff": Parser(txt2bool), - "bool": Parser(txt2bool), - "callsign": CallsignArg(), - "wpt": WptArg(), - "latlon": PosArg(), - "lat": PosArg(), - "lon": None, - "pandir": PandirArg(), - "spd": Parser(txt2spd), - "vspd": Parser(txt2vs), - "alt": Parser(txt2alt), - "hdg": Parser(lambda txt: txt2hdg(txt, refdata.lat, refdata.lon)), - "time": Parser(txt2tim), -} +class ArgumentParser: + """Own argument parser instances and reference data for one command stack. + + The traffic, navigation database, and console references are explicit, + while the parser registry and reference data are isolated from other + MiniSky runtimes. + + Attributes: + traffic: Traffic object used for callsign and aircraft-position lookup. + navigation: Navigation database used for airport and runway lookup. + console: Console used to obtain the current view centre. + refdata: Reference position, aircraft index, heading, and speed from + previously parsed arguments. + parsers: Mapping of argument type names to parser objects. + """ + + def __init__(self, traffic: Traffic, navigation: Navdatabase, console: ConsoleIO) -> None: + self.traffic = traffic + self.navigation = navigation + self.console = console + + # Stack reference data namespace + self.refdata = SimpleNamespace(lat=None, lon=None, alt=None, acidx=-1, hdg=None, cas=None) + + self.parsers: dict[str, Parser | None] = { + "*": None, + "txt": Parser(str.upper), + "word": Parser(str), + "string": StringArg(), + "float": Parser(float), + "int": Parser(int), + "onoff": Parser(txt2bool), + "bool": Parser(txt2bool), + "callsign": CallsignArg(self), + "wpt": WptArg(self), + "latlon": PosArg(self), + "lat": PosArg(self), + "lon": None, + "pandir": PandirArg(), + "spd": Parser(txt2spd), + "vspd": Parser(txt2vs), + "alt": Parser(txt2alt), + "hdg": Parser(self._parse_heading), + "time": Parser(txt2tim), + } + + def parameter( + self, + param: inspect.Parameter, + annotation: str = "", + isopt: bool | None = None, + ) -> Parameter: + """Create a command parameter using this runtime's parser registry.""" + return Parameter(param, self.parsers, annotation, isopt) + + def reset(self) -> None: + """Reset reference data. + + Clears the stored reference position, aircraft index, heading, and + speed used to resolve context-dependent arguments. + """ + self.refdata.lat = None + self.refdata.lon = None + self.refdata.alt = None + self.refdata.acidx = -1 + self.refdata.hdg = None + self.refdata.cas = None + + def _parse_heading(self, text: str) -> float: + return txt2hdg(text, self.refdata.lat, self.refdata.lon) def _annotation_argkeys(annotation: Any) -> list[str]: - """Extract argparsers keys from an Annotated alias or a union of them. + """Extract parser-registry keys from an Annotated alias or a union of them. Returns an empty list when the annotation is not based on Annotated (e.g., a plain type or a DSL string). @@ -438,7 +496,7 @@ def _annotation_argkeys(annotation: Any) -> list[str]: # Annotated type aliases for stack command parameters. The underlying type -# is what the parser produces; the string metadata is the argparsers key. +# is what the parser produces; the string metadata is the parser-registry key. # Use these instead of bare DSL strings so type checkers and linters see # real types, e.g.: def selaltcmd(idx: Acid, alt: Alt, vspd: Vspd | None = None) Acid = Annotated[int, "callsign"] diff --git a/tests/integration/test_stack.py b/tests/integration/test_stack.py index 2fd631a..30105e8 100644 --- a/tests/integration/test_stack.py +++ b/tests/integration/test_stack.py @@ -150,9 +150,9 @@ def test_all_registered_specs_resolve_to_parsers(self, bs): # their parameters were silently dropped, making the commands # unusable from the stack. Every annotation token must resolve to a # parser (or be a documented placeholder). - from minisky.stack import Command - from minisky.stack.argparser import argparsers + from minisky.stack import Command, current + argparsers = current().argument_parser.parsers placeholders = {"...", "lon", "*"} # consumed by the preceding parser seen = set() bad = [] From becdd9f64aad99dfa9c23a6bbcab1425f1d1fa32 Mon Sep 17 00:00:00 2001 From: Abraham Cheung <58929011+abc8747@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:16:57 +0800 Subject: [PATCH 07/16] refactor: `Traffic` and `reset` ownership, traffic-array tree isolation, ASAS --- minisky/core/trafficarrays.py | 68 +++++++------- minisky/plugin/entity.py | 3 +- minisky/runtime.py | 12 ++- minisky/stack/commands.py | 19 ++-- minisky/traffic/activewpdata.py | 41 +++++---- minisky/traffic/aporasas.py | 59 +++++++----- minisky/traffic/asas/detection.py | 33 ++++--- minisky/traffic/asas/mvp.py | 19 +++- minisky/traffic/asas/resolution.py | 129 +++++++++----------------- minisky/traffic/conditional.py | 66 ++++++++----- minisky/traffic/traffic.py | 143 +++++++++++++++++------------ minisky/traffic/trafficgroups.py | 2 +- minisky/traffic/trails.py | 68 +++++++++----- minisky/traffic/turbulence.py | 36 +++++--- minisky/traffic/uncertainty.py | 56 ++++++----- tests/integration/test_traffic.py | 4 +- 16 files changed, 426 insertions(+), 332 deletions(-) diff --git a/minisky/core/trafficarrays.py b/minisky/core/trafficarrays.py index defb31c..e1da4ac 100644 --- a/minisky/core/trafficarrays.py +++ b/minisky/core/trafficarrays.py @@ -100,6 +100,8 @@ def _replace_instance_on_traf( if isinstance(attr_value, base): # Create new instance of selected implementation new_instance = attr_value.new_implementation(impl) + if attr_value._parent is not None: + new_instance.reparent(attr_value._parent) # Copy over any per-aircraft array data from old instance (if they exist) for arr_var in getattr(attr_value, "_ArrVars", []): if hasattr(new_instance, arr_var): @@ -170,31 +172,17 @@ class Autopilot(TrafficArrays, replaceable=True): ... Attributes: - root: Class attribute; root of the TrafficArrays tree (the traffic - object), set with setroot(). - ntraf: Class attribute; the current number of aircraft. _parent: Parent node of this object in the tree. _children: Child TrafficArrays objects of this object. _ArrVars: Names of the registered numpy-array parameters. _LstVars: Names of the registered list parameters. """ - # The TrafficArrays class keeps track of all of the constructed - # TrafficArray objects - root = None - ntraf = 0 - # Replaceable pattern class variables (set per-subclass) _baseimpl: ClassVar[type | None] = None _generator: ClassVar[type] _default: ClassVar[str] = "" - @staticmethod - def setroot(obj: "TrafficArrays") -> None: - """This function is used to set the root of the tree of TrafficArray - objects (which is the traffic object.)""" - TrafficArrays.root = obj - def __init_subclass__(cls, **kwargs) -> None: """Called when a subclass is defined. @@ -281,35 +269,41 @@ def derived(cls): ret.update(sub.derived()) return ret - def __init__(self) -> None: - """Create a TrafficArrays node and attach it to the current root. + def __init__(self, parent: "TrafficArrays | None" = None) -> None: + """Create a TrafficArrays node, optionally attached to `parent`. - The new object registers itself as a child of TrafficArrays.root - (the traffic object), so that aircraft creation and deletion - propagate to its registered arrays. + Aircraft creation and deletion propagate through the explicit tree + of parent and child nodes rooted at the owning traffic object. """ super().__init__() - self._parent = TrafficArrays.root - if self._parent: - self._parent._children.append(self) - self._children = [] - self._ArrVars = [] - self._LstVars = [] - - def new_implementation( - self, implementation: type["TrafficArrays"] - ) -> "TrafficArrays": + self._parent: TrafficArrays | None = None + self._children: list[TrafficArrays] = [] + self._ArrVars: list[str] = [] + self._LstVars: list[str] = [] + if parent is not None: + self.reparent(parent) + + def new_implementation(self, implementation: type["TrafficArrays"]) -> "TrafficArrays": """Construct a selected replacement implementation.""" return implementation() def reparent(self, newparent: "TrafficArrays") -> None: - """Give TrafficArrays object a new parent.""" - # Remove myself from the parent list of children, and add to new parent - assert self._parent is not None, "reparent() called on a root node" - self._parent._children.pop(self._parent._children.index(self)) + """Give this TrafficArrays object a new parent.""" + if self._parent is newparent: + return + if self._parent is not None: + self._parent._children.remove(self) newparent._children.append(self) self._parent = newparent + @property + def tree_root(self) -> "TrafficArrays": + """Return the root node of this object's traffic-array tree.""" + root = self + while root._parent is not None: + root = root._parent + return root + def settrafarrays(self) -> RegisterElementParameters: """Convenience function for with-style traffic array registration.""" return RegisterElementParameters(self) @@ -332,9 +326,11 @@ def _init_trafarrays(self, keys: set[str]) -> None: # In plugins and replaceable classes it could be that their instance # is created when the simulation is already running, and traffic is - # present. Size traffic arrays accordingly here - if TrafficArrays.root is not None and TrafficArrays.root.ntraf: - self.create(TrafficArrays.root.ntraf) + # present. Size traffic arrays accordingly here. + root = self.tree_root + ntraf = getattr(root, "ntraf", 0) + if root is not self and ntraf: + self.create(ntraf) def create(self, n: int = 1) -> None: """Append n elements (aircraft) to all lists and arrays. diff --git a/minisky/plugin/entity.py b/minisky/plugin/entity.py index 848e5f3..ae6e6a0 100644 --- a/minisky/plugin/entity.py +++ b/minisky/plugin/entity.py @@ -21,6 +21,7 @@ def __init__(self): import inspect from typing import Any, ClassVar, Optional +import minisky from minisky.core.trafficarrays import TrafficArrays @@ -146,7 +147,7 @@ def instance(cls) -> "Proxy | Entity | None": return cls._proxy or cls._instance def __init__(self) -> None: - super().__init__() + super().__init__(minisky.traf) cls = type(self) if cls._instance is None: cls._instance = self diff --git a/minisky/runtime.py b/minisky/runtime.py index 94c5128..d54502b 100644 --- a/minisky/runtime.py +++ b/minisky/runtime.py @@ -24,7 +24,17 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No self.navigation = Navdatabase(data("navigation"), self.console) self.areas = AreaFilter() self.variables = VariableExplorer() - self.traffic = Traffic(settings, self.areas) + self.traffic = Traffic( + settings=settings, + areas=self.areas, + navigation=self.navigation, + console=self.console, + get_simulation=lambda: self.simulation, + stack_command=lambda *args, **kwargs: self.commands.stack(*args, **kwargs), + select_implementation=lambda base, impl: self.commands.select_implementation( + base, impl + ), + ) self.commands = CommandStack( traffic=self.traffic, navigation=self.navigation, diff --git a/minisky/stack/commands.py b/minisky/stack/commands.py index aee2a9e..842132e 100644 --- a/minisky/stack/commands.py +++ b/minisky/stack/commands.py @@ -71,7 +71,6 @@ def get_commands(command_stack: CommandStack) -> tuple: """ from minisky import plugin, tools from minisky.traffic import route - from minisky.traffic.asas import resolution as asasresolution cmddict = { "ADDWPT": [ @@ -344,7 +343,7 @@ def get_commands(command_stack: CommandStack) -> tuple: "Turbulence/noise switch", ], "NORESO": [ - asasresolution.setnoreso, + command_stack.traffic.cr.setnoreso, "[callsign,...]", "NORESO callsign...", "ADD or Remove aircraft that nobody will avoid.", @@ -398,7 +397,7 @@ def get_commands(command_stack: CommandStack) -> tuple: "Get info on aircraft, airport or waypoint", ], "PRIORULES": [ - asasresolution.setprio, + command_stack.traffic.cr.setprio, "[bool, txt]", "PRIORULES [flag, priocode]", "Define priority rules (right of way) for conflict resolution.", @@ -428,31 +427,31 @@ def get_commands(command_stack: CommandStack) -> tuple: "Select a Conflict Resolution method.", ], "RESOOFF": [ - asasresolution.setresooff, + command_stack.traffic.cr.setresooff, "[callsign,...]", "RESOOFF callsign...", "ADD or Remove aircraft that will not avoid anybody else.", ], "RMETHH": [ - asasresolution.setresometh, + command_stack.traffic.cr.setresometh, "[txt]", "RMETHH [ON / BOTH / OFF / NONE / SPD / HDG]", "Select the horizontal resolution method for MVP conflict resolution.", ], "RMETHV": [ - asasresolution.setresometv, + command_stack.traffic.cr.setresometv, "[txt]", "RMETHV [ON / V/S / OFF / NONE]", "Select the vertical resolution method for MVP conflict resolution.", ], "RFACH": [ - asasresolution.setresofach, + command_stack.traffic.cr.setresofach, "[float]", "RFACH [factor]", "Set resolution factor horizontal.", ], "RFACV": [ - asasresolution.setresofacv, + command_stack.traffic.cr.setresofacv, "[float]", "RFACV [factor]", "Set resolution factor vertical.", @@ -464,13 +463,13 @@ def get_commands(command_stack: CommandStack) -> tuple: "Add RTA to waypoint record.", ], "RSZONEDH": [ - asasresolution.setresozonedh, + command_stack.traffic.cr.setresozonedh, "[float]", "RSZONEDH [zonedh]", "Set resolution factor vertical, but then with absolute value.", ], "RSZONER": [ - asasresolution.setresozoner, + command_stack.traffic.cr.setresozoner, "[float]", "RSZONER [zoner]", "Set resolution factor horizontal, but then with absolute value.", diff --git a/minisky/traffic/activewpdata.py b/minisky/traffic/activewpdata.py index d629501..ed848fe 100644 --- a/minisky/traffic/activewpdata.py +++ b/minisky/traffic/activewpdata.py @@ -1,22 +1,26 @@ """Active waypoint data for FMS guidance. Holds, as per-aircraft numpy arrays, all data of the waypoint each aircraft -is currently flying towards. The :class:`ActiveWaypoint` arrays form the -interface between the per-aircraft :class:`~minisky.traffic.route.Route` +is currently flying towards. The [`ActiveWaypoint`][minisky.traffic.activewpdata.ActiveWaypoint] arrays form the +interface between the per-aircraft [`Route`][minisky.traffic.route.Route] objects (event-driven, scalar waypoint switching) and the vectorized -LNAV/VNAV guidance in :class:`~minisky.traffic.autopilot.Autopilot`. -Available at runtime as ``minisky.traf.actwp``. +LNAV/VNAV guidance in [`Autopilot`][minisky.traffic.autopilot.Autopilot]. +Available at runtime as `minisky.traf.actwp`. """ -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, Any import numpy as np -import minisky from minisky.core.trafficarrays import TrafficArrays from minisky.tools.aero import g0 from minisky.tools.convert import degto180 +if TYPE_CHECKING: + from minisky.traffic import Traffic + class ActiveWaypoint(TrafficArrays): """Per-aircraft data of the active (and next) waypoint. @@ -67,8 +71,9 @@ class ActiveWaypoint(TrafficArrays): waypoint was activated [m]. """ - def __init__(self): - super().__init__() + def __init__(self, traffic: Traffic) -> None: + super().__init__(traffic) + self.traffic = traffic with self.settrafarrays(): self.lat = np.array([]) # [deg] Active WP latitude self.lon = np.array([]) # [deg] Active WP longitude @@ -158,6 +163,10 @@ def create(self, n: int = 1) -> None: self.curlegdir[-n:] = -999.0 # [deg] direction to active waypoint upon activation self.curleglen[-n:] = -999.0 # [nm] distance to active waypoint upon activation + def new_implementation(self, implementation: type[TrafficArrays]) -> TrafficArrays: + """Construct a replacement with this runtime's traffic object.""" + return implementation(self.traffic) + def reached( self, qdr: Any, @@ -200,9 +209,9 @@ def reached( # First calculate turn distance next_qdr = np.where(self.next_qdr < -900.0, qdr, self.next_qdr) - turntas = np.where(self.turnspd < 0.0, minisky.traf.tas, self.turnspd) + turntas = np.where(self.turnspd < 0.0, self.traffic.tas, self.turnspd) flybyturndist, turnrad = self.calcturn( - turntas, minisky.traf.ap.bankdef, qdr, next_qdr, turnrad, turnhdgr, flyturn + turntas, self.traffic.ap.bankdef, qdr, next_qdr, turnrad, turnhdgr, flyturn ) # Turb dist iz ero for flyover, calculated distance for others @@ -212,14 +221,14 @@ def reached( # flying away and within 4 sec distance based on ground speed (4 sec = sensitivity tuning parameter) close2wp = ( - dist / (np.maximum(0.0001, np.abs(minisky.traf.gs))) < 4.0 + dist / (np.maximum(0.0001, np.abs(self.traffic.gs))) < 4.0 ) # Waypoint is within 4 seconds flight time - tooclose2turn = close2wp * (np.abs(degto180(minisky.traf.trk % 360.0 - qdr % 360.0)) > 90.0) + tooclose2turn = close2wp * (np.abs(degto180(self.traffic.trk % 360.0 - qdr % 360.0)) > 90.0) # When too close to waypoint or we have passed the active waypoint, based on leg direction,switch active waypoint - # was: away = np.logical_or(close2wp,swlastwp)*(np.abs(degto180(minisky.traf.trk%360. - qdr%360.)) > 90.) # difference large than 90 + # was: away = np.logical_or(close2wp,swlastwp)*(np.abs(degto180(self.traffic.trk%360. - qdr%360.)) > 90.) # difference large than 90 awayorpassed = np.logical_or( - tooclose2turn, np.abs(degto180(qdr - minisky.traf.actwp.curlegdir)) > 90.0 + tooclose2turn, np.abs(degto180(qdr - self.traffic.actwp.curlegdir)) > 90.0 ) # Should no longer be needed with leg direction @@ -231,9 +240,9 @@ def reached( # Check whether shift based dist is required, set closer than WP turn distance # Detect indices - # swreached = np.where(minisky.traf.swlnav * np.logical_or(awayorpassed,np.logical_or(dist < self.turndist,circling)))[0] + # swreached = np.where(self.traffic.swlnav * np.logical_or(awayorpassed,np.logical_or(dist < self.turndist,circling)))[0] swreached = np.where( - minisky.traf.swlnav * np.logical_or(awayorpassed, dist < self.turndist) + self.traffic.swlnav * np.logical_or(awayorpassed, dist < self.turndist) )[0] # Return indices for which condition is True/1.0 for a/c where we have reached waypoint diff --git a/minisky/traffic/aporasas.py b/minisky/traffic/aporasas.py index 3072e01..67429e3 100644 --- a/minisky/traffic/aporasas.py +++ b/minisky/traffic/aporasas.py @@ -3,15 +3,21 @@ Selects, per aircraft and per control channel, whether the aircraft follows the autopilot/FMS command or the conflict-resolution (ASAS) command. The resulting desired states are the setpoints that -:class:`~minisky.traffic.traffic.Traffic` flies towards each time step +[`Traffic`][minisky.traffic.traffic.Traffic] flies towards each time step (after being limited by the performance model). """ +from __future__ import annotations + +from typing import TYPE_CHECKING + import numpy as np -import minisky from minisky.core import TrafficArrays +if TYPE_CHECKING: + from minisky.traffic import Traffic + class APorASAS(TrafficArrays): """Selection between autopilot (AP) and conflict resolution (ASAS). @@ -20,7 +26,7 @@ class APorASAS(TrafficArrays): ASAS command is used when the corresponding conflict-resolution channel is active, otherwise the autopilot command is used. The desired heading is derived from the desired track with a wind-drift correction. - Available at runtime as ``minisky.traf.aporasas``. + Available at runtime as `minisky.traf.aporasas`. Attributes: alt (ndarray): Desired altitude [m]. @@ -30,8 +36,9 @@ class APorASAS(TrafficArrays): tas (ndarray): Desired true airspeed [m/s]. """ - def __init__(self) -> None: - super().__init__() + def __init__(self, traffic: Traffic) -> None: + super().__init__(traffic) + self.traffic = traffic with self.settrafarrays(): # Desired aircraft states self.alt = np.array([]) # desired altitude [m] @@ -40,6 +47,10 @@ def __init__(self) -> None: self.vs = np.array([]) # desired vertical speed [m/s] self.tas = np.array([]) # desired speed [m/s] + def new_implementation(self, implementation: type[TrafficArrays]) -> TrafficArrays: + """Construct a replacement with this runtime's traffic object.""" + return implementation(self.traffic) + def create(self, n: int = 1) -> None: """Initialize desired states for n newly created aircraft. @@ -50,10 +61,10 @@ def create(self, n: int = 1) -> None: n: Number of aircraft that were appended to the traffic arrays. """ super().create(n) - self.alt[-n:] = minisky.traf.alt[-n:] - self.tas[-n:] = minisky.traf.tas[-n:] - self.hdg[-n:] = minisky.traf.hdg[-n:] - self.trk[-n:] = minisky.traf.trk[-n:] + self.alt[-n:] = self.traffic.alt[-n:] + self.tas[-n:] = self.traffic.tas[-n:] + self.hdg[-n:] = self.traffic.hdg[-n:] + self.trk[-n:] = self.traffic.trk[-n:] def update(self) -> None: """Select the desired aircraft states from autopilot or ASAS. @@ -69,34 +80,34 @@ def update(self) -> None: """ # --------- Input to Autopilot settings to follow: destination or ASAS ---------- # Convert the ASAS commanded speed from ground speed to TAS - if minisky.traf.wind.winddim > 0: - vwn, vwe = minisky.traf.wind.getdata( - minisky.traf.lat, minisky.traf.lon, minisky.traf.alt + if self.traffic.wind.winddim > 0: + vwn, vwe = self.traffic.wind.getdata( + self.traffic.lat, self.traffic.lon, self.traffic.alt ) - asastasnorth = minisky.traf.cr.tas * np.cos(np.radians(minisky.traf.cr.trk)) - vwn - asastaseast = minisky.traf.cr.tas * np.sin(np.radians(minisky.traf.cr.trk)) - vwe + asastasnorth = self.traffic.cr.tas * np.cos(np.radians(self.traffic.cr.trk)) - vwn + asastaseast = self.traffic.cr.tas * np.sin(np.radians(self.traffic.cr.trk)) - vwe asastas = np.sqrt(asastasnorth**2 + asastaseast**2) # no wind, then ground speed = TAS else: - asastas = minisky.traf.cr.tas # TAS [m/s] + asastas = self.traffic.cr.tas # TAS [m/s] # Select asas if there is a conflict AND resolution is on # Determine desired states per channel whether to use value from ASAS or AP. - # minisky.traf.cr.active may be used as well, will set all of these channels - self.trk = np.where(minisky.traf.cr.hdgactive, minisky.traf.cr.trk, minisky.traf.ap.trk) - self.tas = np.where(minisky.traf.cr.tasactive, asastas, minisky.traf.ap.tas) - self.alt = np.where(minisky.traf.cr.altactive, minisky.traf.cr.alt, minisky.traf.ap.alt) - self.vs = np.where(minisky.traf.cr.vsactive, minisky.traf.cr.vs, minisky.traf.ap.vs) + # `minisky.traf.cr.active` may be used as well, will set all of these channels + self.trk = np.where(self.traffic.cr.hdgactive, self.traffic.cr.trk, self.traffic.ap.trk) + self.tas = np.where(self.traffic.cr.tasactive, asastas, self.traffic.ap.tas) + self.alt = np.where(self.traffic.cr.altactive, self.traffic.cr.alt, self.traffic.ap.alt) + self.vs = np.where(self.traffic.cr.vsactive, self.traffic.cr.vs, self.traffic.ap.vs) # ASAS can give positive and negative VS, but the sign of VS is determined using delalt in Traf.ComputeAirSpeed # Therefore, ensure that pilot.vs is always positive to prevent opposite signs of delalt and VS in Traf.ComputeAirSpeed self.vs = np.abs(self.vs) # Compute the desired heading needed to compensate for the wind - if minisky.traf.wind.winddim > 0: + if self.traffic.wind.winddim > 0: # Calculate wind correction - vwn, vwe = minisky.traf.wind.getdata( - minisky.traf.lat, minisky.traf.lon, minisky.traf.alt + vwn, vwe = self.traffic.wind.getdata( + self.traffic.lat, self.traffic.lon, self.traffic.alt ) Vw = np.sqrt(vwn * vwn + vwe * vwe) winddir = np.arctan2(vwe, vwn) @@ -104,7 +115,7 @@ def update(self) -> None: steer = np.arcsin( np.minimum( 1.0, - np.maximum(-1.0, Vw * np.sin(drift) / np.maximum(0.001, minisky.traf.tas)), + np.maximum(-1.0, Vw * np.sin(drift) / np.maximum(0.001, self.traffic.tas)), ) ) # desired heading diff --git a/minisky/traffic/asas/detection.py b/minisky/traffic/asas/detection.py index 1e03e0a..d34cd1a 100644 --- a/minisky/traffic/asas/detection.py +++ b/minisky/traffic/asas/detection.py @@ -17,17 +17,22 @@ aviation units (NM, ft). """ -from typing import Any +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any import numpy as np from scipy.spatial import KDTree -import minisky from minisky.core.settings import MiniSkySettings from minisky.core.trafficarrays import TrafficArrays from minisky.stack.argparser import Time, Txt from minisky.tools.aero import ft, nm +if TYPE_CHECKING: + from minisky.traffic import Traffic + # Mean earth radius [m], same value as the geo module's flat-earth helpers RE = 6371000.0 @@ -91,9 +96,13 @@ class ConflictDetection(TrafficArrays): dtnolook (ndarray): Per-aircraft detection hold-off interval [s]. """ - def __init__(self, settings: MiniSkySettings) -> None: + def __init__( + self, settings: MiniSkySettings, traffic: Traffic, stack_command: Callable[..., None] + ) -> None: super().__init__() self.settings = settings + self.traffic = traffic + self.stack_command = stack_command ## Default values # [m] Horizontal separation minimum for detection self.rpz_def = self.settings.asas_pzr * nm @@ -138,8 +147,8 @@ def __init__(self, settings: MiniSkySettings) -> None: self.dtnolook = np.array([]) def new_implementation(self, implementation: type[TrafficArrays]) -> TrafficArrays: - """Construct a replacement with this runtime's settings.""" - return implementation(self.settings) + """Construct a replacement with this runtime's traffic and command stack.""" + return implementation(self.settings, self.traffic, self.stack_command) def clearconfdb(self) -> None: """Clear the conflict database. @@ -158,8 +167,8 @@ def clearconfdb(self) -> None: self.tcpa = np.array([]) self.tLOS = np.array([]) self.dalt = np.array([]) - self.inconf = np.zeros(minisky.traf.ntraf) - self.tcpamax = np.zeros(minisky.traf.ntraf) + self.inconf = np.zeros(self.traffic.ntraf) + self.tcpamax = np.zeros(self.traffic.ntraf) def create(self, n: int = 1) -> None: """Initialise per-aircraft detection parameters for new aircraft. @@ -196,7 +205,7 @@ def reset(self) -> None: self.global_rpz = self.global_hpz = True self.global_dtlook = self.global_dtnolook = True - def switch(self, name: Txt = "ON") -> "tuple | None": + def switch(self, name: Txt = "ON") -> tuple | None: """Turn Conflict Detection (CD) ON / OFF. Switching off also clears the current conflict database. @@ -254,8 +263,8 @@ def setrpz(self, radius: float = -1.0, *acidx: int) -> tuple: if self.global_rpz: self.rpz[:] = self.rpz_def # Adjust factors for reso zone if those were set with an absolute value - if not minisky.traf.cr.resorrelative: - minisky.stack.stack(f"RSZONER {minisky.traf.cr.resofach * oldradius / nm}") + if not self.traffic.cr.resorrelative: + self.stack_command(f"RSZONER {self.traffic.cr.resofach * oldradius / nm}") return True, f"Setting default PZ radius to {radius} NM" def sethpz(self, height: float = -1.0, *acidx: int) -> tuple: @@ -291,8 +300,8 @@ def sethpz(self, height: float = -1.0, *acidx: int) -> tuple: if self.global_hpz: self.hpz[:] = self.hpz_def # Adjust factors for reso zone if those were set with an absolute value - if not minisky.traf.cr.resodhrelative: - minisky.stack.stack(f"RSZONEDH {minisky.traf.cr.resofacv * oldhpz / ft}") + if not self.traffic.cr.resodhrelative: + self.stack_command(f"RSZONEDH {self.traffic.cr.resofacv * oldhpz / ft}") return True, f"Setting default PZ height to {height} ft" def setdtlook(self, time: Time = -1.0, *acidx: int) -> tuple: diff --git a/minisky/traffic/asas/mvp.py b/minisky/traffic/asas/mvp.py index cb2d441..e9b4342 100644 --- a/minisky/traffic/asas/mvp.py +++ b/minisky/traffic/asas/mvp.py @@ -14,7 +14,10 @@ way) rules can assign the manoeuvre to only one aircraft of a pair. """ -from typing import Any +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any import numpy as np @@ -22,6 +25,9 @@ from minisky.stack.argparser import Txt from minisky.traffic.asas import ConflictResolution +if TYPE_CHECKING: + from minisky.traffic import Traffic + class MVP(ConflictResolution): """Conflict resolution using the Modified Voltage Potential Method. @@ -45,8 +51,13 @@ class MVP(ConflictResolution): swresovert (bool): Limit resolutions to the vertical direction. """ - def __init__(self, settings: MiniSkySettings) -> None: - super().__init__(settings) + def __init__( + self, + settings: MiniSkySettings, + traffic: Traffic, + select_implementation: Callable[[str, str], tuple[bool, str]], + ) -> None: + super().__init__(settings, traffic, select_implementation) # [-] switch to limit resolution to the horizontal direction self.swresohoriz = True # [-] switch to use only speed resolutions (works with swresohoriz = True) @@ -56,7 +67,7 @@ def __init__(self, settings: MiniSkySettings) -> None: # [-] switch to limit resolution to the vertical direction self.swresovert = False - def setprio(self, flag=None, priocode="") -> "bool | tuple": + def setprio(self, flag=None, priocode="") -> bool | tuple: """Set the prio switch and the type of prio. Implements the PRIORULES stack command for MVP. Validates the diff --git a/minisky/traffic/asas/resolution.py b/minisky/traffic/asas/resolution.py index f7376b6..07a2b2a 100644 --- a/minisky/traffic/asas/resolution.py +++ b/minisky/traffic/asas/resolution.py @@ -13,15 +13,21 @@ [`ConflictResolution.resolve`][minisky.traffic.asas.resolution.ConflictResolution.resolve]. """ -from typing import Any +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any import numpy as np -import minisky from minisky.core.settings import MiniSkySettings -from minisky.core.trafficarrays import TrafficArrays, select_implementation +from minisky.core.trafficarrays import TrafficArrays from minisky.stack.argparser import Txt from minisky.tools.aero import ft, nm +from minisky.traffic import route + +if TYPE_CHECKING: + from minisky.traffic import Traffic class ConflictResolution(TrafficArrays): @@ -61,9 +67,16 @@ class ConflictResolution(TrafficArrays): vs (ndarray): Resolution vertical speed advisory [m/s]. """ - def __init__(self, settings: MiniSkySettings) -> None: + def __init__( + self, + settings: MiniSkySettings, + traffic: Traffic, + select_implementation: Callable[[str, str], tuple[bool, str]], + ) -> None: super().__init__() self.settings = settings + self.traffic = traffic + self.select_implementation = select_implementation self.activate = False # [-] switch to activate priority rules for conflict resolution @@ -94,8 +107,8 @@ def __init__(self, settings: MiniSkySettings) -> None: self.vs = np.array([]) # vspeed provided by the ASAS [m/s] def new_implementation(self, implementation: type[TrafficArrays]) -> TrafficArrays: - """Construct a replacement with this runtime's settings.""" - return implementation(self.settings) + """Construct a replacement with this runtime's traffic and selector.""" + return implementation(self.settings, self.traffic, self.select_implementation) def switch(self, flag: bool | None = None) -> None: """Turn conflict resolution on or off. @@ -236,7 +249,7 @@ def anglediff(a: float, b: float) -> float: past_cpa = False hor_los = False is_bouncing = False - idx1, idx2 = minisky.traf.idx(conflict) + idx1, idx2 = self.traffic.idx(conflict) # If the ownship aircraft is deleted remove its conflict from the list if idx1 < 0: delpairs.add(conflict) @@ -302,14 +315,14 @@ def anglediff(a: float, b: float) -> float: if not active: # Waypoint recovery after conflict: Find the next active waypoint # and send the aircraft to that waypoint. - iwpid = minisky.traf.ap.route[idx].findact(idx) + iwpid = self.traffic.ap.route[idx].findact(idx) if iwpid != -1: # To avoid problems if there are no waypoints - minisky.traffic.route.direct(idx, minisky.traf.ap.route[idx].wpname[iwpid]) + route.direct(idx, self.traffic.ap.route[idx].wpname[iwpid]) # Remove pairs from the list that are past CPA or have deleted aircraft self.resopairs -= delpairs - def setprio(self, flag: bool | None = None, priocode="") -> "bool | tuple": + def setprio(self, flag: bool | None = None, priocode="") -> bool | tuple: """Define priority rules (right of way) for conflict resolution. Implements the PRIORULES stack command. The base class only stores @@ -336,7 +349,7 @@ def setprio(self, flag: bool | None = None, priocode="") -> "bool | tuple": self.priocode = priocode return True - def setnoreso(self, *idx: int) -> "bool | tuple": + def setnoreso(self, *idx: int) -> bool | tuple: """ADD or Remove aircraft that nobody will avoid. Multiple aircraft can be sent to this function at once. @@ -356,13 +369,13 @@ def setnoreso(self, *idx: int) -> "bool | tuple": True, "NORESO [ACID, ... ] OR NORESO [GROUPID]" + "\nCurrent list of aircraft nobody will avoid:" - + ", ".join(np.array(minisky.traf.callsign)[self.noresoac]), + + ", ".join(np.array(self.traffic.callsign)[self.noresoac]), ) indices = list(idx) self.noresoac[indices] = np.logical_not(self.noresoac[indices]) return True - def setresooff(self, *idx: int) -> "bool | tuple": + def setresooff(self, *idx: int) -> bool | tuple: """ADD or Remove aircraft that will not avoid anybody else. Multiple aircraft can be sent to this function at once. @@ -382,7 +395,7 @@ def setresooff(self, *idx: int) -> "bool | tuple": True, "RESOOFF [ACID, ... ] OR RESOOFF [GROUPID]" + "\nCurrent list of aircraft will not avoid anybody:" - + ", ".join(np.array(minisky.traf.callsign)[self.resooffac]), + + ", ".join(np.array(self.traffic.callsign)[self.resooffac]), ) else: indices = list(idx) @@ -456,7 +469,7 @@ def setresozoner(self, zoner: float | None = None) -> tuple: Returns: tuple: (success (bool), message (str)) for the command stack. """ - if not minisky.traf.cd.global_rpz: + if not self.traffic.cd.global_rpz: self.resorrelative = True return ( False, @@ -465,10 +478,10 @@ def setresozoner(self, zoner: float | None = None) -> tuple: if zoner is None: return ( True, - f"RSZONER [radiusnm]\nCurrent horizontal resolution factor is: {self.resofach}, resulting in radius: {self.resofach * minisky.traf.cd.rpz_def / nm} nm", + f"RSZONER [radiusnm]\nCurrent horizontal resolution factor is: {self.resofach}, resulting in radius: {self.resofach * self.traffic.cd.rpz_def / nm} nm", ) - self.resofach = zoner / minisky.traf.cd.rpz_def * nm + self.resofach = zoner / self.traffic.cd.rpz_def * nm # Size of resolution zone r, vertically, no longer relative to CD zone self.resorrelative = False return ( @@ -492,7 +505,7 @@ def setresozonedh(self, zonedh: float | None = None) -> tuple: Returns: tuple: (success (bool), message (str)) for the command stack. """ - if not minisky.traf.cd.global_hpz: + if not self.traffic.cd.global_hpz: self.resodhrelative = True return ( False, @@ -501,10 +514,10 @@ def setresozonedh(self, zonedh: float | None = None) -> tuple: if zonedh is None: return ( True, - f"RSZONEDH [zonedhft]\nCurrent vertical resolution factor is: {self.resofacv}, resulting in height: {self.resofacv * minisky.traf.cd.hpz_def / ft} ft", + f"RSZONEDH [zonedhft]\nCurrent vertical resolution factor is: {self.resofacv}, resulting in height: {self.resofacv * self.traffic.cd.hpz_def / ft} ft", ) - self.resofacv = zonedh / minisky.traf.cd.hpz_def * ft + self.resofacv = zonedh / self.traffic.cd.hpz_def * ft # Size of resolution zone dh, vertically, no longer relative to CD zone self.resodhrelative = False return ( @@ -512,8 +525,7 @@ def setresozonedh(self, zonedh: float | None = None) -> tuple: f"Vertical resolution factor updated to {self.resofacv}, resulting in height: {zonedh} ft", ) - @staticmethod - def setmethod(name: Txt = "") -> tuple: + def setmethod(self, name: Txt = "") -> tuple: """Select a Conflict Resolution method. Implements the RESO stack command. Selecting "MVP" replaces the @@ -530,82 +542,29 @@ def setmethod(name: Txt = "") -> tuple: names = ["OFF", "MVP"] if not name: - curname = type(minisky.traf.cr).__name__ if minisky.traf.cr.activate else "OFF" + curname = type(self.traffic.cr).__name__ if self.traffic.cr.activate else "OFF" return ( True, f"Current CR method: {curname}" + f"\nAvailable CR methods: {', '.join(names)}", ) if name == "OFF": - minisky.traf.cr.switch(False) + self.traffic.cr.switch(False) return True, "Conflict Resolution turned off." if name == "MVP": - success, message = select_implementation("CONFLICTRESOLUTION", name) + success, message = self.select_implementation("CONFLICTRESOLUTION", name) if not success: return success, message - minisky.traf.cr.switch(True) + self.traffic.cr.switch(True) return True, "Selected MVP as Conflict Resolution method." return False, f"Unknown method: {name}. Available: {', '.join(names)}" + def setresometh(self, value: Txt = "") -> tuple: + """Report that horizontal method selection requires the MVP implementation.""" + return False, f"RMETHH is not available for CR method {type(self).__name__}" -# Module-level dispatchers for the stack commands below. RESO can replace -# minisky.traf.cr with a new instance (e.g. MVP), which would leave commands -# registered as bound methods pointing at the stale, replaced object. These -# wrappers resolve the current instance at call time instead. - - -def setprio(flag: bool | None = None, priocode="") -> "bool | tuple": - """PRIORULES stack command; see ConflictResolution.setprio().""" - return minisky.traf.cr.setprio(flag, priocode) - - -def setnoreso(*idx: int) -> "bool | tuple": - """NORESO stack command; see ConflictResolution.setnoreso().""" - return minisky.traf.cr.setnoreso(*idx) - - -def setresooff(*idx: int) -> "bool | tuple": - """RESOOFF stack command; see ConflictResolution.setresooff().""" - return minisky.traf.cr.setresooff(*idx) - - -def setresofach(factor: float | None = None) -> tuple: - """RFACH stack command; see ConflictResolution.setresofach().""" - return minisky.traf.cr.setresofach(factor) - - -def setresofacv(factor: float | None = None) -> tuple: - """RFACV stack command; see ConflictResolution.setresofacv().""" - return minisky.traf.cr.setresofacv(factor) - - -def setresozoner(zoner: float | None = None) -> tuple: - """RSZONER stack command; see ConflictResolution.setresozoner().""" - return minisky.traf.cr.setresozoner(zoner) - - -def setresozonedh(zonedh: float | None = None) -> tuple: - """RSZONEDH stack command; see ConflictResolution.setresozonedh().""" - return minisky.traf.cr.setresozonedh(zonedh) - - -def setresometh(value: Txt = "") -> tuple: - """RMETHH stack command; see MVP.setresometh().""" - from minisky.traffic.asas.mvp import MVP - - cr = minisky.traf.cr - if not isinstance(cr, MVP): - return False, f"RMETHH is not available for CR method {type(cr).__name__}" - return cr.setresometh(value) - - -def setresometv(value: Txt = "") -> tuple: - """RMETHV stack command; see MVP.setresometv().""" - from minisky.traffic.asas.mvp import MVP - - cr = minisky.traf.cr - if not isinstance(cr, MVP): - return False, f"RMETHV is not available for CR method {type(cr).__name__}" - return cr.setresometv(value) + def setresometv(self, value: Txt = "") -> tuple: + """Report that vertical method selection requires the MVP implementation.""" + return False, f"RMETHV is not available for CR method {type(self).__name__}" diff --git a/minisky/traffic/conditional.py b/minisky/traffic/conditional.py index d72b638..40f0553 100644 --- a/minisky/traffic/conditional.py +++ b/minisky/traffic/conditional.py @@ -4,20 +4,25 @@ Implements the ATALT, ATSPD and ATDIST stack commands: a command line is stored together with a trigger condition on an aircraft's altitude, speed -or distance to a position. The :class:`Condition` instance owned by -``minisky.traf`` is checked every simulation step; when the monitored +or distance to a position. The `Condition` instance owned by +`minisky.traf` is checked every simulation step; when the monitored value crosses its target, the stored command is issued on the stack and the condition is removed. """ -from typing import Any +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any import numpy as np -import minisky -from minisky import stack from minisky.tools.geo import qdrdist +if TYPE_CHECKING: + from minisky.simulation import ConsoleIO + from minisky.traffic import Traffic + # Enumerated condtion types alttype, spdtype, postype = 0, 1, 2 @@ -44,7 +49,12 @@ class Condition: cmd (list): Command line to stack when the condition triggers. """ - def __init__(self) -> None: + def __init__( + self, traffic: Traffic, stack_command: Callable[..., None], console: ConsoleIO + ) -> None: + self.traffic = traffic + self.stack_command = stack_command + self.console = console self.ncond = 0 # Number of conditions self.id = [] # Id of aircraft of condition @@ -54,6 +64,16 @@ def __init__(self) -> None: self.posdata = [] # Data for postype: tuples lat[deg],lon[deg] of ref position self.cmd = [] # Commands to be issued + def reset(self) -> None: + """Clear all pending conditional commands.""" + self.ncond = 0 + self.id.clear() + self.condtype = np.array([], dtype=int) + self.target = np.array([], dtype=float) + self.lastdif = np.array([], dtype=float) + self.posdata.clear() + self.cmd.clear() + def update(self) -> None: """Check all pending conditions and execute triggered commands. @@ -67,7 +87,7 @@ def update(self) -> None: return # Update indices based on list of id's - acidxlst = np.array(minisky.traf.idx(self.id)) + acidxlst = np.array(self.traffic.idx(self.id)) if len(acidxlst) > 0: idelcond = sorted(np.where(acidxlst < 0)[0]) for i in idelcond[::-1]: @@ -81,7 +101,7 @@ def update(self) -> None: self.ncond = len(self.id) if self.ncond == 0: return - acidxlst = np.array(minisky.traf.idx(self.id)) + acidxlst = np.array(self.traffic.idx(self.id)) # Check condition types actdist = ( @@ -90,8 +110,8 @@ def update(self) -> None: for j in range(self.ncond): if self.condtype[j] == postype: qdr, dist = qdrdist( - minisky.traf.lat[acidxlst[j]], - minisky.traf.lon[acidxlst[j]], + self.traffic.lat[acidxlst[j]], + self.traffic.lon[acidxlst[j]], self.posdata[j][0], self.posdata[j][1], ) @@ -99,8 +119,8 @@ def update(self) -> None: # Get relevant actual value using index list as index to numpy arrays self.actual = ( - (self.condtype == alttype) * minisky.traf.alt[acidxlst] - + (self.condtype == spdtype) * minisky.traf.cas[acidxlst] + (self.condtype == alttype) * self.traffic.alt[acidxlst] + + (self.condtype == spdtype) * self.traffic.cas[acidxlst] + (self.condtype == postype) * actdist ) @@ -116,9 +136,9 @@ def update(self) -> None: # Execute commands found to have true condition for i in idxtrue: if i >= 0: - stack.stack(self.cmd[i]) + self.stack_command(self.cmd[i]) # debug - # stack.stack(" ECHO Conditional command issued: "+self.cmd[i]) + # self.stack_command(" ECHO Conditional command issued: "+self.cmd[i]) # Delete executed commands to clean up arrays and lists # from highest index to lowest for consistency @@ -135,7 +155,7 @@ def update(self) -> None: self.ncond = len(self.id) if self.ncond != len(self.cmd): - minisky.scr.echo( + self.console.echo( f"delcondition: invalid condition array size (ncond={self.ncond}, cmd={self.cmd})" ) return @@ -144,7 +164,7 @@ def ataltcmd(self, acidx: int, targalt: float, cmdtxt: str) -> bool: """Schedule a command for when an aircraft crosses an altitude. Implements the ATALT stack command: - ``acid ATALT alt cmd`` (e.g. ``KL204 ATALT FL100 KL204 SPD 350``). + `acid ATALT alt cmd` (e.g. `KL204 ATALT FL100 KL204 SPD 350`). Args: acidx: Aircraft index. @@ -154,7 +174,7 @@ def ataltcmd(self, acidx: int, targalt: float, cmdtxt: str) -> bool: Returns: bool: True (the condition is always added). """ - actalt = minisky.traf.alt[acidx] + actalt = self.traffic.alt[acidx] self.addcondition(acidx, alttype, targalt, actalt, cmdtxt) return True @@ -162,7 +182,7 @@ def atspdcmd(self, acidx: int, targspd: float, cmdtxt: str) -> bool: """Schedule a command for when an aircraft crosses a speed. Implements the ATSPD stack command: - ``acid ATSPD spd cmd`` (e.g. ``KL204 ATSPD 250 KL204 LNAV ON``). + `acid ATSPD spd cmd` (e.g. `KL204 ATSPD 250 KL204 LNAV ON`). Args: acidx: Aircraft index. @@ -172,15 +192,15 @@ def atspdcmd(self, acidx: int, targspd: float, cmdtxt: str) -> bool: Returns: bool: True (the condition is always added). """ - actspd = minisky.traf.cas[acidx] + actspd = self.traffic.cas[acidx] self.addcondition(acidx, spdtype, targspd, actspd, cmdtxt) return True def atdistcmd(self, acidx: int, lat: float, lon: float, targdist: float, cmdtxt: str) -> bool: """Schedule a command for a distance from a reference position. - Implements the ATDIST stack command: ``acid ATDIST lat lon dist - cmd``. The command triggers when the aircraft's distance to the + Implements the ATDIST stack command: `acid ATDIST lat lon dist + cmd`. The command triggers when the aircraft's distance to the given position crosses the target distance. Args: @@ -193,7 +213,7 @@ def atdistcmd(self, acidx: int, lat: float, lon: float, targdist: float, cmdtxt: Returns: bool: True (the condition is always added). """ - qdr, actdist = qdrdist(minisky.traf.lat[acidx], minisky.traf.lon[acidx], lat, lon) + qdr, actdist = qdrdist(self.traffic.lat[acidx], self.traffic.lon[acidx], lat, lon) self.addcondition(acidx, postype, targdist, actdist, cmdtxt, (lat, lon)) return True @@ -224,7 +244,7 @@ def addcondition( # print ("addcondition:", acidx, icondtype, target, actual, cmdtxt, latlon) # Add condition to arrays - self.id.append(minisky.traf.callsign[acidx]) + self.id.append(self.traffic.callsign[acidx]) self.condtype = np.append(self.condtype, icondtype) self.target = np.append(self.target, target) diff --git a/minisky/traffic/traffic.py b/minisky/traffic/traffic.py index 961b381..f36ae75 100644 --- a/minisky/traffic/traffic.py +++ b/minisky/traffic/traffic.py @@ -12,13 +12,14 @@ (CRE, MCRE, CRECONFS, MOVE, POS, BANK, THR, NOISE, CRECMD, ...). """ -from collections.abc import Collection, Iterable +from __future__ import annotations + +from collections.abc import Callable, Collection, Iterable from random import randint -from typing import overload +from typing import TYPE_CHECKING, overload import numpy as np -import minisky from minisky.core.settings import MiniSkySettings from minisky.core.trafficarrays import TrafficArrays from minisky.tools import geo @@ -52,6 +53,10 @@ from .uncertainty import SurveillanceUncertainty from .wind import Wind +if TYPE_CHECKING: + from minisky.simulation import ConsoleIO, Simulation + from minisky.tools.navdata import Navdatabase + class Traffic(TrafficArrays): """Central traffic database holding the state of all simulated aircraft. @@ -123,19 +128,31 @@ class Traffic(TrafficArrays): Created by: Jacco M. Hoekstra """ - def __init__(self, settings: MiniSkySettings, areas: AreaFilter) -> None: + def __init__( + self, + settings: MiniSkySettings, + areas: AreaFilter, + navigation: Navdatabase, + console: ConsoleIO, + get_simulation: Callable[[], Simulation], + stack_command: Callable[..., None], + select_implementation: Callable[[str, str], tuple[bool, str]], + ) -> None: super().__init__() self.settings = settings self.areas = areas - - # Traffic is the toplevel trafficarrays object - self.setroot(self) + self.navigation = navigation + self.console = console + self._get_simulation = get_simulation + self.stack_command = stack_command + self.select_implementation = select_implementation self.ntraf = 0 - self.cond = Condition() # Conditional commands list + self.cond = Condition(self, stack_command, console) # Conditional commands list self.wind = Wind() - self.turbulence = Turbulence() + self.wind.reparent(self) + self.turbulence = Turbulence(self, get_simulation) self.translvl = 5000.0 * ft # [m] Default transition level # Default commands issued for an aircraft after creation @@ -188,13 +205,13 @@ def __init__(self, settings: MiniSkySettings, areas: AreaFilter) -> None: self.swvnavspd = np.array([], dtype=bool) # Flight Models - self.cd = ConflictDetection(settings) - self.cr = ConflictResolution(settings) + self.cd = ConflictDetection(settings, self, stack_command) + self.cr = ConflictResolution(settings, self, select_implementation) self.ap = Autopilot() - self.aporasas = APorASAS() - self.noise = SurveillanceUncertainty() - self.trails = Trails() - self.actwp = ActiveWaypoint() + self.aporasas = APorASAS(self) + self.noise = SurveillanceUncertainty(self, get_simulation) + self.trails = Trails(self, get_simulation) + self.actwp = ActiveWaypoint(self) self.perf = OpenAP() # Group Logic @@ -220,6 +237,11 @@ def __init__(self, settings: MiniSkySettings, areas: AreaFilter) -> None: # Default bank angles per flight phase self.bphase = np.deg2rad(np.array([15, 35, 35, 35, 15, 45])) + @property + def simulation(self) -> Simulation: + """Return the simulation that owns this traffic object.""" + return self._get_simulation() + def reset(self) -> None: """Clear all traffic data upon simulation reset. @@ -238,6 +260,7 @@ def reset(self) -> None: # Reset models self.wind.clear() + self.cond.reset() # Build new modules for turbulence self.turbulence.reset() @@ -450,7 +473,7 @@ def __create_aircraft( # If any are there, then stack them for all aircraft for j in range(self.ntraf - n, self.ntraf): for cmdtxt in self.crecmdlist: - minisky.stack.stack(self.callsign[j] + " " + cmdtxt) + self.stack_command(self.callsign[j] + " " + cmdtxt) def creconfs( self, @@ -637,10 +660,10 @@ def update_airspeed(self) -> None: """ # Compute horizontal acceleration delta_spd = self.aporasas.tas - self.tas - need_ax = np.abs(delta_spd) > np.abs(minisky.sim.simdt * self.perf.axmax) + need_ax = np.abs(delta_spd) > np.abs(self.simulation.simdt * self.perf.axmax) self.ax = need_ax * np.sign(delta_spd) * self.perf.axmax # Update velocities - self.tas = np.where(need_ax, self.tas + self.ax * minisky.sim.simdt, self.aporasas.tas) + self.tas = np.where(need_ax, self.tas + self.ax * self.simulation.simdt, self.aporasas.tas) self.cas = vtas2cas(self.tas, self.alt) self.M = vtas2mach(self.tas, self.alt) @@ -659,13 +682,13 @@ def update_airspeed(self) -> None: / np.maximum(self.tas, self.eps) ) delhdg = (self.aporasas.hdg - self.hdg + 180) % 360 - 180 # [deg] - self.swhdgsel = np.abs(delhdg) > np.abs(minisky.sim.simdt * turnrate) + self.swhdgsel = np.abs(delhdg) > np.abs(self.simulation.simdt * turnrate) # Update heading self.hdg = ( np.where( self.swhdgsel, - self.hdg + minisky.sim.simdt * turnrate * np.sign(delhdg), + self.hdg + self.simulation.simdt * turnrate * np.sign(delhdg), self.aporasas.hdg, ) % 360.0 @@ -675,19 +698,19 @@ def update_airspeed(self) -> None: delta_alt = self.aporasas.alt - self.alt # Old dead band version: # self.swaltsel = np.abs(delta_alt) > np.maximum( - # 10 * ft, np.abs(2 * minisky.sim.simdt * self.vs)) + # 10 * ft, np.abs(2 * self.simulation.simdt * self.vs)) # Update version: time based engage of altitude capture (to adapt for UAV vs airliner scale) self.swaltsel = np.abs(delta_alt) > 1.05 * np.maximum( - np.abs(minisky.sim.simdt * self.aporasas.vs), - np.abs(minisky.sim.simdt * self.vs), + np.abs(self.simulation.simdt * self.aporasas.vs), + np.abs(self.simulation.simdt * self.vs), ) target_vs = self.swaltsel * np.sign(delta_alt) * np.abs(self.aporasas.vs) delta_vs = target_vs - self.vs # print(delta_vs / fpm) need_az = np.abs(delta_vs) > 300 * fpm # small threshold self.az = need_az * np.sign(delta_vs) * (300 * fpm) # fixed vertical acc approx 1.6 m/s^2 - self.vs = np.where(need_az, self.vs + self.az * minisky.sim.simdt, target_vs) + self.vs = np.where(need_az, self.vs + self.az * self.simulation.simdt, target_vs) self.vs = np.where(np.isfinite(self.vs), self.vs, 0) # fix vs nan issue def update_groundspeed(self) -> None: @@ -725,7 +748,9 @@ def update_groundspeed(self) -> None: ) self.work += ( - self.perf.thrust * minisky.sim.simdt * np.sqrt(self.gs * self.gs + self.vs * self.vs) + self.perf.thrust + * self.simulation.simdt + * np.sqrt(self.gs * self.gs + self.vs * self.vs) ) def update_pos(self) -> None: @@ -739,13 +764,13 @@ def update_pos(self) -> None: # Update position self.alt = np.where( self.swaltsel, - np.round(self.alt + self.vs * minisky.sim.simdt, 6), + np.round(self.alt + self.vs * self.simulation.simdt, 6), self.aporasas.alt, ) - self.lat = self.lat + np.degrees(minisky.sim.simdt * self.gsnorth / Rearth) + self.lat = self.lat + np.degrees(self.simulation.simdt * self.gsnorth / Rearth) self.coslat = np.cos(np.deg2rad(self.lat)) - self.lon = self.lon + np.degrees(minisky.sim.simdt * self.gseast / self.coslat / Rearth) - self.distflown += self.gs * minisky.sim.simdt + self.lon = self.lon + np.degrees(self.simulation.simdt * self.gseast / self.coslat / Rearth) + self.distflown += self.gs * self.simulation.simdt @overload def idx(self, callsign: str) -> int: ... @@ -952,20 +977,20 @@ def position_by_name(self, name: str) -> tuple[bool, str]: lines = "Information on " + name + ":\n" # First try airports (most used and shorter, hence faster list) - idx_airport = minisky.navdb.getaptidx(name) + idx_airport = self.navigation.getaptidx(name) if idx_airport >= 0: airport_sizes = ["large", "medium", "small"] - airport_size = airport_sizes[max(-1, minisky.navdb.aptype[idx_airport] - 1)] + airport_size = airport_sizes[max(-1, self.navigation.aptype[idx_airport] - 1)] - aptname = minisky.navdb.aptname[idx_airport] - aptlat = minisky.navdb.aptlat[idx_airport] - aptlon = minisky.navdb.aptlon[idx_airport] - aptelev = minisky.navdb.aptelev[idx_airport] + aptname = self.navigation.aptname[idx_airport] + aptlat = self.navigation.aptlat[idx_airport] + aptlon = self.navigation.aptlon[idx_airport] + aptelev = self.navigation.aptelev[idx_airport] # country informatation - idx_cc = minisky.navdb.cocode2.index(minisky.navdb.aptco[idx_airport].upper()) - country_name = minisky.navdb.coname[idx_cc].upper() - country_code = minisky.navdb.aptco[idx_airport] + idx_cc = self.navigation.cocode2.index(self.navigation.aptco[idx_airport].upper()) + country_name = self.navigation.coname[idx_cc].upper() + country_code = self.navigation.aptco[idx_airport] lines += ( f"{aptname} is a {airport_size} airport in {country_name} ({country_code}):\n" @@ -973,8 +998,8 @@ def position_by_name(self, name: str) -> tuple[bool, str]: f"Elevation: {int(round(aptelev / ft))} ft \n" ) - if minisky.navdb.aptid[idx_airport] in minisky.navdb.rwythresholds: - runways = minisky.navdb.rwythresholds[minisky.navdb.aptid[idx_airport]].keys() + if self.navigation.aptid[idx_airport] in self.navigation.rwythresholds: + runways = self.navigation.rwythresholds[self.navigation.aptid[idx_airport]].keys() if runways: lines += f"Runways: {', '.join(runways)}\n" @@ -987,7 +1012,7 @@ def position_by_name(self, name: str) -> tuple[bool, str]: # Not found as airport, try waypoints & navaids else: - idx_waypoints = minisky.navdb.getwpindices(name) + idx_waypoints = self.navigation.getwpindices(name) if idx_waypoints[0] >= 0: typetxt = "" desctxt = "" @@ -995,31 +1020,31 @@ def position_by_name(self, name: str) -> tuple[bool, str]: for i in idx_waypoints: # One line type text if typetxt == "": - typetxt = typetxt + minisky.navdb.wptype[i] + typetxt = typetxt + self.navigation.wptype[i] else: - typetxt = typetxt + " and " + minisky.navdb.wptype[i] + typetxt = typetxt + " and " + self.navigation.wptype[i] # Description: multi-line - samedesc = minisky.navdb.wpdesc[i] == lastdesc + samedesc = self.navigation.wpdesc[i] == lastdesc if desctxt == "": - desctxt = desctxt + minisky.navdb.wpdesc[i] - lastdesc = minisky.navdb.wpdesc[i] + desctxt = desctxt + self.navigation.wpdesc[i] + lastdesc = self.navigation.wpdesc[i] elif not samedesc: - desctxt = desctxt + "\n" + minisky.navdb.wpdesc[i] - lastdesc = minisky.navdb.wpdesc[i] + desctxt = desctxt + "\n" + self.navigation.wpdesc[i] + lastdesc = self.navigation.wpdesc[i] # Navaid: frequency - if minisky.navdb.wptype[i] in ["VOR", "DME", "TACAN"] and not samedesc: - desctxt = desctxt + " " + str(minisky.navdb.wpfreq[i]) + " MHz" - elif minisky.navdb.wptype[i] == "NDB" and not samedesc: - desctxt = desctxt + " " + str(minisky.navdb.wpfreq[i]) + " kHz" + if self.navigation.wptype[i] in ["VOR", "DME", "TACAN"] and not samedesc: + desctxt = desctxt + " " + str(self.navigation.wpfreq[i]) + " MHz" + elif self.navigation.wptype[i] == "NDB" and not samedesc: + desctxt = desctxt + " " + str(self.navigation.wpfreq[i]) + " kHz" iwp = idx_waypoints[0] # Basic info lines += ( f"{name} is a {typetxt} with \n" - f"Position: {latlon2txt(minisky.navdb.wplat[iwp], minisky.navdb.wplon[iwp])}\n" + f"Position: {latlon2txt(self.navigation.wplat[iwp], self.navigation.wplon[iwp])}\n" ) # Navaids have description @@ -1027,17 +1052,17 @@ def position_by_name(self, name: str) -> tuple[bool, str]: lines += f"{desctxt}\n" # VOR give variation - if minisky.navdb.wptype[iwp] == "VOR": - lines += f"Variation: {minisky.navdb.wpvar[iwp]} deg\n" + if self.navigation.wptype[iwp] == "VOR": + lines += f"Variation: {self.navigation.wpvar[iwp]} deg\n" # How many others? - n_other = minisky.navdb.wpid.count(name) - len(idx_waypoints) + n_other = self.navigation.wpid.count(name) - len(idx_waypoints) if n_other > 0: lines += f"Attention: {n_other} other waypoint(s) also has name {name}\n" # In which airways? - connect = minisky.navdb.listconnections( - name, minisky.navdb.wplat[iwp], minisky.navdb.wplon[iwp] + connect = self.navigation.listconnections( + name, self.navigation.wplat[iwp], self.navigation.wplon[iwp] ) if len(connect) > 0: awset = set() @@ -1051,7 +1076,7 @@ def position_by_name(self, name: str) -> tuple[bool, str]: # Try airway id else: # airway awid = name - airway = minisky.navdb.listairway(awid) + airway = self.navigation.listairway(awid) if len(airway) > 0: lines = "" for segment in airway: diff --git a/minisky/traffic/trafficgroups.py b/minisky/traffic/trafficgroups.py index 4786776..8b4df12 100644 --- a/minisky/traffic/trafficgroups.py +++ b/minisky/traffic/trafficgroups.py @@ -53,7 +53,7 @@ class TrafficGroups(TrafficArrays): def __init__(self, traffic: Traffic, areas: AreaFilter) -> None: # Initialize the groups structure - super().__init__() + super().__init__(traffic) self.traffic = traffic self.areas = areas self.groups = {} diff --git a/minisky/traffic/trails.py b/minisky/traffic/trails.py index 43de103..a17b360 100644 --- a/minisky/traffic/trails.py +++ b/minisky/traffic/trails.py @@ -6,23 +6,29 @@ resolution and fade to the "old" color after a configurable time. """ -from typing import Any +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any import numpy as np -import minisky from minisky.core import TrafficArrays +if TYPE_CHECKING: + from minisky.simulation import Simulation + from minisky.traffic import Traffic + class Trails(TrafficArrays): """Data for the aircraft trails shown on the radar display. - Every ``dt`` seconds of simulation time a line segment (from the last + Every `dt` seconds of simulation time a line segment (from the last recorded position to the current position) is appended per aircraft. Segments are kept in a foreground buffer for drawing and can be moved to a background buffer with buffer(). Segment colors fade towards the - "old" color over ``tcol0`` seconds. Available at runtime as - ``minisky.traf.trails``. + "old" color over `tcol0` seconds. Available at runtime as + `minisky.traf.trails`. Attributes: active (bool): Whether trails are recorded and shown. @@ -46,8 +52,15 @@ class Trails(TrafficArrays): Created by: Jacco M. Hoekstra """ - def __init__(self, dttrail: float = 10.0) -> None: - super().__init__() + def __init__( + self, + traffic: Traffic, + get_simulation: Callable[[], Simulation], + dttrail: float = 10.0, + ) -> None: + super().__init__(traffic) + self.traffic = traffic + self._get_simulation = get_simulation self.active = False # Wether or not to show trails self.dt = dttrail # Resolution of trail pieces in time self.tcol0 = 60.0 # After how many seconds old colour @@ -91,6 +104,10 @@ def __init__(self, dttrail: float = 10.0) -> None: return + def new_implementation(self, implementation: type[TrafficArrays]) -> TrafficArrays: + """Construct a replacement with this runtime's traffic and simulation.""" + return implementation(self.traffic, self._get_simulation) + def create(self, n: int = 1) -> None: """Initialize trail data for newly created aircraft. @@ -103,24 +120,24 @@ def create(self, n: int = 1) -> None: super().create(n) self.accolor[-1] = self.defcolor - self.lastlat[-1] = minisky.traf.lat[-1] - self.lastlon[-1] = minisky.traf.lon[-1] + self.lastlat[-1] = self.traffic.lat[-1] + self.lastlon[-1] = self.traffic.lon[-1] def update(self) -> None: """Add new trail segments for aircraft that moved long enough. Called every simulation step. When trails are inactive, only the last-known positions are refreshed. Otherwise, for each aircraft - whose last recorded segment is older than ``dt`` seconds, a new + whose last recorded segment is older than `dt` seconds, a new line segment from the last recorded position to the current position is appended to the drawing buffers, and the color fading factors of all segments are updated. """ - self.acid = minisky.traf.callsign + self.acid = self.traffic.callsign if not self.active: - self.lastlat = minisky.traf.lat - self.lastlon = minisky.traf.lon - self.lasttim[:] = minisky.sim.simt + self.lastlat = self.traffic.lat + self.lastlon = self.traffic.lon + self.lasttim[:] = self._get_simulation().simt return """Add linepieces for trails based on traffic data""" @@ -132,7 +149,7 @@ def update(self) -> None: lsttime = [] # Check for update - delta = minisky.sim.simt - self.lasttim + delta = self._get_simulation().simt - self.lasttim idxs = np.where(delta > self.dt)[0] # Add all a/c which need the update @@ -143,9 +160,9 @@ def update(self) -> None: # Add to lists lstlat0.append(self.lastlat[i]) lstlon0.append(self.lastlon[i]) - lstlat1.append(minisky.traf.lat[i]) - lstlon1.append(minisky.traf.lon[i]) - lsttime.append(minisky.sim.simt) + lstlat1.append(self.traffic.lat[i]) + lstlon1.append(self.traffic.lon[i]) + lsttime.append(self._get_simulation().simt) if isinstance(self.col, np.ndarray): # print type(trailcol[i]) @@ -157,9 +174,9 @@ def update(self) -> None: self.col.append(self.accolor[i]) # Update aircraft record - self.lastlat[i] = minisky.traf.lat[i] - self.lastlon[i] = minisky.traf.lon[i] - self.lasttim[i] = minisky.sim.simt + self.lastlat[i] = self.traffic.lat[i] + self.lastlon[i] = self.traffic.lon[i] + self.lasttim[i] = self._get_simulation().simt # When a/c is no longer part of trail semgment, # it is no longer a/c data => add to the GUI send buffer @@ -168,7 +185,10 @@ def update(self) -> None: self.newlat1.extend(lstlat1) self.newlon1.extend(lstlon1) # Update colours - self.fcol = 1.0 - np.minimum(self.tcol0, np.abs(minisky.sim.simt - self.time)) / self.tcol0 + self.fcol = ( + 1.0 + - np.minimum(self.tcol0, np.abs(self._get_simulation().simt - self.time)) / self.tcol0 + ) return @@ -234,11 +254,11 @@ def clear(self) -> None: self.clearnew() return - def setTrails(self, *args) -> "bool | tuple[bool, str]": + def setTrails(self, *args) -> bool | tuple[bool, str]: """Switch trails on/off, or change the trail color of an aircraft. Implements the TRAIL stack command: - ``TRAIL ON/OFF, [dt]`` or ``TRAIL acid color``. Without arguments, + `TRAIL ON/OFF, [dt]` or `TRAIL acid color`. Without arguments, the current on/off state is reported. Switching trails off clears all recorded segments. diff --git a/minisky/traffic/turbulence.py b/minisky/traffic/turbulence.py index ee0dd02..3f53233 100644 --- a/minisky/traffic/turbulence.py +++ b/minisky/traffic/turbulence.py @@ -6,14 +6,20 @@ stack command (see Traffic.setnoise()). """ -from typing import Any +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any import numpy as np -import minisky from minisky.core.trafficarrays import TrafficArrays from minisky.tools.aero import Rearth +if TYPE_CHECKING: + from minisky.simulation import Simulation + from minisky.traffic import Traffic + class Turbulence(TrafficArrays): """Simple stochastic turbulence model. @@ -29,11 +35,17 @@ class Turbulence(TrafficArrays): clipped to a small positive minimum. """ - def __init__(self) -> None: - super().__init__() + def __init__(self, traffic: Traffic, get_simulation: Callable[[], Simulation]) -> None: + super().__init__(traffic) + self.traffic = traffic + self._get_simulation = get_simulation self.active = False self.SetStandards([0, 0.1, 0.1]) + def new_implementation(self, implementation: type[TrafficArrays]) -> TrafficArrays: + """Construct a replacement with this runtime's traffic and simulation.""" + return implementation(self.traffic, self._get_simulation) + def reset(self) -> None: """Switch turbulence off and restore the default standard deviations.""" self.active = False @@ -71,22 +83,22 @@ def update(self) -> None: if not self.active: return - timescale = np.sqrt(minisky.sim.simdt) + timescale = np.sqrt(self._get_simulation().simdt) # Horizontal flight direction - turbhf = np.random.normal(0, self.sd[0] * timescale, minisky.traf.ntraf) # [m] + turbhf = np.random.normal(0, self.sd[0] * timescale, self.traffic.ntraf) # [m] # Horizontal wing direction - turbhw = np.random.normal(0, self.sd[1] * timescale, minisky.traf.ntraf) # [m] + turbhw = np.random.normal(0, self.sd[1] * timescale, self.traffic.ntraf) # [m] # Vertical direction - turbalt = np.random.normal(0, self.sd[2] * timescale, minisky.traf.ntraf) # [m] + turbalt = np.random.normal(0, self.sd[2] * timescale, self.traffic.ntraf) # [m] - trkrad = np.radians(minisky.traf.trk) + trkrad = np.radians(self.traffic.trk) # Lateral, longitudinal direction turblat = np.cos(trkrad) * turbhf - np.sin(trkrad) * turbhw # [m] turblon = np.sin(trkrad) * turbhf + np.cos(trkrad) * turbhw # [m] # Update the aircraft locations - minisky.traf.alt = minisky.traf.alt + turbalt - minisky.traf.lat = minisky.traf.lat + np.degrees(turblat / Rearth) - minisky.traf.lon = minisky.traf.lon + np.degrees(turblon / Rearth / minisky.traf.coslat) + self.traffic.alt = self.traffic.alt + turbalt + self.traffic.lat = self.traffic.lat + np.degrees(turblat / Rearth) + self.traffic.lon = self.traffic.lon + np.degrees(turblon / Rearth / self.traffic.coslat) diff --git a/minisky/traffic/uncertainty.py b/minisky/traffic/uncertainty.py index f7660d4..7f8c8d7 100644 --- a/minisky/traffic/uncertainty.py +++ b/minisky/traffic/uncertainty.py @@ -7,19 +7,27 @@ the NOISE stack command (see Traffic.setnoise()). """ +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING + import numpy as np -import minisky from minisky.core.trafficarrays import TrafficArrays from minisky.tools.aero import ft +if TYPE_CHECKING: + from minisky.simulation import Simulation + from minisky.traffic import Traffic + class SurveillanceUncertainty(TrafficArrays): """ADS-B model. Implements real-life limitations of ADS-B communication. Keeps a noisy, periodically refreshed copy of the true aircraft state, representing what surveillance-based systems would observe. Available - at runtime as ``minisky.traf.noise``. + at runtime as `minisky.traf.noise`. Attributes: lastupdate (ndarray): Simulation time of the last broadcast per @@ -39,8 +47,10 @@ class SurveillanceUncertainty(TrafficArrays): trunctime (float): Minimum time between broadcast updates [s]. """ - def __init__(self) -> None: - super().__init__() + def __init__(self, traffic: Traffic, get_simulation: Callable[[], Simulation]) -> None: + super().__init__(traffic) + self.traffic = traffic + self._get_simulation = get_simulation # From here, define object arrays with self.settrafarrays(): # Most recent broadcast data @@ -55,6 +65,10 @@ def __init__(self) -> None: self.setnoise(False) + def new_implementation(self, implementation: type[TrafficArrays]) -> TrafficArrays: + """Construct a replacement with this runtime's traffic and simulation.""" + return implementation(self.traffic, self._get_simulation) + def setnoise(self, n: bool) -> None: """Switch surveillance noise on or off (part of the NOISE command). @@ -83,12 +97,12 @@ def create(self, n: int = 1) -> None: super().create(n) self.lastupdate[-n:] = -self.trunctime * np.random.rand(n) - self.lat[-n:] = minisky.traf.lat[-n:] - self.lon[-n:] = minisky.traf.lon[-n:] - self.alt[-n:] = minisky.traf.alt[-n:] - self.trk[-n:] = minisky.traf.trk[-n:] - self.tas[-n:] = minisky.traf.tas[-n:] - self.gs[-n:] = minisky.traf.gs[-n:] + self.lat[-n:] = self.traffic.lat[-n:] + self.lon[-n:] = self.traffic.lon[-n:] + self.alt[-n:] = self.traffic.alt[-n:] + self.trk[-n:] = self.traffic.trk[-n:] + self.tas[-n:] = self.traffic.tas[-n:] + self.gs[-n:] = self.traffic.gs[-n:] def update(self) -> None: """Refresh the broadcast state of aircraft that are due an update. @@ -98,18 +112,18 @@ def update(self) -> None: altitude are copied from the true state, with Gaussian transmission noise added when enabled; track and speeds are copied unmodified. """ - up = np.where(self.lastupdate + self.trunctime < minisky.sim.simt) + up = np.where(self.lastupdate + self.trunctime < self._get_simulation().simt) nup = len(up[0]) if self.transnoise: - self.lat[up] = minisky.traf.lat[up] + np.random.normal(0, self.transerror[0], nup) - self.lon[up] = minisky.traf.lon[up] + np.random.normal(0, self.transerror[0], nup) - self.alt[up] = minisky.traf.alt[up] + np.random.normal(0, self.transerror[1], nup) + self.lat[up] = self.traffic.lat[up] + np.random.normal(0, self.transerror[0], nup) + self.lon[up] = self.traffic.lon[up] + np.random.normal(0, self.transerror[0], nup) + self.alt[up] = self.traffic.alt[up] + np.random.normal(0, self.transerror[1], nup) else: - self.lat[up] = minisky.traf.lat[up] - self.lon[up] = minisky.traf.lon[up] - self.alt[up] = minisky.traf.alt[up] - self.trk[up] = minisky.traf.trk[up] - self.tas[up] = minisky.traf.tas[up] - self.gs[up] = minisky.traf.gs[up] - self.vs[up] = minisky.traf.vs[up] + self.lat[up] = self.traffic.lat[up] + self.lon[up] = self.traffic.lon[up] + self.alt[up] = self.traffic.alt[up] + self.trk[up] = self.traffic.trk[up] + self.tas[up] = self.traffic.tas[up] + self.gs[up] = self.traffic.gs[up] + self.vs[up] = self.traffic.vs[up] self.lastupdate[up] = self.lastupdate[up] + self.trunctime diff --git a/tests/integration/test_traffic.py b/tests/integration/test_traffic.py index 2d0c47c..d160a5c 100644 --- a/tests/integration/test_traffic.py +++ b/tests/integration/test_traffic.py @@ -153,7 +153,6 @@ def test_atspd_seeds_condition_with_cas(self, bs, sim): ncond = bs.traf.cond.ncond bs.traf.cond.update() assert bs.traf.cond.ncond == ncond - bs.traf.cond.__init__() # drop pending conditions (not cleared by reset) def test_renameac_updates_pending_conditions(self, bs, sim): bs.traf.cre("KL001", alt=10000 * FT, spd=150) @@ -164,7 +163,6 @@ def test_renameac_updates_pending_conditions(self, bs, sim): # Unknown callsign takes the early-return path without errors bs.traf.cond.renameac("MISSING", "XX123") assert "XX123" not in bs.traf.cond.id - bs.traf.cond.__init__() # drop pending conditions (not cleared by reset) class TestWind: @@ -243,7 +241,7 @@ class TestTrails: def test_fresh_trails_object_has_background_buffers(self, bs, sim): from minisky.traffic.trails import Trails - trails = Trails() + trails = Trails(bs.traf, lambda: bs.sim) try: assert trails.bgacid == [] # used to exist only after clearbg() assert not hasattr(trails, "pygame") From 6776c2f6c4b83b5040cefb06ee9db56db2f3bec6 Mon Sep 17 00:00:00 2001 From: Abraham Cheung <58929011+abc8747@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:36:18 +0800 Subject: [PATCH 08/16] refactor: make `Autopilot` own traffic/sim refs, `Route` own traffic/nav refs - make `OpenAP` own its traffic reference - make `Position` require explicit nav and traffic objects --- minisky/runtime.py | 1 + minisky/stack/__init__.py | 28 +- minisky/stack/argparser.py | 8 +- minisky/stack/commands.py | 21 +- minisky/tools/position.py | 53 ++- minisky/traffic/asas/resolution.py | 2 +- minisky/traffic/autopilot.py | 499 ++++++++++++---------- minisky/traffic/performance/perfoap.py | 55 ++- minisky/traffic/route.py | 318 +++++++------- minisky/traffic/traffic.py | 11 +- tests/integration/test_route_autopilot.py | 6 +- 11 files changed, 546 insertions(+), 456 deletions(-) diff --git a/minisky/runtime.py b/minisky/runtime.py index d54502b..d0f6139 100644 --- a/minisky/runtime.py +++ b/minisky/runtime.py @@ -31,6 +31,7 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No console=self.console, get_simulation=lambda: self.simulation, stack_command=lambda *args, **kwargs: self.commands.stack(*args, **kwargs), + get_command_registry=lambda: self.commands.cmddict, select_implementation=lambda base, impl: self.commands.select_implementation( base, impl ), diff --git a/minisky/stack/__init__.py b/minisky/stack/__init__.py index e834f8b..1319246 100644 --- a/minisky/stack/__init__.py +++ b/minisky/stack/__init__.py @@ -27,6 +27,7 @@ import os import traceback from collections.abc import Callable, Iterator +from functools import partial from io import StringIO from pathlib import Path from typing import TYPE_CHECKING, Any @@ -187,6 +188,7 @@ def callback(self): @callback.setter def callback(self, function): self._callback = function + self._callback_source = function.func if isinstance(function, partial) else function try: # eval_str resolves stringified hints (from __future__ import annotations) # to the actual objects, so Annotated aliases are recognised either way @@ -199,14 +201,14 @@ def callback(self, function): if self.valid: # Store implementation origin if this is a bound (class or object) method - if not self.impl and inspect.ismethod(function): - if inspect.isclass(function.__self__): - self.impl = function.__self__.__name__ + if not self.impl and inspect.ismethod(self._callback_source): + if inspect.isclass(self._callback_source.__self__): + self.impl = self._callback_source.__self__.__name__ else: - self.impl = function.__self__.__class__.__name__ + self.impl = self._callback_source.__self__.__class__.__name__ self.brief = self.brief or (self.name + " " + ",".join(spec.parameters)) - self.help = self.help or inspect.cleandoc(inspect.getdoc(function) or "") + self.help = self.help or inspect.cleandoc(inspect.getdoc(self._callback_source) or "") paramspecs = list(filter(Parameter.canwrap, spec.parameters.values())) if self.arguments: self.params = [] @@ -231,7 +233,7 @@ def callback(self, function): ): raise IndexError( f"More arguments given than function " - f"{self.callback.__name__} has arguments." + f"{self._callback_source.__name__} has arguments." ) else: self.params = [ @@ -245,17 +247,17 @@ def helptext(self, subcmd: str = "") -> str: msg = f"{self.help}\nUsage:\n{self.brief}" if self.aliases: msg += "\nCommand aliases: " + ",".join(self.aliases) - if self._callback.__name__ == "": + if self._callback_source.__name__ == "": msg += "\nAnonymous (lambda) function, implemented in " else: - msg += f"\nFunction {self._callback.__name__}(), implemented in " - if hasattr(self._callback, "__code__"): - fname = self._callback.__code__.co_filename + msg += f"\nFunction {self._callback_source.__name__}(), implemented in " + if hasattr(self._callback_source, "__code__"): + fname = self._callback_source.__code__.co_filename fname_stripped = fname.replace(os.getcwd(), "").lstrip("/") - firstline = self._callback.__code__.co_firstlineno + firstline = self._callback_source.__code__.co_firstlineno msg += f"{fname_stripped} on line {firstline}" else: - msg += f"module {self._callback.__module__}" + msg += f"module {self._callback_source.__module__}" return msg @@ -740,7 +742,7 @@ def showhelp(self, cmd: Txt = "", subcmd: Txt = "") -> tuple[bool, str]: # Get info for all commands for obj in cmdobjs: - funcname = obj.callback.__name__.replace("<", "").replace(">", "") + funcname = obj._callback_source.__name__.replace("<", "").replace(">", "") args = ",".join(str(p) for p in obj.params) syn = ",".join(obj.aliases) line = f"{obj.name}\t{obj.help}\t{obj.brief}\t{args}\t{funcname}\t{syn}" diff --git a/minisky/stack/argparser.py b/minisky/stack/argparser.py index 1081932..f818e8b 100644 --- a/minisky/stack/argparser.py +++ b/minisky/stack/argparser.py @@ -377,7 +377,13 @@ def parse(self, argstring: str) -> tuple: if refdata.lat is None: refdata.lat, refdata.lon = self.argument_parser.console.getviewctr() - posobj = Position(argu, refdata.lat, refdata.lon) + posobj = Position( + argu, + refdata.lat, + refdata.lon, + self.argument_parser.navigation, + traffic, + ) if posobj.error: raise ArgumentError(f"{argu} is not a valid waypoint, airport, runway, or aircraft id.") diff --git a/minisky/stack/commands.py b/minisky/stack/commands.py index 842132e..f811298 100644 --- a/minisky/stack/commands.py +++ b/minisky/stack/commands.py @@ -53,6 +53,7 @@ from __future__ import annotations +from functools import partial from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -74,19 +75,19 @@ def get_commands(command_stack: CommandStack) -> tuple: cmddict = { "ADDWPT": [ - route.addwpt, + partial(route.addwpt, command_stack.traffic), "callsign,wpt,[alt,spd,wpt,wpt]", "ADDWPT callsign, wpt, [alt, spd, wpt, wpt]", "Add a waypoint to the route.", ], "ADDWPTMODE": [ - route.change_wpt_mode, + partial(route.change_wpt_mode, command_stack.traffic), "callsign, [wpt,alt]", "ADDWPTMODE callsign, [wpt,alt]", "Changes the mode of the ADDWPT command to add waypoints of type 'mode'.", ], "AFTER": [ - route.addwpt_after, + partial(route.addwpt_after, command_stack.traffic), "callsign,wpt,txt,wpt,[alt,spd]", "AFTER callsign, wpt, addwpt, waypoint, [alt, spd]", "Add a waypoint after another waypoint in the route.", @@ -104,7 +105,7 @@ def get_commands(command_stack: CommandStack) -> tuple: "Select a Conflict Detection method.", ], "AT": [ - route.at_wpt, + partial(route.at_wpt, command_stack.traffic), "callsign,wpt,[txt,...]", "AT callsign, wpt, [DEL] ALT/SPD/DO alt/spd/stack command", "Set or show altitude and/or speed constraints at a waypoint.", @@ -134,7 +135,7 @@ def get_commands(command_stack: CommandStack) -> tuple: "Set or show bank limit for this vehicle", ], "BEFORE": [ - route.addwpt_before, + partial(route.addwpt_before, command_stack.traffic), "callsign,wpt,txt,wpt,[alt,spd]", "BEFORE callsign, wpt, addwpt, waypoint, [alt, spd]", "Add a waypoint before another waypoint in the route.", @@ -208,13 +209,13 @@ def get_commands(command_stack: CommandStack) -> tuple: "Delay a stack command until a specific simulation time.", ], "DELRTE": [ - route.delrte, + partial(route.delrte, command_stack.traffic), "callsign", "DELRTE callsign", "Delete the complete route for an aircraft.", ], "DELWPT": [ - route.delwpt, + partial(route.delwpt, command_stack.traffic), "callsign,wpt", "DELWPT callsign,wpt", "Delete a waypoint from a route.", @@ -226,7 +227,7 @@ def get_commands(command_stack: CommandStack) -> tuple: "Set destination of aircraft, aircraft will fly to this airport.", ], "DIRECT": [ - route.direct, + partial(route.direct, command_stack.traffic), "callsign, wpt", "DIRECT callsign, wpt", "Go direct to a specified waypoint in the route.", @@ -301,7 +302,7 @@ def get_commands(command_stack: CommandStack) -> tuple: "Draw a line on the radar screen", ], "LISTRTE": [ - route.listrte, + partial(route.listrte, command_stack.traffic), "callsign,[txt]", "LISTRTE callsign, [pagenr]", "Show list of route in window per page of 5 waypoints.", @@ -457,7 +458,7 @@ def get_commands(command_stack: CommandStack) -> tuple: "Set resolution factor vertical.", ], "RTA": [ - route.set_rta, + partial(route.set_rta, command_stack.traffic), "callsign, wpt, time", "RTA callsign, wpt, time", "Add RTA to waypoint record.", diff --git a/minisky/tools/position.py b/minisky/tools/position.py index 065a26a..7231033 100644 --- a/minisky/tools/position.py +++ b/minisky/tools/position.py @@ -6,12 +6,24 @@ latitude/longitude coordinates [deg] via the Position class. """ -import minisky +from __future__ import annotations + +from typing import TYPE_CHECKING from .convert import txt2lat, txt2lon +if TYPE_CHECKING: + from minisky.tools.navdata import Navdatabase + from minisky.traffic import Traffic + -def txt2pos(name: str, reflat: float, reflon: float) -> "tuple[bool, Position | str]": +def txt2pos( + name: str, + reflat: float, + reflon: float, + navigation: Navdatabase, + traffic: Traffic, +) -> tuple[bool, Position | str]: """Parse a position text into a Position object. Args: @@ -23,7 +35,7 @@ def txt2pos(name: str, reflat: float, reflon: float) -> "tuple[bool, Position | Returns: tuple: (True, Position) on success, or (False, error message). """ - pos = Position(name.upper().strip(), reflat, reflon) + pos = Position(name.upper().strip(), reflat, reflon, navigation, traffic) if not pos.error: return True, pos return False, name + " not found in database" @@ -86,7 +98,14 @@ class Position: # position types: "latlon","nav","apt","rwy" # Initialize using text - def __init__(self, name: str, reflat: float, reflon: float) -> None: + def __init__( + self, + name: str, + reflat: float, + reflon: float, + navigation: Navdatabase, + traffic: Traffic, + ) -> None: """Resolve a position text relative to a reference position. Args: @@ -112,33 +131,33 @@ def __init__(self, name: str, reflat: float, reflon: float) -> None: try: aptname, rwytxt = name.split("/RW") rwyname = rwytxt.lstrip("Y").upper() # remove Y and spaces - self.lat, self.lon, self.refhdg = minisky.navdb.rwythresholds[aptname][rwyname] + self.lat, self.lon, self.refhdg = navigation.rwythresholds[aptname][rwyname] except KeyError: self.error = True self.type = "rwy" # airport? - elif minisky.navdb.aptid.count(name) > 0: - idx = minisky.navdb.aptid.index(name.upper()) + elif navigation.aptid.count(name) > 0: + idx = navigation.aptid.index(name.upper()) - self.lat = minisky.navdb.aptlat[idx] - self.lon = minisky.navdb.aptlon[idx] + self.lat = navigation.aptlat[idx] + self.lon = navigation.aptlon[idx] self.type = "apt" # fix or navaid? - elif minisky.navdb.wpid.count(name) > 0: - idx = minisky.navdb.getwpidx(name, reflat, reflon) - self.lat = minisky.navdb.wplat[idx] - self.lon = minisky.navdb.wplon[idx] + elif navigation.wpid.count(name) > 0: + idx = navigation.getwpidx(name, reflat, reflon) + self.lat = navigation.wplat[idx] + self.lon = navigation.wplon[idx] self.type = "nav" # aircraft id? - elif name in minisky.traf.callsign: - idx = minisky.traf.idx(name) + elif name in traffic.callsign: + idx = traffic.idx(name) self.name = "" self.type = "latlon" - self.lat = minisky.traf.lat[idx] - self.lon = minisky.traf.lon[idx] + self.lat = traffic.lat[idx] + self.lon = traffic.lon[idx] # exception for pan, check for LEFT, RIGHT, ABOVE or DOWN elif name.upper() in ["LEFT", "RIGHT", "ABOVE", "DOWN"]: diff --git a/minisky/traffic/asas/resolution.py b/minisky/traffic/asas/resolution.py index 07a2b2a..be18988 100644 --- a/minisky/traffic/asas/resolution.py +++ b/minisky/traffic/asas/resolution.py @@ -317,7 +317,7 @@ def anglediff(a: float, b: float) -> float: # and send the aircraft to that waypoint. iwpid = self.traffic.ap.route[idx].findact(idx) if iwpid != -1: # To avoid problems if there are no waypoints - route.direct(idx, self.traffic.ap.route[idx].wpname[iwpid]) + route.direct(self.traffic, idx, self.traffic.ap.route[idx].wpname[iwpid]) # Remove pairs from the list that are past CPA or have deleted aircraft self.resopairs -= delpairs diff --git a/minisky/traffic/autopilot.py b/minisky/traffic/autopilot.py index 433f1f1..da9b3c1 100644 --- a/minisky/traffic/autopilot.py +++ b/minisky/traffic/autopilot.py @@ -1,6 +1,6 @@ """Autopilot Implementation. -Contains the :class:`Autopilot` class, which combines classic autopilot +Contains the [`Autopilot`][minisky.traffic.autopilot.Autopilot] class, which combines classic autopilot modes (selected heading, altitude, vertical speed and speed) with FMS guidance along the aircraft route: LNAV (lateral navigation towards the active waypoint, including fly-by/fly-over/fly-turn logic) and VNAV @@ -9,18 +9,19 @@ The autopilot output (commanded track, speed, altitude and vertical speed) is combined with conflict-resolution commands in -:class:`~minisky.traffic.aporasas.APorASAS` before being flown by -:class:`~minisky.traffic.traffic.Traffic`. Many methods implement stack +[`APorASAS`][minisky.traffic.aporasas.APorASAS] before being flown by +[`Traffic`][minisky.traffic.traffic.Traffic]. Many methods implement stack commands (ALT, VS, HDG, SPD, DEST, ORIG, LNAV, VNAV, SWTOC, SWTOD). """ -from collections.abc import Collection +from __future__ import annotations + +from collections.abc import Callable, Collection from math import sqrt -from typing import Any +from typing import TYPE_CHECKING, Any import numpy as np -import minisky from minisky.core.trafficarrays import TrafficArrays from minisky.stack.argparser import Acid, Alt, Hdg, OnOff, Spd, Vspd, Wpt from minisky.tools import geo @@ -37,7 +38,11 @@ from minisky.tools.convert import degto180 from minisky.tools.position import Position, txt2pos -from .route import Route +from .route import Route, direct + +if TYPE_CHECKING: + from minisky.simulation import Simulation + from minisky.traffic import Traffic class Autopilot(TrafficArrays): @@ -45,10 +50,10 @@ class Autopilot(TrafficArrays): Computes, per aircraft, the commanded track, altitude, vertical speed and speed from the selected (pilot) values and, when LNAV/VNAV are - engaged, from the route stored in the per-aircraft :class:`Route` + engaged, from the route stored in the per-aircraft [`Route`][minisky.traffic.route.Route] objects. Waypoint switching is event driven (see wppassingcheck()), while the continuous guidance in update() is fully vectorized over all - aircraft. Accessible at runtime as ``minisky.traf.ap``. + aircraft. Accessible at runtime as `minisky.traf.ap`. Attributes: trk (ndarray): Commanded track angle [deg]. @@ -72,15 +77,18 @@ class Autopilot(TrafficArrays): bankdef (ndarray): Default bank angle limit [rad]. vsdef (ndarray): Default vertical speed [m/s]. turnphi (ndarray): Bank angle used in the current turn [rad]. - route (list): Per-aircraft :class:`Route` (flight plan) objects. + route (list): Per-aircraft [`Route`][minisky.traffic.route.Route] (flight plan) objects. steepness (float): Default climb/descent gradient [-] (3000 ft per 10 nm). idxreached (list): Indices of aircraft that reached their active waypoint during the last update. """ - def __init__(self) -> None: + def __init__(self, traffic: Traffic, get_simulation: Callable[[], Simulation]) -> None: super().__init__() + self.traffic = traffic + self.navigation = traffic.navigation + self._get_simulation = get_simulation # Standard descent steepness self.steepness = 3000.0 * ft / (10.0 * nm) @@ -144,6 +152,15 @@ def __init__(self) -> None: self.idxreached = [] # Indices of aircraft that have reached their active waypoint + @property + def simulation(self) -> Simulation: + """Return the simulation that owns this autopilot.""" + return self._get_simulation() + + def new_implementation(self, implementation: type[TrafficArrays]) -> TrafficArrays: + """Construct a replacement with this runtime's dependencies.""" + return implementation(self.traffic, self._get_simulation) + def create(self, n: int = 1) -> None: """Initialize autopilot state for n newly created aircraft. @@ -158,9 +175,9 @@ def create(self, n: int = 1) -> None: super().create(n) # FMS directions - self.trk[-n:] = minisky.traf.trk[-n:] - self.tas[-n:] = minisky.traf.tas[-n:] - self.alt[-n:] = minisky.traf.alt[-n:] + self.trk[-n:] = self.traffic.trk[-n:] + self.tas[-n:] = self.traffic.tas[-n:] + self.alt[-n:] = self.traffic.alt[-n:] self.vs[-n:] = -999 # Default ToC/ToD logic on @@ -186,8 +203,8 @@ def create(self, n: int = 1) -> None: self.bankdef[-n:] = np.radians(25.0) # Route objects - for ridx, acid in enumerate(minisky.traf.callsign[-n:]): - self.route[ridx - n] = Route(acid) + for ridx, acid in enumerate(self.traffic.callsign[-n:]): + self.route[ridx - n] = Route(self.traffic, acid) def wppassingcheck(self, qdr: Any, dist: Any) -> None: """ @@ -215,17 +232,17 @@ def wppassingcheck(self, qdr: Any, dist: Any) -> None: # Get list of indices of aircraft which have reached their active waypoint # This vectorized function checks the passing of the waypoint using the current turn radius - self.idxreached = minisky.traf.actwp.reached( + self.idxreached = self.traffic.actwp.reached( qdr, dist, - minisky.traf.actwp.flyby, - minisky.traf.actwp.flyturn, - minisky.traf.actwp.turnrad, - minisky.traf.actwp.turnhdgr, - minisky.traf.actwp.swlastwp, + self.traffic.actwp.flyby, + self.traffic.actwp.flyturn, + self.traffic.actwp.turnrad, + self.traffic.actwp.turnhdgr, + self.traffic.actwp.swlastwp, ) - actwp = minisky.traf.actwp + actwp = self.traffic.actwp # Save current waypoint speed for use on next leg when we pass this waypoint # VNAV speeds are always FROM-speeds, so we accelerate/decelerate at the waypoint @@ -260,9 +277,9 @@ def wppassingcheck(self, qdr: Any, dist: Any) -> None: # In case of end of route/no more waypoints: switch off LNAV/VNAV if idxlast: last = np.array(idxlast) - minisky.traf.swlnav[last] = False - minisky.traf.swvnav[last] = False - minisky.traf.swvnavspd[last] = False + self.traffic.swlnav[last] = False + self.traffic.swvnav[last] = False + self.traffic.swvnavspd[last] = False # Vectorized leg data update for guidance, over the aircraft that # switched to a new waypoint @@ -320,7 +337,7 @@ def wppassingcheck(self, qdr: Any, dist: Any) -> None: actwp.nextturnhdgr[nxt] = nextturnhdgr actwp.nextturnidx[nxt] = nextturnidx - tas = minisky.traf.tas[nxt] + tas = self.traffic.tas[nxt] # Special turns: specified by turn radius or bank angle # If no turn speed specified, use current speed @@ -343,14 +360,14 @@ def wppassingcheck(self, qdr: Any, dist: Any) -> None: # Check LNAV switch returned by getnextwp # Switch off LNAV if it failed to get next waypoint data - lnavoff = ~lnavon & minisky.traf.swlnav[nxt] + lnavoff = ~lnavon & self.traffic.swlnav[nxt] # Last waypoint: copy last waypoint values for altitude and speed in autopilot - uselastspd = lnavoff & minisky.traf.swvnavspd[nxt] & (nextspd >= 0.0) - minisky.traf.selspd[nxt] = np.where(uselastspd, nextspd, minisky.traf.selspd[nxt]) - minisky.traf.swlnav[nxt] = minisky.traf.swlnav[nxt] & lnavon + uselastspd = lnavoff & self.traffic.swvnavspd[nxt] & (nextspd >= 0.0) + self.traffic.selspd[nxt] = np.where(uselastspd, nextspd, self.traffic.selspd[nxt]) + self.traffic.swlnav[nxt] = self.traffic.swlnav[nxt] & lnavon # In case of no LNAV, do not allow VNAV mode to be active - minisky.traf.swvnav[nxt] = minisky.traf.swvnav[nxt] & minisky.traf.swlnav[nxt] + self.traffic.swvnav[nxt] = self.traffic.swvnav[nxt] & self.traffic.swlnav[nxt] actwp.lat[nxt] = lat # [deg] actwp.lon[nxt] = lon # [deg] @@ -358,7 +375,7 @@ def wppassingcheck(self, qdr: Any, dist: Any) -> None: actwp.flyby[nxt] = flyby # Update qdr and turn distance for this new waypoint for ComputeVNAV - qdrnxt, distnmi = geo.qdrdist(minisky.traf.lat[nxt], minisky.traf.lon[nxt], lat, lon) + qdrnxt, distnmi = geo.qdrdist(self.traffic.lat[nxt], self.traffic.lon[nxt], lat, lon) qdr[nxt] = qdrnxt self.dist2wp[nxt] = distnmi * nm @@ -374,8 +391,8 @@ def wppassingcheck(self, qdr: Any, dist: Any) -> None: # VNAV speed mode: use speed of this waypoint as commanded speed # while passing waypoint and save next speed for passing next waypoint # Speed is now from speed! Next speed is ready in waypoint data - usewpspd = minisky.traf.swvnavspd[nxt] & (actwp.spd[nxt] >= 0.0) - minisky.traf.selspd[nxt] = np.where(usewpspd, actwp.spd[nxt], minisky.traf.selspd[nxt]) + usewpspd = self.traffic.swvnavspd[nxt] & (actwp.spd[nxt] >= 0.0) + self.traffic.selspd[nxt] = np.where(usewpspd, actwp.spd[nxt], self.traffic.selspd[nxt]) # Update turn distance so ComputeVNAV works, is there a next leg direction or not? local_next_qdr = np.where(next_qdr < -900.0, qdrnxt, next_qdr) @@ -402,7 +419,7 @@ def wppassingcheck(self, qdr: Any, dist: Any) -> None: # Reduce turn distance for reduced turn speed redturn = flyturn & (turnrad < 0.0) & (actwp.turnspd[nxt] >= 0.0) - turntas = vcas2tas(np.where(redturn, actwp.turnspd[nxt], 0.0), minisky.traf.alt[nxt]) + turntas = vcas2tas(np.where(redturn, actwp.turnspd[nxt], 0.0), self.traffic.alt[nxt]) actwp.turndist[nxt] = actwp.turndist[nxt] * np.where( redturn, turntas * turntas / (tas * tas), 1.0 ) @@ -419,7 +436,7 @@ def wppassingcheck(self, qdr: Any, dist: Any) -> None: # Continuous guidance when speed constraint on active leg is in update-method # If still an RTA in the route and currently no speed constraint - for iac in np.where((minisky.traf.actwp.torta > -99.0) * (minisky.traf.actwp.spdcon < 0.0))[ + for iac in np.where((self.traffic.actwp.torta > -99.0) * (self.traffic.actwp.spdcon < 0.0))[ 0 ]: iac = int(iac) @@ -428,21 +445,21 @@ def wppassingcheck(self, qdr: Any, dist: Any) -> None: # For all aircraft flying to an RTA waypoint, recalculate speed more often dist2go4rta = ( geo.kwikdist( - minisky.traf.lat[iac], - minisky.traf.lon[iac], - minisky.traf.actwp.lat[iac], - minisky.traf.actwp.lon[iac], + self.traffic.lat[iac], + self.traffic.lon[iac], + self.traffic.actwp.lat[iac], + self.traffic.actwp.lon[iac], ) * nm + self.route[iac].wpxtorta[iwp] ) # last term zero for active waypoint RTA # Set minisky.traf.actwp.spd to RTA speed, if necessary - self.setspeedforRTA(iac, minisky.traf.actwp.torta[iac], dist2go4rta) + self.setspeedforRTA(iac, self.traffic.actwp.torta[iac], dist2go4rta) # If VNAV speed is on (by default coupled to VNAV), use it for speed guidance - if minisky.traf.swvnavspd[iac] and minisky.traf.actwp.spd[iac] >= 0.0: - minisky.traf.selspd[iac] = minisky.traf.actwp.spd[iac] + if self.traffic.swvnavspd[iac] and self.traffic.actwp.spd[iac] >= 0.0: + self.traffic.selspd[iac] = self.traffic.actwp.spd[iac] def update(self) -> None: """Run the continuous FMS/autopilot guidance for all aircraft. @@ -465,10 +482,10 @@ def update(self) -> None: # FMS LNAV mode: # qdr[deg],distinnm[nm] qdr, distinnm = geo.qdrdist( - minisky.traf.lat, - minisky.traf.lon, - minisky.traf.actwp.lat, - minisky.traf.actwp.lon, + self.traffic.lat, + self.traffic.lon, + self.traffic.actwp.lat, + self.traffic.actwp.lon, ) # [deg][nm]) self.qdr2wp = np.asarray(qdr) @@ -494,13 +511,13 @@ def update(self) -> None: # But when Top of Climb switch is on or off, climb as soon as possible, only difference is steepness used in ComputeVNAV # to calculate minisky.traf.actwp.vs - startdescorclimb = (minisky.traf.actwp.nextaltco >= -0.1) * np.logical_or( - (minisky.traf.alt > minisky.traf.actwp.nextaltco) + startdescorclimb = (self.traffic.actwp.nextaltco >= -0.1) * np.logical_or( + (self.traffic.alt > self.traffic.actwp.nextaltco) * np.logical_or( - (self.dist2wp < self.dist2vs + minisky.traf.actwp.turndist), + (self.dist2wp < self.dist2vs + self.traffic.actwp.turndist), (np.logical_not(self.swtod)), ), - minisky.traf.alt < minisky.traf.actwp.nextaltco, + self.traffic.alt < self.traffic.actwp.nextaltco, ) # print("self.dist2vs =",self.dist2vs) @@ -510,10 +527,10 @@ def update(self) -> None: # to continue descending when you get into a conflict # while descending to the destination (the last waypoint) # Use 0.1 nm (185.2 m) circle in case turn distance might be zero - self.swvnavvs = minisky.traf.swvnav * np.where( - minisky.traf.swlnav, + self.swvnavvs = self.traffic.swvnav * np.where( + self.traffic.swlnav, startdescorclimb, - self.dist2wp <= np.maximum(0.1 * nm, minisky.traf.actwp.turndist), + self.dist2wp <= np.maximum(0.1 * nm, self.traffic.actwp.turndist), ) # Recalculate V/S based on current altitude and distance to next altitude constraint @@ -521,22 +538,22 @@ def update(self) -> None: # Now done in ComputeVNAV # See ComputeVNAV for minisky.traf.actwp.vs calculation - self.vnavvs = np.where(self.swvnavvs, minisky.traf.actwp.vs, self.vnavvs) + self.vnavvs = np.where(self.swvnavvs, self.traffic.actwp.vs, self.vnavvs) # was: self.vnavvs = np.where(self.swvnavvs, self.steepness * minisky.traf.gs, self.vnavvs) # self.vs = np.where(self.swvnavvs, self.vnavvs, self.vsdef * minisky.traf.limvs_flag) # for VNAV use fixed V/S and change start of descent - selvs = np.where(abs(minisky.traf.selvs) > 0.1, minisky.traf.selvs, self.vsdef) # m/s + selvs = np.where(abs(self.traffic.selvs) > 0.1, self.traffic.selvs, self.vsdef) # m/s self.vs = np.where(self.swvnavvs, self.vnavvs, selvs) - self.alt = np.where(self.swvnavvs, minisky.traf.actwp.nextaltco, minisky.traf.selalt) + self.alt = np.where(self.swvnavvs, self.traffic.actwp.nextaltco, self.traffic.selalt) # When descending or climbing in VNAV also update altitude command of select/hold mode - minisky.traf.selalt = np.where( - self.swvnavvs, minisky.traf.actwp.nextaltco, minisky.traf.selalt + self.traffic.selalt = np.where( + self.swvnavvs, self.traffic.actwp.nextaltco, self.traffic.selalt ) # LNAV commanded track angle - self.trk = np.where(minisky.traf.swlnav, self.qdr2wp, self.trk) + self.trk = np.where(self.traffic.swlnav, self.qdr2wp, self.trk) # FMS speed guidance: anticipate accel/decel distance for next leg or turn @@ -547,31 +564,31 @@ def update(self) -> None: # Is turn speed specified and are we not already slow enough? We only decelerate for turns, not accel. turntas = np.where( - minisky.traf.actwp.nextturnspd > 0.0, - vcas2tas(minisky.traf.actwp.nextturnspd, minisky.traf.alt), - -1.0 + 0.0 * minisky.traf.tas, + self.traffic.actwp.nextturnspd > 0.0, + vcas2tas(self.traffic.actwp.nextturnspd, self.traffic.alt), + -1.0 + 0.0 * self.traffic.tas, ) # Switch is now whether the aircraft has any turn waypoints - swturnspd = minisky.traf.actwp.nextturnidx > 0 - np.maximum(0.0, (minisky.traf.tas - turntas) * (turntas > 0.0)) + swturnspd = self.traffic.actwp.nextturnidx > 0 + np.maximum(0.0, (self.traffic.tas - turntas) * (turntas > 0.0)) # t = (v1-v0)/a ; x = v0*t+1/2*a*t*t => dx = (v1*v1-v0*v0)/ (2a) - dxturnspdchg = distaccel(turntas, minisky.traf.tas, minisky.traf.perf.axmax) + dxturnspdchg = distaccel(turntas, self.traffic.tas, self.traffic.perf.axmax) # Decelerate or accelerate for next required speed because of speed constraint or RTA speed # Note that because nextspd comes from the stack, and can be either a mach number or # a calibrated airspeed, it can only be converted from Mach / CAS [kts] to TAS [m/s] # once the altitude is known. - nexttas = vcasormach2tas(minisky.traf.actwp.nextspd, minisky.traf.alt) + nexttas = vcasormach2tas(self.traffic.actwp.nextspd, self.traffic.alt) # - dxspdconchg = distaccel(minisky.traf.tas, nexttas, minisky.traf.perf.axmax) + dxspdconchg = distaccel(self.traffic.tas, nexttas, self.traffic.perf.axmax) qdrturn, dist2turn = geo.qdrdist( - minisky.traf.lat, - minisky.traf.lon, - minisky.traf.actwp.nextturnlat, - minisky.traf.actwp.nextturnlon, + self.traffic.lat, + self.traffic.lon, + self.traffic.actwp.nextturnlat, + self.traffic.actwp.nextturnlon, ) self.qdrturn = qdrturn @@ -579,81 +596,81 @@ def update(self) -> None: # Where we don't have a turn waypoint, as in turn idx is negative, then put distance # as Earth circumference. - self.dist2turn = np.where(minisky.traf.actwp.nextturnidx > 0, dist2turn, 40075000) + self.dist2turn = np.where(self.traffic.actwp.nextturnidx > 0, dist2turn, 40075000) # Check also whether VNAVSPD is on, if not, SPD SEL has override for next leg # and same for turn logic usenextspdcon = ( (self.dist2wp < dxspdconchg) - * (minisky.traf.actwp.nextspd > -990.0) - * minisky.traf.swvnavspd - * minisky.traf.swvnav - * minisky.traf.swlnav + * (self.traffic.actwp.nextspd > -990.0) + * self.traffic.swvnavspd + * self.traffic.swvnav + * self.traffic.swlnav ) useturnspd = ( np.logical_or( - minisky.traf.actwp.turntonextwp, - (self.dist2turn < (dxturnspdchg + minisky.traf.actwp.turndist)), + self.traffic.actwp.turntonextwp, + (self.dist2turn < (dxturnspdchg + self.traffic.actwp.turndist)), ) * swturnspd - * minisky.traf.swvnavspd - * minisky.traf.swvnav - * minisky.traf.swlnav + * self.traffic.swvnavspd + * self.traffic.swvnav + * self.traffic.swlnav ) # Hold turn mode can only be switched on here, cannot be switched off here (happeps upon passing wp) - minisky.traf.actwp.turntonextwp = minisky.traf.swlnav * np.logical_or( - minisky.traf.actwp.turntonextwp, useturnspd + self.traffic.actwp.turntonextwp = self.traffic.swlnav * np.logical_or( + self.traffic.actwp.turntonextwp, useturnspd ) # Which CAS/Mach do we have to keep? VNAV, last turn or next turn? - oncurrentleg = abs(degto180(minisky.traf.trk - qdr)) < 2.0 # [deg] - inoldturn = (minisky.traf.actwp.oldturnspd > 0.0) * np.logical_not(oncurrentleg) + oncurrentleg = abs(degto180(self.traffic.trk - qdr)) < 2.0 # [deg] + inoldturn = (self.traffic.actwp.oldturnspd > 0.0) * np.logical_not(oncurrentleg) # Avoid using old turning speeds when turning of this leg to the next leg # by disabling (old) turningspd when on leg - minisky.traf.actwp.oldturnspd = np.where( - oncurrentleg * (minisky.traf.actwp.oldturnspd > 0.0), + self.traffic.actwp.oldturnspd = np.where( + oncurrentleg * (self.traffic.actwp.oldturnspd > 0.0), -998.0, - minisky.traf.actwp.oldturnspd, + self.traffic.actwp.oldturnspd, ) # turnfromlastwp can only be switched off here, not on (latter happens upon passing wp) - minisky.traf.actwp.turnfromlastwp = np.logical_and( - minisky.traf.actwp.turnfromlastwp, inoldturn + self.traffic.actwp.turnfromlastwp = np.logical_and( + self.traffic.actwp.turnfromlastwp, inoldturn ) # Select speed: turn sped, next speed constraint, or current speed constraint - minisky.traf.selspd = np.where( + self.traffic.selspd = np.where( useturnspd, - minisky.traf.actwp.nextturnspd, + self.traffic.actwp.nextturnspd, np.where( usenextspdcon, - minisky.traf.actwp.nextspd, + self.traffic.actwp.nextspd, np.where( - (minisky.traf.actwp.spdcon >= 0) * minisky.traf.swvnavspd, - minisky.traf.actwp.spd, - minisky.traf.selspd, + (self.traffic.actwp.spdcon >= 0) * self.traffic.swvnavspd, + self.traffic.actwp.spd, + self.traffic.selspd, ), ), ) # Temporary override when still in old turn - minisky.traf.selspd = np.where( + self.traffic.selspd = np.where( inoldturn - * (minisky.traf.actwp.oldturnspd > 0.0) - * minisky.traf.swvnavspd - * minisky.traf.swvnav - * minisky.traf.swlnav, - minisky.traf.actwp.oldturnspd, - minisky.traf.selspd, + * (self.traffic.actwp.oldturnspd > 0.0) + * self.traffic.swvnavspd + * self.traffic.swvnav + * self.traffic.swlnav, + self.traffic.actwp.oldturnspd, + self.traffic.selspd, ) self.inturn = np.logical_or(useturnspd, inoldturn) # Below crossover altitude: CAS=const, above crossover altitude: Mach = const - self.tas = vcasormach2tas(minisky.traf.selspd, minisky.traf.alt) + self.tas = vcasormach2tas(self.traffic.selspd, self.traffic.alt) def ComputeVNAV(self, idx: int, toalt: Any, xtoalt: Any, torta: Any, xtorta: Any) -> None: """ @@ -702,7 +719,7 @@ def ComputeVNAV(self, idx: int, toalt: Any, xtoalt: Any, torta: Any, xtorta: Any self.setspeedforRTA(idx, torta, xtorta + self.dist2wp[idx]) # all scalar # Check if there is a target altitude and VNAV is on, else return doing nothing - if toalt < 0 or not minisky.traf.swvnav[idx]: + if toalt < 0 or not self.traffic.swvnav[idx]: self.dist2vs[ idx ] = -999999.0 # dist to next wp will never be less than this, so VNAV will do nothing @@ -745,20 +762,20 @@ def ComputeVNAV(self, idx: int, toalt: Any, xtoalt: Any, torta: Any, xtorta: Any # which can be many waypoints beyond current actual waypoint epsalt = 2.0 * ft # deadzone # - if minisky.traf.alt[idx] > toalt + epsalt: + if self.traffic.alt[idx] > toalt + epsalt: # Stop potential current climb (e.g. due to not making it to previous altco) # then stop immediately, as in: do not make it worse. - if minisky.traf.vs[idx] > 0.0001: + if self.traffic.vs[idx] > 0.0001: self.vnavvs[idx] = 0.0 - self.alt[idx] = minisky.traf.alt[idx] - if minisky.traf.swvnav[idx]: - minisky.traf.selalt[idx] = minisky.traf.alt[idx] + self.alt[idx] = self.traffic.alt[idx] + if self.traffic.swvnav[idx]: + self.traffic.selalt[idx] = self.traffic.alt[idx] # Descent modes: VNAV (= swtod/Top of Descent logic) or aiming at next alt constraint # Calculate max allowed altitude at next wp (above toalt) - minisky.traf.actwp.nextaltco[idx] = toalt # [m] next alt constraint - minisky.traf.actwp.xtoalt[idx] = ( + self.traffic.actwp.nextaltco[idx] = toalt # [m] next alt constraint + self.traffic.actwp.xtoalt[idx] = ( xtoalt # [m] distance to next alt constraint measured from next waypoint ) @@ -766,15 +783,15 @@ def ComputeVNAV(self, idx: int, toalt: Any, xtoalt: Any, torta: Any, xtorta: Any if self.swtod[idx]: # Get distance to waypoint self.dist2wp[idx] = nm * geo.kwikdist( - minisky.traf.lat[idx], - minisky.traf.lon[idx], - minisky.traf.actwp.lat[idx], - minisky.traf.actwp.lon[idx], + self.traffic.lat[idx], + self.traffic.lon[idx], + self.traffic.actwp.lat[idx], + self.traffic.actwp.lon[idx], ) # was not always up to date, so update first # Distance to next waypoint where we need to start descent (top of descent) [m] descdist = ( - abs(minisky.traf.alt[idx] - toalt) / self.steepness + abs(self.traffic.alt[idx] - toalt) / self.steepness ) # [m] required length for descent, uses default steepness! self.dist2vs[idx] = descdist - xtoalt # [m] part of that length on this leg @@ -784,35 +801,35 @@ def ComputeVNAV(self, idx: int, toalt: Any, xtoalt: Any, torta: Any, xtorta: Any # Exceptions: Descend now? if ( - self.dist2wp[idx] - 1.02 * minisky.traf.actwp.turndist[idx] < self.dist2vs[idx] + self.dist2wp[idx] - 1.02 * self.traffic.actwp.turndist[idx] < self.dist2vs[idx] ): # Urgent descent, we're late![m] # Descend now using whole remaining distance on leg to reach altitude - self.alt[idx] = minisky.traf.actwp.nextaltco[ + self.alt[idx] = self.traffic.actwp.nextaltco[ idx ] # dial in altitude of next waypoint as calculated - t2go = self.dist2wp[idx] / max(0.01, minisky.traf.gs[idx]) - minisky.traf.actwp.vs[idx] = (minisky.traf.alt[idx] - toalt) / max(0.01, t2go) + t2go = self.dist2wp[idx] / max(0.01, self.traffic.gs[idx]) + self.traffic.actwp.vs[idx] = (self.traffic.alt[idx] - toalt) / max(0.01, t2go) elif xtoalt < descdist: # Not on this leg, no descending is needed at next waypoint # Top of decent needs to be on this leg, as next wp is in descent - minisky.traf.actwp.vs[idx] = -abs(self.steepness) * ( - minisky.traf.gs[idx] - + (minisky.traf.gs[idx] < 0.2 * minisky.traf.tas[idx]) - * minisky.traf.tas[idx] + self.traffic.actwp.vs[idx] = -abs(self.steepness) * ( + self.traffic.gs[idx] + + (self.traffic.gs[idx] < 0.2 * self.traffic.tas[idx]) + * self.traffic.tas[idx] ) else: # else still level - minisky.traf.actwp.vs[idx] = 0.0 + self.traffic.actwp.vs[idx] = 0.0 else: # We are higher but swtod = False, so there is no ToD descent logic, simply aim at next altco - steepness_ = (minisky.traf.alt[idx] - minisky.traf.actwp.nextaltco[idx]) / ( + steepness_ = (self.traffic.alt[idx] - self.traffic.actwp.nextaltco[idx]) / ( max(0.01, self.dist2wp[idx] + xtoalt) ) - minisky.traf.actwp.vs[idx] = -abs(steepness_) * ( - minisky.traf.gs[idx] - + (minisky.traf.gs[idx] < 0.2 * minisky.traf.tas[idx]) * minisky.traf.tas[idx] + self.traffic.actwp.vs[idx] = -abs(steepness_) * ( + self.traffic.gs[idx] + + (self.traffic.gs[idx] < 0.2 * self.traffic.tas[idx]) * self.traffic.tas[idx] ) self.dist2vs[idx] = ( 99999.0 # [m] Forces immediate descent as current distance to next wp will be less @@ -821,38 +838,38 @@ def ComputeVNAV(self, idx: int, toalt: Any, xtoalt: Any, torta: Any, xtorta: Any # print("in else swtod for ", minisky.traf.id[idx]) # VNAV climb mode: climb as soon as possible (T/C logic) - elif minisky.traf.alt[idx] < toalt - 9.9 * ft: + elif self.traffic.alt[idx] < toalt - 9.9 * ft: # Stop potential current descent (e.g. due to not making it to previous altco) # then stop immediately, as in: do not make it worse. - if minisky.traf.vs[idx] < -0.0001: + if self.traffic.vs[idx] < -0.0001: self.vnavvs[idx] = 0.0 - self.alt[idx] = minisky.traf.alt[idx] - if minisky.traf.swvnav[idx]: - minisky.traf.selalt[idx] = minisky.traf.alt[idx] + self.alt[idx] = self.traffic.alt[idx] + if self.traffic.swvnav[idx]: + self.traffic.selalt[idx] = self.traffic.alt[idx] # Altitude we want to climb to: next alt constraint in our route (could be further down the route) - minisky.traf.actwp.nextaltco[idx] = toalt # [m] - minisky.traf.actwp.xtoalt[idx] = ( + self.traffic.actwp.nextaltco[idx] = toalt # [m] + self.traffic.actwp.xtoalt[idx] = ( xtoalt # [m] distance to next alt constraint measured from next waypoint ) - self.alt[idx] = minisky.traf.actwp.nextaltco[ + self.alt[idx] = self.traffic.actwp.nextaltco[ idx ] # dial in altitude of next waypoint as calculated self.dist2vs[idx] = ( 99999.0 # [m] Forces immediate climb as current distance to next wp will be less ) - t2go = max(0.1, self.dist2wp[idx] + xtoalt) / max(0.01, minisky.traf.gs[idx]) + t2go = max(0.1, self.dist2wp[idx] + xtoalt) / max(0.01, self.traffic.gs[idx]) if self.swtoc[idx]: steepness_ = self.steepness # default steepness else: - steepness_ = (minisky.traf.alt[idx] - minisky.traf.actwp.nextaltco[idx]) / ( + steepness_ = (self.traffic.alt[idx] - self.traffic.actwp.nextaltco[idx]) / ( max(0.01, self.dist2wp[idx] + xtoalt) ) - minisky.traf.actwp.vs[idx] = np.maximum( - steepness_ * minisky.traf.gs[idx], - (minisky.traf.actwp.nextaltco[idx] - minisky.traf.alt[idx]) / t2go, + self.traffic.actwp.vs[idx] = np.maximum( + steepness_ * self.traffic.gs[idx], + (self.traffic.actwp.nextaltco[idx] - self.traffic.alt[idx]) / t2go, ) # [m/s] # Level leg: never start V/S else: @@ -886,22 +903,22 @@ def setspeedforRTA(self, idx: int, torta: Any, xtorta: float) -> float | bool: if torta < -90.0: # -999 signals there is no RTA defined in remainder of route return False - deltime = torta - minisky.sim.simt # Remaining time to next RTA [s] in simtime + deltime = torta - self.simulation.simt # Remaining time to next RTA [s] in simtime if deltime > 0: # Still possible? - gsrta = calcvrta(minisky.traf.gs[idx], xtorta, deltime, minisky.traf.perf.axmax[idx]) + gsrta = calcvrta(self.traffic.gs[idx], xtorta, deltime, self.traffic.perf.axmax[idx]) # Subtract tail wind speed vector tailwind = ( - minisky.traf.windnorth[idx] * minisky.traf.gsnorth[idx] - + minisky.traf.windeast[idx] * minisky.traf.gseast[idx] - ) / minisky.traf.gs[idx] + self.traffic.windnorth[idx] * self.traffic.gsnorth[idx] + + self.traffic.windeast[idx] * self.traffic.gseast[idx] + ) / self.traffic.gs[idx] # Convert to CAS - rtacas = tas2cas(gsrta - tailwind, minisky.traf.alt[idx]) + rtacas = tas2cas(gsrta - tailwind, self.traffic.alt[idx]) # Performance limits on speed will be applied in traf.update - if minisky.traf.actwp.spdcon[idx] < 0.0 and minisky.traf.swvnavspd[idx]: - minisky.traf.actwp.spd[idx] = rtacas + if self.traffic.actwp.spdcon[idx] < 0.0 and self.traffic.swvnavspd[idx]: + self.traffic.actwp.spd[idx] = rtacas # print("setspeedforRTA: xtorta =",xtorta) return rtacas @@ -909,11 +926,11 @@ def setspeedforRTA(self, idx: int, torta: Any, xtorta: float) -> float | bool: return False def selaltcmd( - self, idx: "int | np.ndarray", alt: Alt, vspd: Vspd | None = None + self, idx: int | np.ndarray, alt: Alt, vspd: Vspd | None = None ) -> tuple[bool, str]: """Select the autopilot altitude, optionally with a vertical speed. - Implements the ALT stack command: ``ALT acid, alt, [vspd]``. + Implements the ALT stack command: `ALT acid, alt, [vspd]`. Selecting an altitude disengages VNAV for this aircraft. When no vertical speed is given and the currently selected vertical speed opposes the required climb/descent direction, it is reset so the @@ -927,29 +944,29 @@ def selaltcmd( Returns: tuple: (True, confirmation message). """ - minisky.traf.selalt[idx] = alt - minisky.traf.swvnav[idx] = False + self.traffic.selalt[idx] = alt + self.traffic.swvnav[idx] = False # Check for optional VS argument if vspd: - minisky.traf.selvs[idx] = vspd + self.traffic.selvs[idx] = vspd else: idxarr = idx if isinstance(idx, np.ndarray) else np.array([idx]) - delalt = alt - minisky.traf.alt[idxarr] + delalt = alt - self.traffic.alt[idxarr] # Check for VS with opposite sign => use default vs # by setting autopilot vs to zero oppositevs = np.logical_and( - minisky.traf.selvs[idxarr] * delalt < 0.0, - abs(minisky.traf.selvs[idxarr]) > 0.01, + self.traffic.selvs[idxarr] * delalt < 0.0, + abs(self.traffic.selvs[idxarr]) > 0.01, ) - minisky.traf.selvs[idxarr[oppositevs]] = 0.0 + self.traffic.selvs[idxarr[oppositevs]] = 0.0 return True, f"altitude set to {alt / ft} ft" def selvspdcmd(self, idx: int, vspd: Vspd) -> tuple[bool, str]: """Select the autopilot vertical speed. - Implements the VS stack command: ``VS acid, vspd (ft/min)``. + Implements the VS stack command: `VS acid, vspd (ft/min)`. Setting a vertical speed disengages VNAV for this aircraft. Args: @@ -959,14 +976,14 @@ def selvspdcmd(self, idx: int, vspd: Vspd) -> tuple[bool, str]: Returns: tuple: (True, confirmation message). """ - minisky.traf.selvs[idx] = vspd - minisky.traf.swvnav[idx] = False + self.traffic.selvs[idx] = vspd + self.traffic.swvnav[idx] = False return True, f"vertical speed set to {vspd / fpm} ft/min" def selhdgcmd(self, idx: int, hdg: Hdg) -> tuple[bool, str]: # HDG command """Select the autopilot heading. - Implements the HDG stack command: ``HDG acid, hdg (deg)``. When a + Implements the HDG stack command: `HDG acid, hdg (deg)`. When a wind field is defined and the aircraft is airborne (above 50 ft), the commanded track is computed from the given heading and the local wind; otherwise track equals heading. Selecting a heading disengages @@ -980,13 +997,13 @@ def selhdgcmd(self, idx: int, hdg: Hdg) -> tuple[bool, str]: # HDG command tuple: (True, confirmation message). """ - if minisky.traf.wind.winddim > 0: - if minisky.traf.alt[idx] > 50.0 * ft: + if self.traffic.wind.winddim > 0: + if self.traffic.alt[idx] > 50.0 * ft: # Above 50ft: compute track based on wind - tasnorth = minisky.traf.tas[idx] * np.cos(np.radians(hdg)) - taseast = minisky.traf.tas[idx] * np.sin(np.radians(hdg)) - wind_v, wind_u = minisky.traf.wind.getdata( - minisky.traf.lat[idx], minisky.traf.lon[idx], minisky.traf.alt[idx] + tasnorth = self.traffic.tas[idx] * np.cos(np.radians(hdg)) + taseast = self.traffic.tas[idx] * np.sin(np.radians(hdg)) + wind_v, wind_u = self.traffic.wind.getdata( + self.traffic.lat[idx], self.traffic.lon[idx], self.traffic.alt[idx] ) gsnorth = tasnorth + wind_v gseast = taseast + wind_u @@ -997,13 +1014,13 @@ def selhdgcmd(self, idx: int, hdg: Hdg) -> tuple[bool, str]: # HDG command else: self.trk[idx] = hdg - minisky.traf.swlnav[idx] = False + self.traffic.swlnav[idx] = False return True, f"heading set to {hdg} deg" def selspdcmd(self, idx: int, casmach: Spd) -> tuple[bool, str]: # SPD command """Select the autopilot speed. - Implements the SPD stack command: ``SPD acid, casmach``. Switches + Implements the SPD stack command: `SPD acid, casmach`. Switches off VNAV speed guidance, as a manually selected speed overrides the FMS speed. Whether CAS or Mach is held during altitude changes depends on the position relative to the crossover altitude. @@ -1019,10 +1036,10 @@ def selspdcmd(self, idx: int, casmach: Spd) -> tuple[bool, str]: # SPD command # Depending on or position relative to crossover altitude, # we will maintain CAS or Mach when altitude changes # We will convert values when needed - minisky.traf.selspd[idx] = casmach + self.traffic.selspd[idx] = casmach # Used to be: Switch off VNAV: SPD command overrides - minisky.traf.swvnavspd[idx] = False + self.traffic.swvnavspd[idx] = False if casmach > 1.0: msg = f"speed set to {casmach / kts} kts" @@ -1036,7 +1053,7 @@ def setdest( ) -> tuple[bool, str]: """Set (or show) the destination of an aircraft. - Implements the DEST stack command: ``DEST acid, latlon/airport``. + Implements the DEST stack command: `DEST acid, latlon/airport`. The destination is looked up in the airport database (or parsed as a position) and appended to the route as its final waypoint. If it is the only route waypoint it is immediately activated, engaging LNAV @@ -1053,20 +1070,26 @@ def setdest( tuple: (success flag, message). """ if wpname is None: - return True, "DEST " + minisky.traf.callsign[acidx] + ": " + self.dest[acidx] + return True, "DEST " + self.traffic.callsign[acidx] + ": " + self.dest[acidx] route = self.route[acidx] - apidx = minisky.navdb.getaptidx(wpname) + apidx = self.navigation.getaptidx(wpname) if apidx < 0: if len(route.wpname) > 0: reflat = route.wplat[-1] reflon = route.wplon[-1] else: - reflat = minisky.traf.lat[acidx] - reflon = minisky.traf.lon[acidx] - - success, posobj = txt2pos(wpname, float(reflat), float(reflon)) + reflat = self.traffic.lat[acidx] + reflon = self.traffic.lon[acidx] + + success, posobj = txt2pos( + wpname, + float(reflat), + float(reflon), + self.navigation, + self.traffic, + ) if success: assert isinstance(posobj, Position) lat = posobj.lat @@ -1075,8 +1098,8 @@ def setdest( return False, "DEST: Position " + wpname + " not found." else: - lat = minisky.navdb.aptlat[apidx] - lon = minisky.navdb.aptlon[apidx] + lat = self.navigation.aptlat[apidx] + lon = self.navigation.aptlon[apidx] # Check if a speed constraint was given at destination dest_spd = -999 if casmach is None else casmach @@ -1085,15 +1108,15 @@ def setdest( iwp = route.add_waypoint(acidx, self.dest[acidx], route.dest, lat, lon, 0.0, dest_spd) # If only waypoint: activate if (iwp == 0) or (self.orig[acidx] != "" and len(route.wpname) == 2): - minisky.traf.actwp.lat[acidx] = route.wplat[iwp] - minisky.traf.actwp.lon[acidx] = route.wplon[iwp] - minisky.traf.actwp.nextaltco[acidx] = route.wpalt[iwp] - minisky.traf.actwp.spd[acidx] = route.wpspd[iwp] + self.traffic.actwp.lat[acidx] = route.wplat[iwp] + self.traffic.actwp.lon[acidx] = route.wplon[iwp] + self.traffic.actwp.nextaltco[acidx] = route.wpalt[iwp] + self.traffic.actwp.spd[acidx] = route.wpspd[iwp] - minisky.traf.swlnav[acidx] = True - minisky.traf.swvnav[acidx] = True + self.traffic.swlnav[acidx] = True + self.traffic.swvnav[acidx] = True route.iactwp = iwp - minisky.traffic.route.direct(acidx, route.wpname[iwp]) + direct(self.traffic, acidx, route.wpname[iwp]) # If not found, say so elif iwp < 0: @@ -1104,7 +1127,7 @@ def setdest( def setorig(self, acidx: int, wpname: Wpt | None = None) -> tuple[bool, str]: """Set (or show) the origin of an aircraft. - Implements the ORIG stack command: ``ORIG acid, latlon/airport``. + Implements the ORIG stack command: `ORIG acid, latlon/airport`. The origin is stored as the first waypoint of the route; it is bookkeeping only and does not activate guidance. @@ -1117,21 +1140,27 @@ def setorig(self, acidx: int, wpname: Wpt | None = None) -> tuple[bool, str]: tuple: (success flag, message). """ if wpname is None: - return True, "ORIG " + minisky.traf.callsign[acidx] + ": " + self.orig[acidx] + return True, "ORIG " + self.traffic.callsign[acidx] + ": " + self.orig[acidx] route = self.route[acidx] - apidx = minisky.navdb.getaptidx(wpname) + apidx = self.navigation.getaptidx(wpname) if apidx < 0: if len(route.wpname) > 0: reflat = route.wplat[-1] reflon = route.wplon[-1] else: - reflat = minisky.traf.lat[acidx] - reflon = minisky.traf.lon[acidx] - - success, posobj = txt2pos(wpname, float(reflat), float(reflon)) + reflat = self.traffic.lat[acidx] + reflon = self.traffic.lon[acidx] + + success, posobj = txt2pos( + wpname, + float(reflat), + float(reflon), + self.navigation, + self.traffic, + ) if success: assert isinstance(posobj, Position) lat = posobj.lat @@ -1140,13 +1169,13 @@ def setorig(self, acidx: int, wpname: Wpt | None = None) -> tuple[bool, str]: return False, ("ORIG: Position " + wpname + " not found.") else: - lat = minisky.navdb.aptlat[apidx] - lon = minisky.navdb.aptlon[apidx] + lat = self.navigation.aptlat[apidx] + lon = self.navigation.aptlon[apidx] # Origin: bookkeeping only for now, store in route as origin self.orig[acidx] = wpname iwp = route.add_waypoint( - acidx, self.orig[acidx], route.orig, lat, lon, 0.0, minisky.traf.cas[acidx] + acidx, self.orig[acidx], route.orig, lat, lon, 0.0, self.traffic.cas[acidx] ) if iwp < 0: return False, (self.orig[acidx] + " not found.") @@ -1156,7 +1185,7 @@ def setorig(self, acidx: int, wpname: Wpt | None = None) -> tuple[bool, str]: def setVNAV(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: """Switch VNAV (vertical FMS guidance) on or off, or show its state. - Implements the VNAV stack command: ``VNAV acid, [ON/OFF]``. VNAV can + Implements the VNAV stack command: `VNAV acid, [ON/OFF]`. VNAV can only be engaged when LNAV is on and a route with waypoints exists; engaging it recalculates the flight plan and the VNAV profile for the active leg. Switching VNAV also switches VNAV speed guidance. @@ -1172,9 +1201,9 @@ def setVNAV(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: if not isinstance(idx, Collection): if idx is None: # All aircraft are targeted - minisky.traf.swvnav = np.array(minisky.traf.ntraf * [flag]) - minisky.traf.swvnavspd = np.array(minisky.traf.ntraf * [flag]) - idx = np.arange(minisky.traf.ntraf) + self.traffic.swvnav = np.array(self.traffic.ntraf * [flag]) + self.traffic.swvnavspd = np.array(self.traffic.ntraf * [flag]) + idx = np.arange(self.traffic.ntraf) else: # Prepare for the loop idx = np.array([idx]) @@ -1184,22 +1213,22 @@ def setVNAV(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: for i in idx: if flag is None: msg = ( - minisky.traf.callsign[i] + self.traffic.callsign[i] + ": VNAV is " - + ("ON" if minisky.traf.swvnav[i] else "OFF") + + ("ON" if self.traffic.swvnav[i] else "OFF") ) - if not minisky.traf.swvnavspd[i]: + if not self.traffic.swvnavspd[i]: msg += " but VNAVSPD is OFF" output.append(msg) elif flag: - if not minisky.traf.swlnav[i]: - return False, (minisky.traf.callsign[i] + ": VNAV ON requires LNAV to be ON") + if not self.traffic.swlnav[i]: + return False, (self.traffic.callsign[i] + ": VNAV ON requires LNAV to be ON") route = self.route[i] if len(route.wpname) > 0: - minisky.traf.swvnav[i] = True - minisky.traf.swvnavspd[i] = True + self.traffic.swvnav[i] = True + self.traffic.swvnavspd[i] = True self.route[i].calcfp() actwpidx = self.route[i].iactwp self.ComputeVNAV( @@ -1209,17 +1238,17 @@ def setVNAV(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: self.route[i].wptorta[actwpidx], self.route[i].wpxtorta[actwpidx], ) - minisky.traf.actwp.nextaltco[i] = self.route[i].wptoalt[actwpidx] + self.traffic.actwp.nextaltco[i] = self.route[i].wptoalt[actwpidx] else: return False, ( "VNAV " - + minisky.traf.callsign[i] + + self.traffic.callsign[i] + ": no waypoints or destination specified" ) else: - minisky.traf.swvnav[i] = False - minisky.traf.swvnavspd[i] = False + self.traffic.swvnav[i] = False + self.traffic.swvnavspd[i] = False if flag == None: return True, "\n".join(output) @@ -1228,7 +1257,7 @@ def setVNAV(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: def setLNAV(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: """Switch LNAV (lateral FMS guidance) on or off, or show its state. - Implements the LNAV stack command: ``LNAV acid, [ON/OFF]``. LNAV can + Implements the LNAV stack command: `LNAV acid, [ON/OFF]`. LNAV can only be engaged when the aircraft has a route; engaging it selects the best waypoint to fly to (see Route.findact()) and issues a direct-to towards it. @@ -1244,8 +1273,8 @@ def setLNAV(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: if not isinstance(idx, Collection): if idx is None: # All aircraft are targeted - minisky.traf.swlnav = np.array(minisky.traf.ntraf * [flag]) - idx = np.arange(minisky.traf.ntraf) + self.traffic.swlnav = np.array(self.traffic.ntraf * [flag]) + idx = np.arange(self.traffic.ntraf) else: # Prepare for the loop idx = np.array([idx]) @@ -1255,9 +1284,9 @@ def setLNAV(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: for i in idx: if flag is None: output.append( - minisky.traf.callsign[i] + self.traffic.callsign[i] + ": LNAV is " - + ("ON" if minisky.traf.swlnav[i] else "OFF") + + ("ON" if self.traffic.swlnav[i] else "OFF") ) elif flag: @@ -1265,14 +1294,14 @@ def setLNAV(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: if len(route.wpname) <= 0: return False, ( "LNAV " - + minisky.traf.callsign[i] + + self.traffic.callsign[i] + ": no waypoints or destination specified" ) - elif not minisky.traf.swlnav[i]: - minisky.traf.swlnav[i] = True - minisky.traffic.route.direct(i, route.wpname[route.findact(i)]) + elif not self.traffic.swlnav[i]: + self.traffic.swlnav[i] = True + direct(self.traffic, i, route.wpname[route.findact(i)]) else: - minisky.traf.swlnav[i] = False + self.traffic.swlnav[i] = False if flag is None: return True, "\n".join(output) @@ -1281,7 +1310,7 @@ def setLNAV(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: def setswtoc(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: """Switch the Top-of-Climb logic on or off, or show its state. - Implements the SWTOC stack command: ``SWTOC acid, [ON/OFF]``. With + Implements the SWTOC stack command: `SWTOC acid, [ON/OFF]`. With ToC logic on (default) the aircraft climbs as early as possible with the default steepness; with it off, the climb angle is chosen to arrive at the altitude constraint exactly at its waypoint. @@ -1298,8 +1327,8 @@ def setswtoc(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: if not isinstance(idx, Collection): if idx is None: # All aircraft are targeted - self.swtoc = np.array(minisky.traf.ntraf * [flag]) - idx = np.arange(minisky.traf.ntraf) + self.swtoc = np.array(self.traffic.ntraf * [flag]) + idx = np.arange(self.traffic.ntraf) else: # Prepare for the loop idx = np.array([idx]) @@ -1309,7 +1338,7 @@ def setswtoc(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: for i in idx: if flag is None: output.append( - minisky.traf.callsign[i] + ": SWTOC is " + ("ON" if self.swtoc[i] else "OFF") + self.traffic.callsign[i] + ": SWTOC is " + ("ON" if self.swtoc[i] else "OFF") ) elif flag: @@ -1324,7 +1353,7 @@ def setswtoc(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: def setswtod(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: """Switch the Top-of-Descent logic on or off, or show its state. - Implements the SWTOD stack command: ``SWTOD acid, [ON/OFF]``. With + Implements the SWTOD stack command: `SWTOD acid, [ON/OFF]`. With ToD logic on (default) the aircraft descends as late as possible with the default steepness; with it off, the descent angle is chosen to arrive at the altitude constraint exactly at its waypoint. @@ -1340,8 +1369,8 @@ def setswtod(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: if not isinstance(idx, Collection): if idx is None: # All aircraft are targeted - self.swtod = np.array(minisky.traf.ntraf * [flag]) - idx = np.arange(minisky.traf.ntraf) + self.swtod = np.array(self.traffic.ntraf * [flag]) + idx = np.arange(self.traffic.ntraf) else: # Prepare for the loop idx = np.array([idx]) @@ -1351,7 +1380,7 @@ def setswtod(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: for i in idx: if flag is None: output.append( - minisky.traf.callsign[i] + ": SWTOD is " + ("ON" if self.swtod[i] else "OFF") + self.traffic.callsign[i] + ": SWTOD is " + ("ON" if self.swtod[i] else "OFF") ) elif flag: diff --git a/minisky/traffic/performance/perfoap.py b/minisky/traffic/performance/perfoap.py index db25764..a7f1e59 100644 --- a/minisky/traffic/performance/perfoap.py +++ b/minisky/traffic/performance/perfoap.py @@ -1,18 +1,19 @@ """OpenAP-based aircraft performance model. -This module provides :class:`OpenAP`, the aircraft performance implementation -used by the MiniSky traffic object (``minisky.traf.perf``). It combines the -coefficient database (``coeff``), flight-phase logic (``phase``), and the -empirical thrust/fuel-flow models (``thrust``) into per-aircraft vectorised +This module provides [`OpenAP`][minisky.traffic.performance.perfoap.OpenAP], the aircraft performance implementation +used by the MiniSky traffic object (`minisky.traf.perf`). It combines the +coefficient database (`coeff`), flight-phase logic (`phase`), and the +empirical thrust/fuel-flow models (`thrust`) into per-aircraft vectorised computations of drag, thrust, fuel flow, and kinematic envelope limits. All internal quantities are in SI units. """ -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, Any import numpy as np -import minisky from minisky.core.trafficarrays import TrafficArrays from minisky.tools import aero from minisky.tools.aero import fpm, ft, kts @@ -20,6 +21,9 @@ from . import coeff, thrust from . import phase as ph +if TYPE_CHECKING: + from minisky.traffic import Traffic + class OpenAP(TrafficArrays): """ @@ -43,7 +47,7 @@ class OpenAP(TrafficArrays): lifttype (ndarray): Lift type, fixed-wing (1) or rotor (2) [-]. Sref (ndarray): Wing reference surface area [m^2]. mass (ndarray): Effective mass, mean of OEW and MTOW [kg]. - phase (ndarray): Current flight phase identifier (see ``phase``) [-]. + phase (ndarray): Current flight phase identifier (see `phase`) [-]. cd0 (ndarray): Zero-lift drag coefficient for current phase [-]. k (ndarray): Induced drag factor for current phase [-]. bank (ndarray): Maximum bank angle for current phase [deg]. @@ -64,8 +68,9 @@ class OpenAP(TrafficArrays): ff_coeff_a/b/c (ndarray): Quadratic ICAO fuel-flow fit coefficients. """ - def __init__(self) -> None: + def __init__(self, traffic: Traffic) -> None: super().__init__() + self.traffic = traffic self.ac_warning = False # aircraft mdl to default warning self.eng_warning = False # aircraft engine to default warning @@ -124,12 +129,16 @@ def __init__(self) -> None: self.hcross = np.array([]) self.mmo = np.array([]) + def new_implementation(self, implementation: type[TrafficArrays]) -> TrafficArrays: + """Construct a replacement with this runtime's traffic object.""" + return implementation(self.traffic) + def create(self, n: int = 1) -> None: """Initialise performance parameters for newly created aircraft. Called by the traffic object when aircraft are created. Looks up the type of the last created aircraft in the OpenAP coefficient database - and fills the last ``n`` array elements with its mass, engine, drag + and fills the last `n` array elements with its mass, engine, drag polar, and flight-envelope coefficients. Rotorcraft types get the (simpler) rotor envelope; unknown fixed-wing types default to B744. @@ -140,7 +149,7 @@ def create(self, n: int = 1) -> None: # cautious! considering multiple created aircraft with same type super().create(n) - actype = minisky.traf.typecode[-1].upper() + actype = self.traffic.typecode[-1].upper() # initialize aircraft / engine performance parameters # check fixwing or rotor, default to fixwing @@ -254,9 +263,9 @@ def update(self, dt: float = 1) -> None: len(self.phase) self.phase = ph.get( self.lifttype, - minisky.traf.tas, - minisky.traf.vs, - minisky.traf.alt, + self.traffic.tas, + self.traffic.vs, + self.traffic.alt, unit="SI", ) @@ -285,8 +294,8 @@ def update(self, dt: float = 1) -> None: self.k[self.phase == ph.DE] = self.k_clean[self.phase == ph.DE] self.k[self.phase == ph.NA] = self.k_clean[self.phase == ph.NA] - rho = aero.vdensity(minisky.traf.alt[idx_fixwing]) - vtas = minisky.traf.tas[idx_fixwing] + rho = aero.vdensity(self.traffic.alt[idx_fixwing]) + vtas = self.traffic.tas[idx_fixwing] rhovs = 0.5 * rho * vtas**2 * self.Sref[idx_fixwing] cl = self.mass[idx_fixwing] * aero.g0 / rhovs self.drag[idx_fixwing] = rhovs * (self.cd0[idx_fixwing] + self.k[idx_fixwing] * cl**2) @@ -295,9 +304,9 @@ def update(self, dt: float = 1) -> None: max_thrustratio_fixwing = thrust.compute_max_thr_ratio( self.phase[idx_fixwing], self.engbpr[idx_fixwing], - minisky.traf.tas[idx_fixwing], - minisky.traf.alt[idx_fixwing], - minisky.traf.vs[idx_fixwing], + self.traffic.tas[idx_fixwing], + self.traffic.alt[idx_fixwing], + self.traffic.vs[idx_fixwing], self.engnum[idx_fixwing] * self.engthrmax[idx_fixwing], ) self.max_thrust[idx_fixwing] = ( @@ -306,7 +315,7 @@ def update(self, dt: float = 1) -> None: # ----- compute net thrust ----- self.thrust[idx_fixwing] = ( - self.drag[idx_fixwing] + self.mass[idx_fixwing] * minisky.traf.ax[idx_fixwing] + self.drag[idx_fixwing] + self.mass[idx_fixwing] * self.traffic.ax[idx_fixwing] ) # ----- compute fuel flow ----- @@ -381,7 +390,7 @@ def limits( (intent_vs < 0) & (intent_vs < self.vsmin), vs_min_with_acc, allow_vs ) # for descent with vs smaller than vsmin (negative) allow_vs = np.where( - (self.phase == ph.GD) & (minisky.traf.tas < self.vminto), 0, allow_vs + (self.phase == ph.GD) & (self.traffic.tas < self.vminto), 0, allow_vs ) # takeoff aircraft # corect rotercraft speed limits @@ -411,11 +420,11 @@ def currentlimits(self, id: Any = None) -> tuple: floats or 1D-arrays: Min TAS [m/s], Max TAS [m/s], Min VS [m/s], Max VS [m/s] """ - vtasmin = aero.vcas2tas(self.vmin, minisky.traf.alt) + vtasmin = aero.vcas2tas(self.vmin, self.traffic.alt) vtasmax = np.minimum( - aero.vcas2tas(self.vmax, minisky.traf.alt), - aero.vmach2tas(self.mmo, minisky.traf.alt), + aero.vcas2tas(self.vmax, self.traffic.alt), + aero.vmach2tas(self.mmo, self.traffic.alt), ) if id is not None: diff --git a/minisky/traffic/route.py b/minisky/traffic/route.py index 5280aa9..3123fe1 100644 --- a/minisky/traffic/route.py +++ b/minisky/traffic/route.py @@ -1,6 +1,6 @@ """Route implementation for the BlueSky FMS. -Contains the per-aircraft :class:`Route` class (the flight plan: an ordered +Contains the per-aircraft [`Route`][minisky.traffic.route.Route] class (the flight plan: an ordered list of waypoints with optional altitude, speed, RTA and turn constraints) plus the module-level functions that implement the route-editing stack commands: ADDWPT, ADDWPTMODE, AFTER, BEFORE, AT, DIRECT, RTA, LISTRTE, @@ -8,26 +8,28 @@ The route itself is passive data with flight-plan pre-calculations (calcfp()); the actual guidance along the route is performed by -:class:`~minisky.traffic.autopilot.Autopilot`, which pulls waypoint data -into the vectorized :class:`~minisky.traffic.activewpdata.ActiveWaypoint` +[`Autopilot`][minisky.traffic.autopilot.Autopilot], which pulls waypoint data +into the vectorized [`ActiveWaypoint`][minisky.traffic.activewpdata.ActiveWaypoint] arrays via getnextwp()/getnextturnwp(). """ +from __future__ import annotations + import math +from typing import TYPE_CHECKING import numpy as np -import minisky -from minisky import stack - # from minisky.core import Replaceable -from minisky.stack import Command from minisky.stack.argparser import Alt, Spd, Time, Wpt from minisky.tools import geo from minisky.tools.aero import casormach2tas, ft, g0, kts, mach2cas, nm from minisky.tools.convert import degto180, txt2alt, txt2spd from minisky.tools.position import Position, txt2pos +if TYPE_CHECKING: + from minisky.traffic import Traffic + class Route: """Flight plan (route) of a single aircraft: basic FMS functionality. @@ -36,7 +38,7 @@ class Route: constraint, speed constraint, required time of arrival (RTA), turn specification (fly-by/fly-over/fly-turn with radius, speed or heading rate) and stack commands to execute when the waypoint is passed. One - Route object is kept per aircraft in ``minisky.traf.ap.route``. + Route object is kept per aircraft in `minisky.traf.ap.route`. Waypoints from the navigation database are resolved to the entry closest to the given lat/lon. For plain lat/lon waypoints the aircraft @@ -91,7 +93,9 @@ class Route: # # Aircraft route objects # _routes: WeakValueDictionary[str, "Route"] = WeakValueDictionary() - def __init__(self, acid: str) -> None: + def __init__(self, traffic: Traffic, acid: str) -> None: + self.traffic = traffic + self.navigation = traffic.navigation self.acid = acid # Waypoint data @@ -234,7 +238,7 @@ def add_waypoint( wpok = True # switch for waypoint check # Check if name already exists, if so add integer 01, 02, 03 etc. - wprtename = get_available_name(self.wpname, name) + wprtename = get_available_name(self.wpname, name, self.traffic.callsign) # Select on wptype # ORIGIN: Wptype is origin/destination? if wptype == Route.orig or wptype == Route.dest: @@ -242,11 +246,11 @@ def add_waypoint( wpidx = 0 if orig else -1 suffix = "ORIG" if orig else "DEST" - if name != minisky.traf.callsign[iac] + suffix: # published identifier - i = minisky.navdb.getaptidx(name) + if name != self.traffic.callsign[iac] + suffix: # published identifier + i = self.navigation.getaptidx(name) if i >= 0: - wplat = minisky.navdb.aptlat[i] - wplon = minisky.navdb.aptlon[i] + wplat = self.navigation.aptlat[i] + wplon = self.navigation.aptlon[i] if not orig and alt < 0: alt = 0 @@ -288,25 +292,25 @@ def add_waypoint( else: # Lat/lon: wpname is then call sign of aircraft: add number if wptype == Route.wplatlon: - newname = get_available_name(self.wpname, name, 3) + newname = get_available_name(self.wpname, name, self.traffic.callsign, 3) # Else make data complete with nav database and closest to given lat,lon else: # so wptypewpnav newname = wprtename if wptype != Route.runway: - i = minisky.navdb.getwpidx(name, lat, lon) + i = self.navigation.getwpidx(name, lat, lon) wpok = i >= 0 if wpok: - wplat = minisky.navdb.wplat[i] - wplon = minisky.navdb.wplon[i] + wplat = self.navigation.wplat[i] + wplon = self.navigation.wplon[i] else: - i = minisky.navdb.getaptidx(name) + i = self.navigation.getaptidx(name) wpok = i >= 0 if wpok: - wplat = minisky.navdb.aptlat[i] - wplon = minisky.navdb.aptlon[i] + wplat = self.navigation.aptlat[i] + wplon = self.navigation.aptlon[i] # Check if afterwp or beforewp is specified and found: aftwp = afterwp.upper().strip() # Remove space, upper case @@ -340,8 +344,8 @@ def add_waypoint( # update qdr and "last waypoint switch" in traffic if idx >= 0: - minisky.traf.actwp.next_qdr[iac] = self.getnextqdr() - minisky.traf.actwp.swlastwp[iac] = self.iactwp == n_wpt - 1 + self.traffic.actwp.next_qdr[iac] = self.getnextqdr() + self.traffic.actwp.swlastwp[iac] = self.iactwp == n_wpt - 1 # Update waypoints if wptype != Route.calcwp: @@ -349,7 +353,7 @@ def add_waypoint( # Update autopilot settings if wpok and 0 <= self.iactwp < n_wpt: - direct(iac, self.wpname[self.iactwp]) + direct(self.traffic, iac, self.wpname[self.iactwp]) return idx @@ -432,16 +436,16 @@ def getnextwp(self) -> tuple: rwykey = name[7:10] # Use this code to look up runway heading - wphdg = minisky.navdb.rwythresholds[name[:4]][rwykey][2] + wphdg = self.navigation.rwythresholds[name[:4]][rwykey][2] # keep constant runway heading - stack.stack("HDG " + str(self.acid) + " " + str(wphdg)) + self.traffic.stack_command("HDG " + str(self.acid) + " " + str(wphdg)) # start decelerating - stack.stack("DELAY " + "10 " + "SPD " + str(self.acid) + " " + "10") + self.traffic.stack_command("DELAY " + "10 " + "SPD " + str(self.acid) + " " + "10") # delete aircraft - stack.stack("DELAY " + "42 " + "DEL " + str(self.acid)) + self.traffic.stack_command("DELAY " + "42 " + "DEL " + str(self.acid)) swlastwp = self.iactwp == n_wpt - 1 @@ -525,7 +529,7 @@ def runactwpstack(self) -> None: and are issued when the aircraft passes the waypoint. """ for cmdline in self.wpstack[self.iactwp]: - stack.stack(cmdline) + self.traffic.stack_command(cmdline) # debug # stack.stack("ECHO "+self.acid+" AT "+self.wpname[self.iactwp]+" command issued:"+cmdline) return @@ -605,9 +609,9 @@ def calcfp(self) -> None: # Also add "from direction" as to directions so no need to shift for actwpdata # direction to will be overwritten in actwpdata in case of a direct to # Add current pos to first waypoint as default value for direction to 1st waypoint - iac = minisky.traf.idx(self.acid) + iac = self.traffic.idx(self.acid) qdr, dist = geo.qdrdist( - minisky.traf.lat[iac], minisky.traf.lon[iac], self.wplat[0], self.wplon[0] + self.traffic.lat[iac], self.traffic.lon[iac], self.wplat[0], self.wplon[0] ) self.wpdirto = [qdr] + self.wpdirfrom[0:-1] # [deg] Direction to waypoints @@ -716,8 +720,8 @@ def findact(self, i: int) -> int: # Find closest wplat = np.array(self.wplat) wplon = np.array(self.wplon) - dy = wplat - minisky.traf.lat[i] - dx = (wplon - minisky.traf.lon[i]) * minisky.traf.coslat[i] + dy = wplat - self.traffic.lat[i] + dx = (wplon - self.traffic.lon[i]) * self.traffic.coslat[i] dist2 = dx * dx + dy * dy # Note: the max() prevents walking back, even in cases when this might be apropriate, # such as when previous waypoints have been deleted @@ -727,16 +731,16 @@ def findact(self, i: int) -> int: # Unless behind us, next waypoint? if iwpnear + 1 < n_wpt: qdr = math.degrees(math.atan2(dx[iwpnear], dy[iwpnear])) - delhdg = abs(degto180(minisky.traf.trk[i] - qdr)) + delhdg = abs(degto180(self.traffic.trk[i] - qdr)) # we only turn to the first waypoint if we can reach the required # heading before reaching the waypoint time_turn = ( - max(0.01, minisky.traf.tas[i]) + max(0.01, self.traffic.tas[i]) * math.radians(delhdg) - / (g0 * math.tan(minisky.traf.ap.bankdef[i])) + / (g0 * math.tan(self.traffic.ap.bankdef[i])) ) - time_straight = math.sqrt(dist2[iwpnear]) * 60.0 * nm / max(0.01, minisky.traf.tas[i]) + time_straight = math.sqrt(dist2[iwpnear]) * 60.0 * nm / max(0.01, self.traffic.tas[i]) if time_turn > time_straight: iwpnear += 1 @@ -765,7 +769,9 @@ def getnextqdr(self): # ---- following are functions managing the routes ---- -def get_available_name(data: list, name_: str, len_: int = 2) -> str: +def get_available_name( + data: list, name_: str, callsigns: list[str], len_: int = 2 +) -> str: """Make a waypoint name unique by appending a zero-padded number. Checks if the name already exists in the given list (or matches an @@ -785,7 +791,7 @@ def get_available_name(data: list, name_: str, len_: int = 2) -> str: fmt_ = "{:0" + str(len_) + "d}" # Avoid using call sign without number - if minisky.traf.callsign.count(name_) > 0: + if callsigns.count(name_) > 0: appi = 1 name_ = name_ + fmt_.format(appi) @@ -795,7 +801,9 @@ def get_available_name(data: list, name_: str, len_: int = 2) -> str: return name_ -def change_wpt_mode(acidx: int, mode=None, value=None) -> bool | None: +def change_wpt_mode( + traffic: Traffic, acidx: int, mode=None, value=None +) -> bool | None: """Change the mode with which ADDWPT adds new waypoints. Implements the ADDWPTMODE stack command. Available modes: FLYBY, @@ -815,13 +823,13 @@ def change_wpt_mode(acidx: int, mode=None, value=None) -> bool | None: bool: True on success. """ # Get aircraft route - minisky.traf.callsign[acidx] - acrte = minisky.traf.ap.route[acidx] + traffic.callsign[acidx] + acrte = traffic.ap.route[acidx] # First, we want to check what 'mode' is, and then call addwpt_stack # accordingly. if mode in ["FLYBY", "FLYOVER", "FLYTURN"]: # We're just changing addwpt mode, call the appropriate function. - addwpt(acidx, mode) + addwpt(traffic, acidx, mode) return True elif mode in [ @@ -834,29 +842,29 @@ def change_wpt_mode(acidx: int, mode=None, value=None) -> bool | None: "TURNHDGR", ]: # We're changing the turn speed or radius - addwpt(acidx, mode, value) + addwpt(traffic, acidx, mode, value) return True elif mode == None: # Just echo the current wptmode if acrte.swflyby == True and acrte.swflyturn == False: - minisky.scr.echo("Current ADDWPT mode is FLYBY.") + traffic.console.echo("Current ADDWPT mode is FLYBY.") return True elif acrte.swflyby == False and acrte.swflyturn == False: - minisky.scr.echo("Current ADDWPT mode is FLYOVER.") + traffic.console.echo("Current ADDWPT mode is FLYOVER.") return True else: - minisky.scr.echo("Current ADDWPT mode is FLYTURN.") + traffic.console.echo("Current ADDWPT mode is FLYTURN.") return True -def addwpt(ac: str | int, *args) -> bool | tuple: # args: all arguments of addwpt +def addwpt(traffic: Traffic, ac: str | int, *args) -> bool | tuple: # args: all arguments of addwpt """Add a waypoint to the route of an aircraft. Implements the ADDWPT stack command: - ``ADDWPT acid, (wpname/lat,lon), [alt], [spd], [afterwp], [beforewp]``. + `ADDWPT acid, (wpname/lat,lon), [alt], [spd], [afterwp], [beforewp]`. Besides adding a regular waypoint (navdb waypoint, airport, runway or lat/lon position, with optional altitude constraint [m] and speed @@ -882,13 +890,13 @@ def addwpt(ac: str | int, *args) -> bool | tuple: # args: all arguments of addw # First get the appropriate ac route if isinstance(ac, str): - acidx = minisky.traf.idx(ac) + acidx = traffic.idx(ac) callsign = ac else: acidx = ac - callsign = minisky.traf.callsign[acidx] + callsign = traffic.callsign[acidx] - acrte = minisky.traf.ap.route[acidx] + acrte = traffic.ap.route[acidx] # Check FLYBY or FLYOVER switch, instead of adding a waypoint @@ -966,8 +974,8 @@ def addwpt(ac: str | int, *args) -> bool | tuple: # args: all arguments of addw # Choose reference position ot look up VOR and waypoints # First waypoint: own position if n_wpt == 0: - reflat = minisky.traf.lat[acidx] - reflon = minisky.traf.lon[acidx] + reflat = traffic.lat[acidx] + reflon = traffic.lon[acidx] # Or last waypoint before destination else: @@ -990,7 +998,7 @@ def addwpt(ac: str | int, *args) -> bool | tuple: # args: all arguments of addw # Normal waypoint (no take-off waypoint => see else) if not takeoffwpt: # Get waypoint position - success, posobj = txt2pos(name, reflat, reflon) + success, posobj = txt2pos(name, reflat, reflon, traffic.navigation, traffic) if success: assert isinstance(posobj, Position) lat = posobj.lat @@ -1040,17 +1048,17 @@ def addwpt(ac: str | int, *args) -> bool | tuple: # args: all arguments of addw if rwyrteidx > 0: rwylat = acrte.wplat[rwyrteidx] rwylon = acrte.wplon[rwyrteidx] - aptidx = minisky.navdb.getapinear(rwylat, rwylon) - aptname = minisky.navdb.aptname[aptidx] + aptidx = traffic.navigation.getapinear(rwylat, rwylon) + aptname = traffic.navigation.aptname[aptidx] rwyname = acrte.wpname[rwyrteidx].split("/")[1] rwyid = rwyname.replace("RWY", "").replace("RW", "") - rwyhdg = minisky.navdb.rwythresholds[aptname][rwyid][2] + rwyhdg = traffic.navigation.rwythresholds[aptname][rwyid][2] else: - rwylat = minisky.traf.lat[acidx] - rwylon = minisky.traf.lon[acidx] - rwyhdg = minisky.traf.trk[acidx] + rwylat = traffic.lat[acidx] + rwylon = traffic.lon[acidx] + rwyhdg = traffic.trk[acidx] elif args[1].count("/") > 0 or len(args) > 2 and args[2]: # we need apt,rwy # Take care of both EHAM/RW06 as well as EHAM,RWY18L (so /&, and RW/RWY) @@ -1066,7 +1074,7 @@ def addwpt(ac: str | int, *args) -> bool | tuple: # args: all arguments of addw # TODO: Add finding the runway heading with rwyrteidx>0 and navdb!!! # Try to get it from the database try: - rwyhdg = minisky.navdb.rwythresholds[aptid][rwyid][2] + rwyhdg = traffic.navigation.rwythresholds[aptid][rwyid][2] except Exception: rwydir = rwyid.replace("L", "").replace("R", "").replace("C", "") try: @@ -1074,13 +1082,19 @@ def addwpt(ac: str | int, *args) -> bool | tuple: # args: all arguments of addw except ValueError: return False, name + " not found." - success, posobj = txt2pos(aptid + "/RW" + rwyid, reflat, reflon) + success, posobj = txt2pos( + aptid + "/RW" + rwyid, + reflat, + reflon, + traffic.navigation, + traffic, + ) if success: assert isinstance(posobj, Position) rwylat, rwylon = posobj.lat, posobj.lon else: - rwylat = minisky.traf.lat[acidx] - rwylon = minisky.traf.lon[acidx] + rwylat = traffic.lat[acidx] + rwylon = traffic.lon[acidx] else: return False, "Use ADDWPT TAKEOFF,AIRPORTID,RWYNAME" @@ -1114,15 +1128,15 @@ def addwpt(ac: str | int, *args) -> bool | tuple: # args: all arguments of addw return False, "Waypoint " + name + " not added." # check for presence of orig/dest - norig = int(minisky.traf.ap.orig[acidx] != "") # 1 if orig is present in route - ndest = int(minisky.traf.ap.dest[acidx] != "") # 1 if dest is present in route + norig = int(traffic.ap.orig[acidx] != "") # 1 if orig is present in route + ndest = int(traffic.ap.dest[acidx] != "") # 1 if dest is present in route # Check whether this is first 'real' waypoint (not orig & dest), # And if so, make active if n_wpt - norig - ndest == 1: # first waypoint: make active - direct(acidx, acrte.wpname[norig]) # 0 if no orig + direct(traffic, acidx, acrte.wpname[norig]) # 0 if no orig # print("direct ",self.wpname[norig]) - minisky.traf.swlnav[acidx] = True + traffic.swlnav[acidx] = True if afterwp and acrte.wpname.count(afterwp) == 0: return ( @@ -1134,6 +1148,7 @@ def addwpt(ac: str | int, *args) -> bool | tuple: # args: all arguments of addw def addwpt_before( + traffic: Traffic, acidx: int, beforewp: Wpt, addwptkey, @@ -1144,7 +1159,7 @@ def addwpt_before( """Add a waypoint to a route before an existing waypoint. Implements the BEFORE stack command: - ``acid BEFORE wpt ADDWPT (wpname/lat,lon), [alt], [spd]``. + `acid BEFORE wpt ADDWPT (wpname/lat,lon), [alt], [spd]`. Thin wrapper around addwpt() with the insertion point set. Args: @@ -1158,10 +1173,11 @@ def addwpt_before( Returns: bool or tuple: Result of addwpt(). """ - return addwpt(acidx, waypoint, alt, spd, None, beforewp) + return addwpt(traffic, acidx, waypoint, alt, spd, None, beforewp) def addwpt_after( + traffic: Traffic, acidx: int, afterwp: Wpt, addwptkey, @@ -1172,7 +1188,7 @@ def addwpt_after( """Add a waypoint to a route after an existing waypoint. Implements the AFTER stack command: - ``acid AFTER wpt ADDWPT (wpname/lat,lon), [alt], [spd]``. + `acid AFTER wpt ADDWPT (wpname/lat,lon), [alt], [spd]`. Thin wrapper around addwpt() with the insertion point set. Args: @@ -1186,24 +1202,24 @@ def addwpt_after( Returns: bool or tuple: Result of addwpt(). """ - return addwpt(acidx, waypoint, alt, spd, afterwp) + return addwpt(traffic, acidx, waypoint, alt, spd, afterwp) -def at_wpt(acidx: int, atwp: Wpt, *args) -> bool | tuple: +def at_wpt(traffic: Traffic, acidx: int, atwp: Wpt, *args) -> bool | tuple: """Show, set or delete constraints and commands at a route waypoint. Implements the AT stack command: - ``AT acid, wpt [DEL] ALT/SPD/DO alt/spd/stack command``. + `AT acid, wpt [DEL] ALT/SPD/DO alt/spd/stack command`. Usage examples: - - ``KL204 AT LOPIK``: show altitude/speed constraints at the waypoint. - - ``KL204 AT LOPIK FL090/250``: set both altitude and speed constraint. - - ``KL204 AT LOPIK ALT FL090``: set the altitude constraint. - - ``KL204 AT LOPIK SPD 250``: set the speed constraint. - - ``KL204 AT LOPIK DO SPD 250``: stack a command when passing the + - `KL204 AT LOPIK`: show altitude/speed constraints at the waypoint. + - `KL204 AT LOPIK FL090/250`: set both altitude and speed constraint. + - `KL204 AT LOPIK ALT FL090`: set the altitude constraint. + - `KL204 AT LOPIK SPD 250`: set the speed constraint. + - `KL204 AT LOPIK DO SPD 250`: stack a command when passing the waypoint (own callsign is prepended when the command needs one). - - ``KL204 AT LOPIK DEL ALT/SPD/BOTH/ALL``: delete constraint(s). + - `KL204 AT LOPIK DEL ALT/SPD/BOTH/ALL`: delete constraint(s). After editing, the flight plan and active-waypoint guidance are recalculated. @@ -1216,8 +1232,8 @@ def at_wpt(acidx: int, atwp: Wpt, *args) -> bool | tuple: Returns: bool or tuple: True on success, or (success flag, message). """ - acid = minisky.traf.callsign[acidx] - acrte = minisky.traf.ap.route[acidx] + acid = traffic.callsign[acidx] + acrte = traffic.ap.route[acidx] if atwp in acrte.wpname: wpidx = acrte.wpname.index(atwp) @@ -1314,7 +1330,7 @@ def at_wpt(acidx: int, atwp: Wpt, *args) -> bool | tuple: # If success: update flight plan and guidance acrte.calcfp() - direct(acidx, acrte.wpname[acrte.iactwp]) + direct(traffic, acidx, acrte.wpname[acrte.iactwp]) # acid AT wpt ALT/SPD alt/spd elif len(args) >= 2: @@ -1350,10 +1366,10 @@ def at_wpt(acidx: int, atwp: Wpt, *args) -> bool | tuple: # IF command starts with aircraft id, it is not missing cmd = args[1].upper() - if cmd not in minisky.traf.callsign: + if cmd not in traffic.callsign: # Look up arg types try: - cmdobj = Command.cmddict[cmd] + cmdobj = traffic.command_registry[cmd] # Command found, check arguments argtypes = cmdobj.annotations # type: ignore[attr-defined] @@ -1361,7 +1377,7 @@ def at_wpt(acidx: int, atwp: Wpt, *args) -> bool | tuple: if ( len(argtypes) > 0 and argtypes[0] == int - and not (len(args) > 2 and args[2].upper() in minisky.traf.callsign) + and not (len(args) > 2 and args[2].upper() in traffic.callsign) ): # missing acid, so add ownship acid acrte.wpstack[wpidx].append(acid + " " + " ".join(args[1:])) @@ -1398,7 +1414,7 @@ def at_wpt(acidx: int, atwp: Wpt, *args) -> bool | tuple: # If success: update flight plan and guidance acrte.calcfp() - direct(acidx, acrte.wpname[acrte.iactwp]) + direct(traffic, acidx, acrte.wpname[acrte.iactwp]) # Waypoint not found in route else: @@ -1407,10 +1423,10 @@ def at_wpt(acidx: int, atwp: Wpt, *args) -> bool | tuple: return True -def direct(acidx: int, wpname: Wpt) -> bool: +def direct(traffic: Traffic, acidx: int, wpname: Wpt) -> bool: """Go direct to a specified waypoint in the route. - Implements the DIRECT stack command: ``DIRECT acid wpname``. Makes the + Implements the DIRECT stack command: `DIRECT acid wpname`. Makes the given waypoint the active waypoint, copies its data (position, fly-by/ fly-turn settings, next-turn data) into the active-waypoint arrays, recalculates the flight plan and the VNAV profile, sets the next-leg @@ -1424,26 +1440,26 @@ def direct(acidx: int, wpname: Wpt) -> bool: Returns: bool: True on success. """ - minisky.traf.callsign[acidx] - acrte = minisky.traf.ap.route[acidx] + traffic.callsign[acidx] + acrte = traffic.ap.route[acidx] wpidx = acrte.wpname.index(wpname) acrte.iactwp = wpidx - minisky.traf.actwp.lat[acidx] = acrte.wplat[wpidx] - minisky.traf.actwp.lon[acidx] = acrte.wplon[wpidx] - minisky.traf.actwp.flyby[acidx] = acrte.wpflyby[wpidx] - minisky.traf.actwp.flyturn[acidx] = acrte.wpflyturn[wpidx] - minisky.traf.actwp.turnrad[acidx] = acrte.wpturnrad[wpidx] - minisky.traf.actwp.turnspd[acidx] = acrte.wpturnspd[wpidx] - minisky.traf.actwp.turnhdgr[acidx] = acrte.wpturnhdgr[wpidx] + traffic.actwp.lat[acidx] = acrte.wplat[wpidx] + traffic.actwp.lon[acidx] = acrte.wplon[wpidx] + traffic.actwp.flyby[acidx] = acrte.wpflyby[wpidx] + traffic.actwp.flyturn[acidx] = acrte.wpflyturn[wpidx] + traffic.actwp.turnrad[acidx] = acrte.wpturnrad[wpidx] + traffic.actwp.turnspd[acidx] = acrte.wpturnspd[wpidx] + traffic.actwp.turnhdgr[acidx] = acrte.wpturnhdgr[wpidx] ( - minisky.traf.actwp.nextturnlat[acidx], - minisky.traf.actwp.nextturnlon[acidx], - minisky.traf.actwp.nextturnspd[acidx], - minisky.traf.actwp.nextturnrad[acidx], - minisky.traf.actwp.nextturnhdgr[acidx], - minisky.traf.actwp.nextturnidx[acidx], + traffic.actwp.nextturnlat[acidx], + traffic.actwp.nextturnlon[acidx], + traffic.actwp.nextturnspd[acidx], + traffic.actwp.nextturnrad[acidx], + traffic.actwp.nextturnhdgr[acidx], + traffic.actwp.nextturnidx[acidx], ) = acrte.getnextturnwp() # Determine next turn waypoint data @@ -1451,14 +1467,14 @@ def direct(acidx: int, wpname: Wpt) -> bool: # Do calculation for VNAV acrte.calcfp() - minisky.traf.actwp.xtoalt[acidx] = acrte.wpxtoalt[wpidx] - minisky.traf.actwp.nextaltco[acidx] = acrte.wptoalt[wpidx] + traffic.actwp.xtoalt[acidx] = acrte.wpxtoalt[wpidx] + traffic.actwp.nextaltco[acidx] = acrte.wptoalt[wpidx] - minisky.traf.actwp.torta[acidx] = acrte.wptorta[wpidx] # available for active RTA-guidance - minisky.traf.actwp.xtorta[acidx] = acrte.wpxtorta[wpidx] # available for active RTA-guidance + traffic.actwp.torta[acidx] = acrte.wptorta[wpidx] # available for active RTA-guidance + traffic.actwp.xtorta[acidx] = acrte.wpxtorta[wpidx] # available for active RTA-guidance # VNAV calculations like V/S and speed for RTA - minisky.traf.ap.ComputeVNAV( + traffic.ap.ComputeVNAV( acidx, acrte.wptoalt[wpidx], acrte.wpxtoalt[wpidx], @@ -1470,45 +1486,45 @@ def direct(acidx: int, wpname: Wpt) -> bool: if acrte.wpspd[wpidx] > 0.0: # Set target speed for autopilot - alt = minisky.traf.alt[acidx] if acrte.wpalt[wpidx] < 0.0 else acrte.wpalt[wpidx] + alt = traffic.alt[acidx] if acrte.wpalt[wpidx] < 0.0 else acrte.wpalt[wpidx] # Check for valid Mach or CAS cas = mach2cas(acrte.wpspd[wpidx], alt) if acrte.wpspd[wpidx] < 2.0 else acrte.wpspd[wpidx] # Save it for next leg - minisky.traf.actwp.nextspd[acidx] = cas + traffic.actwp.nextspd[acidx] = cas # No speed specified for next leg else: - minisky.traf.actwp.nextspd[acidx] = -999.0 + traffic.actwp.nextspd[acidx] = -999.0 qdr_, dist_ = geo.qdrdist( - minisky.traf.lat[acidx], - minisky.traf.lon[acidx], - minisky.traf.actwp.lat[acidx], - minisky.traf.actwp.lon[acidx], + traffic.lat[acidx], + traffic.lon[acidx], + traffic.actwp.lat[acidx], + traffic.actwp.lon[acidx], ) # Save leg length & direction in actwp data - minisky.traf.actwp.curlegdir[acidx] = qdr_ # [deg] - minisky.traf.actwp.curleglen[acidx] = dist_ * nm # [m] + traffic.actwp.curlegdir[acidx] = qdr_ # [deg] + traffic.actwp.curleglen[acidx] = dist_ * nm # [m] if acrte.wpflyturn[wpidx] and acrte.wpturnrad[wpidx] > 0.0: # turn radius specified turnrad = acrte.wpturnrad[wpidx] # Overwrite is hdgrate defined if acrte.wpflyturn[wpidx] and acrte.wpturnhdgr[wpidx] > 0.0: # heading rate specified - turnrad = minisky.traf.tas[acidx] * 360.0 / (2 * math.pi * acrte.wpturnhdgr[wpidx]) + turnrad = traffic.tas[acidx] * 360.0 / (2 * math.pi * acrte.wpturnhdgr[wpidx]) else: # nothing specified, use default bank ang;e turnrad = ( - minisky.traf.tas[acidx] - * minisky.traf.tas[acidx] + traffic.tas[acidx] + * traffic.tas[acidx] / math.tan(math.radians(acrte.bank)) / g0 / nm ) # [nm]default bank angle e.g. 25 deg - minisky.traf.actwp.turndist[acidx] = ( - np.logical_or(acrte.wpturnhdgr[wpidx] > 0.0, minisky.traf.actwp.flyby[acidx] > 0.5) + traffic.actwp.turndist[acidx] = ( + np.logical_or(acrte.wpturnhdgr[wpidx] > 0.0, traffic.actwp.flyby[acidx] > 0.5) * turnrad * abs( math.tan( @@ -1517,14 +1533,14 @@ def direct(acidx: int, wpname: Wpt) -> bool: ) ) # [nm] - minisky.traf.swlnav[acidx] = True + traffic.swlnav[acidx] = True return True -def set_rta(acidx: int, wpname: Wpt, time: Time) -> bool: # all arguments of setRTA +def set_rta(traffic: Traffic, acidx: int, wpname: Wpt, time: Time) -> bool: # all arguments of setRTA """Set a required time of arrival (RTA) at a route waypoint. - Implements the RTA stack command: ``RTA acid, wpname, time``. The RTA + Implements the RTA stack command: `RTA acid, wpname, time`. The RTA is stored with the waypoint and the guidance to the active waypoint is recomputed so the autopilot can adjust its speed schedule. @@ -1536,22 +1552,22 @@ def set_rta(acidx: int, wpname: Wpt, time: Time) -> bool: # all arguments of se Returns: bool: True on success. """ - minisky.traf.callsign[acidx] - acrte = minisky.traf.ap.route[acidx] + traffic.callsign[acidx] + acrte = traffic.ap.route[acidx] wpidx = acrte.wpname.index(wpname) acrte.wprta[wpidx] = time # Recompute route and update actwp because of RTA addition - direct(acidx, acrte.wpname[acrte.iactwp]) + direct(traffic, acidx, acrte.wpname[acrte.iactwp]) return True -def listrte(acidx: int, ipagetxt: str = "0") -> tuple | None: +def listrte(traffic: Traffic, acidx: int, ipagetxt: str = "0") -> tuple | None: """Show the route of an aircraft in the console, page by page. - Implements the LISTRTE stack command: ``LISTRTE acid, [pagenr]``. - Each line shows the waypoint name (active waypoint marked with ``*``), + Implements the LISTRTE stack command: `LISTRTE acid, [pagenr]`. + Each line shows the waypoint name (active waypoint marked with `*`), its altitude constraint (ft or FL), speed constraint (kts or Mach) and type ([orig], [dest], [C] fly-by, [|] fly-over, [U] fly-turn). Seven waypoints are shown per page. @@ -1565,7 +1581,7 @@ def listrte(acidx: int, ipagetxt: str = "0") -> tuple | None: """ # First get the appropriate ac route ipage = int(ipagetxt) - acrte = minisky.traf.ap.route[acidx] + acrte = traffic.ap.route[acidx] n_wpt = len(acrte.wpname) @@ -1612,14 +1628,14 @@ def listrte(acidx: int, ipagetxt: str = "0") -> tuple | None: txt += "[|]" # Display message - minisky.scr.echo(txt) + traffic.console.echo(txt) -def delrte(acidx: int | None = None) -> bool | tuple: +def delrte(traffic: Traffic, acidx: int | None = None) -> bool | tuple: """Delete the complete route (including origin/destination) of an aircraft. - Implements the DELRTE stack command: ``DELRTE acid``. The route is + Implements the DELRTE stack command: `DELRTE acid`. The route is re-initialized empty and LNAV/VNAV are disengaged. When no callsign is given and exactly one aircraft exists, that aircraft is used. @@ -1630,28 +1646,28 @@ def delrte(acidx: int | None = None) -> bool | tuple: bool or tuple: True on success, or (False, error message). """ if acidx is None: - if minisky.traf.ntraf == 0: + if traffic.ntraf == 0: return False, "No aircraft in simulation" - if minisky.traf.ntraf > 1: + if traffic.ntraf > 1: return False, "Specify callsign of aircraft to delete route of" acidx = 0 # Simple re-initialize this route as empty - acid = minisky.traf.callsign[acidx] - acrte = minisky.traf.ap.route[acidx] + acid = traffic.callsign[acidx] + acrte = traffic.ap.route[acidx] acrte.__init__(acid) # Also disable LNAV,VNAV if route is deleted - minisky.traf.swlnav[acidx] = False - minisky.traf.swvnav[acidx] = False - minisky.traf.swvnavspd[acidx] = False + traffic.swlnav[acidx] = False + traffic.swvnav[acidx] = False + traffic.swvnavspd[acidx] = False return True -def delwpt(acidx: int, wpname: Wpt) -> bool | tuple: +def delwpt(traffic: Traffic, acidx: int, wpname: Wpt) -> bool | tuple: """Delete a single waypoint from the route of an aircraft. - Implements the DELWPT stack command: ``DELWPT acid, wpname``. When the + Implements the DELWPT stack command: `DELWPT acid, wpname`. When the deleted waypoint is the active one (and not the last), guidance is redirected to the following waypoint. LNAV/VNAV are disengaged when the route becomes empty. @@ -1665,7 +1681,7 @@ def delwpt(acidx: int, wpname: Wpt) -> bool | tuple: """ # Look up waypoint - acrte = minisky.traf.ap.route[acidx] + acrte = traffic.ap.route[acidx] n_wpt = len(acrte.wpname) try: @@ -1676,7 +1692,7 @@ def delwpt(acidx: int, wpname: Wpt) -> bool | tuple: # check if active way point is the one being deleted and that it is not the last wpt. # If active wpt is deleted then change path of aircraft if acrte.iactwp == wpidx and wpidx != n_wpt - 1: - direct(acidx, acrte.wpname[wpidx + 1]) + direct(traffic, acidx, acrte.wpname[wpidx + 1]) n_wpt = n_wpt - 1 @@ -1701,8 +1717,8 @@ def delwpt(acidx: int, wpname: Wpt) -> bool | tuple: # If no waypoints left, make sure to disable LNAV/VNAV if n_wpt == 0 and (acidx or acidx == 0): - minisky.traf.swlnav[acidx] = False - minisky.traf.swvnav[acidx] = False - minisky.traf.swvnavspd[acidx] = False + traffic.swlnav[acidx] = False + traffic.swvnav[acidx] = False + traffic.swvnavspd[acidx] = False return True diff --git a/minisky/traffic/traffic.py b/minisky/traffic/traffic.py index f36ae75..a47a9b7 100644 --- a/minisky/traffic/traffic.py +++ b/minisky/traffic/traffic.py @@ -136,6 +136,7 @@ def __init__( console: ConsoleIO, get_simulation: Callable[[], Simulation], stack_command: Callable[..., None], + get_command_registry: Callable[[], dict[str, object]], select_implementation: Callable[[str, str], tuple[bool, str]], ) -> None: super().__init__() @@ -145,6 +146,7 @@ def __init__( self.console = console self._get_simulation = get_simulation self.stack_command = stack_command + self._get_command_registry = get_command_registry self.select_implementation = select_implementation self.ntraf = 0 @@ -207,12 +209,12 @@ def __init__( # Flight Models self.cd = ConflictDetection(settings, self, stack_command) self.cr = ConflictResolution(settings, self, select_implementation) - self.ap = Autopilot() + self.ap = Autopilot(self, get_simulation) self.aporasas = APorASAS(self) self.noise = SurveillanceUncertainty(self, get_simulation) self.trails = Trails(self, get_simulation) self.actwp = ActiveWaypoint(self) - self.perf = OpenAP() + self.perf = OpenAP(self) # Group Logic self.groups = TrafficGroups(self, areas) @@ -237,6 +239,11 @@ def __init__( # Default bank angles per flight phase self.bphase = np.deg2rad(np.array([15, 35, 35, 35, 15, 45])) + @property + def command_registry(self) -> dict[str, object]: + """Return the command registry owned by this runtime.""" + return self._get_command_registry() + @property def simulation(self) -> Simulation: """Return the simulation that owns this traffic object.""" diff --git a/tests/integration/test_route_autopilot.py b/tests/integration/test_route_autopilot.py index 1423114..b82b913 100644 --- a/tests/integration/test_route_autopilot.py +++ b/tests/integration/test_route_autopilot.py @@ -98,7 +98,7 @@ class TestRouteEditing: def test_addwpt_accepts_string_callsign(self, bs, run_cmd, aircraft): # addwpt() with a callsign string used to crash on the callsign lookup - result = bs.traffic.route.addwpt(aircraft, "52.5,5.0") + result = bs.traffic.route.addwpt(bs.traf, aircraft, "52.5,5.0") assert result is True route = bs.traf.ap.route[0] assert route.wplat[0] == pytest.approx(52.5) @@ -108,7 +108,7 @@ def test_direct_switches_active_waypoint(self, bs, run_cmd, aircraft): run_cmd(f"ADDWPT {aircraft} 52.5,5.0") run_cmd(f"ADDWPT {aircraft} 53.0,6.0") route = bs.traf.ap.route[0] - assert bs.traffic.route.direct(0, route.wpname[1]) is True + assert bs.traffic.route.direct(bs.traf, 0, route.wpname[1]) is True assert route.iactwp == 1 assert bs.traf.actwp.lat[0] == pytest.approx(53.0) @@ -142,7 +142,7 @@ def test_at_wpt_sets_alt_and_spd_constraints(self, bs, run_cmd, aircraft): run_cmd(f"ADDWPT {aircraft} 52.5,5.0") run_cmd(f"ADDWPT {aircraft} 53.0,6.0") route = bs.traf.ap.route[0] - result = bs.traffic.route.at_wpt(0, route.wpname[1], "FL090/250") + result = bs.traffic.route.at_wpt(bs.traf, 0, route.wpname[1], "FL090/250") assert result is True assert route.wpalt[1] == pytest.approx(9000 * FT, rel=1e-3) assert route.wpspd[1] == pytest.approx(250 * KTS, rel=1e-3) From c6e441b228661bb7e9731b1b17d018ad92d4a11a Mon Sep 17 00:00:00 2001 From: Abraham Cheung <58929011+abc8747@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:04:28 +0800 Subject: [PATCH 09/16] refactor: make `Minisky` own runtime streaming - streaming is now directly driven by the owning `Simulation` after each step --- example_plugins/customautopilot.py | 13 +- example_plugins/tangram.py | 69 ++++-- minisky/cli.py | 40 +++- minisky/runtime.py | 5 + minisky/server.py | 265 +++++++++++++---------- minisky/simulation/simulation.py | 12 +- minisky/streaming.py | 91 ++++---- tests/conftest.py | 18 +- tests/integration/test_streaming.py | 35 ++- tests/integration/test_tangram_bridge.py | 12 +- tests/test_api.py | 39 ++-- 11 files changed, 365 insertions(+), 234 deletions(-) diff --git a/example_plugins/customautopilot.py b/example_plugins/customautopilot.py index fefff45..c8bb320 100644 --- a/example_plugins/customautopilot.py +++ b/example_plugins/customautopilot.py @@ -22,8 +22,17 @@ - ConflictResolution: CR algorithm (traf.cr) """ +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING + from minisky.traffic.autopilot import Autopilot +if TYPE_CHECKING: + from minisky.simulation import Simulation + from minisky.traffic import Traffic + def init_plugin(): config = {"plugin_name": "CUSTOMAUTOPILOT"} @@ -37,8 +46,8 @@ class CustomAutoPilot(Autopilot): Select it with: SELECTIMPL AUTOPILOT CUSTOMAUTOPILOT """ - def __init__(self): - super().__init__() + def __init__(self, traffic: Traffic, get_simulation: Callable[[], Simulation]) -> None: + super().__init__(traffic, get_simulation) # Add custom instance variables here self.new_variable = 10 diff --git a/example_plugins/tangram.py b/example_plugins/tangram.py index a30552a..2fd1327 100644 --- a/example_plugins/tangram.py +++ b/example_plugins/tangram.py @@ -1,7 +1,7 @@ """Tangram bridge: stream MiniSky state to a tangram map over Redis pub/sub. This plugin makes a running MiniSky process act as an *external simulator* -for `tangram `_. It talks to +for [tangram](https://github.com/open-aviation/tangram). It talks to tangram exclusively through Redis, using tangram's stable channel convention (see `docs/architecture/channel.md` in the tangram repo): @@ -23,18 +23,22 @@ simulation is paused (plugin update hooks only fire in the OP state; the command stack itself is processed in every state). -Settings (optional, under a ``[tangram]`` table in `settings.toml`): +Settings (optional, under a `[tangram]` table in `settings.toml`): - `redis_url`: Redis connection URL (default `redis://127.0.0.1:6379`). - `channel`: channel/topic name (default `minisky`). - `max_hz`: wall-clock cap on snapshot publish rate (default 5). -Debug the transport without any frontend:: +Debug the transport without any frontend: - redis-cli psubscribe "to:*" - redis-cli publish "from:minisky:command" '{"command": "ECHO hello"}' +```console +redis-cli psubscribe "to:*" +redis-cli publish "from:minisky:command" '{"command": "ECHO hello"}' +``` """ +from __future__ import annotations + import json import queue import threading @@ -42,7 +46,7 @@ from collections import deque from collections.abc import Callable from datetime import UTC, datetime -from typing import Any, TypedDict, cast +from typing import TYPE_CHECKING, Any, TypedDict, cast from pydantic import BaseModel, ConfigDict, Field @@ -52,9 +56,13 @@ from minisky.streaming import Snapshot, build_snapshot from minisky.tools.aero import fpm, ft, kts +if TYPE_CHECKING: + from minisky.simulation import ConsoleIO, Runner, Simulation + from minisky.traffic import Traffic + class TangramSettings(BaseModel): - """Validated ``[tangram]`` config from ``settings.toml``.""" + """Validated `[tangram]` config from `settings.toml`.""" model_config = ConfigDict(extra="forbid", frozen=True) @@ -213,11 +221,25 @@ def __init__( redis_url: str, channel: str, max_hz: float, + snapshot_builder: Callable[[], Snapshot], + console: ConsoleIO, + simulation: Simulation, + runner: Runner, + traffic: Traffic, + get_scenname: Callable[[], str], + stack_command: Callable[[str], None], redis_factory: Callable[[str], Any] | None = None, ) -> None: self.redis_url = redis_url self.channel = channel self.min_interval = 1.0 / max_hz if max_hz > 0 else 0.0 + self.snapshot_builder = snapshot_builder + self.console = console + self.simulation = simulation + self.runner = runner + self.traffic = traffic + self.get_scenname = get_scenname + self.stack_command = stack_command self.redis_factory = redis_factory self.connected = False @@ -269,12 +291,12 @@ def tick(self) -> None: if now - self._last_build < self.min_interval: return self._last_build = now - self._enqueue(convert_snapshot(build_snapshot())) + self._enqueue(convert_snapshot(self.snapshot_builder())) def reset(self) -> None: """Reset hook: push an empty payload so the frontend clears the map.""" self._last_payload = None - self._enqueue(convert_snapshot(build_snapshot())) + self._enqueue(convert_snapshot(self.snapshot_builder())) def _enqueue(self, payload: TangramPayload) -> None: self._last_payload = payload @@ -290,37 +312,36 @@ def _enqueue(self, payload: TangramPayload) -> None: def _tee_console(self) -> None: """Also capture everything echoed to the console, without consuming it.""" - scr = minisky.scr - original_echo = scr.echo + original_echo = self.console.echo def echo(text: str = "", flag: int = 0) -> None: original_echo(text, flag) if text: self._console.extend(text.splitlines()) - scr.echo = echo # type: ignore[method-assign] + self.console.echo = echo # type: ignore[method-assign] # -- Redis-thread side ------------------------------------------------- def _siminfo_heartbeat(self) -> TangramPayload: """Refresh the cheap scalar fields of the last payload. - Only reads scalar attributes of the singletons (safe enough from a - second thread); the aircraft list and conflict counters are reused + Only reads scalar attributes of the injected runtime components (safe + enough from a second thread); the aircraft list and conflict counters are reused from the last snapshot built on the simulation thread. """ last = self._last_payload - sim = minisky.sim + sim = self.simulation state = int(sim.state) siminfo: TangramSimInfo = { "simt": float(sim.simt), "simdt": float(sim.simdt), "simutc": sim.utc.isoformat(), - "speed": float(minisky.runner.speed) if minisky.runner else 1.0, - "ntraf": int(minisky.traf.ntraf), + "speed": float(self.runner.speed), + "ntraf": int(self.traffic.ntraf), "state": state, "state_name": SIM_STATE_NAMES.get(state, "?"), - "scenname": stack.get_scenname(), + "scenname": self.get_scenname(), "nconf_cur": last["siminfo"]["nconf_cur"] if last is not None else 0, "nlos_cur": last["siminfo"]["nlos_cur"] if last is not None else 0, } @@ -354,7 +375,7 @@ def _run(self) -> None: if topic == command_topic: cmd = extract_command(message.get("data", "")) if cmd: - stack.stack(cmd) + self.stack_command(cmd) published = False while True: @@ -409,10 +430,20 @@ def init_plugin() -> dict[str, Any]: # TODO(abraham): we should namespace it under settings.plugins.tangram. cfg = TangramPluginSettings.model_validate(settings.default_settings).tangram + command_stack = stack.current() bridge = TangramBridge( redis_url=cfg.redis_url, channel=cfg.channel, max_hz=cfg.max_hz, + snapshot_builder=lambda: build_snapshot( + minisky.sim, minisky.traf, minisky.runner, command_stack + ), + console=minisky.scr, + simulation=minisky.sim, + runner=minisky.runner, + traffic=minisky.traf, + get_scenname=command_stack.get_scenname, + stack_command=command_stack.stack, ) success, msg = bridge.start() minisky.scr.echo(msg) diff --git a/minisky/cli.py b/minisky/cli.py index ca78006..cb4d0b5 100644 --- a/minisky/cli.py +++ b/minisky/cli.py @@ -1,12 +1,14 @@ """Command-line interface for MiniSky.""" +from __future__ import annotations + import asyncio import json import os import subprocess from pathlib import Path from pprint import pprint -from typing import Annotated +from typing import TYPE_CHECKING, Annotated import requests import typer @@ -16,6 +18,9 @@ from prompt_toolkit.completion import NestedCompleter, PathCompleter from prompt_toolkit.history import FileHistory +if TYPE_CHECKING: + from minisky.runtime import MiniSky + app = typer.Typer(help="MiniSky command-line tools.", no_args_is_help=True) commands_app = typer.Typer(help="Inspect or regenerate stack-command documentation.") docs_app = typer.Typer(help="Build or serve the documentation site.") @@ -49,15 +54,25 @@ """ +def _new_runtime(scenario: str | None = None) -> MiniSky: + """Construct a runtime from the default settings and discover plugins.""" + from minisky import MiniSky, MiniSkySettings, filename_settings, plugin + + settings = MiniSkySettings.from_file(filename_settings) + runtime = MiniSky(settings, scenario) + plugin.discover() + return runtime + + async def _run_scenario(scenario: str, speed: int) -> None: """Initialise the simulator with a scenario and run it to completion.""" - import minisky + from minisky import plugin - minisky.init(scenario=scenario) - minisky.load_plugins() - minisky.runner.speed = speed + runtime = _new_runtime(scenario) + plugin.load_enabled() + runtime.runner.speed = speed - await minisky.runner.run() + await runtime.run() @app.command("run") @@ -82,7 +97,13 @@ def server_cmd( """Start the REST and WebSocket API server.""" import uvicorn - uvicorn.run("minisky.server:app", host=host, port=port, reload=reload) + uvicorn.run( + "minisky.server:create_app", + factory=True, + host=host, + port=port, + reload=reload, + ) @app.command("console") @@ -163,14 +184,13 @@ def stream_cmd( def _build_command_rows() -> list[str]: - import minisky from minisky.stack import Command - minisky.init() + runtime = _new_runtime() primary: dict[str, Command] = {} synonyms: dict[str, list[str]] = {} - for name, cmdobj in sorted(Command.cmddict.items()): + for name, cmdobj in sorted(runtime.commands.cmddict.items()): if cmdobj.name == name: primary[name] = cmdobj else: diff --git a/minisky/runtime.py b/minisky/runtime.py index d0f6139..807cf2f 100644 --- a/minisky/runtime.py +++ b/minisky/runtime.py @@ -8,6 +8,7 @@ from minisky.simulation import ConsoleIO, Runner, Simulation from minisky.simulation.simulation import OP from minisky.stack import CommandStack +from minisky.streaming import StreamHub, build_snapshot from minisky.tools.areafilter import AreaFilter from minisky.tools.navdata import Navdatabase from minisky.traffic import Traffic @@ -45,6 +46,9 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No get_simulation=lambda: self.simulation, get_runner=lambda: self.runner, ) + self.streaming = StreamHub( + lambda: build_snapshot(self.simulation, self.traffic, self.runner, self.commands) + ) self.simulation = Simulation( traffic=self.traffic, navigation=self.navigation, @@ -52,6 +56,7 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No command_stack=self.commands, areas=self.areas, stop_runner=self._stop_runner, + publish_tick=self.streaming.publish_tick, ) self.runner = Runner(self.simulation, self.console) self.variables.init(self.simulation, self.traffic) diff --git a/minisky/server.py b/minisky/server.py index c37f9d8..f200ff8 100644 --- a/minisky/server.py +++ b/minisky/server.py @@ -1,132 +1,168 @@ """MiniSky REST + streaming API server. -FastAPI application that wraps a live simulation: the simulator is initialised -at import time and stepped continuously by the async Runner once the server -starts. Endpoints expose aircraft state, conflict information, simulation-time -control, plugin management, a passthrough for any stack command, a per-tick -push stream (``GET /stream``, WebSocket), and the command dictionary -(``GET /commands``). +The FastAPI application wraps an explicit [`MiniSky`][minisky.runtime.MiniSky] +runtime and steps it continuously with its async runner while the server is +active. Endpoints expose aircraft state, conflict information, simulation-time +control, plugin management, a passthrough for stack commands, a per-tick push +stream (`GET /stream`, WebSocket), and the command dictionary (`GET /commands`). -This module holds the FastAPI application object (``app``). The supported CLI entry -point is ``minisky server``. +[`create_app`][] constructs the application and stores its runtime on +`app.state.runtime`. The supported CLI entry point is `minisky server`. -Run with:: +Run with: - minisky server # CLI server command (uvicorn) - minisky server --reload # development, auto-reload +```console +minisky server # CLI server command (uvicorn) +minisky server --reload # development, auto-reload +``` -Interactive OpenAPI docs are served at ``/docs``. +Interactive OpenAPI docs are served at `/docs`. """ import asyncio import os -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, suppress from io import StringIO -from typing import Any +from typing import Annotated, Any, cast import pandas as pd -from fastapi import FastAPI, File, Response, UploadFile, WebSocket, WebSocketDisconnect +from fastapi import ( + APIRouter, + Depends, + FastAPI, + File, + Request, + Response, + UploadFile, + WebSocket, + WebSocketDisconnect, +) from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles -import minisky -from minisky.streaming import hub, register_stream_hook +from minisky import MiniSky, MiniSkySettings, filename_settings, plugin from minisky.tools import aero +router = APIRouter() + + +def _get_runtime(request: Request) -> MiniSky: + """Return the runtime owned by the current FastAPI application.""" + return cast(MiniSky, request.app.state.runtime) + + +Runtime = Annotated[MiniSky, Depends(_get_runtime)] + @asynccontextmanager async def lifespan(app: FastAPI): - """Start the simulation loop as a background task in the server's event loop.""" - task = asyncio.create_task(minisky.runner.run()) - yield - task.cancel() + """Run the app-owned simulator for the lifetime of the API server.""" + runtime = cast(MiniSky, app.state.runtime) + task = asyncio.create_task(runtime.run()) + try: + yield + finally: + runtime.runner.running = False + task.cancel() + with suppress(asyncio.CancelledError): + await task -app = FastAPI(lifespan=lifespan) +def create_app(runtime: MiniSky | None = None) -> FastAPI: + """Create a FastAPI application owning a simulator runtime.""" + if runtime is None: + settings = MiniSkySettings.from_file(filename_settings) + runtime = MiniSky(settings) -# Static files live at the repository root (../static relative to this package), -# which resolves correctly for both a source checkout and an editable install. -static_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static") -os.makedirs(static_dir, exist_ok=True) # Create static directory if it doesn't exist -app.mount("/static", StaticFiles(directory=static_dir), name="static") + # TODO(abraham): migrate the plugin ownership + plugin.discover() + plugin.load_enabled() -minisky.init() -minisky.load_plugins() -# Publish a snapshot on every simulation step for the /stream endpoint. -register_stream_hook() + app = FastAPI(lifespan=lifespan) + app.state.runtime = runtime + app.include_router(router) + # Static files live at the repository root (../static relative to this + # package), which resolves correctly for both a source checkout and an + # editable install. + static_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static") + os.makedirs(static_dir, exist_ok=True) + app.mount("/static", StaticFiles(directory=static_dir), name="static") + return app -@app.get("/") + +@router.get("/") def root() -> dict[str, str]: """Health check: confirm the API is up.""" return {"msg": "MiniSky API endpoint ready"} -@app.get("/all") -def all() -> list[dict[str, Any]]: - """Get all aircraft states""" +@router.get("/all") +def all(runtime: Runtime) -> list[dict[str, Any]]: + """Get all aircraft states.""" + traffic = runtime.traffic df = pd.DataFrame( { - "callsign": minisky.traf.callsign, - "typecode": minisky.traf.typecode, - "latitude": minisky.traf.lat, - "longitude": minisky.traf.lon, - "altitude (feet)": (minisky.traf.alt / aero.ft).astype(int), - "heading (degrees)": minisky.traf.hdg.astype(int), - "assigned heading (degrees)": minisky.traf.aporasas.hdg.astype(int), - "track (degrees)": minisky.traf.trk, - "TAS (knots)": (minisky.traf.tas / aero.kts).astype(int), - "groundspeed (knots)": (minisky.traf.gs / aero.kts).astype(int), - "CAS (knots)": (minisky.traf.cas / aero.kts).astype(int), - "mach": minisky.traf.M, - "vertical_rate (feet/minute)": (minisky.traf.vs / aero.fpm).astype(int), - "target altitude (feet)": (minisky.traf.selalt / aero.ft).astype(int), - "assigned speed (knots)": (minisky.traf.selspd / aero.kts).astype(int), + "callsign": traffic.callsign, + "typecode": traffic.typecode, + "latitude": traffic.lat, + "longitude": traffic.lon, + "altitude (feet)": (traffic.alt / aero.ft).astype(int), + "heading (degrees)": traffic.hdg.astype(int), + "assigned heading (degrees)": traffic.aporasas.hdg.astype(int), + "track (degrees)": traffic.trk, + "TAS (knots)": (traffic.tas / aero.kts).astype(int), + "groundspeed (knots)": (traffic.gs / aero.kts).astype(int), + "CAS (knots)": (traffic.cas / aero.kts).astype(int), + "mach": traffic.M, + "vertical_rate (feet/minute)": (traffic.vs / aero.fpm).astype(int), + "target altitude (feet)": (traffic.selalt / aero.ft).astype(int), + "assigned speed (knots)": (traffic.selspd / aero.kts).astype(int), } ) return df.to_dict(orient="records") -@app.get("/simtime") -def simtime() -> dict[str, float]: - """Get the simulation time""" - return {"simulation time (seconds)": minisky.sim.simt} +@router.get("/simtime") +def simtime(runtime: Runtime) -> dict[str, float]: + """Get the simulation time.""" + return {"simulation time (seconds)": runtime.simulation.simt} -@app.get("/speed/{speed}") -def speedup(speed: float) -> dict[str, str]: - """Speed up the simulation""" - minisky.runner.speed = speed +@router.get("/speed/{speed}") +def speedup(speed: float, runtime: Runtime) -> dict[str, str]: + """Speed up the simulation.""" + runtime.runner.speed = speed return {"msg": f"simulation speed set to {speed}x"} -@app.get("/forward/{seconds}") -def forward(seconds: float) -> dict[str, str]: - """Jump to a specific simulation time""" - minisky.runner.forward(seconds) +@router.get("/forward/{seconds}") +def forward(seconds: float, runtime: Runtime) -> dict[str, str]: + """Jump to a specific simulation time.""" + runtime.runner.forward(seconds) return {"msg": f"simulation time jump forward {seconds} seconds"} -@app.get("/conflicts") -def conflicts() -> list[dict[str, Any]] | dict[str, str]: +@router.get("/conflicts") +def conflicts(runtime: Runtime) -> list[dict[str, Any]] | dict[str, str]: """Get all detected conflicts. Returns one record per unique aircraft pair with distance (NM), altitude difference (ft), bearing (deg), time to loss of separation (s), and distance and time to the closest point of approach (m, s). """ - if not hasattr(minisky.traf.cd, "confpairs") or not len(minisky.traf.cd.confpairs): + detection = runtime.traffic.cd + if not detection.confpairs: return {"msg": "No conflicts detected"} - # Ensure there's a structure to hold TCPA for each conflict pair - if not hasattr(minisky.traf.cd, "tcpa") or len(minisky.traf.cd.tcpa) == 0: + if len(detection.tcpa) == 0: return {"msg": "No TCPA data available"} processed_pairs = [] conflict_info = [] - for i, pair in enumerate(minisky.traf.cd.confpairs): + for i, pair in enumerate(detection.confpairs): if set(pair) in processed_pairs: continue @@ -135,54 +171,54 @@ def conflicts() -> list[dict[str, Any]] | dict[str, str]: conflict_info.append( { "conflict pairs": pair, - "distance (nautical miles)": (minisky.traf.cd.dist[i] / aero.nm), - "altitude difference (feet)": (minisky.traf.cd.dalt[i] / aero.ft), - "qdr (degrees)": minisky.traf.cd.qdr[i], - "tlos (seconds)": minisky.traf.cd.tLOS[i], - "dcpa (meters)": minisky.traf.cd.dcpa[i], - "tcpa (seconds)": minisky.traf.cd.tcpa[i], + "distance (nautical miles)": detection.dist[i] / aero.nm, + "altitude difference (feet)": detection.dalt[i] / aero.ft, + "qdr (degrees)": detection.qdr[i], + "tlos (seconds)": detection.tLOS[i], + "dcpa (meters)": detection.dcpa[i], + "tcpa (seconds)": detection.tcpa[i], } ) return conflict_info -@app.get("/stack/{cmd:path}") -async def stack(cmd: str) -> dict[str, Any]: - """Execute a stack command and return the output""" - minisky.scr.event.clear() - minisky.stack.stack(f"{cmd}") - await minisky.scr.event.wait() - msg = minisky.scr.read_output_buffer() - minisky.scr.event.clear() +@router.get("/stack/{cmd:path}") +async def stack(cmd: str, runtime: Runtime) -> dict[str, Any]: + """Execute a stack command and return the output.""" + runtime.console.event.clear() + runtime.commands.stack(cmd) + await runtime.console.event.wait() + msg = runtime.console.read_output_buffer() + runtime.console.event.clear() return {"command to minisky": cmd, "message": msg} -@app.get("/commands") -def commands() -> dict[str, str]: - """Return the command dictionary as ``{name: brief usage}``. +@router.get("/commands") +def commands(runtime: Runtime) -> dict[str, str]: + """Return the command dictionary as `{name: brief usage}`. - Deduplicates aliases (which share a ``Command`` object) and reports each - command under its canonical name, so a console/autocomplete client can list - the available stack commands and their usage. + Deduplicates aliases, which share a [`Command`][minisky.stack.Command] + object, and reports each command under its canonical name so a console or + autocomplete client can list the available commands and their usage. """ - from minisky.stack import Command - seen: dict[str, str] = {} - for cmdobj in dict.fromkeys(Command.cmddict.values()): + for cmdobj in dict.fromkeys(runtime.commands.cmddict.values()): seen[cmdobj.name] = cmdobj.brief return dict(sorted(seen.items())) -@app.websocket("/stream") +@router.websocket("/stream") async def stream(websocket: WebSocket) -> None: - """Push a full simulation snapshot once per sim step (SI units). + """Push a full simulation snapshot once per simulation step in SI units. - Emits one JSON message per published tick (rate-capped, see - :data:`minisky.streaming.STREAM_MAX_HZ`) containing ``siminfo`` and - ``acdata`` as built by :func:`minisky.streaming.build_snapshot`. The most - recent snapshot is sent immediately on connect so a new client is not left - blank until the next tick. + Emits one JSON message per published tick, rate-capped by + [`STREAM_MAX_HZ`][minisky.streaming.STREAM_MAX_HZ], containing `siminfo` + and `acdata` as built by [`build_snapshot`][minisky.streaming.build_snapshot]. + The most recent snapshot is sent immediately on connect so a new client is + not left blank until the next tick. """ + runtime = cast(MiniSky, websocket.app.state.runtime) + hub = runtime.streaming await websocket.accept() hub.subscribe() try: @@ -200,7 +236,7 @@ async def stream(websocket: WebSocket) -> None: hub.unsubscribe() -@app.get("/scn") +@router.get("/scn") def upload_form() -> Response: """Serve a minimal HTML form for uploading a scenario file.""" content = """ @@ -213,47 +249,46 @@ def upload_form() -> Response: return Response(content=content, media_type="text/html") -@app.post("/scn") -async def scn(file: UploadFile = File(...)) -> dict[str, str]: +@router.post("/scn") +async def scn(runtime: Runtime, file: UploadFile = File(...)) -> dict[str, str]: """Load an uploaded scenario file into the running simulation.""" - minisky.scr.event.clear() + runtime.console.event.clear() contents = await file.read() scenario = StringIO(contents.decode("utf-8")) filename = file.filename or "uploaded.scn" - minisky.stack.ic_StringIO(scenario, filename) + runtime.commands.ic_StringIO(scenario, filename) return {"msg": f"scenario {filename} loaded"} -@app.get("/map") +@router.get("/map") def show_map() -> RedirectResponse: - """Display the aircraft map viewer""" + """Display the aircraft map viewer.""" return RedirectResponse(url="/static/display.html") -@app.get("/plugins") +@router.get("/plugins") def list_plugins() -> Any: - """List available and loaded plugins""" - return minisky.plugin.manage_plugins("LIST") + """List available and loaded plugins.""" + return plugin.manage_plugins("LIST") -@app.get("/plugins/load/{name}") +@router.get("/plugins/load/{name}") def load_plugin(name: str) -> Any: - """Load a plugin by name""" - return minisky.plugin.manage_plugins("LOAD", name) + """Load a plugin by name.""" + return plugin.manage_plugins("LOAD", name) def main() -> None: """Console-script entry point: serve the API with uvicorn. - Host and port are read from the ``MINISKY_HOST`` (default ``0.0.0.0``) and - ``MINISKY_PORT`` (default ``8000``) environment variables. This is the - retained for direct module execution. + Host and port are read from `MINISKY_HOST` (default `0.0.0.0`) and + `MINISKY_PORT` (default `8000`). """ import uvicorn host = os.environ.get("MINISKY_HOST", "0.0.0.0") port = int(os.environ.get("MINISKY_PORT", "8000")) - uvicorn.run(app, host=host, port=port) + uvicorn.run("minisky.server:create_app", factory=True, host=host, port=port) if __name__ == "__main__": diff --git a/minisky/simulation/simulation.py b/minisky/simulation/simulation.py index bafc9da..960d2b8 100644 --- a/minisky/simulation/simulation.py +++ b/minisky/simulation/simulation.py @@ -66,6 +66,7 @@ def __init__( command_stack: CommandStack, areas: AreaFilter, stop_runner: Callable[[], None], + publish_tick: Callable[[], None], ) -> None: self.traffic = traffic self.navigation = navigation @@ -73,6 +74,7 @@ def __init__( self.commands = command_stack self.areas = areas self.stop_runner = stop_runner + self.publish_tick = publish_tick self.state = INIT self.prevstate = None @@ -110,6 +112,7 @@ def step(self) -> None: 3. While in `OP`: advance `simt` and the simulated UTC clock by `simdt` seconds, run plugin `preupdate` hooks (including timers), update all aircraft, then run plugin `update` hooks. + 4. Publish the runtime stream snapshot when subscribers are present. """ # Simulation starts as soon as there is traffic, or pending commands if self.state == INIT and ( @@ -133,13 +136,10 @@ def step(self) -> None: # Plugin post-update hooks PluginManager.update() - else: - # Plugin hooks (and with them the stream snapshot hook) only run - # in OP; publish here too so stream consumers still see state - # changes while in INIT/HOLD/END. No-op without subscribers. - from minisky.streaming import hub - hub.publish_tick() + # Publish after command and state processing in every simulation state. + # This is a no-op when the runtime has no stream subscribers. + self.publish_tick() def stop(self) -> None: """Stop the simulation (stack STOP/QUIT command). diff --git a/minisky/streaming.py b/minisky/streaming.py index 2be870f..73ca87d 100644 --- a/minisky/streaming.py +++ b/minisky/streaming.py @@ -1,32 +1,38 @@ """Per-tick streaming of simulation state. -Provides a small, transport-agnostic mechanism to push a full snapshot of the -simulation once per timestep. :func:`build_snapshot` reads the singletons -(``minisky.sim``, ``minisky.traf``) and returns a plain, JSON-serialisable dict -in **SI units**; :class:`StreamHub` fans that snapshot out to any number of -awaiting consumers (e.g. WebSocket connections in :mod:`minisky.server`). +Provides a small, transport-agnostic mechanism to push a full snapshot of one +simulation runtime once per timestep. [`build_snapshot`][] receives the runtime +explicitly and returns a plain, JSON-serialisable dict in **SI units**; +[`StreamHub`][] fans that snapshot out to any number of awaiting consumers +(e.g. WebSocket connections in `minisky.server`). This is a generic streaming API: it emits raw SI state and takes no position on any particular client or wire contract. Unit conversion and field mapping to a specific consumer's format happen downstream, in that consumer, not here. -The snapshot shape is defined by the :class:`Snapshot` / :class:`SimInfo` / -:class:`AcData` TypedDicts below. +The snapshot shape is defined by the [`Snapshot`][], [`SimInfo`][], and +[`AcData`][] TypedDicts below. -Units on the wire here are SI: positions in decimal degrees, ``alt`` in metres, -speeds (``tas``/``cas``/``gs``) in m/s, ``vs`` in m/s, ``trk`` in degrees, -``simt``/``simdt`` in seconds. ``state`` is the numeric simulation state +Units on the wire here are SI: positions in decimal degrees, `alt` in metres, +speeds (`tas`/`cas`/`gs`) in m/s, `vs` in m/s, `trk` in degrees, +`simt`/`simdt` in seconds. `state` is the numeric simulation state (0=INIT, 1=HOLD, 2=OP, 3=END). Each tick is a full snapshot; aircraft are -identified by ``callsign`` for their lifetime. +identified by `callsign` for their lifetime. """ +from __future__ import annotations + import asyncio import time -from typing import Any, TypedDict, cast +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, TypedDict, cast import numpy as np -import minisky +if TYPE_CHECKING: + from minisky.simulation import Runner, Simulation + from minisky.stack import CommandStack + from minisky.traffic import Traffic # Default upper bound on how often a snapshot is published, in Hz. The # simulation may step much faster than this in fast-forward; publishing is @@ -81,27 +87,29 @@ def _tolist(arr: Any) -> list[float]: return list(arr) -def build_snapshot() -> Snapshot: - """Build a full snapshot of the current simulation state (SI units). - - Reads ``minisky.sim`` and ``minisky.traf`` and returns a plain dict of - Python scalars and lists (no numpy types), safe to serialise as JSON. +def build_snapshot( + simulation: Simulation, + traffic: Traffic, + runner: Runner, + commands: CommandStack, +) -> Snapshot: + """Build a full snapshot from explicit runtime components in SI units. Returns: - A :class:`Snapshot` with ``siminfo`` and ``acdata`` keys. + A [`Snapshot`][] with `siminfo` and `acdata` keys. """ - sim = minisky.sim - traf = minisky.traf + sim = simulation + traf = traffic cd = traf.cd siminfo: SimInfo = { - "speed": float(minisky.runner.speed), + "speed": float(runner.speed), "simdt": float(sim.simdt), "simt": float(sim.simt), "simutc": sim.utc.isoformat(), "ntraf": int(traf.ntraf), "state": int(sim.state), - "scenname": minisky.stack.get_scenname(), + "scenname": commands.get_scenname(), } acdata: AcData = { @@ -131,22 +139,25 @@ def build_snapshot() -> Snapshot: class StreamHub: """Fan-out hub distributing per-tick snapshots to awaiting consumers. - A single hub is shared by the streaming endpoint. The simulation loop calls - :meth:`publish_tick` once per step (via a plugin ``update`` hook); each - connected consumer awaits :meth:`wait` and then reads :attr:`latest`. + Each runtime owns a hub. Its simulation calls [`StreamHub.publish_tick`][] + once per step; each connected consumer awaits [`StreamHub.wait`][] and then + reads `latest`. Snapshot construction is skipped entirely when there are no subscribers, - and gated to at most ``max_hz`` publications per wall-clock second so that a + and gated to at most `max_hz` publications per wall-clock second so that a fast-forwarding simulation does not flood consumers. Attributes: - latest: The most recently published snapshot (``None`` until the first + latest: The most recently published snapshot (`None` until the first publish), used to seed newly connected consumers. generation: Monotonically increasing counter incremented on each publish; consumers may use it to detect missed ticks. """ - def __init__(self, max_hz: float = STREAM_MAX_HZ) -> None: + def __init__( + self, build_snapshot: Callable[[], Snapshot], max_hz: float = STREAM_MAX_HZ + ) -> None: + self._build_snapshot = build_snapshot self._subscribers = 0 self._event = asyncio.Event() self._min_interval = 1.0 / max_hz if max_hz > 0 else 0.0 @@ -179,15 +190,15 @@ def publish_tick(self) -> None: """Build and publish a snapshot if warranted (called each sim step). No-op when there are no subscribers or when the rate cap has not yet - elapsed, so the cost of :func:`build_snapshot` is only paid when a + elapsed, so the cost of [`build_snapshot`][] is only paid when a consumer will actually receive it. """ if not self.active or not self._ready(): return - self.publish(build_snapshot()) + self.publish(self._build_snapshot()) def publish(self, snapshot: Snapshot) -> None: - """Store a snapshot as :attr:`latest` and wake awaiting consumers.""" + """Store a snapshot as `latest` and wake awaiting consumers.""" self.latest = snapshot self.generation += 1 # set()+clear() wakes all consumers currently awaiting wait(); the flag @@ -198,19 +209,3 @@ def publish(self, snapshot: Snapshot) -> None: async def wait(self) -> None: """Block until the next snapshot is published.""" await self._event.wait() - - -# Shared hub used by the streaming endpoint. -hub = StreamHub() - - -def register_stream_hook() -> None: - """Register the per-step publish hook on the simulation's ``update`` cycle. - - Attaches :meth:`StreamHub.publish_tick` to the plugin ``update`` hook so a - snapshot is published after every traffic update. Idempotent: registering - twice keeps a single hook. Must be called after :func:`minisky.init`. - """ - from minisky.plugin.timedfunction import hooks - - hooks.update.setdefault("stream_snapshot", hub.publish_tick) diff --git a/tests/conftest.py b/tests/conftest.py index 99f6e09..6c1f0ea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,9 @@ """Shared fixtures for MiniSky integration tests. -MiniSky uses module-level singletons (minisky.traf, minisky.sim, ...), so: -- minisky.init() is called exactly once per test session (re-initializing - would leave modules holding references to stale objects); +Most existing integration tests still exercise the temporary module-level +compatibility aliases (`minisky.traf`, `minisky.sim`, ...), so: +- one explicit runtime is constructed for the test session and activates those + aliases; - each test gets a clean state via minisky.sim.reset(); - always access singletons through the module (bs.traf), never via `from minisky import traf` (that binds None at import time). @@ -17,9 +18,14 @@ @pytest.fixture(scope="session") -def bs(): - """Session-wide initialized minisky module.""" - minisky.init() +def runtime(): + """Session-wide explicit MiniSky runtime.""" + return minisky.init() + + +@pytest.fixture(scope="session") +def bs(runtime): + """Compatibility module activated for the session runtime.""" return minisky diff --git a/tests/integration/test_streaming.py b/tests/integration/test_streaming.py index f1bc6ae..48548d7 100644 --- a/tests/integration/test_streaming.py +++ b/tests/integration/test_streaming.py @@ -1,7 +1,7 @@ """Integration tests for the per-tick streaming API and DTMULT command. -Covers :func:`minisky.streaming.build_snapshot` against a live simulation and -the ``DTMULT`` stack command that sets the runner speed multiplier. +Covers [`build_snapshot`][minisky.streaming.build_snapshot] against a live simulation and +the `DTMULT` stack command that sets the runner speed multiplier. """ import json @@ -11,11 +11,11 @@ from minisky.streaming import STREAM_MAX_HZ, StreamHub, build_snapshot -def test_snapshot_structure_and_units(bs, sim, run_cmd): +def test_snapshot_structure_and_units(runtime, bs, sim, run_cmd): # Two steps: the first creates the aircraft, the second flips INIT -> OP. run_cmd("CRE KL001 A320 52.0 4.0 90 FL100 250", steps=2) - snap = build_snapshot() + snap = build_snapshot(runtime.simulation, runtime.traffic, runtime.runner, runtime.commands) assert set(snap) == {"siminfo", "acdata"} info = snap["siminfo"] @@ -42,14 +42,16 @@ def test_snapshot_structure_and_units(bs, sim, run_cmd): assert ac["inconf"] == [False] -def test_snapshot_is_json_serialisable(bs, sim, run_cmd): +def test_snapshot_is_json_serialisable(runtime, bs, sim, run_cmd): run_cmd("CRE KL001 A320 52.0 4.0 90 FL100 250") # Must not raise: no numpy scalars leak into the snapshot. - json.dumps(build_snapshot()) + json.dumps( + build_snapshot(runtime.simulation, runtime.traffic, runtime.runner, runtime.commands) + ) -def test_snapshot_empty_when_no_traffic(bs, sim): - snap = build_snapshot() +def test_snapshot_empty_when_no_traffic(runtime, bs, sim): + snap = build_snapshot(runtime.simulation, runtime.traffic, runtime.runner, runtime.commands) assert snap["siminfo"]["ntraf"] == 0 assert snap["acdata"]["callsign"] == [] assert snap["acdata"]["alt"] == [] @@ -66,8 +68,12 @@ def test_dtmult_rejects_non_positive(bs, sim): assert "positive" in msg.lower() -def test_hub_skips_publish_without_subscribers(): - hub = StreamHub() +def test_hub_skips_publish_without_subscribers(runtime): + hub = StreamHub( + lambda: build_snapshot( + runtime.simulation, runtime.traffic, runtime.runner, runtime.commands + ) + ) assert hub.active is False hub.publish_tick() # no subscribers -> no snapshot built assert hub.latest is None @@ -76,9 +82,14 @@ def test_hub_skips_publish_without_subscribers(): assert hub.active is True -def test_hub_rate_cap_gates_publishing(): +def test_hub_rate_cap_gates_publishing(runtime): # A very low cap means the second immediate tick is dropped. - hub = StreamHub(max_hz=1.0) + hub = StreamHub( + lambda: build_snapshot( + runtime.simulation, runtime.traffic, runtime.runner, runtime.commands + ), + max_hz=1.0, + ) hub.subscribe() hub.publish_tick() first_gen = hub.generation diff --git a/tests/integration/test_tangram_bridge.py b/tests/integration/test_tangram_bridge.py index 6a5e722..5eb23c0 100644 --- a/tests/integration/test_tangram_bridge.py +++ b/tests/integration/test_tangram_bridge.py @@ -12,6 +12,7 @@ from example_plugins.tangram import TangramBridge from minisky.simulation import Simulation +from minisky.streaming import build_snapshot Observer = tuple[fakeredis.FakeRedis, PubSub] StepUntil = Callable[[Callable[[], bool]], int] @@ -24,12 +25,21 @@ def redis_server() -> fakeredis.FakeServer: @pytest.fixture def bridge( - bs: ModuleType, sim: Simulation, redis_server: fakeredis.FakeServer + runtime, bs: ModuleType, sim: Simulation, redis_server: fakeredis.FakeServer ) -> Iterator[TangramBridge]: bridge = TangramBridge( "redis://fake", "minisky", max_hz=1000, + snapshot_builder=lambda: build_snapshot( + runtime.simulation, runtime.traffic, runtime.runner, runtime.commands + ), + console=runtime.console, + simulation=runtime.simulation, + runner=runtime.runner, + traffic=runtime.traffic, + get_scenname=runtime.commands.get_scenname, + stack_command=runtime.commands.stack, redis_factory=lambda url: fakeredis.FakeRedis(server=redis_server), ) ok, msg = bridge.start() diff --git a/tests/test_api.py b/tests/test_api.py index 33031a3..fbeebe6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,14 +1,15 @@ """Smoke tests for the FastAPI endpoints. -Importing minisky.server calls minisky.init() at module import time, which -would clobber the singletons used by the rest of the suite. These tests are -therefore marked 'api' and excluded from the default run; execute them in a -separate process: +The API application is created explicitly for this test module and owns its +own MiniSky runtime. These tests are marked `api` and excluded from the default +run; execute them in a separate process: - uv run minisky test api +```console +uv run minisky test api +``` -The /stack/{cmd} endpoint requires the async runner loop and is not tested -here (flaky under TestClient). +The `/stack/{cmd}` endpoint requires the async runner loop and is not tested +here because it is flaky under `TestClient`. """ import pytest @@ -17,12 +18,22 @@ @pytest.fixture(scope="module") -def client(): - fastapi_testclient = pytest.importorskip("fastapi.testclient") +def server_app(): + from minisky.server import create_app + + return create_app() + + +@pytest.fixture(scope="module") +def runtime(server_app): + return server_app.state.runtime - from minisky.server import app - with fastapi_testclient.TestClient(app) as test_client: +@pytest.fixture(scope="module") +def client(server_app): + fastapi_testclient = pytest.importorskip("fastapi.testclient") + + with fastapi_testclient.TestClient(server_app) as test_client: yield test_client @@ -45,10 +56,8 @@ def test_all_empty_traffic(client): assert isinstance(resp.json(), list) -def test_all_reflects_created_aircraft(client): - import minisky - - minisky.traf.cre("KL001", "A320", lat=52.0, lon=4.0, hdg=90, alt=3000, spd=150) +def test_all_reflects_created_aircraft(client, runtime): + runtime.traffic.cre("KL001", "A320", lat=52.0, lon=4.0, hdg=90, alt=3000, spd=150) resp = client.get("/all") assert resp.status_code == 200 callsigns = [ac["callsign"] for ac in resp.json()] From fd9243e36780efea6836d57cce792910881244e8 Mon Sep 17 00:00:00 2001 From: Abraham Cheung <58929011+abc8747@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:12:17 +0800 Subject: [PATCH 10/16] refactor: make `Minisky` own `runtime.{plugins,replaceables}` - make `PluginManager` own discovered plugin records, loaded plugin state, plugin-created objects, hooks, timers, plugin command registration - note that we are still using the AST hacks (#24), it will be cleaned up in a separate PR - shared replaceable class catalog contains declaration metadata only, owned by the runtime - significantly pruned earlier escape hatches/compat wrappers --- docs/api/minisky.md | 23 +- docs/api/plugin.md | 20 +- docs/api/stack.md | 14 +- docs/architecture.md | 10 +- docs/guides/plugins.md | 187 +++++---- example_plugins/customautopilot.py | 5 +- example_plugins/example.py | 153 +++++--- example_plugins/tangram.py | 91 +++-- minisky/__init__.py | 10 +- minisky/cli.py | 9 +- minisky/core/__init__.py | 6 +- minisky/core/settings.py | 2 + minisky/core/trafficarrays.py | 302 +++++++------- minisky/core/varexplorer.py | 7 +- minisky/plugin/__init__.py | 47 ++- minisky/plugin/entity.py | 183 +++------ minisky/plugin/plugin.py | 584 +++++++++++++++------------- minisky/plugin/plugin_decorators.py | 142 ++++--- minisky/plugin/timedfunction.py | 223 +++++------ minisky/runtime.py | 24 +- minisky/server.py | 20 +- minisky/simulation/simulation.py | 19 +- minisky/stack/__init__.py | 243 ++---------- minisky/stack/commands.py | 6 +- minisky/tools/areafilter.py | 2 + minisky/tools/geo.py | 2 + minisky/tools/navdata.py | 5 +- minisky/traffic/asas/detection.py | 2 +- minisky/traffic/asas/resolution.py | 2 +- minisky/traffic/autopilot.py | 2 +- minisky/traffic/traffic.py | 6 +- minisky/traffic/trafficgroups.py | 3 +- minisky/traffic/trails.py | 2 +- minisky/traffic/turbulence.py | 2 +- minisky/traffic/uncertainty.py | 2 +- tests/integration/test_plugin.py | 74 ++-- 36 files changed, 1170 insertions(+), 1264 deletions(-) diff --git a/docs/api/minisky.md b/docs/api/minisky.md index fe0a021..38b6a88 100644 --- a/docs/api/minisky.md +++ b/docs/api/minisky.md @@ -1,11 +1,16 @@ # `minisky` -The top-level package: initialisation, simulation-state constants, and the global -singleton objects (`sim`, `traf`, `runner`, `scr`, `navdb`) described in -[Architecture](../architecture.md#the-singletons). - -::: minisky - options: - members: - - init - - load_plugins +The top-level package exposes the explicit runtime owner, validated settings, +simulation-state constants, and a temporary `init()` compatibility constructor. + +## Runtime + +::: minisky.MiniSky + +## Settings + +::: minisky.MiniSkySettings + +## Compatibility constructor + +::: minisky.init diff --git a/docs/api/plugin.md b/docs/api/plugin.md index 96d1569..a727f46 100644 --- a/docs/api/plugin.md +++ b/docs/api/plugin.md @@ -1,20 +1,26 @@ # `minisky.plugin` -Plugin discovery, loading, and the building blocks for writing plugins — see the -[plugin guide](../guides/plugins.md). +Runtime-owned plugin discovery, loading, timed hooks, per-aircraft entities, +and command declarations. See the [plugin guide](../guides/plugins.md). ## Plugin management -::: minisky.plugin.plugin +::: minisky.plugin.plugin.PluginManager + +## Plugin records + +::: minisky.plugin.plugin.Plugin ## Entity ::: minisky.plugin.entity.Entity -## Timed functions +## Timed hooks + +::: minisky.plugin.timedfunction.TimedFunctionManager -::: minisky.plugin.timedfunction +::: minisky.plugin.timedfunction.Timer -## Stack command decorators +## Stack command declarations -::: minisky.plugin.plugin_decorators +::: minisky.plugin.plugin_decorators.command diff --git a/docs/api/stack.md b/docs/api/stack.md index 52dd591..7cfb6a9 100644 --- a/docs/api/stack.md +++ b/docs/api/stack.md @@ -1,19 +1,19 @@ # `minisky.stack` The text-command interpreter. Every command — from scenario files, the console, or the -REST API — is queued with [`stack()`][minisky.stack.stack] and executed during -[`process()`][minisky.stack.process] on the next simulation step. See the +REST API — is queued with [`stack()`][minisky.stack.stack] and executed by +[`CommandStack.process`][minisky.stack.CommandStack.process] on the next simulation step. See the [stack command reference](../reference/commands.md) for the available commands. ::: minisky.stack options: members: - - stack - - process - - ic - - ic_StringIO - - reset + - CommandStack - Command + - stack + - readscn + - showhelp + - get_scenname ## Argument parsing diff --git a/docs/architecture.md b/docs/architecture.md index ccf9e53..4ef1451 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,8 +20,8 @@ of the code refers to: ```python import minisky -minisky.init() # create the singletons -minisky.load_plugins() # optional: load plugins enabled in settings.toml +runtime = minisky.init() # compatibility constructor +runtime.load_plugins() # optional: load enabled plugins on this runtime ``` ## The simulation loop @@ -30,7 +30,7 @@ The simulation advances in discrete timesteps of `sim.simdt` seconds (default 1 call to [`sim.step()`][minisky.simulation.simulation.Simulation.step] does, in order: 1. **Stack processing** — pending text commands are parsed and executed - ([`stack.process()`][minisky.stack.process]). + ([`CommandStack.process`][minisky.stack.CommandStack.process]). 2. **Time advance** — `sim.simt` and the simulated UTC clock move forward by `simdt` (only in the `OP` state). 3. **Plugin pre-update** — timed plugin functions registered with the `preupdate` hook. @@ -67,8 +67,8 @@ Classes that hold per-aircraft data derive from it and register their arrays: ```python class Example(Entity): - def __init__(self): - super().__init__() + def __init__(self, traffic): + super().__init__(traffic) with self.settrafarrays(): self.npassengers = np.array([]) ``` diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md index 885a605..636ac06 100644 --- a/docs/guides/plugins.md +++ b/docs/guides/plugins.md @@ -1,127 +1,158 @@ # Writing plugins -Plugins extend the simulator without touching its code: they can hold per-aircraft data, -run periodic update functions inside the simulation loop, and add new stack commands. -The `example_plugins/` directory contains working examples. +Plugins extend a [`MiniSky`][minisky.MiniSky] runtime without modifying +core code. A plugin can own per-aircraft data, register timed lifecycle hooks, +and add stack commands. The `example_plugins/` directory contains working +examples. ## Anatomy of a plugin -A plugin is a Python file in the plugin directory (`plugin_path` in `settings.toml`, -default `example_plugins`) that defines an `init_plugin()` function: +A plugin is a Python file in the directory configured by `plugin_path` that +defines `init_plugin(runtime)`: ```python """My example plugin.""" -from random import randint -import numpy as np -import minisky -from minisky import plugin, stack +from typing import TYPE_CHECKING + +import numpy as np +from minisky import plugin -example = None +if TYPE_CHECKING: + from minisky import MiniSky + from minisky.traffic import Traffic -def init_plugin(): - """Required entry point. Returns the plugin config dict.""" - global example - example = Example() - return { - "plugin_name": "EXAMPLE", # name used by PLUGIN LOAD / settings.toml - "update_interval": 5, # seconds of sim time between update calls - "update": example.update, # called every update_interval - # "preupdate": ..., # called before traf.update() - # "reset": ..., # called on simulation reset +def init_plugin(runtime: MiniSky): + instance = Example(runtime.traffic) + config = { + "plugin_name": "EXAMPLE", + "update_interval": 5, + "update": instance.update, + "state": instance, + } + commands = { + "PASSENGERS": [ + instance.passengers, + "txt,[int]", + "PASSENGERS callsign, [count]", + "Set or get the number of passengers on an aircraft.", + ] } + return config, commands ``` -The config dict registers the plugin's hooks: +The runtime is passed explicitly. Plugin code should retain only the specific +runtime components it needs instead of reading package-level aliases. + +The config dictionary supports these lifecycle entries: | Key | Meaning | | --- | --- | -| `plugin_name` | Uppercase name the plugin is known by | -| `update_interval` | Simulation seconds between hook calls (minimum: `sim.simdt`) | -| `preupdate` | Called each interval *before* the traffic update | -| `update` | Called each interval *after* the traffic update | -| `reset` | Called when the simulation resets | +| `plugin_name` | Name used by `PLUGINS LOAD` and `enabled_plugins` | +| `update_interval` | Simulation seconds between timed callbacks | +| `preupdate` | Callback before the traffic update | +| `update` | Callback after the traffic update | +| `reset` | Callback when the simulation resets | +| `hold` | Callback when the simulation enters hold | +| `shutdown` | Callback when the owning runtime shuts down | +| `state` | Optional plugin-owned object exposed through the variable explorer | + +Plugin records, loaded state, timers, hooks, and returned state belong to +`runtime.plugins`. Loading the same plugin into two runtimes creates separate +records and hook sets. ## Per-aircraft data: `Entity` -Derive from [`Entity`][minisky.plugin.entity.Entity] and register arrays inside a -`settrafarrays()` block — they then grow and shrink automatically with aircraft creation -and deletion, staying index-aligned with `minisky.traf` (see -[Architecture](../architecture.md#per-aircraft-arrays-trafficarrays)): +Derive from [`Entity`][minisky.plugin.entity.Entity], pass the owning traffic +object to `super().__init__()`, and register arrays inside a +`settrafarrays()` block. They then grow, shrink, and reset with that traffic +tree. ```python class Example(plugin.Entity): - def __init__(self): - super().__init__() + def __init__(self, traffic: Traffic) -> None: + super().__init__(traffic) with self.settrafarrays(): self.npassengers = np.array([]) - def create(self, n=1): - """Called automatically when n new aircraft are created.""" + def create(self, n: int = 1) -> None: super().create(n) - self.npassengers[-n:] = [randint(50, 250) for _ in range(n)] + self.npassengers[-n:] = 100 - def update(self): - if minisky.traf.ntraf > 0: - print(f"{minisky.traf.ntraf} aircraft, {int(sum(self.npassengers))} pax") + def update(self) -> None: + if self.traffic.ntraf: + print(f"{self.traffic.ntraf} aircraft") ``` +`Entity` is not a singleton and does not use a proxy. Each plugin load creates +an ordinary object attached to one runtime's traffic-array tree. + ## Adding stack commands -Use the [`@stack.command`][minisky.plugin.plugin_decorators.command] decorator. The -function's docstring becomes the in-simulator help text (shown by `HELP PASSENGERS`), -and the `arguments` string declares the parameter types the -[argument parser](../api/stack.md) should use: +Return a command dictionary as the second value from `init_plugin()`. Binding a +method from the plugin-owned state object keeps the command attached to the +correct runtime: ```python -@stack.command(name="PASSENGERS", arguments="txt,[int]") -def passengers(callsign: str, count: int = -1): - """Set or get the number of passengers on an aircraft. - - Arguments: - - callsign: Aircraft callsign - - count: Number of passengers (optional, omit to query) - """ - callsign = callsign.upper() - if callsign not in minisky.traf.callsign: - return False, f"Aircraft {callsign} not found" - - idx = minisky.traf.callsign.index(callsign) - if count < 0: - return True, f"{callsign} has {int(example.npassengers[idx])} passengers" - - example.npassengers[idx] = count - return True, f"Set {callsign} passengers to {count}" +class Example(plugin.Entity): + # ... + + def passengers(self, callsign: str, count: int = -1): + callsign = callsign.upper() + if callsign not in self.traffic.callsign: + return False, f"Aircraft {callsign} not found" + + index = self.traffic.callsign.index(callsign) + if count < 0: + return True, f"{callsign} has {int(self.npassengers[index])} passengers" + + self.npassengers[index] = count + return True, f"Set {callsign} passengers to {count}" ``` -Command handlers return `(success, message)`; the message is echoed to the console or -REST client. Returning `None` counts as success with no message. +A command entry contains the callback, argument parser specification, brief +usage text, and help text. Command handlers return `(success, message)`; +returning `None` counts as success with no message. + +The [`@stack.command`][minisky.plugin.plugin_decorators.command] decorator is +also available for stateless module-level declarations. Importing a decorated +function only stores metadata. The command is registered when the owning +runtime loads that plugin module. ## Discovery and loading -Plugin files are *discovered* at startup by parsing their source (no import happens until -the plugin is loaded), so a broken plugin can't crash the simulator at startup. +Discovery parses plugin source without importing it. `MiniSky` performs this +discovery during construction. -Load plugins in any of three ways: +Load plugins in any of these ways: -- **At startup** — list them in `settings.toml`: +- **At startup** — list names under `enabled_plugins`, then call + `runtime.load_plugins()`. +- **From the stack** — use `PLUGINS LIST` and `PLUGINS LOAD EXAMPLE`. +- **From Python** — call `runtime.plugins.load("EXAMPLE")`. +- **Over the REST API** — use `GET /plugins` and + `GET /plugins/load/EXAMPLE`. - ```toml - plugin_path = "example_plugins" - enabled_plugins = ["EXAMPLE"] - ``` +```python +from minisky import MiniSky, MiniSkySettings - (Requires the host program to call [`minisky.load_plugins()`][minisky.load_plugins] - after `init()` — `minisky run` and `minisky server` both do.) +settings = MiniSkySettings.from_file("settings.toml") +runtime = MiniSky(settings) +runtime.load_plugins() +``` -- **From the stack** — `PLUGINS LIST` to see what's available, `PLUGINS LOAD EXAMPLE` to - load one (`PLUGIN` works as a synonym). +## Replaceable implementations -- **Over the REST API** — `GET /plugins` and `GET /plugins/load/EXAMPLE`. +A plugin can declare a subclass of a replaceable traffic component, such as +[`Autopilot`][minisky.traffic.autopilot.Autopilot]. Importing the class adds it +to the shared declaration catalog, while selection belongs to each runtime: -## A complete second example +```python +runtime.replaceables.select("AUTOPILOT", "CUSTOMAUTOPILOT") +``` -`example_plugins/customautopilot.py` shows a plugin that subclasses a core simulator -class — have a look at both examples before writing your own. +The `SELECTIMPL` stack command calls the same runtime-owned manager. Resetting a +simulation restores base implementations only on that runtime's traffic tree. +See `example_plugins/customautopilot.py` for a complete example. diff --git a/example_plugins/customautopilot.py b/example_plugins/customautopilot.py index c8bb320..0bdd666 100644 --- a/example_plugins/customautopilot.py +++ b/example_plugins/customautopilot.py @@ -9,7 +9,7 @@ 2. Your subclass is automatically registered by name (uppercase class name) 3. Select your implementation via scenario command or programmatically: - Scenario: SELECTIMPL AUTOPILOT CUSTOMAUTOPILOT - - Python: CustomAutoPilot.select() + - Python: runtime.replaceables.select("AUTOPILOT", "CUSTOMAUTOPILOT") 4. On simulation reset, implementations revert to defaults The SELECTIMPL command replaces the existing instance on traf immediately, @@ -30,11 +30,12 @@ from minisky.traffic.autopilot import Autopilot if TYPE_CHECKING: + from minisky import MiniSky from minisky.simulation import Simulation from minisky.traffic import Traffic -def init_plugin(): +def init_plugin(_runtime: MiniSky) -> dict[str, str]: config = {"plugin_name": "CUSTOMAUTOPILOT"} return config diff --git a/example_plugins/example.py b/example_plugins/example.py index 1cb6e68..053860d 100644 --- a/example_plugins/example.py +++ b/example_plugins/example.py @@ -1,90 +1,127 @@ """MiniSky example plugin. This plugin demonstrates the plugin system capabilities: -- Registering per-aircraft data arrays -- Periodic update functions -- Stack commands + +- registering per-aircraft data arrays; +- periodic update functions; +- stack commands bound to runtime-owned plugin state. """ from __future__ import annotations from random import randint +from typing import TYPE_CHECKING, Any import numpy as np -import minisky -from minisky import plugin, stack +from minisky import plugin -# Global reference to the example instance -example: Example | None = None +if TYPE_CHECKING: + from minisky import MiniSky + from minisky.traffic import Traffic -def init_plugin(): - """Plugin initialization function. +def init_plugin( + runtime: MiniSky, +) -> tuple[dict[str, Any], dict[str, list[Any]]]: + """Initialize the plugin for one MiniSky runtime. - This function is required for all plugins. It should return a configuration - dictionary, and optionally a second dictionary of stack functions. - """ - global example + This function is required for all plugins. It returns a configuration + dictionary and, optionally, a second dictionary of stack functions. The + runtime argument makes ownership explicit: every plugin load creates a new + entity and command binding for that runtime. - # Instantiate our example entity - example = instance = Example() + Args: + runtime: MiniSky runtime loading this plugin. - # Configuration parameters + Returns: + A `(config, stack_functions)` tuple consumed by the runtime's plugin + manager. + """ + # Instantiate the example entity on this runtime's traffic-array tree. + instance = Example(runtime.traffic) + + # Configuration parameters and lifecycle callbacks. config = { "plugin_name": "EXAMPLE", - "update_interval": 5, # Update every 5 seconds - "update": instance.update, # Register update function via config + "update_interval": 5, # Update every 5 seconds of simulation time. + "update": instance.update, + "state": instance, } - return config + # Bind the PASSENGERS command directly to this runtime's entity instance. + stack_functions = { + "PASSENGERS": [ + instance.passengers, + "txt,[int]", + "PASSENGERS callsign, [count]", + "Set or get the number of passengers on an aircraft.", + ] + } + return config, stack_functions class Example(plugin.Entity): - """Example entity that tracks passenger count per aircraft.""" + """Example entity that tracks passenger count per aircraft. - def __init__(self): - super().__init__() - # Register per-aircraft data arrays - # These automatically resize when aircraft are created/deleted + Each loaded runtime owns a separate `Example` instance. Its passenger + array remains index-aligned with the aircraft arrays of the traffic object + passed to the constructor. + """ + + def __init__(self, traffic: Traffic) -> None: + """Attach the entity to `traffic` and register its passenger array.""" + super().__init__(traffic) + + # Register per-aircraft data arrays. These automatically resize when + # aircraft are created or deleted in the owning runtime. with self.settrafarrays(): self.npassengers = np.array([]) - def create(self, n: int = 1): - """Called automatically when new aircraft are created.""" + def create(self, n: int = 1) -> None: + """Initialize passenger counts for newly created aircraft. + + Called automatically by the traffic-array tree whenever `n` aircraft + are added to the owning traffic object. + + Args: + n: Number of newly created aircraft. + """ super().create(n) - # Set passenger count for new aircraft self.npassengers[-n:] = [randint(50, 250) for _ in range(n)] - def update(self): - """Periodic update function called every 5 simulation seconds.""" - if minisky.traf.ntraf > 0: + def update(self) -> None: + """Periodic update function called every five simulation seconds.""" + if self.traffic.ntraf > 0: total = int(sum(self.npassengers)) - print(f"Example plugin: {minisky.traf.ntraf} aircraft, {total} total passengers") - - -# Stack command for passengers - defined as module-level function -@stack.command(name="PASSENGERS", arguments="txt,[int]") -def passengers(callsign: str, count: int = -1): - """Set or get the number of passengers on an aircraft. - - Arguments: - - callsign: Aircraft callsign - - count: Number of passengers (optional, omit to query) - """ - if example is None: - return False, "Example plugin not initialised" - - callsign = callsign.upper() - - # Find aircraft index - if callsign not in minisky.traf.callsign: - return False, f"Aircraft {callsign} not found" - - idx = minisky.traf.callsign.index(callsign) - - if count < 0: - return True, f"Aircraft {callsign} has {int(example.npassengers[idx])} passengers" - - example.npassengers[idx] = count - return True, f"Set {callsign} passengers to {count}" + print( + f"Example plugin: {self.traffic.ntraf} aircraft, " + f"{total} total passengers" + ) + + def passengers(self, callsign: str, count: int = -1) -> tuple[bool, str]: + """Set or get the number of passengers on an aircraft. + + Args: + callsign: Aircraft callsign. + count: Passenger count to set. Omit it, or pass a negative value, + to query the current count. + + Returns: + A `(success, message)` tuple suitable for the stack command. + """ + callsign = callsign.upper() + + # Find the aircraft index in this runtime's traffic object. + if callsign not in self.traffic.callsign: + return False, f"Aircraft {callsign} not found" + + index = self.traffic.callsign.index(callsign) + if count < 0: + return ( + True, + f"Aircraft {callsign} has {int(self.npassengers[index])} passengers", + ) + + self.npassengers[index] = count + return True, f"Set {callsign} passengers to {count}" diff --git a/example_plugins/tangram.py b/example_plugins/tangram.py index 2fd1327..3bb6d64 100644 --- a/example_plugins/tangram.py +++ b/example_plugins/tangram.py @@ -46,17 +46,16 @@ from collections import deque from collections.abc import Callable from datetime import UTC, datetime +from types import MappingProxyType from typing import TYPE_CHECKING, Any, TypedDict, cast from pydantic import BaseModel, ConfigDict, Field -import minisky -from minisky import stack -from minisky.core import settings from minisky.streaming import Snapshot, build_snapshot from minisky.tools.aero import fpm, ft, kts if TYPE_CHECKING: + from minisky import MiniSky from minisky.simulation import ConsoleIO, Runner, Simulation from minisky.traffic import Traffic @@ -79,9 +78,8 @@ class TangramPluginSettings(BaseModel): # not advancing (paused/init), so the frontend still sees state changes. HEARTBEAT_SECS = 1.0 -SIM_STATE_NAMES = {0: "INIT", 1: "HOLD", 2: "OP", 3: "END"} - -bridge = None +# Immutable mapping used when serializing simulation state names. +SIM_STATE_NAMES = MappingProxyType({0: "INIT", 1: "HOLD", 2: "OP", 3: "END"}) class TangramSimInfo(TypedDict): @@ -285,6 +283,22 @@ def stop(self) -> None: if self._thread is not None: self._thread.join(timeout=2.0) + def status(self) -> tuple[bool, str]: + """Return the current Redis bridge status for the TANGRAM command. + + Returns: + A `(True, message)` tuple reporting connection state, Redis URL, + channel, publication count, and the most recent transport error. + """ + status = "connected" if self.connected else "disconnected" + text = ( + f"Tangram bridge: {status} to {self.redis_url}\n" + f"Channel: to:{self.channel}:new-data ({self.published} messages published)" + ) + if self.last_error: + text += f"\nLast error: {self.last_error}" + return True, text + def tick(self) -> None: """Update hook: build and enqueue a snapshot (rate-capped). Runs in OP.""" now = time.monotonic() @@ -409,51 +423,58 @@ def _run(self) -> None: return -@stack.command(name="TANGRAM") -def tangram_status() -> tuple[bool, str]: - """Show the status of the tangram Redis bridge.""" - if bridge is None: - return False, "Tangram bridge not initialised" - status = "connected" if bridge.connected else "disconnected" - text = ( - f"Tangram bridge: {status} to {bridge.redis_url}\n" - f"Channel: to:{bridge.channel}:new-data ({bridge.published} messages published)" - ) - if bridge.last_error: - text += f"\nLast error: {bridge.last_error}" - return True, text +def init_plugin( + runtime: MiniSky, +) -> tuple[dict[str, Any], dict[str, list[Any]]]: + """Create the bridge and register its simulation hooks for one runtime. + The returned state, lifecycle callbacks, shutdown callback, and TANGRAM + command are all bound to the supplied runtime. Loading the plugin in a + second runtime therefore creates an independent Redis bridge. -def init_plugin() -> dict[str, Any]: - """Create the bridge and register its simulation hooks.""" - global bridge + Args: + runtime: MiniSky runtime loading the plugin. + Returns: + A `(config, stack_functions)` tuple consumed by the runtime-owned + plugin manager. + """ + extras = runtime.settings.model_extra or {} # TODO(abraham): we should namespace it under settings.plugins.tangram. - cfg = TangramPluginSettings.model_validate(settings.default_settings).tangram - command_stack = stack.current() + cfg = TangramPluginSettings.model_validate({"tangram": extras.get("tangram", {})}).tangram bridge = TangramBridge( redis_url=cfg.redis_url, channel=cfg.channel, max_hz=cfg.max_hz, snapshot_builder=lambda: build_snapshot( - minisky.sim, minisky.traf, minisky.runner, command_stack + runtime.simulation, runtime.traffic, runtime.runner, runtime.commands ), - console=minisky.scr, - simulation=minisky.sim, - runner=minisky.runner, - traffic=minisky.traf, - get_scenname=command_stack.get_scenname, - stack_command=command_stack.stack, + console=runtime.console, + simulation=runtime.simulation, + runner=runtime.runner, + traffic=runtime.traffic, + get_scenname=runtime.commands.get_scenname, + stack_command=runtime.commands.stack, ) - success, msg = bridge.start() - minisky.scr.echo(msg) + success, message = bridge.start() + runtime.console.echo(message) if not success: - raise RuntimeError(msg) + raise RuntimeError(message) config = { "plugin_name": "TANGRAM", "update_interval": 0.0, "update": bridge.tick, "reset": bridge.reset, + "shutdown": bridge.stop, + "state": bridge, + } + stack_functions = { + "TANGRAM": [ + bridge.status, + "", + "TANGRAM", + "Show the status of the tangram Redis bridge.", + ] } - return config + return config, stack_functions diff --git a/minisky/__init__.py b/minisky/__init__.py index 21c7481..543882f 100644 --- a/minisky/__init__.py +++ b/minisky/__init__.py @@ -25,6 +25,9 @@ BS_FUNERR = 2 BS_CMDERR = 4 +# TODO(abraham): remove the active-runtime compatibility facade in the final +# explicit-runtime migration. These aliases intentionally remain only for current +# public and test callers. _current: MiniSky | None = None runner: Runner = None # type: ignore[assignment] traf: Traffic = None # type: ignore[assignment] @@ -65,11 +68,4 @@ def init( instance = MiniSky(settings, scenario) - # plugin discovery remains part of the legacy startup path for now. - plugin.discover() return instance - - -def load_plugins() -> None: - """Load plugins enabled by the compatibility settings module.""" - plugin.load_enabled() diff --git a/minisky/cli.py b/minisky/cli.py index cb4d0b5..577f0e3 100644 --- a/minisky/cli.py +++ b/minisky/cli.py @@ -55,21 +55,18 @@ def _new_runtime(scenario: str | None = None) -> MiniSky: - """Construct a runtime from the default settings and discover plugins.""" - from minisky import MiniSky, MiniSkySettings, filename_settings, plugin + """Construct a runtime from the default settings.""" + from minisky import MiniSky, MiniSkySettings, filename_settings settings = MiniSkySettings.from_file(filename_settings) runtime = MiniSky(settings, scenario) - plugin.discover() return runtime async def _run_scenario(scenario: str, speed: int) -> None: """Initialise the simulator with a scenario and run it to completion.""" - from minisky import plugin - runtime = _new_runtime(scenario) - plugin.load_enabled() + runtime.load_plugins() runtime.runner.speed = speed await runtime.run() diff --git a/minisky/core/__init__.py b/minisky/core/__init__.py index 760a365..0b63a76 100644 --- a/minisky/core/__init__.py +++ b/minisky/core/__init__.py @@ -6,14 +6,16 @@ simulation entities build on, are re-exported here for convenience. """ +from __future__ import annotations + from minisky.core.trafficarrays import RegisterElementParameters, TrafficArrays from . import settings, trafficarrays, varexplorer -__all__ = [ +__all__ = ( "RegisterElementParameters", "TrafficArrays", "settings", "trafficarrays", "varexplorer", -] +) diff --git a/minisky/core/settings.py b/minisky/core/settings.py index feb4ff9..1f569ff 100644 --- a/minisky/core/settings.py +++ b/minisky/core/settings.py @@ -39,6 +39,8 @@ def from_file(cls, path: str | Path) -> MiniSkySettings: DEFAULT_SETTINGS_FILE = Path(__file__).parent.parent.parent / "settings.toml" PACKAGE_DATA_DIR = Path(__file__).parent.parent / "data" +# TODO(abraham): remove these module-level compatibility settings once all +# callers receive MiniSkySettings explicitly. filename_settings = DEFAULT_SETTINGS_FILE default_settings = MiniSkySettings.from_file(filename_settings) prefer_compiled = default_settings.prefer_compiled diff --git a/minisky/core/trafficarrays.py b/minisky/core/trafficarrays.py index e1da4ac..43ebfc9 100644 --- a/minisky/core/trafficarrays.py +++ b/minisky/core/trafficarrays.py @@ -20,111 +20,172 @@ in the simulation grows and shrinks in lockstep. """ -from typing import Any, ClassVar +from __future__ import annotations -import numpy as np +from collections.abc import Callable, Mapping +from types import MappingProxyType +from typing import TYPE_CHECKING -defaults = {"float": 0.0, "int": 0, "uint": 0, "bool": False, "S": "", "str": ""} +import numpy as np -# Global dictionary of replaceable classes -replaceables: dict[str, type["TrafficArrays"]] = {} +if TYPE_CHECKING: + from minisky.stack import Command -def reset_replaceables(traffic: "TrafficArrays", cmddict: dict[str, Any]) -> None: - """Reset all replaceables to their default implementation and reinstantiate on traf.""" - for base in replaceables.values(): - base.selectdefault() - # Reinstantiate on traf with default implementation - _replace_instance_on_traf(base, base._generator, traffic, cmddict) +defaults = MappingProxyType( + {"float": 0.0, "int": 0, "uint": 0, "bool": False, "S": "", "str": ""} +) -def select_implementation( - basename: str = "", - implname: str = "", - traffic: "TrafficArrays | None" = None, - cmddict: "dict[str, Any] | None" = None, -) -> tuple[bool, str]: - """Select an implementation for a replaceable class. +class ReplaceableManager: + """Own replaceable implementation choices for one traffic tree. - Arguments: - - basename: Name of the replaceable base class (e.g., 'AUTOPILOT') - - implname: Name of the implementation to select (e.g., 'MYAUTOPILOT') + Replaceable base classes are discovered from the actual objects attached + to this manager's traffic tree. Alternative implementations are discovered + from each base class's Python subclass hierarchy. No process-wide registry + or selected implementation is maintained. - Returns: (success, message) tuple + Attributes: + traffic: Root traffic object whose replaceable child instances are + inspected and replaced. + _get_command_registry: Lazy callback returning the owning runtime's + command registry so bound callbacks can be rebound after a + replacement. """ - if not basename: - return True, "Replaceable classes in MiniSky:\n" + ", ".join(replaceables) - - base = replaceables.get(basename.upper()) - if not base: - return False, f"Replaceable {basename} not found." - - impls = base.derived() - if not implname: - current = base._generator.__name__ - return True, ( - f"Current implementation for {basename}: {current}\n" - f"Available implementations: {', '.join(impls)}" - ) - - impl = impls.get(base.__name__ if implname.upper() == "BASE" else implname.upper()) - if not impl: - return False, f"Implementation {implname} not found for {basename}." - impl.select() + def __init__( + self, + traffic: TrafficArrays, + get_command_registry: Callable[[], Mapping[str, Command]], + ) -> None: + self.traffic = traffic + self._get_command_registry = get_command_registry + + def _instance(self, base: type[TrafficArrays]) -> TrafficArrays | None: + """Return the instance of `base` attached directly to this traffic object.""" + return next( + (value for value in self.traffic.__dict__.values() if isinstance(value, base)), + None, + ) - if traffic is None or cmddict is None: - import minisky - from minisky.stack import Command + def _available(self) -> dict[str, type[TrafficArrays]]: + """Return replaceable base classes represented on this traffic tree. - traffic = minisky.traf - cmddict = Command.cmddict + The runtime's actual component instances are the source of truth. This + avoids a mutable import-time catalog while retaining load-order + independence for plugin subclasses. + """ + available: dict[str, type[TrafficArrays]] = {} + for value in self.traffic.__dict__.values(): + if isinstance(value, TrafficArrays): + base = type(value).replaceable_base() + available[base.__name__.upper()] = base + return available + + def select(self, basename: str = "", implname: str = "") -> tuple[bool, str]: + """Select an implementation for a replaceable class. + + Arguments: + - basename: Name of the replaceable base class, for example + `AUTOPILOT`. + - implname: Name of the implementation to select, for example + `CUSTOMAUTOPILOT`. + + Returns: + A `(success, message)` tuple. With no arguments, the message lists + the replaceable classes available on this runtime. With only a + base name, it reports the current and available implementations. + """ + available = self._available() + if not basename: + return True, "Replaceable classes in MiniSky:\n" + ", ".join(sorted(available)) + + base = available.get(basename.upper()) + if base is None: + return False, f"Replaceable {basename} not found." + + impls = base.derived() + current_instance = self._instance(base) + current = type(current_instance) if current_instance is not None else base + if not implname: + return True, ( + f"Current implementation for {basename}: {current.__name__}\n" + f"Available implementations: {', '.join(sorted(impls))}" + ) + + impl = impls.get(base.__name__.upper() if implname.upper() == "BASE" else implname.upper()) + if impl is None: + return False, f"Implementation {implname} not found for {basename}." + + if current is not impl: + replaced = _replace_instance_on_traf( + base, impl, self.traffic, self._get_command_registry() + ) + if not replaced: + return False, f"No {basename} instance exists on this traffic tree." + + return True, f"Selected {implname} for {basename}" - _replace_instance_on_traf(base, impl, traffic, cmddict) + def reset(self) -> None: + """Reset all replaceables to their base implementation. - return True, f"Selected {implname} for {basename}" + Every replaceable component currently attached to this runtime's + traffic object is reinstantiated with its base implementation. Existing + per-aircraft arrays are preserved and stack commands bound to the old + object are rebound to the replacement. + """ + registry = self._get_command_registry() + for base in self._available().values(): + current = self._instance(base) + if current is not None and type(current) is not base: + _replace_instance_on_traf(base, base, self.traffic, registry) def _replace_instance_on_traf( - base: type["TrafficArrays"], - impl: type["TrafficArrays"], - traffic: "TrafficArrays", - cmddict: dict[str, Any], -) -> None: - """Replace existing instance of base class on traf with new impl instance. - - This ensures SELECTIMPL takes effect immediately, not just for future instantiations. + base: type[TrafficArrays], + impl: type[TrafficArrays], + traffic: TrafficArrays, + cmddict: Mapping[str, Command], +) -> bool: + """Replace an existing instance of `base` on traffic with `impl`. + + This ensures `SELECTIMPL` takes effect immediately, not just for future + instantiations. It returns `True` when a matching component was found and + replaced, and `False` otherwise. """ - # Find attribute on traffic that is an instance of the base class + # Find the attribute on traffic that contains an instance of the base class. for attr_name, attr_value in traffic.__dict__.items(): if isinstance(attr_value, base): - # Create new instance of selected implementation + # Create a new instance with the old component's runtime dependencies. new_instance = attr_value.new_implementation(impl) if attr_value._parent is not None: new_instance.reparent(attr_value._parent) - # Copy over any per-aircraft array data from old instance (if they exist) + + # Copy any existing per-aircraft array and list data to the replacement. for arr_var in getattr(attr_value, "_ArrVars", []): if hasattr(new_instance, arr_var): setattr(new_instance, arr_var, getattr(attr_value, arr_var)) for lst_var in getattr(attr_value, "_LstVars", []): if hasattr(new_instance, lst_var): setattr(new_instance, lst_var, getattr(attr_value, lst_var)) - # Replace on traf and detach the old child from the traffic tree. + + # Replace the traffic attribute and detach the old tree node. setattr(traffic, attr_name, new_instance) if attr_value._parent is not None: attr_value._parent._children.remove(attr_value) - # Stack commands registered as bound methods of the old instance - # would silently mutate the orphaned object; rebind them + + # Commands bound to the old instance would otherwise mutate an orphan. _rebind_stack_commands(attr_value, new_instance, cmddict) - break + return True + return False def _rebind_stack_commands( - old_instance: "TrafficArrays", - new_instance: "TrafficArrays", - cmddict: dict[str, Any], + old_instance: TrafficArrays, + new_instance: TrafficArrays, + cmddict: Mapping[str, Command], ) -> None: - """Rebind stack command callbacks from old_instance to new_instance.""" + """Rebind stack command callbacks from `old_instance` to `new_instance`.""" import inspect for cmdobj in set(cmddict.values()): @@ -144,7 +205,7 @@ class RegisterElementParameters: aircraft creation and deletion. """ - def __init__(self, parent: "TrafficArrays") -> None: + def __init__(self, parent: TrafficArrays) -> None: self._parent = parent self.keys0 = set(parent.__dict__.keys()) @@ -167,9 +228,9 @@ class TrafficArrays: that all registered per-aircraft arrays in the simulation keep the same length as the number of aircraft. - Supports the replaceable pattern when subclassed with replaceable=True: - class Autopilot(TrafficArrays, replaceable=True): - ... + Replaceable implementations are discovered from the Python subclass + hierarchy by `ReplaceableManager`; selection state belongs to an + individual runtime rather than to this class. Attributes: _parent: Parent node of this object in the tree. @@ -178,88 +239,19 @@ class Autopilot(TrafficArrays, replaceable=True): _LstVars: Names of the registered list parameters. """ - # Replaceable pattern class variables (set per-subclass) - _baseimpl: ClassVar[type | None] = None - _generator: ClassVar[type] - _default: ClassVar[str] = "" - - def __init_subclass__(cls, **kwargs) -> None: - """Called when a subclass is defined. - - This is the key to load-order independence: Python calls this - automatically when any class subclasses TrafficArrays or its descendants. - The subclass is registered and can be selected later. - - All first-level subclasses become replaceable base implementations. - Further subclasses inherit the _baseimpl and can be selected as - alternative implementations. - """ - super().__init_subclass__(**kwargs) - - # Each subclass can generate instances of itself - cls._generator = cls - - # First-level subclasses become base implementations (all are replaceable) - if not hasattr(cls, "_baseimpl") or cls._baseimpl is None: - cls._baseimpl = cls - cls._default = "" - replaceables[cls.__name__.upper()] = cls - - def __new__(cls, *args, **kwargs): - """Factory method: calling base class instantiates selected implementation. - - This is what makes the replaceable pattern work: - - When you call Autopilot(), if Autopilot is the base class, - it actually creates an instance of _generator (the selected impl) - - When you call MyAutopilot() directly, it creates MyAutopilot - """ - # Only apply factory pattern for replaceable classes - if cls._baseimpl is not None: - # If calling the base class, use the generator (selected implementation) - # If calling a subclass directly, use that subclass - generator = cls._generator if cls is cls._baseimpl else cls - return object.__new__(generator) - return object.__new__(cls) - - @classmethod - def setdefault(cls, name: str) -> None: - """Set a default implementation by name.""" - if cls._baseimpl is None: - return - impl = cls._baseimpl.derived().get(name.upper()) - if impl: - cls._baseimpl._default = name.upper() - cls._baseimpl._generator = impl - @classmethod - def getdefault(cls) -> "type[TrafficArrays] | None": - """Get the default implementation class.""" - if cls._baseimpl is None: - return cls - default = cls._baseimpl._default - return cls._baseimpl.derived().get(default) if default else cls._baseimpl + def replaceable_base(cls) -> type[TrafficArrays]: + """Return the first-level TrafficArrays subclass for this class family. - @classmethod - def selectdefault(cls) -> None: - """Select the default implementation.""" - if cls._baseimpl is None: - return - base = cls._baseimpl - base.derived().get(base._default, base).select() - - @classmethod - def select(cls) -> None: - """Select this class as the active implementation.""" - if cls._baseimpl is None: - return - cls._baseimpl._generator = cls - - @classmethod - def selected(cls) -> "type[TrafficArrays]": - """Return the currently selected implementation class.""" - if cls._baseimpl is None: - return cls - return cls._baseimpl._generator + A direct subclass such as `Autopilot` is the replaceable base. A plugin + subclass such as `CustomAutoPilot` resolves to that same base. The + result is derived from the method-resolution order and therefore does + not require mutable class or module registration state. + """ + for candidate in cls.mro(): + if TrafficArrays in candidate.__bases__: + return candidate + return cls @classmethod def derived(cls): @@ -269,7 +261,7 @@ def derived(cls): ret.update(sub.derived()) return ret - def __init__(self, parent: "TrafficArrays | None" = None) -> None: + def __init__(self, parent: TrafficArrays | None = None) -> None: """Create a TrafficArrays node, optionally attached to `parent`. Aircraft creation and deletion propagate through the explicit tree @@ -283,11 +275,11 @@ def __init__(self, parent: "TrafficArrays | None" = None) -> None: if parent is not None: self.reparent(parent) - def new_implementation(self, implementation: type["TrafficArrays"]) -> "TrafficArrays": + def new_implementation(self, implementation: type[TrafficArrays]) -> TrafficArrays: """Construct a selected replacement implementation.""" return implementation() - def reparent(self, newparent: "TrafficArrays") -> None: + def reparent(self, newparent: TrafficArrays) -> None: """Give this TrafficArrays object a new parent.""" if self._parent is newparent: return @@ -297,7 +289,7 @@ def reparent(self, newparent: "TrafficArrays") -> None: self._parent = newparent @property - def tree_root(self) -> "TrafficArrays": + def tree_root(self) -> TrafficArrays: """Return the root node of this object's traffic-array tree.""" root = self while root._parent is not None: diff --git a/minisky/core/varexplorer.py b/minisky/core/varexplorer.py index f678ca1..2f2e176 100644 --- a/minisky/core/varexplorer.py +++ b/minisky/core/varexplorer.py @@ -184,6 +184,8 @@ def getvarsfromobj(obj: Any) -> list[str] | None: return None +# TODO(abraham): remove this active explorer after compatibility callers use +# `MiniSky.variables` directly. _active: VariableExplorer | None = None @@ -199,11 +201,6 @@ def _current() -> VariableExplorer: return _active -def register_data_parent(obj: Any, name: str) -> None: - """Register a data source on the active runtime's variable explorer.""" - _current().register_data_parent(obj, name) - - def findvar(varname: str) -> Variable | None: """Find a variable on the active runtime's variable explorer.""" return _current().findvar(varname) diff --git a/minisky/plugin/__init__.py b/minisky/plugin/__init__.py index e6efe4d..9ff2452 100644 --- a/minisky/plugin/__init__.py +++ b/minisky/plugin/__init__.py @@ -1,19 +1,40 @@ """Plugin system for MiniSky. -Plugins are Python modules in the plugins directory that define an -``init_plugin()`` function. They are discovered without being imported (AST -parsing only) and loaded on demand, at which point their periodic update -functions and stack commands are hooked into the simulation loop. +Plugins are Python modules in the configured plugin directory that define an +`init_plugin(runtime)` function. They are discovered without being imported +(AST parsing only) and loaded on demand by the runtime-owned +[`PluginManager`][minisky.plugin.plugin.PluginManager]. Loading registers the +plugin's periodic update functions, lifecycle callbacks, variable-explorer +state, and stack commands with that runtime only. This module provides the plugin infrastructure including: -- Entity: Base class for singleton plugins with TrafficArrays -- Plugin: Plugin discovery and loading -- timed_function: Decorator for periodic update functions -- PluginManager: Central manager for plugin lifecycle events -- command: Decorator for registering stack commands + +- [`Entity`][minisky.plugin.entity.Entity]: Base class for plugin-owned + per-aircraft data attached to a runtime's traffic tree. +- [`Plugin`][minisky.plugin.plugin.Plugin]: Discovery and loaded-state record + for one plugin in one runtime. +- [`PluginManager`][minisky.plugin.plugin.PluginManager]: Runtime-owned plugin + discovery, loading, and lifecycle management. +- [`TimedFunctionManager`][minisky.plugin.timedfunction.TimedFunctionManager]: + Runtime-owned periodic callbacks and simulation lifecycle hooks. +- [`Timer`][minisky.plugin.timedfunction.Timer]: Simulation-time periodic + trigger used by the timed-function manager. +- [`command`][minisky.plugin.plugin_decorators.command]: Decorator for declaring + plugin stack commands without import-time registry mutation. """ -from minisky.plugin.entity import Entity, Proxy, getproxied, isproxied -from minisky.plugin.plugin import Plugin, discover, load_enabled, manage_plugins -from minisky.plugin.plugin_decorators import append_commands, command -from minisky.plugin.timedfunction import PluginManager, Timer, hooks, timed_function +from __future__ import annotations + +from minisky.plugin.entity import Entity +from minisky.plugin.plugin import Plugin, PluginManager +from minisky.plugin.plugin_decorators import command +from minisky.plugin.timedfunction import TimedFunctionManager, Timer + +__all__ = ( + "Entity", + "Plugin", + "PluginManager", + "TimedFunctionManager", + "Timer", + "command", +) diff --git a/minisky/plugin/entity.py b/minisky/plugin/entity.py index ae6e6a0..9c0e00d 100644 --- a/minisky/plugin/entity.py +++ b/minisky/plugin/entity.py @@ -1,153 +1,68 @@ -"""Entity base class for MiniSky singleton plugins. +"""Entity base class for MiniSky plugin-owned per-aircraft data. -Entity extends TrafficArrays to add: -- Singleton behavior (only one instance per class) -- Proxy support for runtime hot-swapping of implementations - -Since TrafficArrays already provides the replaceable pattern, -Entity just adds singleton semantics on top. +`Entity` extends [`TrafficArrays`][minisky.core.trafficarrays.TrafficArrays] +so plugin data can participate in the owning runtime's aircraft-array tree. +An entity is attached explicitly to one [`Traffic`][minisky.traffic.Traffic] +object; it is not a process-wide singleton and does not use a proxy. Usage: - class MyPlugin(Entity): - def __init__(self): - super().__init__() - with self.settrafarrays(): - self.mydata = np.array([]) -For non-singleton TrafficArrays that are replaceable, -just inherit from TrafficArrays directly. +```python +class MyPlugin(Entity): + def __init__(self, traffic: Traffic) -> None: + super().__init__(traffic) + with self.settrafarrays(): + self.mydata = np.array([]) +``` + +Arrays and lists registered inside `settrafarrays()` grow, shrink, and reset +with the aircraft in that runtime. Separate runtimes can therefore load +independent instances of the same plugin class. + +For replaceable core traffic components, inherit from `TrafficArrays` +directly and select implementations through the runtime's +`ReplaceableManager`. """ -import inspect -from typing import Any, ClassVar, Optional - -import minisky -from minisky.core.trafficarrays import TrafficArrays - - -class Proxy: - """Proxy class for replaceable singleton entities. - - Allows plugins to replace core functionality by routing all - attribute access through the currently selected implementation. - """ - - def __init__(self) -> None: - self.__dict__["_refobj"] = None - self.__dict__["_proxied"] = [] - - def _selected(self) -> type: - """Return the class of the currently selected implementation.""" - return self._refobj.__class__ +from __future__ import annotations - def _replace(self, refobj: object) -> None: - """Replace the reference object with a new implementation.""" - self.__dict__["_refobj"] = refobj - # Clear all proxied functions/methods - for name in self._proxied: - delattr(self, name) - self._proxied.clear() - # Copy all public functions/methods of reference object - for name, value in inspect.getmembers(refobj, callable): - if name[0] != "_": - self.__dict__[name] = value - self._proxied.append(name) +from typing import TYPE_CHECKING - def __getattr__(self, attr: str) -> Any: - return getattr(self._refobj, attr) - - def __setattr__(self, name: str, value: Any) -> None: - return setattr(self._refobj, name, value) - - -def isproxied(obj: object) -> bool: - """Returns True if obj is a proxied object.""" - return isinstance(obj, Proxy) - - -def getproxied(obj: object) -> Any: - """Return wrapped proxy object if proxied, otherwise the original object.""" - return obj.__dict__["_refobj"] if isinstance(obj, Proxy) else obj +from minisky.core.trafficarrays import TrafficArrays +if TYPE_CHECKING: + from minisky.traffic import Traffic -class EntityMeta(type): - """Meta class to make Entity subclasses singletons.""" - def __call__(cls, *args, **kwargs) -> Any: - """Object creation with proxy wrapping and singleton behavior.""" - # Create singleton instance if it doesn't exist yet - if not cls.is_instantiated(): # type: ignore[attr-defined] - super().__call__(*args, **kwargs) +class Entity(TrafficArrays): + """Base class for plugin-owned per-aircraft arrays. - # When proxied, calling base constructor returns the proxy - if cls._proxy and cls is cls._baseimpl: # type: ignore[attr-defined] - if getproxied(cls._proxy) is None: # type: ignore[attr-defined] - cls.select(cls._instance) # type: ignore[attr-defined] - return cls._proxy # type: ignore[attr-defined] + Combines the automatic create, delete, and reset behavior of + [`TrafficArrays`][minisky.core.trafficarrays.TrafficArrays] with an + explicit reference to the traffic tree that owns the plugin entity. - return cls._instance # type: ignore[attr-defined] + Usage: + ```python + class MyPlugin(Entity): + def __init__(self, traffic: Traffic) -> None: + super().__init__(traffic) + with self.settrafarrays(): + self.mydata = np.array([]) -class Entity(TrafficArrays, metaclass=EntityMeta): - """Base class for MiniSky singleton entities with TrafficArrays. + def create(self, n: int = 1) -> None: + super().create(n) + self.mydata[-n:] = default_values + ``` - Combines TrafficArrays (replaceable per-aircraft data) with - singleton behavior (one instance per class). + Args: + traffic: Traffic object whose tree owns this entity. - Usage: - class MyPlugin(Entity): - def __init__(self): - super().__init__() - with self.settrafarrays(): - self.mydata = np.array([]) - - def create(self, n=1): - super().create(n) - self.mydata[-n:] = default_values + Attributes: + traffic: The owning runtime's traffic object. """ - # Singleton instance tracking - _proxy: ClassVar[Proxy | None] = None - _instance: ClassVar[Optional["Entity"]] = None - - def __init_subclass__(cls, **kwargs) -> None: - """Called when a subclass is defined.""" - super().__init_subclass__(**kwargs) - - # Each Entity subclass keeps its own singleton instance - cls._instance = None - - # First-level Entity subclasses get a proxy for hot-swapping, - # but only replaceable classes (with a base implementation) get one - if (not hasattr(cls, "_proxy") or cls._proxy is None) and cls._baseimpl is not None: - cls._proxy = Proxy() - - @classmethod - def select(cls, instance: Optional["Entity"] = None) -> None: - """Select this class/instance as the active implementation.""" - # Call parent's select to update _generator - super().select() - - # Handle singleton instance - if instance is None: - instance = cls._instance or cls() - - cls._baseimpl._instance = instance # type: ignore[attr-defined] - if cls._proxy: - cls._proxy._replace(instance) - - @classmethod - def is_instantiated(cls) -> bool: - """Returns True if the singleton has been instantiated.""" - return cls._instance is not None - - @classmethod - def instance(cls) -> "Proxy | Entity | None": - """Return the current instance (proxy if replaceable, else instance).""" - return cls._proxy or cls._instance - - def __init__(self) -> None: - super().__init__(minisky.traf) - cls = type(self) - if cls._instance is None: - cls._instance = self + def __init__(self, traffic: Traffic) -> None: + """Attach this entity to `traffic` and initialize its array bookkeeping.""" + self.traffic = traffic + super().__init__(traffic) diff --git a/minisky/plugin/plugin.py b/minisky/plugin/plugin.py index 63f3930..59bb0b9 100644 --- a/minisky/plugin/plugin.py +++ b/minisky/plugin/plugin.py @@ -1,331 +1,383 @@ """MiniSky plugin system. -Provides plugin discovery, loading, and management. - -Discovery (:func:`discover`) scans the plugins directory and parses each -Python file's AST — without importing it — looking for an ``init_plugin()`` -function, from which the plugin name, docstring and stack commands are -extracted. Loading (:meth:`Plugin.load`) then imports the module, calls its -``init_plugin()``, and registers the returned update hooks and stack -commands with the simulation. The :func:`manage_plugins` function backs the -in-simulator ``PLUGINS`` stack command. +Provides runtime-owned plugin discovery, loading, and management. + +Discovery ([`PluginManager.discover`][]) scans the plugin directory and parses +each Python file's AST without importing it, looking for an `init_plugin` +function from which the plugin name, docstring, and stack commands are +extracted. Loading ([`PluginManager.load`][]) imports the module, calls +`init_plugin(runtime)`, and registers the returned update hooks and stack +commands with that runtime. [`PluginManager.manage`][] backs the in-simulator +`PLUGINS` stack command. """ +from __future__ import annotations + import ast import importlib import sys +import traceback +from dataclasses import dataclass, field from pathlib import Path -from typing import ClassVar +from types import ModuleType +from typing import TYPE_CHECKING, Any -import minisky -from minisky.core import settings, varexplorer -from minisky.plugin import plugin_decorators -from minisky.plugin.timedfunction import timed_function +from minisky.plugin.plugin_decorators import append_commands, register_declared_commands +from minisky.plugin.timedfunction import TimedFunctionManager +if TYPE_CHECKING: + from collections.abc import Callable -class Plugin: - """MiniSky plugin class. + from minisky.core.settings import MiniSkySettings + from minisky.core.varexplorer import VariableExplorer + from minisky.runtime import MiniSky + from minisky.simulation import ConsoleIO, Simulation + from minisky.stack import CommandStack - Stores information about plugins found in the plugins directory. One - instance is created per discovered plugin module; the class additionally - keeps registries of all discovered and all loaded plugins. + +@dataclass +class Plugin: + """Information about one plugin discovered for one runtime. Attributes: - plugins: Class-level dict mapping upper-case plugin name to its - :class:`Plugin` instance, for all discovered plugins. - loaded_plugins: Class-level dict of plugins that have been loaded. fullname: Importable module name of the plugin (dotted path). filepath: Path to the plugin's source file. - plugin_doc: Module docstring of the plugin, extracted during discovery. - plugin_name: Name of the plugin as declared in its config dict - (falls back to the file stem in upper case). - plugin_stack: List of (command name, help text) tuples of the stack - commands the plugin declares. - loaded: True once the plugin module has been imported and initialized. - imp: The imported plugin module (None until loaded). + plugin_doc: Module docstring, extracted during discovery. + plugin_name: Name declared in the config dict, falling back to the + upper-case file stem. + plugin_stack: List of `(command name, help text)` tuples declared by + the plugin. + loaded: True once the plugin has been imported and initialized for the + owning runtime. + module: Imported plugin module, or None until loaded. + config: Config dictionary returned by `init_plugin(runtime)`. + state: Optional runtime-owned state object returned in the config. """ - # Dictionary of all available plugins - plugins: ClassVar[dict[str, "Plugin"]] = {} + fullname: str + filepath: Path + plugin_doc: str = "" + plugin_name: str = "" + plugin_stack: list[tuple[str, str]] = field(default_factory=list) + loaded: bool = False + module: ModuleType | None = None + config: dict[str, Any] = field(default_factory=dict) + state: Any = None - # Plugins that have been loaded - loaded_plugins: ClassVar[dict[str, "Plugin"]] = {} - def __init__(self, fullname: str, filepath: Path) -> None: - """Create a plugin record for a discovered plugin module. +class PluginManager: + """Plugin discovery, loading, hooks, and state for one MiniSky runtime. - Args: - fullname: Importable (dotted) module name of the plugin. - filepath: Path to the plugin's Python source file. - """ - self.fullname = fullname - self.filepath = filepath - self.plugin_doc = "" - self.plugin_name = "" - self.plugin_stack = [] - self.loaded = False - self.imp = None - - def _load(self) -> tuple[bool, str]: - """Import and initialize this plugin. - - Imports the plugin module, calls its ``init_plugin()`` function, and - registers the returned ``preupdate``/``update``/``reset`` hooks as - timed functions (with the declared ``update_interval`` in seconds, - never smaller than the simulation timestep), registers the module - with the variable explorer, and appends any declared stack commands. + Attributes: + plugins: Dict mapping upper-case plugin names to all discovered plugin + records for this runtime. + loaded_plugins: Dict containing the plugins loaded into this runtime. + timed: Runtime-owned timer and lifecycle-hook manager. + """ - Returns: - Tuple of (success flag, status message). + def __init__( + self, + settings: MiniSkySettings, + console: ConsoleIO, + variables: VariableExplorer, + get_runtime: Callable[[], MiniSky], + get_simulation: Callable[[], Simulation], + get_command_stack: Callable[[], CommandStack], + ) -> None: + self.settings = settings + self.console = console + self.variables = variables + self._get_runtime = get_runtime + self._get_simulation = get_simulation + self._get_command_stack = get_command_stack + self.plugins: dict[str, Plugin] = {} + self.loaded_plugins: dict[str, Plugin] = {} + self.timed = TimedFunctionManager(lambda: self.simulation.simdt) + + @property + def runtime(self) -> MiniSky: + """Return the runtime that owns this manager.""" + return self._get_runtime() + + @property + def simulation(self) -> Simulation: + """Return the owning runtime's simulation.""" + return self._get_simulation() + + @property + def commands(self) -> CommandStack: + """Return the owning runtime's command stack.""" + return self._get_command_stack() + + def discover(self) -> None: + """Discover plugins in the configured directory using AST parsing. + + Resolves `plugin_path` relative to the package root and then the current + working directory, adds its parent to `sys.path`, and scans all `*.py` + files except names starting with an underscore. Modules containing a + top-level `init_plugin` function are registered without being imported. """ - if self.loaded: - return False, f"Plugin {self.plugin_name} already loaded" + # Get plugin path from settings. + plugin_path = Path(self.settings.plugin_path) - try: - # Load the plugin module - self.imp = importlib.import_module(self.fullname) + # Make the path absolute if it is relative. + if not plugin_path.is_absolute(): + package_path = Path(__file__).parent.parent.parent / plugin_path + working_path = Path.cwd() / plugin_path + if package_path.exists(): + plugin_path = package_path + elif working_path.exists(): + plugin_path = working_path + else: + self.console.echo(f"Plugin directory not found: {plugin_path}") + return - # Initialize the plugin - result = self.imp.init_plugin() - config = result if isinstance(result, dict) else result[0] + if not plugin_path.exists(): + self.console.echo(f"Plugin directory not found: {plugin_path}") + return - # Get update interval (minimum is simdt) - dt = max(config.get("update_interval", 0.0), minisky.sim.simdt) + # TODO(abraham): replace this process-wide sys.path mutation with a + # path-based importer that still supports plugin-local package imports. + plugin_parent = str(plugin_path.parent) + if plugin_parent not in sys.path: + sys.path.insert(0, plugin_parent) - # Register timed functions if present - for hook in ("preupdate", "update", "reset"): - func = config.get(hook) - if func: - timed_function( - func, name=f"{self.plugin_name}.{func.__name__}", dt=dt, hook=hook - ) + # Scan Python files without importing them. + for filepath in plugin_path.glob("**/*.py"): + if filepath.name.startswith("_"): + continue - # Register with variable explorer - varexplorer.register_data_parent(self.imp, self.plugin_name.lower()) + relative_path = filepath.relative_to(plugin_path.parent) + fullname = ".".join(relative_path.with_suffix("").parts) + try: + tree = ast.parse(filepath.read_bytes()) + except Exception: + continue - # Register stack functions if provided - if isinstance(result, (tuple, list)) and len(result) > 1: - stackfuns = result[1] - plugin_decorators.append_commands(stackfuns) + # Find the required synchronous init_plugin function. + init_node = next( + ( + item + for item in tree.body + if isinstance(item, ast.FunctionDef) and item.name == "init_plugin" + ), + None, + ) + if init_node is None: + continue - self.loaded = True - return True, f"Successfully loaded plugin {self.plugin_name}" + plugin_info = self._parse_init_plugin(init_node) + if plugin_info is None: + continue - except ImportError as e: - return False, f"Failed to load {self.plugin_name}: {e}" - except Exception as e: - import traceback + plugin_name = str( + plugin_info.get("plugin_name", filepath.stem.upper()) + ).upper() + existing = self.plugins.get(plugin_name) + if existing is not None and existing.loaded: + continue - traceback.print_exc() - return False, f"Error loading {self.plugin_name}: {e}" + self.plugins[plugin_name] = Plugin( + fullname=fullname, + filepath=filepath, + plugin_doc=ast.get_docstring(tree) or "", + plugin_name=plugin_name, + plugin_stack=plugin_info.get("stack_functions", []), + ) - @classmethod - def load(cls, name: str) -> tuple[bool, str]: + def load(self, name: str) -> tuple[bool, str]: """Load a previously discovered plugin by name. + Imports the module, calls `init_plugin(runtime)`, registers returned + `preupdate`, `update`, `reset`, and `hold` hooks as timed functions, + registers plugin data with the variable explorer, and appends declared + stack commands to the owning runtime's command stack. + Args: - name: Plugin name (case-insensitive) as found during discovery. + name: Plugin name, case-insensitive, as found during discovery. Returns: - Tuple of (success flag, status message). Fails when the plugin is - unknown, already loaded, or raises during import/initialization. + Tuple of `(success flag, status message)`. Loading fails when the + plugin is unknown, already loaded, returns an invalid config, or + raises during import or initialization. """ - plugin = cls.plugins.get(name.upper()) + plugin = self.plugins.get(name.upper()) if plugin is None: return False, f"Error loading plugin: plugin {name} not found." + if plugin.loaded: + return False, f"Plugin {plugin.plugin_name} already loaded" - success, msg = plugin._load() - if success: - cls.loaded_plugins[name.upper()] = plugin - return success, msg - - @classmethod - def find_plugins(cls) -> None: - """Discover plugins in the plugins directory using AST parsing. - - Resolves the plugin directory from the ``plugin_path`` setting - (default ``plugins``, looked up relative to the package root and then - the current working directory), adds it to ``sys.path``, and scans - all ``*.py`` files (skipping names starting with an underscore). Any - module whose AST contains a top-level ``init_plugin`` function is - registered in :attr:`plugins` without being imported. - """ - # Get plugin path from settings or use default - plugin_path = Path(getattr(settings, "plugin_path", "plugins")) - - # Make path absolute if relative - if not plugin_path.is_absolute(): - # Look relative to the minisky package first, then cwd - pkg_path = Path(__file__).parent.parent.parent / plugin_path - cwd_path = Path.cwd() / plugin_path - - if pkg_path.exists(): - plugin_path = pkg_path - elif cwd_path.exists(): - plugin_path = cwd_path - else: - print(f"Plugin directory not found: {plugin_path}") - return - - if not plugin_path.exists(): - print(f"Plugin directory not found: {plugin_path}") - return - - # Add plugin path to sys.path for imports - plugin_path_str = str(plugin_path.parent) - if plugin_path_str not in sys.path: - sys.path.insert(0, plugin_path_str) + try: + # Load and initialize the plugin for this runtime. + module = importlib.import_module(plugin.fullname) + result = module.init_plugin(self.runtime) + config = result if isinstance(result, dict) else result[0] + stack_functions = ( + result[1] if isinstance(result, (tuple, list)) and len(result) > 1 else None + ) + if not isinstance(config, dict): + return False, f"Plugin {plugin.plugin_name} returned an invalid config" + + # Get update interval (minimum is simdt) and register hooks. + interval = max(float(config.get("update_interval", 0.0)), self.simulation.simdt) + for hook_name in ("preupdate", "update", "reset", "hold"): + callback = config.get(hook_name) + if callback is not None: + self.timed.register( + callback, + name=f"{plugin.plugin_name}.{callback.__name__}", + dt=interval, + hook=hook_name, + ) - # Scan for Python files - for filepath in plugin_path.glob("**/*.py"): - if filepath.name.startswith("_"): - continue + # Register stack functions only on this runtime. + register_declared_commands(self.commands, module) + if stack_functions: + append_commands(self.commands, stack_functions) + + # Register plugin state, or the module when no state object is returned. + state = config.get("state") + self.variables.register_data_parent( + state if state is not None else module, + plugin.plugin_name.lower(), + ) + + plugin.loaded = True + plugin.module = module + plugin.config = config + plugin.state = state + self.loaded_plugins[plugin.plugin_name] = plugin + return True, f"Successfully loaded plugin {plugin.plugin_name}" + + except ImportError as exc: + return False, f"Failed to load {plugin.plugin_name}: {exc}" + except Exception as exc: + traceback.print_exc() + return False, f"Error loading {plugin.plugin_name}: {exc}" - # Construct module name - rel_path = filepath.relative_to(plugin_path.parent) - module_parts = list(rel_path.with_suffix("").parts) - fullname = ".".join(module_parts) + def load_enabled(self) -> None: + """Load plugins enabled in this runtime's settings.""" + for plugin_name in self.settings.enabled_plugins: + _, message = self.load(plugin_name) + self.console.echo(message) - # Parse the source code using AST - try: - with open(filepath, "rb") as f: - source = f.read() - tree = ast.parse(source) - except Exception: - continue + def manage(self, command: str = "LIST", plugin_name: str = "") -> tuple[bool, str]: + """List available plugins or load a plugin. - # Look for init_plugin function - for item in tree.body: - if isinstance(item, ast.FunctionDef) and item.name == "init_plugin": - # Found a plugin, parse its config - plugin_info = cls._parse_init_plugin(item, tree) - if plugin_info: - plugin = Plugin(fullname, filepath) - plugin.plugin_doc = ast.get_docstring(tree) or "" - plugin.plugin_name = plugin_info.get("plugin_name", filepath.stem.upper()) - plugin.plugin_stack = plugin_info.get("stack_functions", []) - cls.plugins[plugin.plugin_name.upper()] = plugin - break - - @classmethod - def _parse_init_plugin(cls, func_node: ast.FunctionDef, tree: ast.Module) -> dict | None: - """Parse an ``init_plugin`` AST node to extract the plugin config. - - Walks the function body backwards from its return statement to find - the returned config dict (and optional stack-functions dict), reading - literal keys and values without executing any plugin code. + Arguments: + - command: `LIST` to show plugins, or `LOAD` / `ENABLE` to load one. + - plugin_name: Name of the plugin to load. + """ + command = command.upper() + + if command == "LIST": + running = set(self.loaded_plugins) + available = set(self.plugins) - running + text = f"\nLoaded plugins: {', '.join(sorted(running)) if running else '(none)'}" + if available: + text += f"\nAvailable plugins: {', '.join(sorted(available))}" + else: + text += "\nNo additional plugins available." + return True, text + + if command in ("LOAD", "ENABLE") or not plugin_name: + # If no command is given, assume loading a plugin. + return self.load(plugin_name or command) + + return False, f"Unknown command: {command}" + + def preupdate(self) -> None: + """Called before traffic update each simulation step.""" + self.timed.preupdate() + + def update(self) -> None: + """Called after traffic update each simulation step.""" + self.timed.update() + + def reset(self) -> None: + """Called on simulation reset.""" + self.timed.reset() + + def hold(self) -> None: + """Called when simulation pauses.""" + self.timed.hold() + + def shutdown(self) -> None: + """Run shutdown callbacks and clear runtime-owned hook state.""" + # TODO(abraham): call this from the final `MiniSky` lifecycle/context + # manager and unregister plugin variable-explorer parents at the same + # time. + for plugin in reversed(tuple(self.loaded_plugins.values())): + callback = plugin.config.get("shutdown") + if callback is not None: + callback() + self.timed.clear() + self.loaded_plugins.clear() + for plugin in self.plugins.values(): + plugin.loaded = False + plugin.config.clear() + plugin.state = None + + @staticmethod + def _parse_init_plugin(func_node: ast.FunctionDef) -> dict[str, Any] | None: + """Parse an `init_plugin` AST node to extract the plugin config. + + Walks the function body backwards from its return statement to find the + returned config dict and optional stack-functions dict, reading literal + keys and values without executing plugin code. Args: - func_node: ``ast.FunctionDef`` node of the ``init_plugin`` function. - tree: Parsed AST of the full plugin module. + func_node: AST node of the `init_plugin` function. Returns: - Dict with the literal config values (e.g. ``plugin_name``, - ``update_interval``) plus a ``stack_functions`` list of - (command name, help text) tuples, or None if no return value - could be found. + Dict with literal config values plus a `stack_functions` list of + `(command name, help text)` tuples, or None if no return value can + be found. """ - ret_dicts = [] - ret_names = ["", ""] + returned: list[ast.expr] = [] + return_names = ["", ""] for item in reversed(func_node.body): - # Find return statement + # Find return statement. if isinstance(item, ast.Return): if isinstance(item.value, ast.Tuple): - ret_dicts = list(item.value.elts) - elif item.value: - ret_dicts = [item.value] - - if not ret_dicts: - continue - - # Get variable names if return value is a Name - ret_names = [el.id if isinstance(el, ast.Name) else "" for el in ret_dicts] - - # Check if this is assignment of a return value dict + returned = list(item.value.elts) + elif item.value is not None: + returned = [item.value] + if returned: + return_names = [ + value.id if isinstance(value, ast.Name) else "" for value in returned + ] + + # Resolve assignments of returned config dictionaries. if isinstance(item, ast.Assign) and isinstance(item.value, ast.Dict): - for i, name in enumerate(ret_names): - if name and hasattr(item.targets[0], "id") and item.targets[0].id == name: # type: ignore[union-attr] - ret_dicts[i] = item.value + target = item.targets[0] + for index, name in enumerate(return_names): + if name and isinstance(target, ast.Name) and target.id == name: + returned[index] = item.value - if not ret_dicts: + if not returned: return None - # Parse the config dict - config = {} - if isinstance(ret_dicts[0], ast.Dict): - for key, value in zip(ret_dicts[0].keys, ret_dicts[0].values, strict=False): + # Parse the config dict. + config: dict[str, Any] = {} + if isinstance(returned[0], ast.Dict): + for key, value in zip(returned[0].keys, returned[0].values, strict=False): + if isinstance(key, ast.Constant) and isinstance(value, ast.Constant): + config[str(key.value)] = value.value + + # Parse stack functions if present. + if len(returned) > 1 and isinstance(returned[1], ast.Dict): + stack_functions: list[tuple[str, str]] = [] + for key, value in zip(returned[1].keys, returned[1].values, strict=False): if not isinstance(key, ast.Constant): continue - key_str = key.value - - if isinstance(value, ast.Constant): - config[key_str] = value.value - - # Parse stack functions if present - if len(ret_dicts) > 1 and isinstance(ret_dicts[1], ast.Dict): - stack_funcs = [] - for key, value in zip(ret_dicts[1].keys, ret_dicts[1].values, strict=False): - if not isinstance(key, ast.Constant): - continue - cmd_name = key.value - - # Extract help text (last element of the list/tuple) + help_text = "" if isinstance(value, (ast.List, ast.Tuple)) and value.elts: last = value.elts[-1] - help_text = last.value if isinstance(last, ast.Constant) else "" - stack_funcs.append((cmd_name, help_text)) - config["stack_functions"] = stack_funcs + if isinstance(last, ast.Constant): + help_text = str(last.value) + stack_functions.append((str(key.value), help_text)) + config["stack_functions"] = stack_functions return config - - -def discover() -> None: - """Discover available plugins (AST parsing only, no imports). - - Convenience wrapper around :meth:`Plugin.find_plugins`; called once from - :func:`minisky.init` so that the ``PLUGINS`` command can list what is - available without importing anything. - """ - Plugin.find_plugins() - - -def load_enabled() -> None: - """Load enabled plugins from settings. - - Loads every plugin listed under ``enabled_plugins`` in the settings and - echoes the resulting status message for each. Called from - :func:`minisky.load_plugins` after the simulator has been initialized. - """ - enabled = getattr(settings, "enabled_plugins", []) - for plugin_name in enabled: - success, msg = Plugin.load(plugin_name) - minisky.scr.echo(msg) - - -def manage_plugins(cmd: str = "LIST", plugin_name: str = "") -> tuple[bool, str]: - """List available plugins or load/unload a plugin. - - Arguments: - - cmd: 'LIST' to show plugins, 'LOAD' to load a plugin - - plugin_name: Name of plugin to load - """ - cmd = cmd.upper() - - if cmd == "LIST": - running = set(Plugin.loaded_plugins.keys()) - available = set(Plugin.plugins.keys()) - running - - text = f"\nLoaded plugins: {', '.join(running) if running else '(none)'}" - if available: - text += f"\nAvailable plugins: {', '.join(available)}" - else: - text += "\nNo additional plugins available." - return True, text - - if cmd in ("LOAD", "ENABLE") or not plugin_name: - # If no command given, assume loading a plugin - target = plugin_name or cmd - return Plugin.load(target) - - return False, f"Unknown command: {cmd}" diff --git a/minisky/plugin/plugin_decorators.py b/minisky/plugin/plugin_decorators.py index dc26323..ffa48d0 100644 --- a/minisky/plugin/plugin_decorators.py +++ b/minisky/plugin/plugin_decorators.py @@ -1,14 +1,21 @@ """Stack command declarations for MiniSky plugins. -The `@command` decorator stores command metadata on a function. It registers -immediately when a runtime is active; otherwise the declaration is collected -by `CommandStack.init()` when the runtime is constructed. +The `@command` decorator stores command metadata on a function. Importing a +plugin module does not register that command globally. Instead, the +[`PluginManager`][minisky.plugin.plugin.PluginManager] that loads the module +registers its declarations with the owning runtime's +[`CommandStack`][minisky.stack.CommandStack]. """ +from __future__ import annotations + import inspect -import sys from collections.abc import Callable -from typing import Any +from types import ModuleType +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from minisky.stack import CommandStack def command( @@ -19,35 +26,50 @@ def command( help: str = "", arguments: str = "", ) -> Any: - """Decorator to register a function as a stack command. + """Declare a function as a stack command. + + The declaration is stored on the function and registered only when the + runtime-owned plugin manager loads the containing module. This keeps module + import free of command-registry side effects while preserving the familiar + decorator syntax. Args: - func: The function to decorate (can be omitted for @command() style) - name: Command name (defaults to function name in uppercase) - aliases: Tuple of command aliases - brief: Brief usage string - help: Detailed help text - arguments: Argument specification string (e.g., "callsign,alt,[spd]") + func: Function to decorate. It may be omitted when using + `@command(...)` syntax. + name: Command name. Defaults to the function name. + aliases: Alternative command names. + brief: Brief usage string. + help: Detailed help text. When omitted, the function docstring is used. + arguments: Argument specification string, for example + `callsign,alt,[spd]`. Example: + from minisky import stack from minisky.stack.argparser import Txt - @command + @stack.command def mycommand(arg1: Txt, arg2: int = 5): '''Help text for mycommand.''' return True, "Success" - @command(name='MYCMD', aliases=('MC',)) + @stack.command(name="MYCMD", aliases=("MC",)) def my_command(arg: str): '''Help text.''' return True, "Done" Returns: - The original function (unmodified) + The original function or descriptor, unmodified apart from the stored + declaration metadata. """ - def deco(func): - actual_func = func.__func__ if isinstance(func, (staticmethod, classmethod)) else func + def deco(declared: Callable[..., Any]) -> Any: + # Static and class methods store their declaration on the underlying + # function so the plugin loader can inspect them uniformly. + actual_func = ( + declared.__func__ + if isinstance(declared, (staticmethod, classmethod)) + else declared + ) declaration = { "name": name or actual_func.__name__, "aliases": aliases, @@ -56,65 +78,65 @@ def deco(func): "arguments": arguments, } actual_func.__stack_command__ = declaration # type: ignore[reportFunctionMemberAccess] + return declared - try: - from minisky.stack import Command, current - - current() - except (ImportError, RuntimeError): - return func - - Command.addcommand(actual_func, **declaration) - return func - - # Allow both @command and @command(args) + # Allow both `@command` and `@command(...)` forms. return deco(func) if func else deco -def register_declared_commands() -> None: - """Register command declarations from modules imported before runtime startup.""" - from minisky.stack import Command +def register_declared_commands(command_stack: CommandStack, module: ModuleType) -> None: + """Register command declarations from one plugin module. - for module in tuple(sys.modules.values()): - if module is None: - continue - for value in vars(module).values(): - actual_func = ( - value.__func__ if isinstance(value, (staticmethod, classmethod)) else value - ) - declaration = getattr(actual_func, "__stack_command__", None) - if declaration is not None: - Command.addcommand(actual_func, **declaration) + Only the supplied module is inspected. This is intentionally narrower than + scanning `sys.modules`: loading a plugin into one runtime must not register + commands imported for another runtime. + Args: + command_stack: Runtime-owned command registry that receives the + declarations. + module: Imported plugin module whose decorated functions are inspected. + """ + for value in vars(module).values(): + actual_func = ( + value.__func__ if isinstance(value, (staticmethod, classmethod)) else value + ) + declaration = getattr(actual_func, "__stack_command__", None) + if declaration is not None: + command_stack.addcommand(actual_func, **declaration) -def append_commands(newcommands: dict, syndict: dict | None = None) -> None: - """Append additional functions to the stack command dictionary. - Used by plugin loader to register plugin commands. +def append_commands( + command_stack: CommandStack, + newcommands: dict[str, list[Any] | tuple[Any, ...]], + syndict: dict[str, list[str]] | None = None, +) -> None: + """Append a plugin command dictionary to one runtime's command registry. + + This supports the original plugin return format in which `init_plugin` + returns a second dictionary mapping command names to a callback, argument + specification, brief usage text, and full help text. Args: - newcommands: Dict of command name -> [function, arguments, brief, help] - syndict: Optional dict of command name -> list of synonyms + command_stack: Runtime-owned command registry that receives the + commands. + newcommands: Mapping of command name to + `[function, arguments, brief, help]`. Missing trailing values are + treated as empty strings. + syndict: Optional mapping of command name to aliases. """ - # Import here to avoid circular import - from minisky.stack import Command - - syndict = syndict or {} + synonyms = syndict or {} for name, values in newcommands.items(): - if len(values) >= 4: - function, arguments, brief, help_text = values[:4] - else: - function = values[0] - arguments = values[1] if len(values) > 1 else "" - brief = values[2] if len(values) > 2 else "" - help_text = values[3] if len(values) > 3 else "" - - Command.addcommand( + function = values[0] + arguments = values[1] if len(values) > 1 else "" + brief = values[2] if len(values) > 2 else "" + help_text = values[3] if len(values) > 3 else "" + + command_stack.addcommand( function, name=name, arguments=arguments, brief=brief, help=help_text, - aliases=syndict.get(name, []), + aliases=synonyms.get(name, []), ) diff --git a/minisky/plugin/timedfunction.py b/minisky/plugin/timedfunction.py index 67c407e..073d34e 100644 --- a/minisky/plugin/timedfunction.py +++ b/minisky/plugin/timedfunction.py @@ -5,37 +5,33 @@ - update: After traffic update each step - reset: On simulation reset - hold: When simulation pauses + +Each `TimedFunctionManager` owns the hooks and timers for one runtime. """ +from __future__ import annotations + import functools import inspect from collections import OrderedDict -from collections.abc import Callable, ValuesView -from types import SimpleNamespace -from typing import ClassVar - -import minisky +from collections.abc import Callable -class _Hook(OrderedDict): +class _Hook(OrderedDict[str, Callable[[], None]]): """Ordered dictionary of callbacks that can be triggered.""" def trigger(self) -> None: """Call all registered callbacks.""" - for callback in self.values(): + for callback in tuple(self.values()): callback() -# Dictionaries of timed functions for different trigger points -hooks = SimpleNamespace(update=_Hook(), preupdate=_Hook(), hold=_Hook(), reset=_Hook()) - - class Timer: """Timer class for simulation-time periodic functions. - A timer fires every ``dt`` simulation seconds, quantised to whole simulation + A timer fires every `dt` simulation seconds, quantised to whole simulation timesteps: the requested interval is converted to a step count relative to the - current ``sim.simdt``, so the actual interval is never smaller than one timestep. + current `sim.simdt`, so the actual interval is never smaller than one timestep. Attributes: name: Unique name of the timer (also the registry key). @@ -46,9 +42,7 @@ class Timer: readynext: True when the timer fires on the current step. """ - _timers: ClassVar[dict[str, "Timer"]] = {} - - def __init__(self, name: str, dt: float) -> None: + def __init__(self, name: str, dt: float, get_simdt: Callable[[], float]) -> None: self.name = name self.dt_default = dt self.dt_requested = dt @@ -56,12 +50,12 @@ def __init__(self, name: str, dt: float) -> None: self.counter = 0 self.rel_freq = 1 self.readynext = True - Timer._timers[name] = self + self._get_simdt = get_simdt self._update_freq() def _update_freq(self) -> None: """Update the relative frequency based on current simdt.""" - simdt = getattr(minisky.sim, "simdt", 1.0) if minisky.sim else 1.0 + simdt = self._get_simdt() self.rel_freq = max(1, int(self.dt_requested / simdt)) self.dt_act = self.rel_freq * simdt @@ -77,121 +71,112 @@ def step(self) -> None: self.counter = (self.counter or self.rel_freq) - 1 self.readynext = self.counter == 0 - @classmethod - def timers(cls) -> ValuesView["Timer"]: - """Return all registered timers.""" - return cls._timers.values() - - @classmethod - def step_all(cls) -> None: - """Step all timers.""" - for timer in cls._timers.values(): - timer.step() - @classmethod - def reset_all(cls) -> None: - """Reset all timers.""" - for timer in cls._timers.values(): - timer.reset() - - -def timed_function( - func: Callable | None = None, name: str = "", dt: float = 0, hook: str = "update" -) -> Callable: - """Decorator to turn a function into a periodically timed function. - - Args: - func: The function to decorate - name: Name for the timer (auto-generated if not provided) - dt: Update interval in seconds (0 means every step) - hook: Which hook to attach to ('update', 'preupdate', 'reset', 'hold') +class TimedFunctionManager: + """Central manager for plugin lifecycle events. - Example: - @timed_function(name='myplugin', dt=5, hook='update') - def my_update(): - # Called every 5 simulation seconds - pass + Provides a clean interface for simulation.py to trigger this runtime's + plugin hooks without knowing about Timer or hook internals. """ - def deco(func: Callable) -> Callable: - # Generate a name if none is provided - if not name: - if inspect.ismethod(func): - if inspect.isclass(func.__self__): - tname = f"{func.__self__.__name__}.{func.__name__}" - else: - tname = f"{func.__self__.__class__.__name__}.{func.__name__}" - else: - tname = f"{func.__module__}.{func.__name__}" + _hook_names = ("preupdate", "update", "reset", "hold") + + def __init__(self, get_simdt: Callable[[], float]) -> None: + self._get_simdt = get_simdt + self.timers: dict[str, Timer] = {} + self.preupdate_hooks = _Hook() + self.update_hooks = _Hook() + self.reset_hooks = _Hook() + self.hold_hooks = _Hook() + + def _hook(self, name: str) -> _Hook: + if name not in self._hook_names: + raise KeyError(f"No timing hook found with name {name}") + return getattr(self, f"{name}_hooks") + + def register( + self, + func: Callable[..., None], + *, + name: str = "", + dt: float = 0, + hook: str | tuple[str, ...] = "update", + ) -> Callable[..., None]: + """Turn a function into a periodically timed function. + + Args: + func: The function to register. + name: Name for the timer (auto-generated if not provided). + dt: Update interval in seconds (0 means every step). + hook: Which hook to attach to (`update`, `preupdate`, `reset`, or + `hold`). + + Returns: + The original function. + """ + # Generate a name if none is provided. + timer_name = name or self._callback_name(func) + hook_names = (hook,) if isinstance(hook, str) else hook + + if any(hook_name in ("update", "preupdate") for hook_name in hook_names): + # Create a timer for update/preupdate hooks. + timer = Timer(timer_name, dt, self._get_simdt) + self.timers[timer_name] = timer else: - tname = name - - if "update" in hook or "preupdate" in hook: - # Create a timer for update/preupdate hooks - timer = Timer(tname, dt) - - # Check if function accepts dt argument - has_dt_param = "dt" in inspect.signature(func).parameters - - if has_dt_param: - - @functools.wraps(func) - def callback(*args): - if timer.readynext: - func(*args, dt=float(timer.dt_act)) - else: + timer = None + + # Check if function accepts dt argument. + has_dt_param = "dt" in inspect.signature(func).parameters + + @functools.wraps(func) + def callback() -> None: + if timer is None: + func() + elif timer.readynext: + if has_dt_param: + func(dt=float(timer.dt_act)) + else: + func() - @functools.wraps(func) - def callback(*args): - if timer.readynext: - func(*args) - else: - # For reset/hold hooks, just wrap the function directly - @functools.wraps(func) - def callback(*args): - func(*args) - - # Add callback to appropriate hook(s) - hooknames = hook if isinstance(hook, (list, tuple)) else (hook,) - for hookname in hooknames: - target = getattr(hooks, hookname, None) - if target is None: - raise KeyError(f"No timing hook found with name {hookname}") - if tname not in target: - # For reset/hold, store the original function, for update/preupdate store callback - target[tname] = func if hookname in ("reset", "hold") else callback + # Add callback to appropriate hook(s). + for hook_name in hook_names: + target = self._hook(hook_name) + # For reset/hold, store the original function; for + # update/preupdate, store the timed callback. + registered = func if hook_name in ("reset", "hold") else callback + target.setdefault(timer_name, registered) return func - # Allow both @timed_function and @timed_function(args) - return deco(func) if func else deco - - -class PluginManager: - """Central manager for plugin lifecycle events. - - Provides a clean interface for simulation.py to trigger plugin hooks - without knowing about Timer or hooks internals. - """ - @staticmethod - def preupdate() -> None: + def _callback_name(func: Callable[..., None]) -> str: + if inspect.ismethod(func): + owner = func.__self__ if inspect.isclass(func.__self__) else type(func.__self__) + return f"{owner.__name__}.{func.__name__}" + return f"{func.__module__}.{func.__name__}" + + def preupdate(self) -> None: """Called before traffic update each simulation step.""" - Timer.step_all() - hooks.preupdate.trigger() + for timer in self.timers.values(): + timer.step() + self.preupdate_hooks.trigger() - @staticmethod - def update() -> None: + def update(self) -> None: """Called after traffic update each simulation step.""" - hooks.update.trigger() + self.update_hooks.trigger() - @staticmethod - def reset() -> None: + def reset(self) -> None: """Called on simulation reset.""" - Timer.reset_all() - hooks.reset.trigger() + for timer in self.timers.values(): + timer.reset() + self.reset_hooks.trigger() - @staticmethod - def hold() -> None: + def hold(self) -> None: """Called when simulation pauses.""" - hooks.hold.trigger() + self.hold_hooks.trigger() + + def clear(self) -> None: + """Remove all callbacks and timers owned by this manager.""" + self.timers.clear() + for name in self._hook_names: + self._hook(name).clear() diff --git a/minisky/runtime.py b/minisky/runtime.py index 807cf2f..31fb1ce 100644 --- a/minisky/runtime.py +++ b/minisky/runtime.py @@ -4,7 +4,9 @@ from minisky import tools from minisky.core.settings import MiniSkySettings, data +from minisky.core.trafficarrays import ReplaceableManager from minisky.core.varexplorer import VariableExplorer +from minisky.plugin import PluginManager from minisky.simulation import ConsoleIO, Runner, Simulation from minisky.simulation.simulation import OP from minisky.stack import CommandStack @@ -33,9 +35,16 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No get_simulation=lambda: self.simulation, stack_command=lambda *args, **kwargs: self.commands.stack(*args, **kwargs), get_command_registry=lambda: self.commands.cmddict, - select_implementation=lambda base, impl: self.commands.select_implementation( - base, impl - ), + select_implementation=lambda base, impl: self.replaceables.select(base, impl), + ) + self.replaceables = ReplaceableManager(self.traffic, lambda: self.commands.cmddict) + self.plugins = PluginManager( + settings=settings, + console=self.console, + variables=self.variables, + get_runtime=lambda: self, + get_simulation=lambda: self.simulation, + get_command_stack=lambda: self.commands, ) self.commands = CommandStack( traffic=self.traffic, @@ -43,6 +52,8 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No console=self.console, areas=self.areas, variables=self.variables, + plugins=self.plugins, + replaceables=self.replaceables, get_simulation=lambda: self.simulation, get_runner=lambda: self.runner, ) @@ -55,6 +66,8 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No console=self.console, command_stack=self.commands, areas=self.areas, + plugins=self.plugins, + replaceables=self.replaceables, stop_runner=self._stop_runner, publish_tick=self.streaming.publish_tick, ) @@ -67,6 +80,7 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No minisky._activate(self) self.commands.init() + self.plugins.discover() if scenario: self.commands.stack(f"IC {scenario}") @@ -76,6 +90,10 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No def _stop_runner(self) -> None: self.runner.stop() + def load_plugins(self) -> None: + """Load plugins enabled in this runtime's settings.""" + self.plugins.load_enabled() + async def run(self) -> None: """Run the simulation until its runner stops.""" await self.runner.run() diff --git a/minisky/server.py b/minisky/server.py index f200ff8..05fac29 100644 --- a/minisky/server.py +++ b/minisky/server.py @@ -1,6 +1,6 @@ """MiniSky REST + streaming API server. -The FastAPI application wraps an explicit [`MiniSky`][minisky.runtime.MiniSky] +The FastAPI application wraps an explicit [`MiniSky`][minisky.MiniSky] runtime and steps it continuously with its async runner while the server is active. Endpoints expose aircraft state, conflict information, simulation-time control, plugin management, a passthrough for stack commands, a per-tick push @@ -19,6 +19,8 @@ Interactive OpenAPI docs are served at `/docs`. """ +from __future__ import annotations + import asyncio import os from contextlib import asynccontextmanager, suppress @@ -40,9 +42,11 @@ from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles -from minisky import MiniSky, MiniSkySettings, filename_settings, plugin +from minisky import MiniSky, MiniSkySettings, filename_settings from minisky.tools import aero +# TODO(abraham): move router construction into `create_app` when the final +# lifecycle pass removes all remaining process-wide application declarations. router = APIRouter() @@ -74,9 +78,7 @@ def create_app(runtime: MiniSky | None = None) -> FastAPI: settings = MiniSkySettings.from_file(filename_settings) runtime = MiniSky(settings) - # TODO(abraham): migrate the plugin ownership - plugin.discover() - plugin.load_enabled() + runtime.load_plugins() app = FastAPI(lifespan=lifespan) app.state.runtime = runtime @@ -267,15 +269,15 @@ def show_map() -> RedirectResponse: @router.get("/plugins") -def list_plugins() -> Any: +def list_plugins(runtime: Runtime) -> Any: """List available and loaded plugins.""" - return plugin.manage_plugins("LIST") + return runtime.plugins.manage("LIST") @router.get("/plugins/load/{name}") -def load_plugin(name: str) -> Any: +def load_plugin(name: str, runtime: Runtime) -> Any: """Load a plugin by name.""" - return plugin.manage_plugins("LOAD", name) + return runtime.plugins.manage("LOAD", name) def main() -> None: diff --git a/minisky/simulation/simulation.py b/minisky/simulation/simulation.py index 960d2b8..f6fb0bd 100644 --- a/minisky/simulation/simulation.py +++ b/minisky/simulation/simulation.py @@ -17,10 +17,9 @@ import numpy as np -from minisky.core.trafficarrays import reset_replaceables -from minisky.plugin import PluginManager - if TYPE_CHECKING: + from minisky.core.trafficarrays import ReplaceableManager + from minisky.plugin import PluginManager from minisky.simulation.console import ConsoleIO from minisky.stack import CommandStack from minisky.tools.areafilter import AreaFilter @@ -65,6 +64,8 @@ def __init__( console: ConsoleIO, command_stack: CommandStack, areas: AreaFilter, + plugins: PluginManager, + replaceables: ReplaceableManager, stop_runner: Callable[[], None], publish_tick: Callable[[], None], ) -> None: @@ -73,6 +74,8 @@ def __init__( self.console = console self.commands = command_stack self.areas = areas + self.plugins = plugins + self.replaceables = replaceables self.stop_runner = stop_runner self.publish_tick = publish_tick self.state = INIT @@ -130,12 +133,12 @@ def step(self) -> None: self.utc += datetime.timedelta(seconds=self.simdt) # Plugin pre-update (timers + preupdate hooks) - PluginManager.preupdate() + self.plugins.preupdate() self.traffic.update() # Plugin post-update hooks - PluginManager.update() + self.plugins.update() # Publish after command and state processing in every simulation state. # This is a no-op when the runtime has no stream subscribers. @@ -172,7 +175,7 @@ def hold(self) -> None: """ self.syst = time.time() + self.simdt self.state = HOLD - PluginManager.hold() + self.plugins.hold() self.console.echo("Simulation paused") def reset(self) -> None: @@ -197,9 +200,9 @@ def reset(self) -> None: self.areas.reset() self.console.reset() # Reset replaceables (Autopilot, PerfBase, etc.) to defaults - reset_replaceables(self.traffic, self.commands.cmddict) + self.replaceables.reset() # Reset plugins (timers + reset hooks) - PluginManager.reset() + self.plugins.reset() self.console.echo("Simulation reset") def realtime(self, flag: bool | None = None) -> tuple[bool, str]: diff --git a/minisky/stack/__init__.py b/minisky/stack/__init__.py index 1319246..8055fbd 100644 --- a/minisky/stack/__init__.py +++ b/minisky/stack/__init__.py @@ -4,21 +4,23 @@ simulator—typed by a user, read from a scenario (`.scn`) file, or issued by a plugin—enters as a line of text such as `CRE KL204 B744 52.0 4.0 90 FL300 250`. Command lines are queued with -[stack][minisky.stack.stack] and executed once per simulation step by [process][minisky.stack.process]. +[stack][minisky.stack.stack] and executed once per simulation step by [`CommandStack.process`][minisky.stack.CommandStack.process]. Each available command is represented by a [Command][minisky.stack.Command] object, which couples the command name to the Python function that implements it and to the argument parsers that convert argument text into typed values. The base command set is defined in `minisky.stack.commands` and registered by -`init()`. +[`CommandStack.init`][minisky.stack.CommandStack.init]. Each `CommandStack` owns one runtime's command registry, pending command -queue, scenario buffer, and sender state. The module-level functions and -`Command.cmddict` remain compatibility aliases for the active runtime. - -This module also implements scenario handling: [ic][minisky.stack.ic] loads a scenario -file, whose timestamped command lines are buffered and moved onto the stack -by `checkscen()` when the simulation time passes their timestamps. +queue, scenario buffer, and sender state. The remaining module-level +functions and `Command.cmddict` are temporary compatibility aliases for +the active runtime. + +This module also implements scenario handling: [`CommandStack.ic`][minisky.stack.CommandStack.ic] loads a scenario file, +whose timestamped command lines are buffered and moved onto the stack by +[`CommandStack.checkscen`][minisky.stack.CommandStack.checkscen] when the +simulation time passes their timestamps. """ from __future__ import annotations @@ -34,13 +36,14 @@ import numpy as np -from minisky.core import trafficarrays -from minisky.plugin.plugin_decorators import append_commands, command, register_declared_commands +from minisky.plugin.plugin_decorators import command from minisky.stack import argparser, commands from minisky.stack.argparser import ArgumentError, Parameter, String, Time, Txt, getnextarg if TYPE_CHECKING: + from minisky.core.trafficarrays import ReplaceableManager from minisky.core.varexplorer import VariableExplorer + from minisky.plugin import PluginManager from minisky.simulation import ConsoleIO, Runner, Simulation from minisky.tools.areafilter import AreaFilter from minisky.tools.navdata import Navdatabase @@ -71,31 +74,10 @@ class Command: valid: False when the callback is an unbound class/instance method. """ - # Dictionary with all command objects + # TODO(abraham): remove this active-runtime registry alias when the + # remaining compatibility tests and public stack facade use CommandStack directly. cmddict: dict[str, Command] = {} - @classmethod - def addcommand( - cls, func: Callable, parent: Command | None = None, name: str = "", **kwargs: Any - ) -> None: - """Add `func` as a stack command. - - Delegates registration to the active runtime's `CommandStack`, - which creates a [Command][minisky.stack.Command] object for the function and registers - its name and aliases. When a command with the same name already - exists, the existing command object is kept. - - Args: - func: Function, static method, or class method implementing the - command. - parent: Optional parent command when this is a subcommand. - name: Command name. Defaults to the function name in upper case. - **kwargs: Command options: `arguments` (an argument type - specification such as `callsign,alt,[vspd]`), `brief`, `help`, - and `aliases`. - """ - current().addcommand(func, parent=parent, name=name, command_type=cls, **kwargs) - def __init__( self, func, @@ -318,6 +300,8 @@ def __init__( console: ConsoleIO, areas: AreaFilter, variables: VariableExplorer, + plugins: PluginManager, + replaceables: ReplaceableManager, get_simulation: Callable[[], Simulation], get_runner: Callable[[], Runner], scenario_root: Path | None = None, @@ -327,6 +311,8 @@ def __init__( self.console = console self.areas = areas self.variables = variables + self.plugins = plugins + self.replaceables = replaceables self.argument_parser = argparser.ArgumentParser(traffic, navigation, console) self._get_simulation = get_simulation self._get_runner = get_runner @@ -422,10 +408,6 @@ def commands(self) -> Iterator[str]: for self.current, self.sender_rte in pending: yield self.current - def select_implementation(self, basename: str = "", implname: str = "") -> tuple[bool, str]: - """Select a replaceable implementation on this runtime's traffic tree.""" - return trafficarrays.select_implementation(basename, implname, self.traffic, self.cmddict) - def init(self) -> None: """Initialise BlueSky base stack commands.""" @@ -444,9 +426,7 @@ def init(self) -> None: aliases=synonyms.get(name, []), ) - register_declared_commands() - - def delete_element(self, *arg): + def delete_element(self, *arg: Any) -> Any: """DEL: Delete an element (aircraft, wind field, area shape, or group). Dispatches based on the first argument: the string "WIND" clears the @@ -822,6 +802,8 @@ def set_scendata(self, newtime, newcmd) -> None: self.scencmd = newcmd +# TODO(abraham): remove the active stack pointer with the final module-level +# stack facade migration. _active_stack: CommandStack | None = None @@ -862,54 +844,6 @@ def commands(cls) -> Iterator[str]: return current().commands() -def init() -> None: - """Initialise the base stack commands for the active runtime.""" - current().init() - - -def delete_element(*arg): - """DEL: Delete an element (aircraft, wind field, area shape, or group). - - Dispatches based on the first argument: the string `WIND` clears the wind - field, any other string deletes the area with that name, a traffic group - object deletes that group, and anything else is treated as aircraft - indices to delete. - - Args: - *arg: Element or elements to delete: `WIND`, an area name, a traffic - group, or one or more aircraft indices. - - Returns: - The result of the dispatched delete function. - """ - return current().delete_element(*arg) - - -def reset() -> None: - """Reset the stack. - - Clears the command queue and buffered scenario data, and resets the - argument-parser reference data for position, heading, and speed. - """ - current().reset() - - -def process() -> None: - """Process the active runtime's command stack once. - - First moves due scenario commands onto the stack, then parses and executes - every queued command line. The first word is looked up in - `Command.cmddict`; an aircraft callsign may also be used as a prefix, in - which case the second word is the command and defaults to `POS`. Remaining - text is parsed into typed arguments and passed to the command callback. - - The pending commands are detached before processing, so commands stacked - during processing, including from other threads, are retained for the next - simulation step. - """ - current().process() - - def readscn(scn: str | Path | StringIO) -> Iterator[tuple[float, str]]: """Read a scenario file and yield its timestamped commands. @@ -929,80 +863,6 @@ def readscn(scn: str | Path | StringIO) -> Iterator[tuple[float, str]]: return current().readscn(scn) -def ic(scn: str) -> tuple[bool, str]: - """IC: Load a scenario file. - - Resets the simulation, reads the scenario file, and buffers its timestamped - commands for execution when simulation time passes their timestamps. - - Args: - scn: Scenario filename relative to the project root. - - Returns: - A `(success, message)` tuple. - """ - return current().ic(scn) - - -def ic_StringIO(scn: StringIO, scn_name: str | None = None) -> tuple[bool, str]: - """IC: Load a scenario from a `StringIO` object. - - Resets the simulation, reads scenario lines from the object, and buffers - the timestamped commands for execution. - - Args: - scn: Object containing scenario lines. - scn_name: Optional scenario name. - - Returns: - A `(success, message)` tuple. - """ - return current().ic_StringIO(scn, scn_name) - - -def scenario(name: String) -> tuple[bool, str]: - """SCENARIO: Set the scenario name for the current simulation. - - Args: - name: Name to give the scenario. - - Returns: - A `(True, confirmation message)` tuple. - """ - return current().scenario(name) - - -def schedule(time: Time, cmdline: String) -> bool: - """SCHEDULE: Schedule a command at a specific simulation time. - - The command is inserted into the scenario buffer while preserving its - execution-time ordering. - - Args: - time: Absolute simulation time [s] at which to execute the command. - cmdline: Command line to execute. - - Returns: - `True`; the command is always scheduled. - """ - return current().schedule(time, cmdline) - - -def delay(time: Time, cmdline: String) -> bool: - """DELAY: Delay a command by a time interval. - - Like [schedule][minisky.stack.schedule], but `time` is relative to the current simulation time. - - Args: - time: Time interval [s] by which to delay the command. - cmdline: Command line to execute after the delay. - - Returns: - `True`; the command is always scheduled. - """ - return current().delay(time, cmdline) - - def showhelp(cmd: Txt = "", subcmd: Txt = "") -> tuple[bool, str]: """HELP: Display command help or write a command reference file. @@ -1017,20 +877,10 @@ def showhelp(cmd: Txt = "", subcmd: Txt = "") -> tuple[bool, str]: return current().showhelp(cmd, subcmd) -def checkscen() -> None: - """Move due scenario commands onto the active runtime's command queue. - - All buffered scenario commands with a timestamp at or before the current - simulation time are removed from the scenario buffer and queued for - execution. - """ - current().checkscen() - - def stack(*cmdlines: str, sender_id: bytes | None = None) -> None: """Stack one or more commands separated by semicolons. - Queued commands are executed on the next call to [process][minisky.stack.process]. + Queued commands are executed on the next call to [`CommandStack.process`][minisky.stack.CommandStack.process]. Args: *cmdlines: Command line strings. Each may contain multiple commands @@ -1040,24 +890,6 @@ def stack(*cmdlines: str, sender_id: bytes | None = None) -> None: current().stack(*cmdlines, sender_id=sender_id) -def sender(): - """Return the sender of the command currently being executed. - - Returns `None` when the command has no sender identifier, such as a command - originating from a scenario file. - """ - return current().sender() - - -def routetosender(): - """Return the route to the sender of the current command. - - Returns `None` when the command has no sender identifier, such as a command - originating from a scenario file. - """ - return current().routetosender() - - def get_scenname() -> str: """Return the current scenario name. @@ -1067,39 +899,10 @@ def get_scenname() -> str: return current().get_scenname() -def get_scendata() -> tuple[list[float], list[str]]: - """Return the buffered scenario data. - - Returns: - A `(scentime, scencmd)` tuple containing command times [s] and command - lines still buffered for execution. - """ - return current().get_scendata() - - -def set_scendata(newtime, newcmd) -> None: - """Replace the buffered scenario data used by batch execution.""" - current().set_scendata(newtime, newcmd) - - for _name in ( - "init", - "delete_element", - "reset", - "process", "readscn", - "ic", - "ic_StringIO", - "scenario", - "schedule", - "delay", "showhelp", - "checkscen", "stack", - "sender", - "routetosender", "get_scenname", - "get_scendata", - "set_scendata", ): globals()[_name].__doc__ = getattr(CommandStack, _name).__doc__ diff --git a/minisky/stack/commands.py b/minisky/stack/commands.py index f811298..18ec90a 100644 --- a/minisky/stack/commands.py +++ b/minisky/stack/commands.py @@ -70,7 +70,7 @@ def get_commands(command_stack: CommandStack) -> tuple: of [function, argument type string, brief usage text, help text]; synonyms maps a command name to a list of alias names. """ - from minisky import plugin, tools + from minisky import tools from minisky.traffic import route cmddict = { @@ -368,7 +368,7 @@ def get_commands(command_stack: CommandStack) -> tuple: "Set origin of aircraft.", ], "PLUGINS": [ - plugin.manage_plugins, + command_stack.plugins.manage, "[txt,txt]", "PLUGINS [LIST/LOAD, plugin_name]", "List available plugins or load a plugin", @@ -494,7 +494,7 @@ def get_commands(command_stack: CommandStack) -> tuple: "Set seed for all functions using a randomizer (e.g.mcre,noise)", ], "SELECTIMPL": [ - command_stack.select_implementation, + command_stack.replaceables.select, "[txt,txt]", "SELECTIMPL [classname, implname]", "Select implementation for a replaceable class (e.g., SELECTIMPL AUTOPILOT MYAUTOPILOT)", diff --git a/minisky/tools/areafilter.py b/minisky/tools/areafilter.py index b3d9bfe..1011cd7 100644 --- a/minisky/tools/areafilter.py +++ b/minisky/tools/areafilter.py @@ -267,6 +267,8 @@ def get_knearest( return [self.areas_by_id[area_id] for area_id in ids if area_id in self.areas_by_id] +# TODO(abraham): remove this standalone active filter after compatibility +# callers and unit tests construct or receive an AreaFilter explicitly. _active = AreaFilter() diff --git a/minisky/tools/geo.py b/minisky/tools/geo.py index 6fef9e3..098696c 100644 --- a/minisky/tools/geo.py +++ b/minisky/tools/geo.py @@ -26,6 +26,8 @@ nm = 1852.0 # m 1 nautical mile # Read data for declination switch +# TODO(abraham): move the mutable magnetic-declination cache into an +# explicit navigation/tool service instead of process-wide module state. decl_read = False diff --git a/minisky/tools/navdata.py b/minisky/tools/navdata.py index 461e323..ba816ff 100644 --- a/minisky/tools/navdata.py +++ b/minisky/tools/navdata.py @@ -2,8 +2,9 @@ Loads waypoint, airport, airway, FIR, and country data from the package data directory and provides lookup functions to find navaids and airports -by identifier or position. The global Navdatabase instance is available -as `minisky.navdb`; it backs the DEFWPT stack command and every position +by identifier or position. Each `MiniSky` runtime owns a Navdatabase at +`runtime.navigation`; `minisky.navdb` remains a temporary compatibility alias. +The database backs the DEFWPT stack command and every position argument that references a navaid, airport, or runway. """ diff --git a/minisky/traffic/asas/detection.py b/minisky/traffic/asas/detection.py index d34cd1a..79935dd 100644 --- a/minisky/traffic/asas/detection.py +++ b/minisky/traffic/asas/detection.py @@ -146,7 +146,7 @@ def __init__( self.dtlookahead = np.array([]) self.dtnolook = np.array([]) - def new_implementation(self, implementation: type[TrafficArrays]) -> TrafficArrays: + def new_implementation(self, implementation: Callable[..., TrafficArrays]) -> TrafficArrays: """Construct a replacement with this runtime's traffic and command stack.""" return implementation(self.settings, self.traffic, self.stack_command) diff --git a/minisky/traffic/asas/resolution.py b/minisky/traffic/asas/resolution.py index be18988..d41c7ba 100644 --- a/minisky/traffic/asas/resolution.py +++ b/minisky/traffic/asas/resolution.py @@ -106,7 +106,7 @@ def __init__( self.alt = np.array([]) # alt provided by the ASAS [m] self.vs = np.array([]) # vspeed provided by the ASAS [m/s] - def new_implementation(self, implementation: type[TrafficArrays]) -> TrafficArrays: + def new_implementation(self, implementation: Callable[..., TrafficArrays]) -> TrafficArrays: """Construct a replacement with this runtime's traffic and selector.""" return implementation(self.settings, self.traffic, self.select_implementation) diff --git a/minisky/traffic/autopilot.py b/minisky/traffic/autopilot.py index da9b3c1..bad6012 100644 --- a/minisky/traffic/autopilot.py +++ b/minisky/traffic/autopilot.py @@ -157,7 +157,7 @@ def simulation(self) -> Simulation: """Return the simulation that owns this autopilot.""" return self._get_simulation() - def new_implementation(self, implementation: type[TrafficArrays]) -> TrafficArrays: + def new_implementation(self, implementation: Callable[..., TrafficArrays]) -> TrafficArrays: """Construct a replacement with this runtime's dependencies.""" return implementation(self.traffic, self._get_simulation) diff --git a/minisky/traffic/traffic.py b/minisky/traffic/traffic.py index a47a9b7..48ece03 100644 --- a/minisky/traffic/traffic.py +++ b/minisky/traffic/traffic.py @@ -14,7 +14,7 @@ from __future__ import annotations -from collections.abc import Callable, Collection, Iterable +from collections.abc import Callable, Collection, Iterable, Mapping from random import randint from typing import TYPE_CHECKING, overload @@ -136,7 +136,7 @@ def __init__( console: ConsoleIO, get_simulation: Callable[[], Simulation], stack_command: Callable[..., None], - get_command_registry: Callable[[], dict[str, object]], + get_command_registry: Callable[[], Mapping[str, object]], select_implementation: Callable[[str, str], tuple[bool, str]], ) -> None: super().__init__() @@ -240,7 +240,7 @@ def __init__( self.bphase = np.deg2rad(np.array([15, 35, 35, 35, 15, 45])) @property - def command_registry(self) -> dict[str, object]: + def command_registry(self) -> Mapping[str, object]: """Return the command registry owned by this runtime.""" return self._get_command_registry() diff --git a/minisky/traffic/trafficgroups.py b/minisky/traffic/trafficgroups.py index 8b4df12..5ca32bd 100644 --- a/minisky/traffic/trafficgroups.py +++ b/minisky/traffic/trafficgroups.py @@ -11,6 +11,7 @@ from __future__ import annotations +from collections.abc import Callable from typing import TYPE_CHECKING, Any import numpy as np @@ -61,7 +62,7 @@ def __init__(self, traffic: Traffic, areas: AreaFilter) -> None: with self.settrafarrays(): self.ingroup = np.array([], dtype=np.int64) - def new_implementation(self, implementation: type[TrafficArrays]) -> TrafficArrays: + 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) diff --git a/minisky/traffic/trails.py b/minisky/traffic/trails.py index a17b360..4d6d393 100644 --- a/minisky/traffic/trails.py +++ b/minisky/traffic/trails.py @@ -104,7 +104,7 @@ def __init__( return - def new_implementation(self, implementation: type[TrafficArrays]) -> TrafficArrays: + def new_implementation(self, implementation: Callable[..., TrafficArrays]) -> TrafficArrays: """Construct a replacement with this runtime's traffic and simulation.""" return implementation(self.traffic, self._get_simulation) diff --git a/minisky/traffic/turbulence.py b/minisky/traffic/turbulence.py index 3f53233..e84ec5b 100644 --- a/minisky/traffic/turbulence.py +++ b/minisky/traffic/turbulence.py @@ -42,7 +42,7 @@ def __init__(self, traffic: Traffic, get_simulation: Callable[[], Simulation]) - self.active = False self.SetStandards([0, 0.1, 0.1]) - def new_implementation(self, implementation: type[TrafficArrays]) -> TrafficArrays: + def new_implementation(self, implementation: Callable[..., TrafficArrays]) -> TrafficArrays: """Construct a replacement with this runtime's traffic and simulation.""" return implementation(self.traffic, self._get_simulation) diff --git a/minisky/traffic/uncertainty.py b/minisky/traffic/uncertainty.py index 7f8c8d7..b4971dc 100644 --- a/minisky/traffic/uncertainty.py +++ b/minisky/traffic/uncertainty.py @@ -65,7 +65,7 @@ def __init__(self, traffic: Traffic, get_simulation: Callable[[], Simulation]) - self.setnoise(False) - def new_implementation(self, implementation: type[TrafficArrays]) -> TrafficArrays: + def new_implementation(self, implementation: Callable[..., TrafficArrays]) -> TrafficArrays: """Construct a replacement with this runtime's traffic and simulation.""" return implementation(self.traffic, self._get_simulation) diff --git a/tests/integration/test_plugin.py b/tests/integration/test_plugin.py index 9de5f78..7f77cae 100644 --- a/tests/integration/test_plugin.py +++ b/tests/integration/test_plugin.py @@ -1,73 +1,65 @@ -"""Integration tests for the plugin system (AST discovery + loading). +"""Integration tests for runtime-owned plugin discovery and loading.""" -Plugin loading imports modules and registers stack commands globally, so -these tests run against the session singletons like other integration tests. -""" +from __future__ import annotations import warnings import pytest -import minisky -from minisky.plugin import Plugin - class TestDiscovery: - def test_discover_finds_example_plugins(self, bs): - # discovery already ran during minisky.init(); it is idempotent - minisky.plugin.discover() - assert "EXAMPLE" in Plugin.plugins - - def test_discovery_does_not_import(self, bs): - plug = Plugin.plugins["EXAMPLE"] - if not plug.loaded: - assert plug.imp is None # AST parsing only, no module import - - def test_manage_plugins_list(self, bs): - ok, text = minisky.plugin.manage_plugins("LIST") + def test_discover_finds_example_plugins(self, runtime): + runtime.plugins.discover() + assert "EXAMPLE" in runtime.plugins.plugins + + def test_discovery_does_not_import(self, runtime): + plugin = runtime.plugins.plugins["EXAMPLE"] + if not plugin.loaded: + assert plugin.module is None + + def test_manage_plugins_list(self, runtime): + ok, text = runtime.plugins.manage("LIST") assert ok assert "EXAMPLE" in text - def test_unknown_plugin_load_fails(self, bs): - ok, msg = Plugin.load("NOSUCHPLUGIN") + def test_unknown_plugin_load_fails(self, runtime): + ok, message = runtime.plugins.load("NOSUCHPLUGIN") assert not ok - assert "not found" in msg.lower() + assert "not found" in message.lower() - def test_discovery_emits_no_deprecation_warning(self, bs): - # AST config parsing used the ast.Constant.s alias, deprecated - # since Python 3.12 and removed in 3.14. + def test_discovery_emits_no_deprecation_warning(self, runtime): with warnings.catch_warnings(): warnings.simplefilter("error", DeprecationWarning) - minisky.plugin.discover() - assert "EXAMPLE" in Plugin.plugins + runtime.plugins.discover() + assert "EXAMPLE" in runtime.plugins.plugins @pytest.fixture -def loaded_example(bs): - """Load the EXAMPLE plugin (loading is not reversible, so load only once).""" - if not Plugin.plugins["EXAMPLE"].loaded: - ok, msg = Plugin.load("EXAMPLE") - assert ok, msg - return Plugin.plugins["EXAMPLE"] +def loaded_example(runtime): + """Load the EXAMPLE plugin once into the session runtime.""" + plugin = runtime.plugins.plugins["EXAMPLE"] + if not plugin.loaded: + ok, message = runtime.plugins.load("EXAMPLE") + assert ok, message + return plugin class TestLoading: - def test_load_registers_plugin(self, bs, loaded_example): + def test_load_registers_plugin(self, runtime, loaded_example): assert loaded_example.loaded - assert "EXAMPLE" in Plugin.loaded_plugins + assert "EXAMPLE" in runtime.plugins.loaded_plugins - def test_double_load_rejected(self, bs, loaded_example): - ok, msg = Plugin.load("EXAMPLE") + def test_double_load_rejected(self, runtime, loaded_example): + ok, message = runtime.plugins.load("EXAMPLE") assert not ok - assert "already loaded" in msg.lower() + assert "already loaded" in message.lower() - def test_plugin_stack_command_registered(self, bs, sim, loaded_example, run_cmd): - # example.py registers the PASSENGERS command via @stack.command + def test_plugin_stack_command_registered(self, sim, loaded_example, run_cmd): run_cmd("CRE KL001,A320,52,4,90,FL100,250") output = run_cmd("PASSENGERS KL001 150") assert "150" in output - def test_plugin_entity_tracks_aircraft(self, bs, sim, loaded_example, run_cmd): + def test_plugin_entity_tracks_aircraft(self, sim, loaded_example, run_cmd): run_cmd("CRE KL001,A320,52,4,90,FL100,250") run_cmd("PASSENGERS KL001 42") output = run_cmd("PASSENGERS KL001") From ce34c184c7e8d5b681d712c80f78e311ebfab92f Mon Sep 17 00:00:00 2001 From: Abraham Cheung <58929011+abc8747@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:15:48 +0800 Subject: [PATCH 11/16] refactor: explicit lifecycle using `async with Minisky(settings) as runtime` - replaces `minisky.{init,traf,sim,scr,runner,navdb,_current,_activate}` - replaces `minisky.stack.{current(),Stack,stack(),readscn(),showhelp(),` `get_scenname(),_active_stack,Command.cmddict}` - remove temp `minisky.core.varexplorer`, `minisky.tools.areafilter` - replace module global fastapi router, mutable magnetic declination globals, import-time runtime activation, standalone area filter instance, active variable explorer, active command stack and registry alias. - use `IntEnum` for `SimulationState` --- docs/api/minisky.md | 10 +- docs/api/simulation.md | 4 + docs/api/stack.md | 18 +- docs/api/traffic.md | 26 ++ docs/architecture.md | 70 +++--- docs/getting-started.md | 35 +-- docs/guides/plugins.md | 2 +- docs/guides/python-api.md | 156 ++++++------ docs/guides/tangram.md | 1 - docs/index.md | 15 +- docs/reference/commands.md | 2 +- example_plugins/tangram.py | 30 ++- minisky/__init__.py | 77 ++---- minisky/cli.py | 50 ++-- minisky/core/settings.py | 18 -- minisky/core/trafficarrays.py | 6 + minisky/core/varexplorer.py | 26 +- minisky/plugin/plugin.py | 41 +++- minisky/runtime.py | 75 +++++- minisky/server.py | 57 +++-- minisky/simulation/__init__.py | 15 +- minisky/simulation/console.py | 8 +- minisky/simulation/runner.py | 7 +- minisky/simulation/simulation.py | 32 +-- minisky/stack/__init__.py | 122 +--------- minisky/streaming.py | 17 +- minisky/tools/__init__.py | 23 +- minisky/tools/areafilter.py | 36 --- minisky/tools/geo.py | 23 +- minisky/tools/navdata.py | 2 +- minisky/traffic/__init__.py | 34 +-- minisky/traffic/activewpdata.py | 2 +- minisky/traffic/aporasas.py | 4 +- minisky/traffic/asas/__init__.py | 21 +- minisky/traffic/asas/resolution.py | 2 +- minisky/traffic/autopilot.py | 24 +- minisky/traffic/conditional.py | 2 +- minisky/traffic/performance/__init__.py | 15 +- minisky/traffic/performance/coeff.py | 8 +- minisky/traffic/performance/perfoap.py | 2 +- minisky/traffic/route.py | 2 +- minisky/traffic/traffic.py | 4 +- minisky/traffic/trafficgroups.py | 6 +- minisky/traffic/trails.py | 4 +- minisky/traffic/uncertainty.py | 2 +- minisky/traffic/wind.py | 20 +- settings.toml | 2 - tests/conftest.py | 56 ++--- tests/integration/test_conflict.py | 112 +++++---- tests/integration/test_navdata.py | 24 +- tests/integration/test_route_autopilot.py | 165 ++++++------- tests/integration/test_scenario.py | 50 ++-- tests/integration/test_stack.py | 106 ++++---- tests/integration/test_streaming.py | 17 +- tests/integration/test_tangram_bridge.py | 43 ++-- tests/integration/test_traffic.py | 282 +++++++++++----------- tests/unit/test_areafilter.py | 86 +++---- 57 files changed, 990 insertions(+), 1109 deletions(-) diff --git a/docs/api/minisky.md b/docs/api/minisky.md index 38b6a88..37351b1 100644 --- a/docs/api/minisky.md +++ b/docs/api/minisky.md @@ -1,16 +1,16 @@ # `minisky` The top-level package exposes the explicit runtime owner, validated settings, -simulation-state constants, and a temporary `init()` compatibility constructor. +immutable default-settings path, and simulation-state constants. ## Runtime ::: minisky.MiniSky -## Settings +## Simulation state -::: minisky.MiniSkySettings +::: minisky.SimulationState -## Compatibility constructor +## Settings -::: minisky.init +::: minisky.MiniSkySettings diff --git a/docs/api/simulation.md b/docs/api/simulation.md index 3f1f613..b1dacd1 100644 --- a/docs/api/simulation.md +++ b/docs/api/simulation.md @@ -7,6 +7,10 @@ console output. ::: minisky.simulation.simulation.Simulation +## Simulation state + +::: minisky.simulation.simulation.SimulationState + ## Runner ::: minisky.simulation.runner.Runner diff --git a/docs/api/stack.md b/docs/api/stack.md index 7cfb6a9..8f3cf6d 100644 --- a/docs/api/stack.md +++ b/docs/api/stack.md @@ -1,23 +1,21 @@ # `minisky.stack` -The text-command interpreter. Every command — from scenario files, the console, or the -REST API — is queued with [`stack()`][minisky.stack.stack] and executed by -[`CommandStack.process`][minisky.stack.CommandStack.process] on the next simulation step. See the -[stack command reference](../reference/commands.md) for the available commands. +The text-command interpreter. Every command from scenario files, the console, +or the REST API is queued with +[`CommandStack.stack`][minisky.stack.CommandStack.stack] and executed by +[`CommandStack.process`][minisky.stack.CommandStack.process] on the next +simulation step. See the [stack command reference](../reference/commands.md) +for the available commands. ::: minisky.stack options: members: - CommandStack - Command - - stack - - readscn - - showhelp - - get_scenname ## Argument parsing -Parsers for the aviation-aware argument types (`alt`, `spd`, `hdg`, `latlon`, `wpt`, ...) -used in command signatures. +Parsers for the aviation-aware argument types (`alt`, `spd`, `hdg`, `latlon`, +`wpt`, ...) used in command signatures. ::: minisky.stack.argparser diff --git a/docs/api/traffic.md b/docs/api/traffic.md index e9e9094..29906ae 100644 --- a/docs/api/traffic.md +++ b/docs/api/traffic.md @@ -11,10 +11,18 @@ routes, conflict detection and resolution, aircraft performance, wind, and turbu ::: minisky.traffic.autopilot.Autopilot +## Active waypoint + +::: minisky.traffic.activewpdata.ActiveWaypoint + ## Route ::: minisky.traffic.route.Route +## ASAS command target + +::: minisky.traffic.aporasas.APorASAS + ## Conflict detection ::: minisky.traffic.asas.detection.ConflictDetection @@ -25,6 +33,24 @@ routes, conflict detection and resolution, aircraft performance, wind, and turbu ::: minisky.traffic.asas.mvp.MVP +## Wind + +::: minisky.traffic.wind.Windfield + +::: minisky.traffic.wind.Wind + +## Uncertainty + +::: minisky.traffic.uncertainty.SurveillanceUncertainty + +## Trails + +::: minisky.traffic.trails.Trails + +## Groups + +::: minisky.traffic.trafficgroups.TrafficGroups + ## Performance (OpenAP) ::: minisky.traffic.performance.perfoap.OpenAP diff --git a/docs/architecture.md b/docs/architecture.md index 4ef1451..5220596 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,66 +1,70 @@ # Architecture -MiniSky keeps BlueSky's core simulation model but removes the GUI, networking, and node -management. What remains is a single-process simulator built around a handful of global -singleton objects and a text-command stack. +MiniSky keeps BlueSky's core simulation model but removes the GUI, networking, +and node management. What remains is a single-process simulator whose mutable +state is owned by one [`MiniSky`][minisky.MiniSky] runtime. -## The singletons +## Runtime ownership -Calling [`minisky.init()`][minisky.init] creates the module-level singletons that the rest -of the code refers to: +Constructing `MiniSky` creates an independent object graph: -| Singleton | Class | Role | +| Runtime attribute | Class | Role | | --- | --- | --- | -| `minisky.sim` | [`Simulation`][minisky.simulation.simulation.Simulation] | Simulation clock, timestep, and state machine | -| `minisky.traf` | [`Traffic`][minisky.traffic.traffic.Traffic] | All per-aircraft state and the flight-dynamics update | -| `minisky.runner` | [`Runner`][minisky.simulation.runner.Runner] | Async loop that calls `sim.step()` at a controllable rate | -| `minisky.scr` | [`ConsoleIO`][minisky.simulation.console.ConsoleIO] | Text output buffer (console and REST API read from it) | -| `minisky.navdb` | [`Navdatabase`][minisky.tools.navdata.Navdatabase] | Waypoints, airports, and airways loaded from parquet files | +| [`runtime.simulation`][minisky.simulation.simulation.Simulation] | [`Simulation`][minisky.simulation.simulation.Simulation] | Clock, timestep, and state machine | +| [`runtime.traffic`][minisky.traffic.traffic.Traffic] | [`Traffic`][minisky.traffic.traffic.Traffic] | Per-aircraft state and flight-dynamics update | +| [`runtime.runner`][minisky.simulation.runner.Runner] | [`Runner`][minisky.simulation.runner.Runner] | Async loop that steps the simulation | +| [`runtime.console`][minisky.simulation.console.ConsoleIO] | [`ConsoleIO`][minisky.simulation.console.ConsoleIO] | Buffered text output | +| [`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 records, hooks, timers, and state | ```python -import minisky +from minisky import DEFAULT_SETTINGS_FILE, MiniSky, MiniSkySettings -runtime = minisky.init() # compatibility constructor -runtime.load_plugins() # optional: load enabled plugins on this runtime +settings = MiniSkySettings.from_file(DEFAULT_SETTINGS_FILE) +with MiniSky(settings) as runtime: + runtime.load_plugins() + runtime.simulation.step() ``` ## The simulation loop -The simulation advances in discrete timesteps of `sim.simdt` seconds (default 1 s). One -call to [`sim.step()`][minisky.simulation.simulation.Simulation.step] does, in order: +The simulation advances in discrete timesteps of [`runtime.simulation.simdt`][minisky.simulation.simulation.Simulation] seconds (default 1 s). One +call to [`Simulation.step`][minisky.simulation.simulation.Simulation.step] does, in order: 1. **Stack processing** — pending text commands are parsed and executed ([`CommandStack.process`][minisky.stack.CommandStack.process]). -2. **Time advance** — `sim.simt` and the simulated UTC clock move forward by `simdt` +2. **Time advance** — [`runtime.simulation.simt`][minisky.simulation.simulation.Simulation] and the simulated UTC clock move forward by `simdt` (only in the `OP` state). 3. **Plugin pre-update** — timed plugin functions registered with the `preupdate` hook. -4. **Traffic update** — [`traf.update()`][minisky.traffic.traffic.Traffic.update] +4. **Traffic update** — [`Traffic.update`][minisky.traffic.traffic.Traffic.update] integrates aircraft state: autopilot/FMS logic, conflict detection and resolution, aircraft performance limits, wind, and finally position integration. 5. **Plugin update** — timed plugin functions registered with the `update` hook. -The simulation state machine has four states, exposed as constants on the `minisky` -package: `INIT` (waiting for traffic), `OP` (running), `HOLD` (paused), and `END`. -The simulation switches from `INIT` to `OP` automatically as soon as there is traffic +The simulation state machine uses [`SimulationState`][minisky.simulation.simulation.SimulationState]: +`SimulationState.INIT` waits for traffic, `SimulationState.OP` runs, +`SimulationState.HOLD` pauses, and `SimulationState.END` stops. The simulation +switches from `SimulationState.INIT` to `SimulationState.OP` automatically as soon as there is traffic or pending scenario commands. ### Real time vs. fast time There are two ways to drive the loop: -- **Manual stepping** — call `sim.step()` yourself in a plain loop. Each call advances the - simulation by `simdt` simulated seconds, as fast as your CPU allows. This is what you +- **Manual stepping** — call `runtime.simulation.step()` yourself in a plain loop. Each call advances the + simulation by [`runtime.simulation.simdt`][minisky.simulation.simulation.Simulation] simulated seconds, as fast as your CPU allows. This is what you want when embedding MiniSky in your own code or experiments. -- **The runner** — `await minisky.runner.run()` steps the simulation once per wall-clock - interval. `runner.speed = 10` makes simulated time pass 10× faster than wall time, and - `runner.forward(seconds)` fast-forwards by stepping at the maximum rate until the target +- **The runner** — `await runtime.run()` steps the simulation once per wall-clock + interval. `runtime.runner.speed = 10` makes simulated time pass 10× faster than wall time, and + `runtime.runner.forward(seconds)` fast-forwards by stepping at the maximum rate until the target simulation time is reached. The REST API server and `minisky run` both use the runner. ## Per-aircraft arrays: `TrafficArrays` Aircraft state is stored as NumPy arrays (and lists for strings), one element per -aircraft, spread across many objects: `traf.lat`, `traf.alt`, `traf.ap.route`, -`traf.perf.mass`, and so on. Keeping all of these in sync when aircraft are created and +aircraft, spread across many objects: [`runtime.traffic.lat`][minisky.traffic.traffic.Traffic], [`runtime.traffic.alt`][minisky.traffic.traffic.Traffic], [`runtime.traffic.ap.route`][minisky.traffic.route.Route], +[`runtime.traffic.perf.mass`][minisky.traffic.performance.perfoap.OpenAP], and so on. Keeping all of these in sync when aircraft are created and deleted is the job of [`TrafficArrays`][minisky.core.trafficarrays.TrafficArrays]. Classes that hold per-aircraft data derive from it and register their arrays: @@ -73,7 +77,7 @@ class Example(Entity): self.npassengers = np.array([]) ``` -`TrafficArrays` instances form a tree rooted at `traf`. When an aircraft is created or +`TrafficArrays` instances form a tree rooted at [`runtime.traffic`][minisky.traffic.traffic.Traffic]. When an aircraft is created or deleted, the whole tree is walked and every registered array grows or shrinks in lockstep, so index `i` refers to the same aircraft everywhere. @@ -82,8 +86,8 @@ so index `i` refers to the same aircraft everywhere. Every text command — whether it comes from a scenario file, the REST `stack/` endpoint, or the console — goes through the same interpreter: [`minisky.stack`](api/stack.md). -- Commands are queued with [`stack.stack("CRE KL001 B738 52 4 90 FL100 250")`][minisky.stack.stack] - and executed on the next `sim.step()`. +- Commands are queued with `runtime.commands.stack("CRE KL001 B738 52 4 90 FL100 250")` + and executed on the next `runtime.simulation.step()`. - Each command is a [`Command`][minisky.stack.Command] object with typed parameters. Argument strings like `"callsign,wpt,[alt,spd]"` are parsed by [`minisky.stack.argparser`](api/stack.md#argument-parsing), which knows aviation types @@ -119,7 +123,7 @@ argument parsers convert on the way in. ## I/O: how output gets back to you -Simulation code reports through `minisky.scr` (a +Simulation code reports through [`runtime.console`][minisky.simulation.console.ConsoleIO] (a [`ConsoleIO`][minisky.simulation.console.ConsoleIO]), which buffers echo text instead of printing it. The REST API's `stack/` endpoint sends a command, waits for the stack to process it, then reads the buffer back to the HTTP client — which is how the console shows diff --git a/docs/getting-started.md b/docs/getting-started.md index dda1ee6..a3e60fc 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -60,23 +60,28 @@ See the [command-line interface](guides/cli.md), [REST API](guides/rest-api.md), ## From Python ```python -import minisky - -minisky.init() - -minisky.sim.reset() -minisky.traf.cre("KL315", lat=52.0, lon=4.0, hdg=45, alt=5000, spd=250) -minisky.stack.stack("KL315 ADDWPT HELEN FL100 250") - -minisky.sim.simdt = 10 # 10-second timesteps - -for _ in range(5): - minisky.sim.step() - print(f"t={minisky.sim.simt}s lat={minisky.traf.lat} lon={minisky.traf.lon}") +from minisky import DEFAULT_SETTINGS_FILE, MiniSky, MiniSkySettings + +settings = MiniSkySettings.from_file(DEFAULT_SETTINGS_FILE) +with MiniSky(settings) as runtime: + runtime.simulation.reset() + runtime.traffic.cre( + "KL315", lat=52.0, lon=4.0, hdg=45, alt=5000, spd=250 + ) + runtime.commands.stack("KL315 ADDWPT HELEN FL100 250") + + runtime.simulation.simdt = 10 # 10-second timesteps + + for _ in range(5): + runtime.simulation.step() + print( + f"t={runtime.simulation.simt}s " + f"lat={runtime.traffic.lat} lon={runtime.traffic.lon}" + ) ``` -See the [Python library guide](guides/python-api.md) for details on the singleton objects -and stepping the simulation yourself. +See the [Python library guide](guides/python-api.md) for details on runtime +ownership and stepping the simulation yourself. ## Configuration diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md index 636ac06..985ad2f 100644 --- a/docs/guides/plugins.md +++ b/docs/guides/plugins.md @@ -60,7 +60,7 @@ The config dictionary supports these lifecycle entries: | `state` | Optional plugin-owned object exposed through the variable explorer | Plugin records, loaded state, timers, hooks, and returned state belong to -`runtime.plugins`. Loading the same plugin into two runtimes creates separate +[`runtime.plugins`][minisky.plugin.plugin.PluginManager]. Loading the same plugin into two runtimes creates separate records and hook sets. ## Per-aircraft data: `Entity` diff --git a/docs/guides/python-api.md b/docs/guides/python-api.md index 0c6ca74..38845fd 100644 --- a/docs/guides/python-api.md +++ b/docs/guides/python-api.md @@ -1,128 +1,148 @@ # Python library -MiniSky can be embedded in your own Python code — step the simulation yourself, read -aircraft state straight from NumPy arrays, and drive everything programmatically. This is -the fastest way to run large numbers of simulations for experiments. +MiniSky can be embedded in your own Python code. Construct one explicit runtime, +step its simulation, read aircraft state directly from NumPy arrays, and close +the runtime when finished. ## Minimal example ```python -import minisky - -minisky.init() +from minisky import DEFAULT_SETTINGS_FILE, MiniSky, MiniSkySettings + +settings = MiniSkySettings.from_file(DEFAULT_SETTINGS_FILE) +with MiniSky(settings) as runtime: + runtime.simulation.reset() + runtime.traffic.cre( + "KL315", lat=52.0, lon=4.0, hdg=45, alt=5000, spd=250 + ) + runtime.commands.stack("KL315 ADDWPT HELEN FL100 250") + + runtime.simulation.simdt = 10 + for _ in range(5): + runtime.simulation.step() + print( + f"t={runtime.simulation.simt}s " + f"lat={runtime.traffic.lat} lon={runtime.traffic.lon}" + ) +``` -minisky.sim.reset() -minisky.traf.cre("KL315", lat=52.0, lon=4.0, hdg=45, alt=5000, spd=250) -minisky.stack.stack("KL315 ADDWPT HELEN FL100 250") +## Runtime ownership -minisky.sim.simdt = 10 # advance 10 simulated seconds per step +A [`MiniSky`][minisky.MiniSky] instance owns all mutable simulator state: -for _ in range(5): - minisky.sim.step() - print(f"t={minisky.sim.simt}s lat={minisky.traf.lat} lon={minisky.traf.lon}") +```python +runtime.simulation # clock, timestep, and state machine +runtime.traffic # aircraft state and traffic subsystems +runtime.runner # optional async real-time loop +runtime.console # buffered text output +runtime.navigation # waypoints, airports, and airways +runtime.commands # command registry, queue, and scenario state +runtime.plugins # plugin records, hooks, timers, and state +runtime.streaming # per-runtime snapshot fan-out ``` -## The singletons - -[`minisky.init()`][minisky.init] creates the global objects everything else hangs off -(see [Architecture](../architecture.md) for how they interact): +Pass a scenario to the constructor to queue it immediately: ```python -minisky.sim # Simulation: clock, timestep, state machine -minisky.traf # Traffic: all per-aircraft state and subsystems -minisky.runner # Runner: async real-time loop (optional — you can step manually) -minisky.scr # ConsoleIO: buffered text output -minisky.navdb # Navdatabase: waypoints, airports, airways +runtime = MiniSky(settings, scenario="scenarios/kl204.scn") ``` -Pass a scenario to start from a file: `minisky.init(scenario="scenarios/kl204.scn")`. +Use `with MiniSky(...)` or `async with MiniSky(...)` so plugin resources, +stream consumers, and the runner are closed deterministically. ## Creating and commanding aircraft -Directly through the Traffic object: +Directly through the traffic object: ```python -minisky.traf.cre( - "KL315", # callsign - actype="B738", # aircraft type (OpenAP performance model) - lat=52.0, # deg - lon=4.0, # deg - hdg=45, # deg - alt=5000, # ft (stack units) - spd=250, # CAS kts +runtime.traffic.cre( + "KL315", + actype="B738", + lat=52.0, + lon=4.0, + hdg=45, + alt=5000, + spd=250, ) ``` -Or through the stack, using the same command language as scenario files: +Or through the runtime-owned command stack: ```python -minisky.stack.stack("CRE KL315 B738 52.0 4.0 45 5000 250") -minisky.stack.stack("KL315 ALT FL200") -minisky.stack.stack("KL315 ADDWPT HELEN FL100 250") +runtime.commands.stack("CRE KL315 B738 52.0 4.0 45 5000 250") +runtime.commands.stack("KL315 ALT FL200") +runtime.commands.stack("KL315 ADDWPT HELEN FL100 250") ``` -Stack commands are queued and execute on the next `sim.step()`. +Commands are queued and execute on the next `runtime.simulation.step()`. ## Reading state -Aircraft state lives in per-aircraft NumPy arrays on `minisky.traf` — index `i` is the -same aircraft in every array: +Aircraft state lives in parallel per-aircraft arrays on [`runtime.traffic`][minisky.traffic.traffic.Traffic]: ```python -traf = minisky.traf - -traf.ntraf # number of aircraft -traf.callsign # list of callsigns -traf.lat, traf.lon # position [deg] -traf.alt # altitude [m] -traf.tas # true airspeed [m/s] -traf.cas # calibrated airspeed [m/s] -traf.gs # ground speed [m/s] -traf.hdg, traf.trk # heading / track [deg] -traf.vs # vertical speed [m/s] +traffic = runtime.traffic + +traffic.ntraf +traffic.callsign +traffic.lat, traffic.lon +traffic.alt +traffic.tas +traffic.cas +traffic.gs +traffic.hdg, traffic.trk +traffic.vs ``` !!! warning "Units" - Internal state is SI (metres, m/s). Convert with the constants in + Internal state is SI. Convert with constants in [`minisky.tools.aero`](../api/tools.md): ```python from minisky.tools import aero - alt_ft = minisky.traf.alt / aero.ft - tas_kts = minisky.traf.tas / aero.kts + alt_ft = runtime.traffic.alt / aero.ft + tas_kts = runtime.traffic.tas / aero.kts ``` -Conflict detection results are on `traf.cd`: +Conflict-detection results are available on [`runtime.traffic.cd`][minisky.traffic.asas.detection.ConflictDetection]: ```python -traf.cd.confpairs # list of conflicting callsign pairs -traf.cd.tcpa # time to closest point of approach [s] -traf.cd.tLOS # time to loss of separation [s] +runtime.traffic.cd.confpairs +runtime.traffic.cd.tcpa +runtime.traffic.cd.tLOS ``` ## Stepping vs. running -For experiments, call [`sim.step()`][minisky.simulation.simulation.Simulation.step] in a -loop — each call advances the simulation by `sim.simdt` seconds as fast as the CPU allows: +For experiments, step manually: ```python -minisky.sim.simdt = 1 -while minisky.sim.simt < 3600: - minisky.sim.step() +runtime.simulation.simdt = 1 +while runtime.simulation.simt < 3600: + runtime.simulation.step() ``` -To run in (scaled) real time instead, use the async runner: +To run a scenario with scaled wall-clock pacing: ```python import asyncio -minisky.runner.speed = 10 # 10x wall time -asyncio.run(minisky.runner.run()) +async def main() -> None: + async with MiniSky(settings, scenario="scenarios/kl204.scn") as runtime: + runtime.load_plugins() + runtime.runner.speed = 10 + await runtime.run() + +asyncio.run(main()) ``` ## Resetting between runs -[`sim.reset()`][minisky.simulation.simulation.Simulation.reset] clears traffic, the -stack, areas, and plugin state, and rewinds the clock — use it between repeated -experiments in the same process rather than re-importing. +[`Simulation.reset`][minisky.simulation.simulation.Simulation.reset] clears +traffic, command state, areas, plugin timers, replaceable selections, and the +clock: + +```python +runtime.simulation.reset() +``` diff --git a/docs/guides/tangram.md b/docs/guides/tangram.md index 417629b..7493922 100644 --- a/docs/guides/tangram.md +++ b/docs/guides/tangram.md @@ -55,7 +55,6 @@ meet on the channel name, not on any shared configuration. just sync ``` - In `settings.toml`: diff --git a/docs/index.md b/docs/index.md index a941132..ba8c585 100644 --- a/docs/index.md +++ b/docs/index.md @@ -31,11 +31,14 @@ reach a bare-minimum simulator that is easy to read, embed, and extend. - **Python library** — import `minisky` and step the simulation from your own code. ```python - import minisky - - minisky.init() - minisky.traf.cre("KL315", lat=52.0, lon=4.0, hdg=45, alt=5000, spd=250) - minisky.sim.step() + from minisky import DEFAULT_SETTINGS_FILE, MiniSky, MiniSkySettings + + settings = MiniSkySettings.from_file(DEFAULT_SETTINGS_FILE) + with MiniSky(settings) as runtime: + runtime.traffic.cre( + "KL315", lat=52.0, lon=4.0, hdg=45, alt=5000, spd=250 + ) + runtime.simulation.step() ``` → [Python library](guides/python-api.md) @@ -46,7 +49,7 @@ reach a bare-minimum simulator that is easy to read, embed, and extend. | Component | Module | What it does | | --- | --- | --- | -| Simulation loop | [`minisky.simulation`](api/simulation.md) | Time keeping, state machine (INIT/HOLD/OP/END), async runner | +| Simulation loop | [`minisky.simulation`](api/simulation.md) | Time keeping, [`SimulationState`][minisky.simulation.simulation.SimulationState], async runner | | Traffic | [`minisky.traffic`](api/traffic.md) | Per-aircraft state arrays, autopilot, routes, conflict detection & resolution, OpenAP performance | | Command stack | [`minisky.stack`](api/stack.md) | Text-command interpreter shared by scenario files, the console, and the REST API | | Plugins | [`minisky.plugin`](api/plugin.md) | Discover and load user plugins with per-aircraft data and stack commands | diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 7c3140f..f5cfb82 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -2,7 +2,7 @@ Every text command understood by the simulator — usable in scenario files, the [console](../guides/console.md), the REST [`stack/` endpoint](../guides/rest-api.md), -or [`minisky.stack.stack()`](../api/stack.md) from Python. Commands are +or `runtime.commands.stack()` from Python. Commands are case-insensitive. Argument conventions: optional arguments are enclosed in `[...]`; `callsign` is an diff --git a/example_plugins/tangram.py b/example_plugins/tangram.py index 3bb6d64..9183373 100644 --- a/example_plugins/tangram.py +++ b/example_plugins/tangram.py @@ -46,11 +46,11 @@ from collections import deque from collections.abc import Callable from datetime import UTC, datetime -from types import MappingProxyType from typing import TYPE_CHECKING, Any, TypedDict, cast from pydantic import BaseModel, ConfigDict, Field +from minisky.simulation import SimulationState from minisky.streaming import Snapshot, build_snapshot from minisky.tools.aero import fpm, ft, kts @@ -78,8 +78,12 @@ class TangramPluginSettings(BaseModel): # not advancing (paused/init), so the frontend still sees state changes. HEARTBEAT_SECS = 1.0 -# Immutable mapping used when serializing simulation state names. -SIM_STATE_NAMES = MappingProxyType({0: "INIT", 1: "HOLD", 2: "OP", 3: "END"}) +def _state_name(state: int) -> str: + """Return the enum member name for a serialized simulation state.""" + try: + return SimulationState(state).name + except ValueError: + return "?" class TangramSimInfo(TypedDict): @@ -90,7 +94,7 @@ class TangramSimInfo(TypedDict): simutc: str # ISO-8601 speed: float # runner speed multiplier (x realtime) ntraf: int - state: int # 0=INIT, 1=HOLD, 2=OP, 3=END + state: int # Serialized SimulationState value. state_name: str scenname: str # "" when no scenario is loaded nconf_cur: int @@ -151,7 +155,7 @@ def convert_snapshot(snapshot: Snapshot) -> TangramPayload: "speed": siminfo["speed"], "ntraf": siminfo["ntraf"], "state": state, - "state_name": SIM_STATE_NAMES.get(state, "?"), + "state_name": _state_name(state), "scenname": siminfo["scenname"], "nconf_cur": acdata["nconf_cur"], "nlos_cur": acdata["nlos_cur"], @@ -250,6 +254,7 @@ def __init__( self._console: deque[str] = deque(maxlen=200) self._stop = threading.Event() self._thread: threading.Thread | None = None + self._original_echo: Callable[[str, int], None] | None = None self.ready = threading.Event() """Set once the command subscription is live (commands published before this are lost -- Redis pub/sub has no replay).""" @@ -279,9 +284,14 @@ def start(self) -> tuple[bool, str]: return True, f"Tangram bridge publishing to to:{self.channel}:* at {self.redis_url}" def stop(self) -> None: + """Stop Redis I/O and restore the runtime console.""" self._stop.set() if self._thread is not None: self._thread.join(timeout=2.0) + self._thread = None + if self._original_echo is not None: + self.console.echo = self._original_echo # type: ignore[method-assign] + self._original_echo = None def status(self) -> tuple[bool, str]: """Return the current Redis bridge status for the TANGRAM command. @@ -326,10 +336,13 @@ def _enqueue(self, payload: TangramPayload) -> None: def _tee_console(self) -> None: """Also capture everything echoed to the console, without consuming it.""" - original_echo = self.console.echo + if self._original_echo is not None: + return + self._original_echo = self.console.echo def echo(text: str = "", flag: int = 0) -> None: - original_echo(text, flag) + assert self._original_echo is not None + self._original_echo(text, flag) if text: self._console.extend(text.splitlines()) @@ -354,7 +367,7 @@ def _siminfo_heartbeat(self) -> TangramPayload: "speed": float(self.runner.speed), "ntraf": int(self.traffic.ntraf), "state": state, - "state_name": SIM_STATE_NAMES.get(state, "?"), + "state_name": _state_name(state), "scenname": self.get_scenname(), "nconf_cur": last["siminfo"]["nconf_cur"] if last is not None else 0, "nlos_cur": last["siminfo"]["nlos_cur"] if last is not None else 0, @@ -440,7 +453,6 @@ def init_plugin( plugin manager. """ extras = runtime.settings.model_extra or {} - # TODO(abraham): we should namespace it under settings.plugins.tangram. cfg = TangramPluginSettings.model_validate({"tangram": extras.get("tangram", {})}).tangram bridge = TangramBridge( redis_url=cfg.redis_url, diff --git a/minisky/__init__.py b/minisky/__init__.py index 543882f..3c5b207 100644 --- a/minisky/__init__.py +++ b/minisky/__init__.py @@ -1,71 +1,26 @@ """MiniSky air traffic simulator. -`MiniSky` is the explicit owner of a simulator -runtime. The module-level `traf`, `sim`, `scr`, `runner`, and `navdb` names are -temporary compatibility aliases for the active runtime. +[`MiniSky`][minisky.runtime.MiniSky] is the explicit ownership root for one +simulator runtime. Construct it with validated [`MiniSkySettings`][] and access +simulation components through that instance. """ -from __future__ import annotations +from minisky.core.settings import DEFAULT_SETTINGS_FILE, MiniSkySettings +from minisky.runtime import MiniSky +from minisky.simulation import SimulationState -from minisky import core, plugin, stack, tools -from minisky.core.settings import MiniSkySettings, data, filename_settings -from minisky.simulation import ConsoleIO, Runner, Simulation -from minisky.simulation.simulation import END, HOLD, INIT, OP -from minisky.tools.navdata import Navdatabase - -# isort: split -# traffic remains last because importing the performance model reads -# `minisky.data` during module initialization. -from minisky import traffic -from minisky.traffic import Traffic - -# Constants BS_OK = 0 BS_ARGERR = 1 BS_FUNERR = 2 BS_CMDERR = 4 -# TODO(abraham): remove the active-runtime compatibility facade in the final -# explicit-runtime migration. These aliases intentionally remain only for current -# public and test callers. -_current: MiniSky | None = None -runner: Runner = None # type: ignore[assignment] -traf: Traffic = None # type: ignore[assignment] -navdb: Navdatabase = None # type: ignore[assignment] -sim: Simulation = None # type: ignore[assignment] -scr: ConsoleIO = None # type: ignore[assignment] - - -def _activate(instance: MiniSky) -> None: - """Point the compatibility aliases at an active runtime.""" - global _current, runner, traf, navdb, sim, scr - - _current = instance - runner = instance.runner - traf = instance.traffic - navdb = instance.navigation - sim = instance.simulation - scr = instance.console - stack._activate(instance.commands) - core.varexplorer._activate(instance.variables) - tools.areafilter._activate(instance.areas) - - -from minisky.runtime import MiniSky # noqa: E402 - - -def init( - scenario: str | None = None, - settings: MiniSkySettings | None = None, -) -> MiniSky: - """Construct and activate a MiniSky runtime. - - This function is a compatibility adapter. New code should construct - `MiniSky` directly with explicit settings. - """ - if settings is None: - settings = MiniSkySettings.from_file(filename_settings) - - instance = MiniSky(settings, scenario) - - return instance +__all__ = ( + "BS_ARGERR", + "BS_CMDERR", + "BS_FUNERR", + "BS_OK", + "DEFAULT_SETTINGS_FILE", + "MiniSky", + "MiniSkySettings", + "SimulationState", +) diff --git a/minisky/cli.py b/minisky/cli.py index 577f0e3..36281e4 100644 --- a/minisky/cli.py +++ b/minisky/cli.py @@ -39,7 +39,7 @@ Every text command understood by the simulator — usable in scenario files, the [console](../guides/console.md), the REST [`stack/` endpoint](../guides/rest-api.md), -or [`minisky.stack.stack()`](../api/stack.md) from Python. Commands are +or `runtime.commands.stack()` from Python. Commands are case-insensitive. Argument conventions: optional arguments are enclosed in `[...]`; `callsign` is an @@ -56,20 +56,19 @@ def _new_runtime(scenario: str | None = None) -> MiniSky: """Construct a runtime from the default settings.""" - from minisky import MiniSky, MiniSkySettings, filename_settings + from minisky import DEFAULT_SETTINGS_FILE, MiniSky, MiniSkySettings - settings = MiniSkySettings.from_file(filename_settings) + settings = MiniSkySettings.from_file(DEFAULT_SETTINGS_FILE) runtime = MiniSky(settings, scenario) return runtime async def _run_scenario(scenario: str, speed: int) -> None: """Initialise the simulator with a scenario and run it to completion.""" - runtime = _new_runtime(scenario) - runtime.load_plugins() - runtime.runner.speed = speed - - await runtime.run() + async with _new_runtime(scenario) as runtime: + runtime.load_plugins() + runtime.runner.speed = speed + await runtime.run() @app.command("run") @@ -183,24 +182,23 @@ def stream_cmd( def _build_command_rows() -> list[str]: from minisky.stack import Command - runtime = _new_runtime() - - primary: dict[str, Command] = {} - synonyms: dict[str, list[str]] = {} - for name, cmdobj in sorted(runtime.commands.cmddict.items()): - if cmdobj.name == name: - primary[name] = cmdobj - else: - synonyms.setdefault(cmdobj.name, []).append(name) - - lines: list[str] = [] - for name, cmdobj in sorted(primary.items()): - usage = (cmdobj.brief or "").replace("|", "\\|").replace("\n", " ") - help_text = (cmdobj.help or "").replace("|", "\\|") - help_text = help_text.strip().splitlines()[0] if help_text.strip() else "" - syns = ", ".join(f"`{s}`" for s in sorted(synonyms.get(name, []))) - lines.append(f"| `{name}` | `{usage}` | {help_text} | {syns} |\n") - return lines + with _new_runtime() as runtime: + primary: dict[str, Command] = {} + synonyms: dict[str, list[str]] = {} + for name, cmdobj in sorted(runtime.commands.cmddict.items()): + if cmdobj.name == name: + primary[name] = cmdobj + else: + synonyms.setdefault(cmdobj.name, []).append(name) + + lines: list[str] = [] + for name, cmdobj in sorted(primary.items()): + usage = (cmdobj.brief or "").replace("|", "\\|").replace("\n", " ") + help_text = (cmdobj.help or "").replace("|", "\\|") + help_text = help_text.strip().splitlines()[0] if help_text.strip() else "" + syns = ", ".join(f"`{s}`" for s in sorted(synonyms.get(name, []))) + lines.append(f"| `{name}` | `{usage}` | {help_text} | {syns} |\n") + return lines @commands_app.command("list") diff --git a/minisky/core/settings.py b/minisky/core/settings.py index 1f569ff..af651c9 100644 --- a/minisky/core/settings.py +++ b/minisky/core/settings.py @@ -15,7 +15,6 @@ class MiniSkySettings(BaseModel): model_config = ConfigDict(frozen=True, extra="allow") - prefer_compiled: bool = True asas_dtlookahead: Annotated[float, Field(), annotated_types.Ge(0)] = 300.0 asas_pzr: Annotated[float, Field(), annotated_types.Gt(0)] = 5.0 asas_pzh: Annotated[float, Field(), annotated_types.Gt(0)] = 1000.0 @@ -32,26 +31,9 @@ def from_file(cls, path: str | Path) -> MiniSkySettings: return cls.model_validate(tomllib.load(file)) -# -# compat -# - DEFAULT_SETTINGS_FILE = Path(__file__).parent.parent.parent / "settings.toml" PACKAGE_DATA_DIR = Path(__file__).parent.parent / "data" -# TODO(abraham): remove these module-level compatibility settings once all -# callers receive MiniSkySettings explicitly. -filename_settings = DEFAULT_SETTINGS_FILE -default_settings = MiniSkySettings.from_file(filename_settings) -prefer_compiled = default_settings.prefer_compiled -asas_dtlookahead = default_settings.asas_dtlookahead -asas_pzr = default_settings.asas_pzr -asas_pzh = default_settings.asas_pzh -asas_marh = default_settings.asas_marh -asas_marv = default_settings.asas_marv -plugin_path = default_settings.plugin_path -enabled_plugins = list(default_settings.enabled_plugins) - def data(path: str) -> Path: """Return an absolute path inside the package data directory.""" diff --git a/minisky/core/trafficarrays.py b/minisky/core/trafficarrays.py index 43ebfc9..135a674 100644 --- a/minisky/core/trafficarrays.py +++ b/minisky/core/trafficarrays.py @@ -288,6 +288,12 @@ def reparent(self, newparent: TrafficArrays) -> None: newparent._children.append(self) self._parent = newparent + def detach(self) -> None: + """Detach this object from its current traffic-array parent.""" + if self._parent is not None: + self._parent._children.remove(self) + self._parent = None + @property def tree_root(self) -> TrafficArrays: """Return the root node of this object's traffic-array tree.""" diff --git a/minisky/core/varexplorer.py b/minisky/core/varexplorer.py index 2f2e176..550f48a 100644 --- a/minisky/core/varexplorer.py +++ b/minisky/core/varexplorer.py @@ -51,6 +51,10 @@ def register_data_parent(self, obj: Any, name: str) -> None: """ self.varlist[name] = (obj, getvarsfromobj(obj)) + def unregister_data_parent(self, name: str) -> None: + """Remove a previously registered top-level data source.""" + self.varlist.pop(name, None) + def lsvar(self, varname: str = "") -> tuple[bool, str]: """Stack function to list information on simulation variables in the BlueSky console.""" @@ -182,25 +186,3 @@ def getvarsfromobj(obj: Any) -> list[str] | None: return [name for name in vars(obj) if name[0] != "_"] except TypeError: return None - - -# TODO(abraham): remove this active explorer after compatibility callers use -# `MiniSky.variables` directly. -_active: VariableExplorer | None = None - - -def _activate(explorer: VariableExplorer) -> None: - """Activate a runtime variable explorer for temporary compatibility calls.""" - global _active - _active = explorer - - -def _current() -> VariableExplorer: - if _active is None: - raise RuntimeError("MiniSky variable explorer is not initialized") - return _active - - -def findvar(varname: str) -> Variable | None: - """Find a variable on the active runtime's variable explorer.""" - return _current().findvar(varname) diff --git a/minisky/plugin/plugin.py b/minisky/plugin/plugin.py index 59bb0b9..4828840 100644 --- a/minisky/plugin/plugin.py +++ b/minisky/plugin/plugin.py @@ -22,6 +22,7 @@ from types import ModuleType from typing import TYPE_CHECKING, Any +from minisky.core.trafficarrays import TrafficArrays from minisky.plugin.plugin_decorators import append_commands, register_declared_commands from minisky.plugin.timedfunction import TimedFunctionManager @@ -52,6 +53,7 @@ class Plugin: module: Imported plugin module, or None until loaded. config: Config dictionary returned by `init_plugin(runtime)`. state: Optional runtime-owned state object returned in the config. + command_names: Command names and aliases registered by this plugin. """ fullname: str @@ -63,6 +65,7 @@ class Plugin: module: ModuleType | None = None config: dict[str, Any] = field(default_factory=dict) state: Any = None + command_names: set[str] = field(default_factory=set) class PluginManager: @@ -231,9 +234,11 @@ def load(self, name: str) -> tuple[bool, str]: ) # Register stack functions only on this runtime. + command_names_before = set(self.commands.cmddict) register_declared_commands(self.commands, module) if stack_functions: append_commands(self.commands, stack_functions) + command_names = set(self.commands.cmddict) - command_names_before # Register plugin state, or the module when no state object is returned. state = config.get("state") @@ -246,6 +251,7 @@ def load(self, name: str) -> tuple[bool, str]: plugin.module = module plugin.config = config plugin.state = state + plugin.command_names = command_names self.loaded_plugins[plugin.plugin_name] = plugin return True, f"Successfully loaded plugin {plugin.plugin_name}" @@ -303,20 +309,33 @@ def hold(self) -> None: self.timed.hold() def shutdown(self) -> None: - """Run shutdown callbacks and clear runtime-owned hook state.""" - # TODO(abraham): call this from the final `MiniSky` lifecycle/context - # manager and unregister plugin variable-explorer parents at the same - # time. + """Run shutdown callbacks and release all runtime-owned plugin state.""" + errors: list[Exception] = [] for plugin in reversed(tuple(self.loaded_plugins.values())): - callback = plugin.config.get("shutdown") - if callback is not None: - callback() + try: + callback = plugin.config.get("shutdown") + if callback is not None: + callback() + except Exception as exc: # noqa: BLE001 - finish releasing every plugin + errors.append(exc) + finally: + for command_name in plugin.command_names: + self.commands.cmddict.pop(command_name, None) + + self.variables.unregister_data_parent(plugin.plugin_name.lower()) + if isinstance(plugin.state, TrafficArrays): + plugin.state.detach() + + plugin.loaded = False + plugin.module = None + plugin.config.clear() + plugin.state = None + plugin.command_names.clear() + self.timed.clear() self.loaded_plugins.clear() - for plugin in self.plugins.values(): - plugin.loaded = False - plugin.config.clear() - plugin.state = None + if errors: + raise ExceptionGroup("Plugin shutdown failed", errors) @staticmethod def _parse_init_plugin(func_node: ast.FunctionDef) -> dict[str, Any] | None: diff --git a/minisky/runtime.py b/minisky/runtime.py index 31fb1ce..3803e03 100644 --- a/minisky/runtime.py +++ b/minisky/runtime.py @@ -2,13 +2,14 @@ from __future__ import annotations -from minisky import tools +import asyncio +from contextlib import suppress + from minisky.core.settings import MiniSkySettings, data from minisky.core.trafficarrays import ReplaceableManager from minisky.core.varexplorer import VariableExplorer from minisky.plugin import PluginManager -from minisky.simulation import ConsoleIO, Runner, Simulation -from minisky.simulation.simulation import OP +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 @@ -21,9 +22,11 @@ class MiniSky: def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> None: self.settings = settings - tools.init() - - self.console = ConsoleIO(lambda: self.simulation.state == OP) + self._run_task: asyncio.Task[None] | None = None + self._closed = False + self.console = ConsoleIO( + lambda: self.simulation.state == SimulationState.OP + ) self.navigation = Navdatabase(data("navigation"), self.console) self.areas = AreaFilter() self.variables = VariableExplorer() @@ -74,11 +77,6 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No self.runner = Runner(self.simulation, self.console) self.variables.init(self.simulation, self.traffic) - # the compatibility facade must be active before commands and variable - # explorer parents are registered against this runtime. - import minisky - - minisky._activate(self) self.commands.init() self.plugins.discover() @@ -96,4 +94,59 @@ def load_plugins(self) -> None: async def run(self) -> None: """Run the simulation until its runner stops.""" + if self._closed: + raise RuntimeError("MiniSky runtime is closed") await self.runner.run() + + def start(self) -> asyncio.Task[None]: + """Start the simulation runner in an owned asyncio task.""" + if self._closed: + raise RuntimeError("MiniSky runtime is closed") + if self._run_task is None or self._run_task.done(): + self._run_task = asyncio.create_task(self.run()) + return self._run_task + + def close(self) -> None: + """Release synchronous resources owned by this runtime.""" + if self._closed: + return + self.runner.shutdown() + self.streaming.close() + try: + self.plugins.shutdown() + finally: + self._closed = True + + async def aclose(self) -> None: + """Stop the runner task and release all runtime-owned resources.""" + error: BaseException | None = None + try: + self.close() + except BaseException as exc: # cleanup the runner task before re-raising + error = exc + + task = self._run_task + if task is not None and task is not asyncio.current_task() and not task.done(): + task.cancel() + with suppress(asyncio.CancelledError): + await task + self._run_task = None + + if error is not None: + raise error + + def __enter__(self) -> MiniSky: + """Enter a synchronous runtime lifecycle context.""" + return self + + def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None: + """Close the runtime when leaving a synchronous context.""" + self.close() + + async def __aenter__(self) -> MiniSky: + """Enter an asynchronous runtime lifecycle context.""" + return self + + async def __aexit__(self, exc_type: object, exc_value: object, traceback: object) -> None: + """Close the runtime when leaving an asynchronous context.""" + await self.aclose() diff --git a/minisky/server.py b/minisky/server.py index 05fac29..acbc8de 100644 --- a/minisky/server.py +++ b/minisky/server.py @@ -21,9 +21,8 @@ from __future__ import annotations -import asyncio import os -from contextlib import asynccontextmanager, suppress +from contextlib import asynccontextmanager from io import StringIO from typing import Annotated, Any, cast @@ -42,13 +41,9 @@ from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles -from minisky import MiniSky, MiniSkySettings, filename_settings +from minisky import DEFAULT_SETTINGS_FILE, MiniSky, MiniSkySettings from minisky.tools import aero -# TODO(abraham): move router construction into `create_app` when the final -# lifecycle pass removes all remaining process-wide application declarations. -router = APIRouter() - def _get_runtime(request: Request) -> MiniSky: """Return the runtime owned by the current FastAPI application.""" @@ -62,27 +57,23 @@ def _get_runtime(request: Request) -> MiniSky: async def lifespan(app: FastAPI): """Run the app-owned simulator for the lifetime of the API server.""" runtime = cast(MiniSky, app.state.runtime) - task = asyncio.create_task(runtime.run()) + runtime.load_plugins() + runtime.start() try: yield finally: - runtime.runner.running = False - task.cancel() - with suppress(asyncio.CancelledError): - await task + await runtime.aclose() def create_app(runtime: MiniSky | None = None) -> FastAPI: """Create a FastAPI application owning a simulator runtime.""" if runtime is None: - settings = MiniSkySettings.from_file(filename_settings) + settings = MiniSkySettings.from_file(DEFAULT_SETTINGS_FILE) runtime = MiniSky(settings) - runtime.load_plugins() - app = FastAPI(lifespan=lifespan) app.state.runtime = runtime - app.include_router(router) + app.include_router(create_router()) # Static files live at the repository root (../static relative to this # package), which resolves correctly for both a source checkout and an @@ -93,13 +84,11 @@ def create_app(runtime: MiniSky | None = None) -> FastAPI: return app -@router.get("/") def root() -> dict[str, str]: """Health check: confirm the API is up.""" return {"msg": "MiniSky API endpoint ready"} -@router.get("/all") def all(runtime: Runtime) -> list[dict[str, Any]]: """Get all aircraft states.""" traffic = runtime.traffic @@ -126,27 +115,23 @@ def all(runtime: Runtime) -> list[dict[str, Any]]: return df.to_dict(orient="records") -@router.get("/simtime") def simtime(runtime: Runtime) -> dict[str, float]: """Get the simulation time.""" return {"simulation time (seconds)": runtime.simulation.simt} -@router.get("/speed/{speed}") def speedup(speed: float, runtime: Runtime) -> dict[str, str]: """Speed up the simulation.""" runtime.runner.speed = speed return {"msg": f"simulation speed set to {speed}x"} -@router.get("/forward/{seconds}") def forward(seconds: float, runtime: Runtime) -> dict[str, str]: """Jump to a specific simulation time.""" runtime.runner.forward(seconds) return {"msg": f"simulation time jump forward {seconds} seconds"} -@router.get("/conflicts") def conflicts(runtime: Runtime) -> list[dict[str, Any]] | dict[str, str]: """Get all detected conflicts. @@ -184,7 +169,6 @@ def conflicts(runtime: Runtime) -> list[dict[str, Any]] | dict[str, str]: return conflict_info -@router.get("/stack/{cmd:path}") async def stack(cmd: str, runtime: Runtime) -> dict[str, Any]: """Execute a stack command and return the output.""" runtime.console.event.clear() @@ -195,7 +179,6 @@ async def stack(cmd: str, runtime: Runtime) -> dict[str, Any]: return {"command to minisky": cmd, "message": msg} -@router.get("/commands") def commands(runtime: Runtime) -> dict[str, str]: """Return the command dictionary as `{name: brief usage}`. @@ -209,7 +192,6 @@ def commands(runtime: Runtime) -> dict[str, str]: return dict(sorted(seen.items())) -@router.websocket("/stream") async def stream(websocket: WebSocket) -> None: """Push a full simulation snapshot once per simulation step in SI units. @@ -238,7 +220,6 @@ async def stream(websocket: WebSocket) -> None: hub.unsubscribe() -@router.get("/scn") def upload_form() -> Response: """Serve a minimal HTML form for uploading a scenario file.""" content = """ @@ -251,7 +232,6 @@ def upload_form() -> Response: return Response(content=content, media_type="text/html") -@router.post("/scn") async def scn(runtime: Runtime, file: UploadFile = File(...)) -> dict[str, str]: """Load an uploaded scenario file into the running simulation.""" runtime.console.event.clear() @@ -262,24 +242,41 @@ async def scn(runtime: Runtime, file: UploadFile = File(...)) -> dict[str, str]: return {"msg": f"scenario {filename} loaded"} -@router.get("/map") def show_map() -> RedirectResponse: """Display the aircraft map viewer.""" return RedirectResponse(url="/static/display.html") -@router.get("/plugins") def list_plugins(runtime: Runtime) -> Any: """List available and loaded plugins.""" return runtime.plugins.manage("LIST") -@router.get("/plugins/load/{name}") def load_plugin(name: str, runtime: Runtime) -> Any: """Load a plugin by name.""" return runtime.plugins.manage("LOAD", name) +def create_router() -> APIRouter: + """Create the API router for one FastAPI application.""" + router = APIRouter() + router.add_api_route("/", root, methods=["GET"]) + router.add_api_route("/all", all, methods=["GET"]) + router.add_api_route("/simtime", simtime, methods=["GET"]) + router.add_api_route("/speed/{speed}", speedup, methods=["GET"]) + router.add_api_route("/forward/{seconds}", forward, methods=["GET"]) + router.add_api_route("/conflicts", conflicts, methods=["GET"]) + router.add_api_route("/stack/{cmd:path}", stack, methods=["GET"]) + router.add_api_route("/commands", commands, methods=["GET"]) + router.add_api_websocket_route("/stream", stream) + router.add_api_route("/scn", upload_form, methods=["GET"]) + router.add_api_route("/scn", scn, methods=["POST"]) + router.add_api_route("/map", show_map, methods=["GET"]) + router.add_api_route("/plugins", list_plugins, methods=["GET"]) + router.add_api_route("/plugins/load/{name}", load_plugin, methods=["GET"]) + return router + + def main() -> None: """Console-script entry point: serve the API with uvicorn. diff --git a/minisky/simulation/__init__.py b/minisky/simulation/__init__.py index c44841c..2671361 100644 --- a/minisky/simulation/__init__.py +++ b/minisky/simulation/__init__.py @@ -2,14 +2,19 @@ Bundles the three objects that drive a simulation run: -- :class:`Simulation`: owns simulation time, state (INIT/HOLD/OP/END) and - performs one timestep per call to :meth:`Simulation.step`. -- :class:`Runner`: the asyncio loop that repeatedly steps the simulation at a +- [`Simulation`][minisky.simulation.simulation.Simulation]: owns simulation + time and performs one timestep per call to + [`Simulation.step`][minisky.simulation.simulation.Simulation.step]. +- [`SimulationState`][minisky.simulation.simulation.SimulationState]: lifecycle + state represented as an `IntEnum`. +- [`Runner`][minisky.simulation.runner.Runner]: the asyncio loop that repeatedly steps the simulation at a configurable real-time speed, with support for fast-forward jumps. -- :class:`ConsoleIO`: collects console/echo output from the simulation so it +- [`ConsoleIO`][minisky.simulation.console.ConsoleIO]: collects console/echo output from the simulation so it can be printed and forwarded to remote clients (e.g. the HTTP API). """ from .console import ConsoleIO from .runner import Runner -from .simulation import Simulation +from .simulation import Simulation, SimulationState + +__all__ = ("ConsoleIO", "Runner", "Simulation", "SimulationState") diff --git a/minisky/simulation/console.py b/minisky/simulation/console.py index bac9092..196efad 100644 --- a/minisky/simulation/console.py +++ b/minisky/simulation/console.py @@ -4,8 +4,7 @@ commands and simulation state changes report back through its `echo` method, which prints to stdout and stores the message in a buffer that remote clients (such as the HTTP API served by `minisky server`) can read asynchronously. -A single instance is owned by `MiniSky` and temporarily available as -`minisky.scr` through the compatibility facade. +Each `MiniSky` runtime owns one instance as [`runtime.console`][minisky.simulation.console.ConsoleIO]. """ from __future__ import annotations @@ -21,7 +20,7 @@ class ConsoleIO: """Class within sim task which sends/receives data to/from GUI task. - Acts as the simulator's screen/console object (`minisky.scr`). Output + Acts as the runtime's screen/console object ([`runtime.console`][minisky.simulation.console.ConsoleIO]). Output produced with `echo` is printed to stdout and kept in an in-memory buffer; an `asyncio.Event` is set on every echo so that awaiting consumers (e.g. the HTTP API's `/stack` endpoint) know new output is @@ -63,7 +62,8 @@ def update(self) -> None: """Count one simulation sample while the simulation is operating. Increments the sample counter only when the simulation state is - `OP`; used for bookkeeping of the effective update rate. + [`SimulationState.OP`][minisky.simulation.simulation.SimulationState]; + used for bookkeeping of the effective update rate. """ if self.is_operating(): self.samplecount += 1 diff --git a/minisky/simulation/runner.py b/minisky/simulation/runner.py index c5c5f84..aa839e4 100644 --- a/minisky/simulation/runner.py +++ b/minisky/simulation/runner.py @@ -4,8 +4,7 @@ `self.simulation.step()` repeatedly at an interval derived from the requested simulation speed, and supports fast-forward jumps where the sleep interval is reduced to a minimum until a target simulation time is reached. A single -instance is owned by `MiniSky` and temporarily available as `minisky.runner` -through the compatibility facade. +instance is owned by `MiniSky`. """ from __future__ import annotations @@ -140,6 +139,10 @@ async def run(self) -> None: self.console.echo("Simulation completed") + def shutdown(self) -> None: + """Stop the run loop regardless of scenario shutdown policy.""" + self.running = False + def stop(self) -> None: """Request the run loop to stop. diff --git a/minisky/simulation/simulation.py b/minisky/simulation/simulation.py index f6fb0bd..4687c22 100644 --- a/minisky/simulation/simulation.py +++ b/minisky/simulation/simulation.py @@ -3,8 +3,7 @@ Defines the `Simulation` class, the central clock and state machine of the simulator. It advances simulation time, processes the command stack, triggers plugin pre-/post-update hooks, and updates all aircraft in the -traffic object once per timestep. A single instance is created by -`minisky.init` and made available as `minisky.sim`. +traffic object once per timestep. Each `MiniSky` runtime owns one instance. """ from __future__ import annotations @@ -12,6 +11,7 @@ import datetime import time from collections.abc import Callable +from enum import IntEnum from random import seed from typing import TYPE_CHECKING, Any @@ -26,8 +26,13 @@ from minisky.tools.navdata import Navdatabase from minisky.traffic import Traffic -# Simulation states -INIT, HOLD, OP, END = (0, 1, 2, 3) # TODO(abraham): use IntEnum. +class SimulationState(IntEnum): + """Simulation lifecycle states.""" + + INIT = 0 + HOLD = 1 + OP = 2 + END = 3 # Minimum sleep interval MINSLEEP = 1e-3 @@ -44,8 +49,7 @@ class Simulation: `reset` and `stop`. Attributes: - state: Current simulation state, one of `minisky.INIT`, - `minisky.HOLD`, `minisky.OP` or `minisky.END`. + state: Current [`SimulationState`][minisky.simulation.simulation.SimulationState] value. prevstate: Previous simulation state (unused placeholder). simt: Elapsed simulation time [s]. simdt: Simulation timestep [s]. @@ -78,8 +82,8 @@ def __init__( self.replaceables = replaceables self.stop_runner = stop_runner self.publish_tick = publish_tick - self.state = INIT - self.prevstate = None + self.state = SimulationState.INIT + self.prevstate: SimulationState | None = None # Simulation time [seconds] self.simt: float = 0 @@ -118,7 +122,7 @@ def step(self) -> None: 4. Publish the runtime stream snapshot when subscribers are present. """ # Simulation starts as soon as there is traffic, or pending commands - if self.state == INIT and ( + if self.state == SimulationState.INIT and ( self.traffic.ntraf > 0 or len(self.commands.get_scendata()[0]) > 0 ): self.op() @@ -126,7 +130,7 @@ def step(self) -> None: # Always update stack self.commands.process() - if self.state == OP: + if self.state == SimulationState.OP: self.simt += self.simdt # Update UTC time @@ -152,7 +156,7 @@ def stop(self) -> None: `minisky.simulation.runner.Runner.prevent_shutdown`, the loop keeps running and only the state changes. """ - self.state = END + self.state = SimulationState.END self.stop_runner() def op(self) -> None: @@ -163,7 +167,7 @@ def op(self) -> None: timestep [s]. """ self.syst = time.time() + self.simdt - self.state = OP + self.state = SimulationState.OP self.console.echo("Simulation running") def hold(self) -> None: @@ -174,7 +178,7 @@ def hold(self) -> None: simulation can be resumed with the `OP` command. """ self.syst = time.time() + self.simdt - self.state = HOLD + self.state = SimulationState.HOLD self.plugins.hold() self.console.echo("Simulation paused") @@ -187,7 +191,7 @@ def reset(self) -> None: console output, replaceable entities (autopilot, performance models, etc.) and plugin timers/hooks reset to their defaults. """ - self.state = INIT + self.state = SimulationState.INIT self.syst = 0 self.simt = 0 self.simdt = 1 diff --git a/minisky/stack/__init__.py b/minisky/stack/__init__.py index 8055fbd..105c3c7 100644 --- a/minisky/stack/__init__.py +++ b/minisky/stack/__init__.py @@ -4,7 +4,7 @@ simulator—typed by a user, read from a scenario (`.scn`) file, or issued by a plugin—enters as a line of text such as `CRE KL204 B744 52.0 4.0 90 FL300 250`. Command lines are queued with -[stack][minisky.stack.stack] and executed once per simulation step by [`CommandStack.process`][minisky.stack.CommandStack.process]. +[`CommandStack.stack`][minisky.stack.CommandStack.stack] and executed once per simulation step by [`CommandStack.process`][minisky.stack.CommandStack.process]. Each available command is represented by a [Command][minisky.stack.Command] object, which couples the command name to the Python function that implements it and to @@ -13,9 +13,7 @@ [`CommandStack.init`][minisky.stack.CommandStack.init]. Each `CommandStack` owns one runtime's command registry, pending command -queue, scenario buffer, and sender state. The remaining module-level -functions and `Command.cmddict` are temporary compatibility aliases for -the active runtime. +queue, scenario buffer, and sender state. This module also implements scenario handling: [`CommandStack.ic`][minisky.stack.CommandStack.ic] loads a scenario file, whose timestamped command lines are buffered and moved onto the stack by @@ -60,9 +58,6 @@ class Command: the callback. Calling a Command instance with an argument string parses the arguments and executes the callback. - `cmddict` is a compatibility alias for the active runtime registry and - maps command names and aliases to Command instances. - Attributes: name: Command name in upper case (e.g., "CRE"). help: Full help text shown by the HELP command. @@ -74,9 +69,6 @@ class Command: valid: False when the callback is an unbound class/instance method. """ - # TODO(abraham): remove this active-runtime registry alias when the - # remaining compatibility tests and public stack facade use CommandStack directly. - cmddict: dict[str, Command] = {} def __init__( self, @@ -399,7 +391,7 @@ def commands(self) -> Iterator[str]: """Iterate over the command lines pending for this simulation step. Detaches the pending command list before iterating so that a - [stack][minisky.stack.stack] call from another thread, such as a plugin I/O thread, + [`CommandStack.stack`][minisky.stack.CommandStack.stack] call from another thread, such as a plugin I/O thread, cannot race with processing: a command lands either on the detached list processed in this step or on the fresh list processed next step. """ @@ -469,7 +461,7 @@ def process(self) -> None: remaining text is passed to the Command object for argument parsing and execution, and any resulting message is echoed to the screen. The pending commands are detached from the stack up front (see - Stack.commands), so commands stacked while processing runs — including + CommandStack.commands), so commands stacked while processing runs — including from other threads — are kept for the next step instead of being lost. """ # First check for commands in scenario file @@ -800,109 +792,3 @@ def set_scendata(self, newtime, newcmd) -> None: """Set the scenario data. This is used by the batch logic.""" self.scentime = newtime self.scencmd = newcmd - - -# TODO(abraham): remove the active stack pointer with the final module-level -# stack facade migration. -_active_stack: CommandStack | None = None - - -def _activate(command_stack: CommandStack) -> None: - """Activate a runtime command stack for compatibility APIs.""" - global _active_stack - _active_stack = command_stack - Command.cmddict = command_stack.cmddict - - -def current() -> CommandStack: - """Return the active runtime command stack.""" - if _active_stack is None: - raise RuntimeError("MiniSky command stack is not initialized") - return _active_stack - - -class Stack: - """Compatibility namespace for the former static stack class. - - The command queue and scenario state now belong to the active runtime's - `CommandStack`. This class preserves the former `Stack.reset()` and - `Stack.commands()` entry points by delegating to that active instance. - """ - - @classmethod - def reset(cls) -> None: - """Reset the active runtime's stack variables.""" - current()._reset_state() - - @classmethod - def commands(cls) -> Iterator[str]: - """Iterate over the active runtime's pending command lines. - - The pending list is detached before iteration so commands added while - processing are retained for the next simulation step. - """ - return current().commands() - - -def readscn(scn: str | Path | StringIO) -> Iterator[tuple[float, str]]: - """Read a scenario file and yield its timestamped commands. - - Parses lines of the form `HH:MM:SS.hh>CMDLINE`, skipping comments and empty - lines and supporting line continuation with a trailing backslash. - - Args: - scn: Scenario source: a path to a `.scn` file, or a `StringIO` object. - The `.scn` suffix is added to paths when missing. - - Yields: - A `(command time [s], command line)` tuple for each valid line. - - Raises: - TypeError: When `scn` is neither a path nor a `StringIO` object. - """ - return current().readscn(scn) - - -def showhelp(cmd: Txt = "", subcmd: Txt = "") -> tuple[bool, str]: - """HELP: Display command help or write a command reference file. - - Args: - cmd: Command name to display, or `>filename` to write a tab-delimited - command reference in the documentation directory. - subcmd: Optional subcommand to display. - - Returns: - A `(success, help text or status message)` tuple. - """ - return current().showhelp(cmd, subcmd) - - -def stack(*cmdlines: str, sender_id: bytes | None = None) -> None: - """Stack one or more commands separated by semicolons. - - Queued commands are executed on the next call to [`CommandStack.process`][minisky.stack.CommandStack.process]. - - Args: - *cmdlines: Command line strings. Each may contain multiple commands - separated by semicolons. - sender_id: Optional network route or identifier of the sender. - """ - current().stack(*cmdlines, sender_id=sender_id) - - -def get_scenname() -> str: - """Return the current scenario name. - - This is the name defined by the `SCENARIO` command or, when no explicit - name was set, the scenario filename. - """ - return current().get_scenname() - - -for _name in ( - "readscn", - "showhelp", - "stack", - "get_scenname", -): - globals()[_name].__doc__ = getattr(CommandStack, _name).__doc__ diff --git a/minisky/streaming.py b/minisky/streaming.py index 73ca87d..ed60c25 100644 --- a/minisky/streaming.py +++ b/minisky/streaming.py @@ -15,8 +15,8 @@ Units on the wire here are SI: positions in decimal degrees, `alt` in metres, speeds (`tas`/`cas`/`gs`) in m/s, `vs` in m/s, `trk` in degrees, -`simt`/`simdt` in seconds. `state` is the numeric simulation state -(0=INIT, 1=HOLD, 2=OP, 3=END). Each tick is a full snapshot; aircraft are +`simt`/`simdt` in seconds. `state` is the integer value of +[`SimulationState`][minisky.simulation.simulation.SimulationState]. Each tick is a full snapshot; aircraft are identified by `callsign` for their lifetime. """ @@ -48,7 +48,7 @@ class SimInfo(TypedDict): simt: float # s simutc: str # ISO-8601, timezone-aware ntraf: int - state: int # 0=INIT, 1=HOLD, 2=OP, 3=END + state: int # Serialized SimulationState value. scenname: str # "" when no scenario is loaded @@ -164,6 +164,7 @@ def __init__( self._last_publish = 0.0 self.latest: Snapshot | None = None self.generation = 0 + self._closed = False @property def active(self) -> bool: @@ -193,7 +194,7 @@ def publish_tick(self) -> None: elapsed, so the cost of [`build_snapshot`][] is only paid when a consumer will actually receive it. """ - if not self.active or not self._ready(): + if self._closed or not self.active or not self._ready(): return self.publish(self._build_snapshot()) @@ -209,3 +210,11 @@ def publish(self, snapshot: Snapshot) -> None: async def wait(self) -> None: """Block until the next snapshot is published.""" await self._event.wait() + if self._closed: + raise RuntimeError("Stream hub is closed") + + def close(self) -> None: + """Close the hub and wake any waiting consumers.""" + self._closed = True + self._subscribers = 0 + self._event.set() diff --git a/minisky/tools/__init__.py b/minisky/tools/__init__.py index fd1a64e..c7de0f2 100644 --- a/minisky/tools/__init__.py +++ b/minisky/tools/__init__.py @@ -2,33 +2,20 @@ Bundles the utility modules used throughout the simulator: unit conversions and the ISA atmosphere (aero), geodesy functions (geo, or the -compiled cgeo variant when available and preferred via settings), +compiled cgeo variant when available), text/value converters (convert), named area shapes and inside-tests (areafilter), the navigation database (navdata), and position-text parsing (position). """ -from minisky.core import settings +from typing import TYPE_CHECKING -# Register settings defaults -if settings.prefer_compiled: +if TYPE_CHECKING: + from . import geo as geo +else: try: from . import cgeo as geo # type: ignore[import-not-found] - - # print("Using compiled geo functions") except ImportError: from . import geo - # print("Using Python-based geo functions") -else: - from . import geo - - print("Using Python-based geo functions") - from . import aero, areafilter, convert, navdata, position # noqa: E402 - - -def init() -> None: - """Initialise the tools package by loading the magnetic declination table.""" - # print("Reading magnetic variation data") - geo.load_magnetic_declination() diff --git a/minisky/tools/areafilter.py b/minisky/tools/areafilter.py index 1011cd7..f0eee9f 100644 --- a/minisky/tools/areafilter.py +++ b/minisky/tools/areafilter.py @@ -267,42 +267,6 @@ def get_knearest( return [self.areas_by_id[area_id] for area_id in ids if area_id in self.areas_by_id] -# TODO(abraham): remove this standalone active filter after compatibility -# callers and unit tests construct or receive an AreaFilter explicitly. -_active = AreaFilter() - - -def _activate(area_filter: AreaFilter) -> None: - """Activate an area filter for temporary compatibility calls.""" - global _active - _active = area_filter - - -def has_area(areaname: str) -> bool: - """Compatibility escape hatch for `AreaFilter.has_area`.""" - return _active.has_area(areaname) - - -def define_area( - areaname: str, - areatype: str, - coordinates: tuple[float, ...] | list[float], - top: float = 1e9, - bottom: float = -1e9, -) -> tuple[bool, str]: - """Compatibility escape hatch for `AreaFilter.define_area`.""" - return _active.define_area(areaname, areatype, coordinates, top, bottom) - - -def checkInside(areaname: str, lat: np.ndarray, lon: np.ndarray, alt: np.ndarray) -> np.ndarray: - """Compatibility escape hatch for `AreaFilter.checkInside`.""" - return _active.checkInside(areaname, lat, lon, alt) - - -def reset() -> None: - """Compatibility escape hatch for `AreaFilter.reset`.""" - _active.reset() - class Shape: """ diff --git a/minisky/tools/geo.py b/minisky/tools/geo.py index 098696c..952b987 100644 --- a/minisky/tools/geo.py +++ b/minisky/tools/geo.py @@ -14,10 +14,12 @@ are in nautical miles unless stated otherwise. """ +from functools import cache + import numpy as np import pandas as pd -import minisky +from minisky.core.settings import data # Type alias for values that may be a scalar or a numpy array FloatOrArray = float | np.ndarray @@ -25,12 +27,6 @@ # Constants nm = 1852.0 # m 1 nautical mile -# Read data for declination switch -# TODO(abraham): move the mutable magnetic-declination cache into an -# explicit navigation/tool service instead of process-wide module state. -decl_read = False - - def rwgs84(latd: FloatOrArray) -> FloatOrArray: """Calculate the earths radius with WGS'84 geoid definition. @@ -625,10 +621,7 @@ def magdec(latd, lond) -> float: of the actual data. Axes were regularly spaced at one degree. The direct manual linear interpolation also 6 x times faster. """ - global decl_read, decl_lat_lon - if not decl_read: - load_magnetic_declination() - decl_read = True + decl_lat_lon = load_magnetic_declination() # Use fact that whole degrees are used as ticks on both lat & lon axis i_lat = min(max(0, int(90.0 - latd)), 180) @@ -651,6 +644,7 @@ def magdec(latd, lond) -> float: return d_hdg +@cache def load_magnetic_declination() -> np.ndarray: """ Called by Init @@ -690,9 +684,7 @@ def load_magnetic_declination() -> np.ndarray: # # lat : 89 ... -90 # Lon: -180 ... 179 - global decl_read, decl_lat_lon - - file_path = minisky.data("navigation") / "geo_declination_data.csv" + file_path = data("navigation") / "geo_declination_data.csv" df = pd.read_csv(file_path, comment="#", header=None) # Extract the declination column (index 4) as a NumPy array @@ -710,8 +702,7 @@ def load_magnetic_declination() -> np.ndarray: # Result is a 181x361 table for # lat = 90 ... -90 (rows) # lon = -180 ... 180 (columns) - decl_read = True - + decl_lat_lon.setflags(write=False) return decl_lat_lon diff --git a/minisky/tools/navdata.py b/minisky/tools/navdata.py index ba816ff..24f1d2d 100644 --- a/minisky/tools/navdata.py +++ b/minisky/tools/navdata.py @@ -3,7 +3,7 @@ Loads waypoint, airport, airway, FIR, and country data from the package data directory and provides lookup functions to find navaids and airports by identifier or position. Each `MiniSky` runtime owns a Navdatabase at -`runtime.navigation`; `minisky.navdb` remains a temporary compatibility alias. +[`runtime.navigation`][minisky.tools.navdata.Navdatabase]. The database backs the DEFWPT stack command and every position argument that references a navaid, airport, or runway. """ diff --git a/minisky/traffic/__init__.py b/minisky/traffic/__init__.py index 9e3a09a..dba452c 100644 --- a/minisky/traffic/__init__.py +++ b/minisky/traffic/__init__.py @@ -1,30 +1,10 @@ -"""Traffic-related classes. +"""Air-traffic state and dynamics. -This subpackage contains everything needed to simulate the aircraft in -MiniSky. The central object is :class:`~minisky.traffic.traffic.Traffic` -(available at runtime as ``minisky.traf``), which owns the per-aircraft -state arrays and, on every simulation step, updates the atmosphere, runs -the autopilot/FMS guidance, applies aircraft performance limits, and -integrates the aircraft states. - -The main building blocks re-exported here are: - -- ``Traffic``: top-level traffic database and state integration. -- ``Autopilot``: LNAV/VNAV flight management and autopilot guidance. -- ``Route``: per-aircraft flight-plan (waypoint list) implementation. -- ``ActiveWaypoint``: vectorized data of each aircraft's active waypoint. -- ``APorASAS``: per-channel selection between autopilot and conflict - resolution (ASAS) commands. -- ``Wind``: wind-field model used for ground-speed computation. -- ``Turbulence``: simple stochastic turbulence model. -- ``SurveillanceUncertainty``: ADS-B-like surveillance noise model. +[`Traffic`][minisky.traffic.traffic.Traffic] is owned as [`runtime.traffic`][minisky.traffic.traffic.Traffic] and +contains the per-aircraft arrays plus autopilot, routes, conflict detection and +resolution, performance, wind, turbulence, uncertainty, trails, and groups. """ -from .activewpdata import ActiveWaypoint -from .aporasas import APorASAS -from .autopilot import Autopilot -from .route import Route -from .traffic import Traffic -from .turbulence import Turbulence -from .uncertainty import SurveillanceUncertainty -from .wind import Wind +from minisky.traffic.traffic import Traffic + +__all__ = ("Traffic",) diff --git a/minisky/traffic/activewpdata.py b/minisky/traffic/activewpdata.py index ed848fe..eb79cd0 100644 --- a/minisky/traffic/activewpdata.py +++ b/minisky/traffic/activewpdata.py @@ -5,7 +5,7 @@ interface between the per-aircraft [`Route`][minisky.traffic.route.Route] objects (event-driven, scalar waypoint switching) and the vectorized LNAV/VNAV guidance in [`Autopilot`][minisky.traffic.autopilot.Autopilot]. -Available at runtime as `minisky.traf.actwp`. +Available as [`runtime.traffic.actwp`][minisky.traffic.activewpdata.ActiveWaypoint]. """ from __future__ import annotations diff --git a/minisky/traffic/aporasas.py b/minisky/traffic/aporasas.py index 67429e3..15ee62e 100644 --- a/minisky/traffic/aporasas.py +++ b/minisky/traffic/aporasas.py @@ -26,7 +26,7 @@ class APorASAS(TrafficArrays): ASAS command is used when the corresponding conflict-resolution channel is active, otherwise the autopilot command is used. The desired heading is derived from the desired track with a wind-drift correction. - Available at runtime as `minisky.traf.aporasas`. + Available as [`runtime.traffic.aporasas`][minisky.traffic.aporasas.APorASAS]. Attributes: alt (ndarray): Desired altitude [m]. @@ -93,7 +93,7 @@ def update(self) -> None: # Select asas if there is a conflict AND resolution is on # Determine desired states per channel whether to use value from ASAS or AP. - # `minisky.traf.cr.active` may be used as well, will set all of these channels + # `self.traffic.cr.active` may be used as well, will set all of these channels self.trk = np.where(self.traffic.cr.hdgactive, self.traffic.cr.trk, self.traffic.ap.trk) self.tas = np.where(self.traffic.cr.tasactive, asastas, self.traffic.ap.tas) self.alt = np.where(self.traffic.cr.altactive, self.traffic.cr.alt, self.traffic.ap.alt) diff --git a/minisky/traffic/asas/__init__.py b/minisky/traffic/asas/__init__.py index 61fe14b..9bde372 100644 --- a/minisky/traffic/asas/__init__.py +++ b/minisky/traffic/asas/__init__.py @@ -1,17 +1,16 @@ -"""Airborne Separation Assurance System (ASAS) package. +"""Airborne Separation Assurance System package. -This package bundles MiniSky's conflict detection and resolution (CD&R) -functionality: +This package bundles MiniSky's conflict detection and resolution: -- ``detection``: pairwise state-based conflict detection (:class:`ConflictDetection`), - which linearly extrapolates aircraft states to find protected-zone intrusions - within a lookahead time. -- ``resolution``: the conflict resolution base class (:class:`ConflictResolution`), - which manages resolution state and navigation recovery after conflicts. -- ``mvp``: the Modified Voltage Potential (:class:`MVP`) resolution algorithm. +- `detection`: pairwise state-based + [`ConflictDetection`][minisky.traffic.asas.detection.ConflictDetection]. +- `resolution`: shared + [`ConflictResolution`][minisky.traffic.asas.resolution.ConflictResolution] + state and navigation recovery. +- `mvp`: the Modified Voltage Potential + [`MVP`][minisky.traffic.asas.mvp.MVP] resolution algorithm. -The active detection and resolution instances live on the traffic object as -``minisky.traf.cd`` and ``minisky.traf.cr``. +The active instances are [`runtime.traffic.cd`][minisky.traffic.asas.detection.ConflictDetection] and [`runtime.traffic.cr`][minisky.traffic.asas.resolution.ConflictResolution]. """ # isort: off diff --git a/minisky/traffic/asas/resolution.py b/minisky/traffic/asas/resolution.py index d41c7ba..9b5e3e5 100644 --- a/minisky/traffic/asas/resolution.py +++ b/minisky/traffic/asas/resolution.py @@ -529,7 +529,7 @@ def setmethod(self, name: Txt = "") -> tuple: """Select a Conflict Resolution method. Implements the RESO stack command. Selecting "MVP" replaces the - traffic object's resolution instance (`minisky.traf.cr`) with a new + traffic object's resolution instance ([`runtime.traffic.cr`][minisky.traffic.asas.resolution.ConflictResolution]) with a new MVP instance and activates it. Args: diff --git a/minisky/traffic/autopilot.py b/minisky/traffic/autopilot.py index bad6012..b976485 100644 --- a/minisky/traffic/autopilot.py +++ b/minisky/traffic/autopilot.py @@ -53,7 +53,7 @@ class Autopilot(TrafficArrays): engaged, from the route stored in the per-aircraft [`Route`][minisky.traffic.route.Route] objects. Waypoint switching is event driven (see wppassingcheck()), while the continuous guidance in update() is fully vectorized over all - aircraft. Accessible at runtime as `minisky.traf.ap`. + aircraft. Accessible as [`runtime.traffic.ap`][minisky.traffic.autopilot.Autopilot]. Attributes: trk (ndarray): Commanded track angle [deg]. @@ -454,7 +454,7 @@ def wppassingcheck(self, qdr: Any, dist: Any) -> None: + self.route[iac].wpxtorta[iwp] ) # last term zero for active waypoint RTA - # Set minisky.traf.actwp.spd to RTA speed, if necessary + # Set self.traffic.actwp.spd to RTA speed, if necessary self.setspeedforRTA(iac, self.traffic.actwp.torta[iac], dist2go4rta) # If VNAV speed is on (by default coupled to VNAV), use it for speed guidance @@ -509,7 +509,7 @@ def update(self) -> None: # # When Top of Descent (ToD) switch is on, descend as late as possible, # But when Top of Climb switch is on or off, climb as soon as possible, only difference is steepness used in ComputeVNAV - # to calculate minisky.traf.actwp.vs + # to calculate self.traffic.actwp.vs startdescorclimb = (self.traffic.actwp.nextaltco >= -0.1) * np.logical_or( (self.traffic.alt > self.traffic.actwp.nextaltco) @@ -536,12 +536,12 @@ def update(self) -> None: # Recalculate V/S based on current altitude and distance to next altitude constraint # How much time do we have before we need to descend? # Now done in ComputeVNAV - # See ComputeVNAV for minisky.traf.actwp.vs calculation + # See ComputeVNAV for self.traffic.actwp.vs calculation self.vnavvs = np.where(self.swvnavvs, self.traffic.actwp.vs, self.vnavvs) - # was: self.vnavvs = np.where(self.swvnavvs, self.steepness * minisky.traf.gs, self.vnavvs) + # was: self.vnavvs = np.where(self.swvnavvs, self.steepness * self.traffic.gs, self.vnavvs) - # self.vs = np.where(self.swvnavvs, self.vnavvs, self.vsdef * minisky.traf.limvs_flag) + # self.vs = np.where(self.swvnavvs, self.vnavvs, self.vsdef * self.traffic.limvs_flag) # for VNAV use fixed V/S and change start of descent selvs = np.where(abs(self.traffic.selvs) > 0.1, self.traffic.selvs, self.vsdef) # m/s self.vs = np.where(self.swvnavvs, self.vnavvs, selvs) @@ -698,7 +698,7 @@ def ComputeVNAV(self, idx: int, toalt: Any, xtoalt: Any, torta: Any, xtorta: Any Output if this function: self.dist2vs = distance 2 next waypoint where climb/descent needs to activated - minisky.traf.actwp.vs = V/S to be used during climb/descent part, so when dist2wp bool: # Check if a group with a name exists return groupname in self.groups or groupname == "*" - def group(self, groupname: str = "", *args) -> tuple[bool, str]: + def group(self, groupname: str = "", *args: Any) -> tuple[bool, str]: """Add aircraft to a group, list its members, or list all groups. Implements the GROUP stack command. Without arguments the existing @@ -141,7 +141,7 @@ def delgroup(self, grouparray: Any) -> None: if grouparray.groupname != "*": self.allmasks ^= self.groups.pop(grouparray.groupname) - def ungroup(self, groupname: str, *args) -> tuple[bool, str] | None: + def ungroup(self, groupname: str, *args: Any) -> tuple[bool, str] | None: """Remove members from a group by aircraft index. Implements the UNGROUP stack command. diff --git a/minisky/traffic/trails.py b/minisky/traffic/trails.py index 4d6d393..aa1f97e 100644 --- a/minisky/traffic/trails.py +++ b/minisky/traffic/trails.py @@ -28,7 +28,7 @@ class Trails(TrafficArrays): Segments are kept in a foreground buffer for drawing and can be moved to a background buffer with buffer(). Segment colors fade towards the "old" color over `tcol0` seconds. Available at runtime as - `minisky.traf.trails`. + [`runtime.traffic.trails`][minisky.traffic.trails.Trails]. Attributes: active (bool): Whether trails are recorded and shown. @@ -254,7 +254,7 @@ def clear(self) -> None: self.clearnew() return - def setTrails(self, *args) -> bool | tuple[bool, str]: + def setTrails(self, *args: Any) -> bool | tuple[bool, str]: """Switch trails on/off, or change the trail color of an aircraft. Implements the TRAIL stack command: diff --git a/minisky/traffic/uncertainty.py b/minisky/traffic/uncertainty.py index b4971dc..60d05e2 100644 --- a/minisky/traffic/uncertainty.py +++ b/minisky/traffic/uncertainty.py @@ -27,7 +27,7 @@ class SurveillanceUncertainty(TrafficArrays): Keeps a noisy, periodically refreshed copy of the true aircraft state, representing what surveillance-based systems would observe. Available - at runtime as `minisky.traf.noise`. + as [`runtime.traffic.noise`][minisky.traffic.uncertainty.SurveillanceUncertainty]. Attributes: lastupdate (ndarray): Simulation time of the last broadcast per diff --git a/minisky/traffic/wind.py b/minisky/traffic/wind.py index 081eba5..0677bc1 100644 --- a/minisky/traffic/wind.py +++ b/minisky/traffic/wind.py @@ -3,13 +3,15 @@ Implements a wind field defined by wind vectors at arbitrary lat/lon positions, optionally with altitude profiles. The field is interpolated (inverse-distance weighting horizontally, linear in altitude) to obtain -the wind at any aircraft position. :class:`Windfield` contains the field -data and interpolation; :class:`Wind` adds the stack-command interface +the wind at any aircraft position. [`Windfield`][] contains the field +data and interpolation; [`Wind`][] adds the stack-command interface (WIND to define wind, GETWIND to query it) and is available at runtime as -``minisky.traf.wind``. The traffic model uses the wind to compute ground +[`runtime.traffic.wind`][minisky.traffic.wind.Wind]. The traffic model uses the wind to compute ground speed and track from heading and airspeed. """ +from __future__ import annotations + from typing import Any import numpy as np @@ -92,7 +94,7 @@ def addpointvne( lon: np.ndarray, vnorth: np.ndarray, veast: np.ndarray, - windalt: "np.ndarray | None" = None, + windalt: np.ndarray | None = None, ) -> None: """Add wind vectors given as north/east speed components. @@ -259,7 +261,7 @@ def addpoint( def getdata( self, userlat: Any, userlon: Any, useralt: Any = 0.0 - ) -> "tuple[Any, Any]": # in case no altitude specified and field is 3D, use sea level wind + ) -> tuple[Any, Any]: # in case no altitude specified and field is 3D, use sea level wind """Interpolate the wind field at one or more positions. Uses inverse-distance-squared weighting between the defined wind @@ -408,13 +410,13 @@ def remove(self, idx: int) -> None: # remove a point using the returned index w class Wind(TrafficArrays, Windfield): """Wind field with the stack-command interface of the simulation. - Combines the :class:`Windfield` data and interpolation with the + Combines the [`Windfield`][minisky.traffic.wind.Windfield] data and interpolation with the TrafficArrays machinery so the field is cleared on simulation reset. Implements the WIND (add()) and GETWIND (get()) stack commands. - Available at runtime as ``minisky.traf.wind``. + Available at runtime as [`runtime.traffic.wind`][minisky.traffic.wind.Wind]. """ - def add(self, lat: Lat, lon: Lon, *winddata: float) -> "bool | tuple[bool, str]": + def add(self, lat: Lat, lon: Lon, *winddata: float) -> bool | tuple[bool, str]: """Define a wind vector as part of the 2D or 3D wind field. Implements the WIND stack command. @@ -463,7 +465,7 @@ def add(self, lat: Lat, lon: Lon, *winddata: float) -> "bool | tuple[bool, str]" return True - def get(self, lat: Lat, lon: Lon, alt: Alt | None = None) -> "tuple[bool, str]": + def get(self, lat: Lat, lon: Lon, alt: Alt | None = None) -> tuple[bool, str]: """Get wind at a specified position (and optionally at altitude) Implements the GETWIND stack command. The result is reported as diff --git a/settings.toml b/settings.toml index 0cc7e8a..c3fe47e 100644 --- a/settings.toml +++ b/settings.toml @@ -1,5 +1,3 @@ -prefer_compiled = true # geo functions - asas_dtlookahead = 300 # ASAS lookahead time [sec] asas_pzr = 5 # ASAS horizontal PZ margin [nm] asas_pzh = 1000 # ASAS vertical PZ margin [ft] diff --git a/tests/conftest.py b/tests/conftest.py index 6c1f0ea..815afce 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,62 +1,56 @@ """Shared fixtures for MiniSky integration tests. -Most existing integration tests still exercise the temporary module-level -compatibility aliases (`minisky.traf`, `minisky.sim`, ...), so: -- one explicit runtime is constructed for the test session and activates those - aliases; -- each test gets a clean state via minisky.sim.reset(); -- always access singletons through the module (bs.traf), never via - `from minisky import traf` (that binds None at import time). - -Note on output: scr.echo() truncates the buffer on every call, so -scr.read_output_buffer() only ever returns the *last* echoed message. +One explicit runtime is constructed for the test session. Each test resets the +simulation state before use. Output from `ConsoleIO.echo()` is destructive: +`read_output_buffer()` returns only the most recently echoed message. """ -import pytest +from __future__ import annotations -import minisky +from collections.abc import Callable, Iterator +import pytest -@pytest.fixture(scope="session") -def runtime(): - """Session-wide explicit MiniSky runtime.""" - return minisky.init() +from minisky import MiniSky +from minisky.core.settings import DEFAULT_SETTINGS_FILE, MiniSkySettings @pytest.fixture(scope="session") -def bs(runtime): - """Compatibility module activated for the session runtime.""" - return minisky +def runtime() -> Iterator[MiniSky]: + """Session-wide explicit MiniSky runtime.""" + instance = MiniSky(MiniSkySettings.from_file(DEFAULT_SETTINGS_FILE)) + yield instance + instance.close() @pytest.fixture -def sim(bs): +def sim(runtime: MiniSky): """Fresh simulation state for each test.""" - bs.sim.reset() - bs.scr.read_output_buffer() # drain "Simulation reset" echo - return bs.sim + runtime.simulation.reset() + runtime.console.read_output_buffer() # drain "Simulation reset" echo + return runtime.simulation @pytest.fixture -def run_cmd(bs, sim): +def run_cmd(runtime: MiniSky, sim) -> Callable[..., str]: """Queue a stack command, step the sim, and return the last echoed output.""" - def _run(cmd, steps=1): - minisky.stack.stack(cmd) + def _run(cmd: str, steps: int = 1) -> str: + runtime.commands.stack(cmd) for _ in range(steps): - bs.sim.step() - return bs.scr.read_output_buffer() + runtime.simulation.step() + return runtime.console.read_output_buffer() return _run @pytest.fixture -def step_until(bs): +def step_until(runtime: MiniSky): """Step the simulation until a predicate holds, failing after max_steps.""" - def _step(pred, max_steps=600): + def _step(pred: Callable[[], bool], max_steps: int = 600) -> int: for i in range(max_steps): - bs.sim.step() + runtime.simulation.step() if pred(): return i pytest.fail(f"condition not met within {max_steps} simulation steps") diff --git a/tests/integration/test_conflict.py b/tests/integration/test_conflict.py index 102676b..f4b0eaf 100644 --- a/tests/integration/test_conflict.py +++ b/tests/integration/test_conflict.py @@ -2,154 +2,152 @@ import pytest -import minisky - FT = 0.3048 @pytest.fixture -def converging(bs, run_cmd): +def converging(runtime, run_cmd): """Two converging aircraft at the same flight level (from 2ac_converging.scn).""" run_cmd("ASAS ON") run_cmd("CRE FLIGHT1,B744,0.6655,0.0,180,FL200,290") run_cmd("CRE FLIGHT2,B744,0.4706,0.4706,225,FL200,290") - assert bs.traf.ntraf == 2 + assert runtime.traffic.ntraf == 2 class TestConflictDetection: - def test_converging_pair_detected(self, bs, step_until, converging): - step_until(lambda: len(bs.traf.cd.confpairs) > 0, max_steps=400) - callsigns = {ac for pair in bs.traf.cd.confpairs for ac in pair} + def test_converging_pair_detected(self, runtime, step_until, converging): + step_until(lambda: len(runtime.traffic.cd.confpairs) > 0, max_steps=400) + callsigns = {ac for pair in runtime.traffic.cd.confpairs for ac in pair} assert callsigns == {"FLIGHT1", "FLIGHT2"} - def test_conflict_pairs_symmetric(self, bs, step_until, converging): - step_until(lambda: len(bs.traf.cd.confpairs) > 0, max_steps=400) - pairs = set(bs.traf.cd.confpairs) + def test_conflict_pairs_symmetric(self, runtime, step_until, converging): + step_until(lambda: len(runtime.traffic.cd.confpairs) > 0, max_steps=400) + pairs = set(runtime.traffic.cd.confpairs) for a, b in pairs: assert (b, a) in pairs - def test_tcpa_positive_before_cpa(self, bs, step_until, converging): - step_until(lambda: len(bs.traf.cd.confpairs) > 0, max_steps=400) - assert all(t > 0 for t in bs.traf.cd.tcpa) + def test_tcpa_positive_before_cpa(self, runtime, step_until, converging): + step_until(lambda: len(runtime.traffic.cd.confpairs) > 0, max_steps=400) + assert all(t > 0 for t in runtime.traffic.cd.tcpa) - def test_lookahead_metrics_present(self, bs, step_until, converging): - step_until(lambda: len(bs.traf.cd.confpairs) > 0, max_steps=400) - n = len(bs.traf.cd.confpairs) - assert len(bs.traf.cd.tcpa) == n - assert len(bs.traf.cd.dcpa) == n + def test_lookahead_metrics_present(self, runtime, step_until, converging): + step_until(lambda: len(runtime.traffic.cd.confpairs) > 0, max_steps=400) + n = len(runtime.traffic.cd.confpairs) + assert len(runtime.traffic.cd.tcpa) == n + assert len(runtime.traffic.cd.dcpa) == n class TestResolutionCommands: - def test_reso_off_via_stack(self, bs, run_cmd): + def test_reso_off_via_stack(self, runtime, run_cmd): run_cmd("RESO MVP") - assert bs.traf.cr.activate + assert runtime.traffic.cr.activate output = run_cmd("RESO OFF") - assert not bs.traf.cr.activate + assert not runtime.traffic.cr.activate assert "turned off" in output - def test_reso_status_reports_current_method(self, bs, run_cmd): + def test_reso_status_reports_current_method(self, runtime, run_cmd): run_cmd("RESO MVP") output = run_cmd("RESO") assert "Current CR method: MVP" in output - def test_reso_status_reports_off(self, bs, run_cmd): + def test_reso_status_reports_off(self, runtime, run_cmd): run_cmd("RESO OFF") output = run_cmd("RESO") assert "Current CR method: OFF" in output - def test_rmethh_returns_success_tuple(self, bs, run_cmd): + def test_rmethh_returns_success_tuple(self, runtime, run_cmd): run_cmd("RESO MVP") - result = bs.traf.cr.setresometh("SPD") + result = runtime.traffic.cr.setresometh("SPD") assert result == (True, "Horizontal resolution method set to SPD") - def test_rmethv_returns_success_tuple(self, bs, run_cmd): + def test_rmethv_returns_success_tuple(self, runtime, run_cmd): run_cmd("RESO MVP") - result = bs.traf.cr.setresometv("ON") + result = runtime.traffic.cr.setresometv("ON") assert result == (True, "Vertical resolution method set to ON") - def test_rmethh_via_stack(self, bs, run_cmd): + def test_rmethh_via_stack(self, runtime, run_cmd): run_cmd("RESO MVP") output = run_cmd("RMETHH SPD") assert "Horizontal resolution method set to SPD" in output - assert bs.traf.cr.swresospd - assert not bs.traf.cr.swresohdg + assert runtime.traffic.cr.swresospd + assert not runtime.traffic.cr.swresohdg - def test_rmethv_via_stack(self, bs, run_cmd): + def test_rmethv_via_stack(self, runtime, run_cmd): run_cmd("RESO MVP") output = run_cmd("RMETHV ON") assert "Vertical resolution method set to ON" in output - assert bs.traf.cr.swresovert + assert runtime.traffic.cr.swresovert - def test_rmethh_requires_mvp(self, bs, run_cmd): + def test_rmethh_requires_mvp(self, runtime, run_cmd): output = run_cmd("RMETHH SPD") assert "not available" in output - def test_resooff_report_mentions_resooff(self, bs, sim): - success, message = bs.traf.cr.setresooff() + def test_resooff_report_mentions_resooff(self, runtime, sim): + success, message = runtime.traffic.cr.setresooff() assert success assert "RESOOFF" in message assert "NORESO" not in message class TestDetectionCommands: - def test_zoner_status_query(self, bs, run_cmd): + def test_zoner_status_query(self, runtime, run_cmd): run_cmd("CRE KL204,B744,52,4,45,FL250,350") output = run_cmd("ZONER") assert "Current default PZ radius" in output - def test_zonedh_status_query(self, bs, run_cmd): + def test_zonedh_status_query(self, runtime, run_cmd): run_cmd("CRE KL204,B744,52,4,45,FL250,350") output = run_cmd("ZONEDH") assert "Current default PZ height" in output - def test_sethpz_status_uses_default(self, bs, run_cmd): + def test_sethpz_status_uses_default(self, runtime, run_cmd): run_cmd("CRE KL204,B744,52,4,45,FL250,350") - success, message = bs.traf.cd.sethpz() + success, message = runtime.traffic.cd.sethpz() assert success - assert f"{bs.traf.cd.hpz_def / FT:.2f} ft" in message + assert f"{runtime.traffic.cd.hpz_def / FT:.2f} ft" in message - def test_hpz_default_consistent_after_reset(self, bs, sim): + def test_hpz_default_consistent_after_reset(self, runtime, sim): # reset() must restore the same default as __init__ - assert bs.traf.cd.hpz_def == pytest.approx(minisky.core.settings.asas_pzh * FT) + assert runtime.traffic.cd.hpz_def == pytest.approx(runtime.settings.asas_pzh * FT) - def test_zoner_with_callsign_sets_aircraft_rpz(self, bs, run_cmd): + def test_zoner_with_callsign_sets_aircraft_rpz(self, runtime, run_cmd): # The ZONER/ZONEDH specs had an unparseable "callsign..." token, # so per-aircraft zone sizes could not be set from the stack run_cmd("CRE KL204,B744,52,4,45,FL250,350") out = run_cmd("ZONER 6.0,KL204") assert "Error" not in out - assert bs.traf.cd.rpz[0] == pytest.approx(6.0 * 1852.0) + assert runtime.traffic.cd.rpz[0] == pytest.approx(6.0 * 1852.0) - def test_resooff_with_callsign_sets_flag(self, bs, run_cmd): + def test_resooff_with_callsign_sets_flag(self, runtime, run_cmd): # The RESOOFF/NORESO specs had an unparseable "callsign..." token, # so the per-aircraft variants of these commands never worked run_cmd("CRE KL204,B744,52,4,45,FL250,350") out = run_cmd("RESOOFF KL204") assert "Error" not in out - assert bs.traf.cr.resooffac[0] + assert runtime.traffic.cr.resooffac[0] out = run_cmd("NORESO KL204") assert "Error" not in out - assert bs.traf.cr.noresoac[0] + assert runtime.traffic.cr.noresoac[0] class TestNoConflict: - def test_single_aircraft_no_conflicts(self, bs, run_cmd): + def test_single_aircraft_no_conflicts(self, runtime, run_cmd): run_cmd("ASAS ON") run_cmd("CRE SOLO,A320,52,4,90,FL100,250") for _ in range(50): - bs.sim.step() - assert len(bs.traf.cd.confpairs) == 0 + runtime.simulation.step() + assert len(runtime.traffic.cd.confpairs) == 0 - def test_vertically_separated_aircraft_no_conflict(self, bs, run_cmd): + def test_vertically_separated_aircraft_no_conflict(self, runtime, run_cmd): run_cmd("ASAS ON") # Same converging geometry but 10000 ft apart vertically run_cmd("CRE HIGH1,B744,0.6655,0.0,180,FL300,290") run_cmd("CRE LOW1,B744,0.4706,0.4706,225,FL200,290") for _ in range(100): - bs.sim.step() - assert len(bs.traf.cd.confpairs) == 0 + runtime.simulation.step() + assert len(runtime.traffic.cd.confpairs) == 0 - def test_reset_clears_conflicts(self, bs, step_until, converging): - step_until(lambda: len(bs.traf.cd.confpairs) > 0, max_steps=400) - bs.sim.reset() - assert len(bs.traf.cd.confpairs) == 0 + def test_reset_clears_conflicts(self, runtime, step_until, converging): + step_until(lambda: len(runtime.traffic.cd.confpairs) > 0, max_steps=400) + runtime.simulation.reset() + assert len(runtime.traffic.cd.confpairs) == 0 diff --git a/tests/integration/test_navdata.py b/tests/integration/test_navdata.py index 3c0559c..a4bdeb7 100644 --- a/tests/integration/test_navdata.py +++ b/tests/integration/test_navdata.py @@ -1,13 +1,13 @@ """Integration tests for the navigation database (defwpt/delwpt). -These need an initialized simulator: defwpt/delwpt update the screen -singleton (minisky.scr), and sim.reset() reloads the navdatabase. +These need an initialized runtime: defwpt/delwpt update its console, and +simulation reset reloads the navigation database. """ class TestDefwpt: - def test_defwpt_adds_waypoint(self, bs, sim): - navdb = bs.navdb + def test_defwpt_adds_waypoint(self, runtime, sim): + navdb = runtime.navigation n = len(navdb.wpid) ok, msg = navdb.defwpt("TSTWPT1", 52.0, 4.0, "FIX") assert ok @@ -19,10 +19,10 @@ def test_defwpt_adds_waypoint(self, bs, sim): assert navdb.wplat[idx] == 52.0 assert navdb.wplon[idx] == 4.0 - def test_delwpt_removes_coordinates(self, bs, sim): + def test_delwpt_removes_coordinates(self, runtime, sim): # Regression: delwpt discarded the result of np.delete, so # wplat/wplon kept the deleted waypoint's coordinates - navdb = bs.navdb + navdb = runtime.navigation n = len(navdb.wpid) navdb.defwpt("TSTWPTA", 52.0, 4.0, "FIX") navdb.defwpt("TSTWPTB", 10.0, 20.0, "FIX") @@ -38,10 +38,10 @@ def test_delwpt_removes_coordinates(self, bs, sim): assert navdb.wplat[idx] == 10.0 assert navdb.wplon[idx] == 20.0 - def test_defwpt_delete_via_lon_delete_keyword(self, bs, sim): + def test_defwpt_delete_via_lon_delete_keyword(self, runtime, sim): # Regression: `lon.upper == "DELETE"` (missing call parentheses) # made deletion via the DELETE keyword silently impossible - navdb = bs.navdb + navdb = runtime.navigation n = len(navdb.wpid) navdb.defwpt("TSTWPT2", 52.0, 4.0) @@ -53,17 +53,17 @@ def test_defwpt_delete_via_lon_delete_keyword(self, bs, sim): assert len(navdb.wplat) == n assert len(navdb.wplon) == n - def test_defwpt_delete_via_wptype_del(self, bs, sim): - navdb = bs.navdb + def test_defwpt_delete_via_wptype_del(self, runtime, sim): + navdb = runtime.navigation navdb.defwpt("TSTWPT3", 52.0, 4.0) ok, msg = navdb.defwpt("TSTWPT3", 52.0, 4.0, "DEL") assert ok assert "TSTWPT3" not in navdb.wpid - def test_delwpt_accepts_lowercase_name(self, bs, sim): + def test_delwpt_accepts_lowercase_name(self, runtime, sim): # Regression: delwpt uppercased the name for the existence check but # searched wpid with the raw name, raising ValueError for lowercase input - navdb = bs.navdb + navdb = runtime.navigation navdb.defwpt("TSTWPT4", 52.0, 4.0, "FIX") ok, _ = navdb.delwpt("tstwpt4") assert ok diff --git a/tests/integration/test_route_autopilot.py b/tests/integration/test_route_autopilot.py index b82b913..f0f5329 100644 --- a/tests/integration/test_route_autopilot.py +++ b/tests/integration/test_route_autopilot.py @@ -3,187 +3,188 @@ import pytest from minisky.tools import geo +from minisky.traffic import route as route_commands FT = 0.3048 KTS = 0.514444 @pytest.fixture -def aircraft(bs, run_cmd): +def aircraft(runtime, run_cmd): """A single aircraft at (52, 4) heading east at FL100.""" run_cmd("CRE KL001,A320,52,4,90,FL100,250") - assert bs.traf.ntraf == 1 + assert runtime.traffic.ntraf == 1 return "KL001" class TestAddwpt: - def test_addwpt_by_latlon(self, bs, run_cmd, aircraft): + def test_addwpt_by_latlon(self, runtime, run_cmd, aircraft): run_cmd(f"ADDWPT {aircraft} 52.5,5.0") - route = bs.traf.ap.route[0] + route = runtime.traffic.ap.route[0] assert len(route.wpname) == 1 assert route.wplat[0] == pytest.approx(52.5) assert route.wplon[0] == pytest.approx(5.0) - def test_addwpt_by_navdb_name(self, bs, run_cmd, aircraft): + def test_addwpt_by_navdb_name(self, runtime, run_cmd, aircraft): # SUGOL is a real waypoint near EHAM in the bundled navdata run_cmd(f"ADDWPT {aircraft} SUGOL") - route = bs.traf.ap.route[0] + route = runtime.traffic.ap.route[0] assert len(route.wpname) == 1 assert "SUGOL" in route.wpname[0] - def test_addwpt_multiple_in_order(self, bs, run_cmd, aircraft): + def test_addwpt_multiple_in_order(self, runtime, run_cmd, aircraft): run_cmd(f"ADDWPT {aircraft} 52.5,5.0") run_cmd(f"ADDWPT {aircraft} 53.0,6.0") - route = bs.traf.ap.route[0] + route = runtime.traffic.ap.route[0] assert len(route.wpname) == 2 assert route.wplat == [52.5, 53.0] - def test_addwpt_with_altitude_constraint(self, bs, run_cmd, aircraft): + def test_addwpt_with_altitude_constraint(self, runtime, run_cmd, aircraft): run_cmd(f"ADDWPT {aircraft} 52.5,5.0 FL150") - route = bs.traf.ap.route[0] + route = runtime.traffic.ap.route[0] assert route.wpalt[0] == pytest.approx(15000 * FT, rel=1e-3) - def test_dest_resolves_airport(self, bs, run_cmd, aircraft): + def test_dest_resolves_airport(self, runtime, run_cmd, aircraft): run_cmd(f"DEST {aircraft} EHAM") - route = bs.traf.ap.route[0] + route = runtime.traffic.ap.route[0] # EHAM (Schiphol) is at approximately (52.31, 4.76) assert route.wplat[-1] == pytest.approx(52.31, abs=0.1) assert route.wplon[-1] == pytest.approx(4.76, abs=0.1) class TestLnav: - def test_lnav_turns_toward_waypoint(self, bs, run_cmd, step_until, aircraft): + def test_lnav_turns_toward_waypoint(self, runtime, run_cmd, step_until, aircraft): # Waypoint to the north; aircraft initially heading east run_cmd(f"ADDWPT {aircraft} 54.0,4.0") run_cmd(f"LNAV {aircraft} ON") - assert bs.traf.swlnav[0] + assert runtime.traffic.swlnav[0] def heading_north(): - hdg = bs.traf.hdg[0] % 360.0 + hdg = runtime.traffic.hdg[0] % 360.0 return hdg > 350.0 or hdg < 10.0 step_until(heading_north, max_steps=300) - def test_lnav_off_keeps_heading(self, bs, run_cmd, aircraft): + def test_lnav_off_keeps_heading(self, runtime, run_cmd, aircraft): run_cmd(f"ADDWPT {aircraft} 54.0,4.0") run_cmd(f"LNAV {aircraft} OFF") for _ in range(30): - bs.sim.step() - assert bs.traf.hdg[0] == pytest.approx(90.0, abs=1.0) + runtime.simulation.step() + assert runtime.traffic.hdg[0] == pytest.approx(90.0, abs=1.0) class TestVerticalGuidance: - def test_alt_command_captures_altitude(self, bs, run_cmd, step_until, aircraft): + def test_alt_command_captures_altitude(self, runtime, run_cmd, step_until, aircraft): target = 11000 * FT run_cmd(f"ALT {aircraft} FL110") - step_until(lambda: abs(bs.traf.alt[0] - target) < 50 * FT, max_steps=600) + step_until(lambda: abs(runtime.traffic.alt[0] - target) < 50 * FT, max_steps=600) - def test_vertical_speed_settles_after_capture(self, bs, run_cmd, step_until, aircraft): + def test_vertical_speed_settles_after_capture(self, runtime, run_cmd, step_until, aircraft): target = 11000 * FT run_cmd(f"ALT {aircraft} FL110") - step_until(lambda: abs(bs.traf.alt[0] - target) < 20 * FT, max_steps=600) + step_until(lambda: abs(runtime.traffic.alt[0] - target) < 20 * FT, max_steps=600) for _ in range(20): - bs.sim.step() - assert bs.traf.vs[0] == pytest.approx(0.0, abs=0.5) - assert bs.traf.alt[0] == pytest.approx(target, rel=1e-2) + runtime.simulation.step() + assert runtime.traffic.vs[0] == pytest.approx(0.0, abs=0.5) + assert runtime.traffic.alt[0] == pytest.approx(target, rel=1e-2) - def test_descent(self, bs, run_cmd, step_until, aircraft): + def test_descent(self, runtime, run_cmd, step_until, aircraft): target = 8000 * FT run_cmd(f"ALT {aircraft} FL080") - step_until(lambda: abs(bs.traf.alt[0] - target) < 50 * FT, max_steps=600) + step_until(lambda: abs(runtime.traffic.alt[0] - target) < 50 * FT, max_steps=600) class TestRouteEditing: """Regression tests for route-editing bugs from docs/known-issues.md.""" - def test_addwpt_accepts_string_callsign(self, bs, run_cmd, aircraft): + def test_addwpt_accepts_string_callsign(self, runtime, run_cmd, aircraft): # addwpt() with a callsign string used to crash on the callsign lookup - result = bs.traffic.route.addwpt(bs.traf, aircraft, "52.5,5.0") + result = route_commands.addwpt(runtime.traffic, aircraft, "52.5,5.0") assert result is True - route = bs.traf.ap.route[0] + route = runtime.traffic.ap.route[0] assert route.wplat[0] == pytest.approx(52.5) assert route.wplon[0] == pytest.approx(5.0) - def test_direct_switches_active_waypoint(self, bs, run_cmd, aircraft): + def test_direct_switches_active_waypoint(self, runtime, run_cmd, aircraft): run_cmd(f"ADDWPT {aircraft} 52.5,5.0") run_cmd(f"ADDWPT {aircraft} 53.0,6.0") - route = bs.traf.ap.route[0] - assert bs.traffic.route.direct(bs.traf, 0, route.wpname[1]) is True + route = runtime.traffic.ap.route[0] + assert route_commands.direct(runtime.traffic, 0, route.wpname[1]) is True assert route.iactwp == 1 - assert bs.traf.actwp.lat[0] == pytest.approx(53.0) + assert runtime.traffic.actwp.lat[0] == pytest.approx(53.0) - def test_direct_with_turn_heading_rate(self, bs, run_cmd, aircraft): + def test_direct_with_turn_heading_rate(self, runtime, run_cmd, aircraft): # direct() used bare `pi` in the heading-rate branch (NameError) run_cmd(f"ADDWPT {aircraft} TURNHDG 3") run_cmd(f"ADDWPT {aircraft} 52.5,5.0") # Second waypoint activates the first one via direct() out = run_cmd(f"ADDWPT {aircraft} 53.0,6.0") assert "Error" not in out - route = bs.traf.ap.route[0] + route = runtime.traffic.ap.route[0] assert route.wpturnhdgr == [3.0, 3.0] assert route.iactwp == 0 - assert bs.traf.swlnav[0] + assert runtime.traffic.swlnav[0] - def test_delwpt_active_waypoint_redirects(self, bs, run_cmd, aircraft): + def test_delwpt_active_waypoint_redirects(self, runtime, run_cmd, aircraft): # delwpt() used to call the nonexistent Route.direct method run_cmd(f"ADDWPT {aircraft} 52.5,5.0") run_cmd(f"ADDWPT {aircraft} 53.0,6.0") - route = bs.traf.ap.route[0] + route = runtime.traffic.ap.route[0] first, second = route.wpname out = run_cmd(f"DELWPT {aircraft} {first}") assert "Error" not in out assert route.wpname == [second] assert route.iactwp == 0 - assert bs.traf.actwp.lat[0] == pytest.approx(53.0) + assert runtime.traffic.actwp.lat[0] == pytest.approx(53.0) - def test_at_wpt_sets_alt_and_spd_constraints(self, bs, run_cmd, aircraft): + def test_at_wpt_sets_alt_and_spd_constraints(self, runtime, run_cmd, aircraft): # The alt/spd branch wrote the speed into the altitude constraint # and called the nonexistent Route.direct method run_cmd(f"ADDWPT {aircraft} 52.5,5.0") run_cmd(f"ADDWPT {aircraft} 53.0,6.0") - route = bs.traf.ap.route[0] - result = bs.traffic.route.at_wpt(bs.traf, 0, route.wpname[1], "FL090/250") + route = runtime.traffic.ap.route[0] + result = route_commands.at_wpt(runtime.traffic, 0, route.wpname[1], "FL090/250") assert result is True assert route.wpalt[1] == pytest.approx(9000 * FT, rel=1e-3) assert route.wpspd[1] == pytest.approx(250 * KTS, rel=1e-3) - def test_lnav_reengage_issues_direct(self, bs, run_cmd, aircraft): + def test_lnav_reengage_issues_direct(self, runtime, run_cmd, aircraft): # setLNAV used to call the nonexistent Route.direct method run_cmd(f"ADDWPT {aircraft} 52.5,5.0") run_cmd(f"ADDWPT {aircraft} 53.0,6.0") run_cmd(f"LNAV {aircraft} OFF") - assert not bs.traf.swlnav[0] + assert not runtime.traffic.swlnav[0] out = run_cmd(f"LNAV {aircraft} ON") assert "Error" not in out - assert bs.traf.swlnav[0] + assert runtime.traffic.swlnav[0] - def test_at_via_stack_sets_constraints(self, bs, run_cmd, aircraft): + def test_at_via_stack_sets_constraints(self, runtime, run_cmd, aircraft): # The AT registration used help text as its argument spec, so the # command never reached at_wpt() from the stack run_cmd(f"ADDWPT {aircraft} 52.5,5.0") run_cmd(f"ADDWPT {aircraft} 53.0,6.0") - route = bs.traf.ap.route[0] + route = runtime.traffic.ap.route[0] out = run_cmd(f"{aircraft} AT {route.wpname[1]} FL090/250") assert "Error" not in out assert route.wpalt[1] == pytest.approx(9000 * FT, rel=1e-3) assert route.wpspd[1] == pytest.approx(250 * KTS, rel=1e-3) - def test_direct_via_stack(self, bs, run_cmd, aircraft): + def test_direct_via_stack(self, runtime, run_cmd, aircraft): # The DIRECT argument spec had a stray space (" wpt"), dropping the # waypoint parameter so DIRECT always rejected its second argument run_cmd(f"ADDWPT {aircraft} 52.5,5.0") run_cmd(f"ADDWPT {aircraft} 53.0,6.0") - route = bs.traf.ap.route[0] + route = runtime.traffic.ap.route[0] out = run_cmd(f"DIRECT {aircraft} {route.wpname[1]}") assert "Error" not in out assert route.iactwp == 1 - def test_after_and_before_via_stack(self, bs, run_cmd, aircraft): + def test_after_and_before_via_stack(self, runtime, run_cmd, aircraft): # AFTER/BEFORE specs contained unparseable tokens, and the ADDWPT # keyword parameter shadowed the addwpt() function run_cmd(f"ADDWPT {aircraft} EH007") run_cmd(f"ADDWPT {aircraft} HELEN") - route = bs.traf.ap.route[0] + route = runtime.traffic.ap.route[0] out = run_cmd(f"{aircraft} AFTER EH007 ADDWPT SPY") assert "Error" not in out out = run_cmd(f"{aircraft} BEFORE HELEN ADDWPT PAM") @@ -192,8 +193,8 @@ def test_after_and_before_via_stack(self, bs, run_cmd, aircraft): class TestStatusQueries: - def test_vnav_query_reports_state(self, bs, run_cmd, aircraft): - # The VNAV query path referenced nonexistent minisky.traf.id + def test_vnav_query_reports_state(self, runtime, run_cmd, aircraft): + # The VNAV query path referenced nonexistent traffic.id run_cmd(f"ADDWPT {aircraft} 52.5,5.0 FL110") run_cmd(f"ADDWPT {aircraft} 53.0,6.0") run_cmd(f"VNAV {aircraft} ON") @@ -203,33 +204,33 @@ def test_vnav_query_reports_state(self, bs, run_cmd, aircraft): out = run_cmd(f"VNAV {aircraft}") assert f"{aircraft}: VNAV is OFF" in out - def test_swtod_status_reflects_switch(self, bs, run_cmd, aircraft): + def test_swtod_status_reflects_switch(self, runtime, run_cmd, aircraft): # SWTOD status output used to read swtoc instead of swtod out = run_cmd(f"SWTOD {aircraft}") assert f"{aircraft}: SWTOD is ON" in out run_cmd(f"SWTOD {aircraft} OFF") - assert bs.traf.ap.swtoc[0] # ToC switch must stay untouched + assert runtime.traffic.ap.swtoc[0] # ToC switch must stay untouched out = run_cmd(f"SWTOD {aircraft}") assert f"{aircraft}: SWTOD is OFF" in out class TestActiveWaypointDefaults: - def test_mcre_initialises_nextaltco_for_all(self, bs, run_cmd): + def test_mcre_initialises_nextaltco_for_all(self, runtime, run_cmd): # ActiveWaypoint.create() used nextaltco[-n] instead of [-n:], # leaving all but one new aircraft without the -999 sentinel run_cmd("MCRE 3") - assert bs.traf.ntraf == 3 - assert (bs.traf.actwp.nextaltco == -999.0).all() + assert runtime.traffic.ntraf == 3 + assert (runtime.traffic.actwp.nextaltco == -999.0).all() class TestGuidanceGeometry: - def test_aircraft_approaches_waypoint_with_lnav(self, bs, run_cmd, step_until, aircraft): + def test_aircraft_approaches_waypoint_with_lnav(self, runtime, run_cmd, step_until, aircraft): wplat, wplon = 52.6, 4.0 run_cmd(f"ADDWPT {aircraft} {wplat},{wplon}") run_cmd(f"LNAV {aircraft} ON") def dist_nm(): - return geo.kwikdist(bs.traf.lat[0], bs.traf.lon[0], wplat, wplon) + return geo.kwikdist(runtime.traffic.lat[0], runtime.traffic.lon[0], wplat, wplon) start = dist_nm() step_until(lambda: dist_nm() < start / 2, max_steps=600) @@ -248,33 +249,33 @@ class TestWaypointSwitching: WPTS = [(52.00, 4.05), (52.03, 4.10), (52.00, 4.15), (52.03, 4.20)] @pytest.fixture - def route(self, bs, run_cmd, aircraft): + def route(self, runtime, run_cmd, aircraft): for lat, lon in self.WPTS: run_cmd(f"ADDWPT {aircraft} {lat},{lon}") run_cmd(f"LNAV {aircraft} ON") run_cmd(f"VNAV {aircraft} ON") - return bs.traf.ap.route[0] + return runtime.traffic.ap.route[0] - def test_switches_through_route_and_disengages_at_end(self, bs, step_until, route): + def test_switches_through_route_and_disengages_at_end(self, runtime, step_until, route): assert route.iactwp == 0 for target in range(1, len(self.WPTS)): step_until(lambda target=target: route.iactwp == target, max_steps=200) - assert bs.traf.actwp.lat[0] == pytest.approx(self.WPTS[target][0]) - assert bs.traf.actwp.lon[0] == pytest.approx(self.WPTS[target][1]) + assert runtime.traffic.actwp.lat[0] == pytest.approx(self.WPTS[target][0]) + assert runtime.traffic.actwp.lon[0] == pytest.approx(self.WPTS[target][1]) # Passing the final waypoint switches LNAV and VNAV off - step_until(lambda: not bs.traf.swlnav[0], max_steps=200) - assert not bs.traf.swvnav[0] + step_until(lambda: not runtime.traffic.swlnav[0], max_steps=200) + assert not runtime.traffic.swvnav[0] - def test_next_qdr_matches_next_leg_bearing(self, bs, step_until, route): + def test_next_qdr_matches_next_leg_bearing(self, runtime, step_until, route): step_until(lambda: route.iactwp == 1, max_steps=200) expected, _ = geo.qdrdist(*self.WPTS[1], *self.WPTS[2]) - assert bs.traf.actwp.next_qdr[0] == pytest.approx(expected) + assert runtime.traffic.actwp.next_qdr[0] == pytest.approx(expected) - def test_next_qdr_sentinel_on_last_waypoint(self, bs, step_until, route): + def test_next_qdr_sentinel_on_last_waypoint(self, runtime, step_until, route): step_until(lambda: route.iactwp == len(self.WPTS) - 1, max_steps=600) - assert bs.traf.actwp.next_qdr[0] == -999.0 + assert runtime.traffic.actwp.next_qdr[0] == -999.0 - def test_nextturn_data_tracks_upcoming_flyturn(self, bs, run_cmd, step_until, aircraft): + def test_nextturn_data_tracks_upcoming_flyturn(self, runtime, run_cmd, step_until, aircraft): # Waypoint 2 is a fly-turn waypoint with a turn speed; 0, 1 and 3 are fly-by run_cmd(f"ADDWPT {aircraft} {self.WPTS[0][0]},{self.WPTS[0][1]}") run_cmd(f"ADDWPT {aircraft} {self.WPTS[1][0]},{self.WPTS[1][1]}") @@ -283,24 +284,24 @@ def test_nextturn_data_tracks_upcoming_flyturn(self, bs, run_cmd, step_until, ai run_cmd(f"ADDWPT {aircraft} FLYBY") run_cmd(f"ADDWPT {aircraft} {self.WPTS[3][0]},{self.WPTS[3][1]}") run_cmd(f"LNAV {aircraft} ON") - route = bs.traf.ap.route[0] + route = runtime.traffic.ap.route[0] assert route.wpflyturn == [False, False, True, False] # After passing waypoint 0, the next fly-turn waypoint is index 2 step_until(lambda: route.iactwp == 1, max_steps=200) - assert bs.traf.actwp.nextturnidx[0] == 2 - assert bs.traf.actwp.nextturnlat[0] == pytest.approx(self.WPTS[2][0]) - assert bs.traf.actwp.nextturnlon[0] == pytest.approx(self.WPTS[2][1]) - assert bs.traf.actwp.nextturnspd[0] == pytest.approx(250 * KTS, rel=1e-3) + assert runtime.traffic.actwp.nextturnidx[0] == 2 + assert runtime.traffic.actwp.nextturnlat[0] == pytest.approx(self.WPTS[2][0]) + assert runtime.traffic.actwp.nextturnlon[0] == pytest.approx(self.WPTS[2][1]) + assert runtime.traffic.actwp.nextturnspd[0] == pytest.approx(250 * KTS, rel=1e-3) # The active waypoint itself counts: still index 2 while flying to it step_until(lambda: route.iactwp == 2, max_steps=200) - assert bs.traf.actwp.nextturnidx[0] == 2 + assert runtime.traffic.actwp.nextturnidx[0] == 2 # Once past the fly-turn waypoint there is no upcoming turn: defaults step_until(lambda: route.iactwp == 3, max_steps=600) - assert bs.traf.actwp.nextturnidx[0] == -999 + assert runtime.traffic.actwp.nextturnidx[0] == -999 - def test_no_flyturn_waypoints_gives_defaults(self, bs, step_until, route): + def test_no_flyturn_waypoints_gives_defaults(self, runtime, step_until, route): step_until(lambda: route.iactwp == 1, max_steps=200) - assert bs.traf.actwp.nextturnidx[0] == -999 + assert runtime.traffic.actwp.nextturnidx[0] == -999 diff --git a/tests/integration/test_scenario.py b/tests/integration/test_scenario.py index ea62cf1..ac3ed3c 100644 --- a/tests/integration/test_scenario.py +++ b/tests/integration/test_scenario.py @@ -2,61 +2,59 @@ import pytest -import minisky - FT = 0.3048 class TestIcLoading: - def test_ic_kl204_creates_aircraft(self, bs, run_cmd): + def test_ic_kl204_creates_aircraft(self, runtime, run_cmd): run_cmd("IC scenarios/kl204.scn", steps=2) - assert bs.traf.ntraf == 1 - assert bs.traf.callsign[0] == "KL204" + assert runtime.traffic.ntraf == 1 + assert runtime.traffic.callsign[0] == "KL204" - def test_ic_sets_scenario_name(self, bs, run_cmd): + def test_ic_sets_scenario_name(self, runtime, run_cmd): run_cmd("IC scenarios/kl204.scn", steps=2) - assert minisky.stack.get_scenname() == "kl204" + assert runtime.commands.get_scenname() == "kl204" - def test_ic_missing_file_reports_error(self, bs, run_cmd): + def test_ic_missing_file_reports_error(self, runtime, run_cmd): output = run_cmd("IC scenarios/doesnotexist.scn") assert "not found" in output.lower() - assert bs.traf.ntraf == 0 + assert runtime.traffic.ntraf == 0 - def test_ic_resets_previous_state(self, bs, run_cmd): + def test_ic_resets_previous_state(self, runtime, run_cmd): run_cmd("CRE OLD1,A320,50,3,90,FL100,250") - assert bs.traf.ntraf == 1 + assert runtime.traffic.ntraf == 1 run_cmd("IC scenarios/kl204.scn", steps=2) - assert "OLD1" not in bs.traf.callsign - assert bs.traf.callsign[0] == "KL204" + assert "OLD1" not in runtime.traffic.callsign + assert runtime.traffic.callsign[0] == "KL204" class TestTimedCommands: - def test_timed_commands_fire_at_simtime(self, bs, run_cmd, step_until): + def test_timed_commands_fire_at_simtime(self, runtime, run_cmd, step_until): run_cmd("IC scenarios/kl204.scn", steps=2) # The t=2s commands (ALT FL260, HDG 340) have been processed once # simt reaches 3; at t=3s ADDWPT re-enables LNAV, overriding HDG, # so assert exactly at simt == 3 - step_until(lambda: bs.sim.simt >= 3.0, max_steps=20) - assert bs.traf.selalt[0] == pytest.approx(26000 * FT, rel=1e-3) + step_until(lambda: runtime.simulation.simt >= 3.0, max_steps=20) + assert runtime.traffic.selalt[0] == pytest.approx(26000 * FT, rel=1e-3) # scenario wind makes the commanded track deviate a few degrees from 340 - assert bs.traf.ap.trk[0] == pytest.approx(340.0, abs=5.0) + assert runtime.traffic.ap.trk[0] == pytest.approx(340.0, abs=5.0) - def test_future_commands_not_executed_early(self, bs, run_cmd): + def test_future_commands_not_executed_early(self, runtime, run_cmd): run_cmd("IC scenarios/kl204.scn", steps=2) # Before t=2s the FL260 command must not have fired yet - assert bs.sim.simt < 2.0 - assert bs.traf.selalt[0] == pytest.approx(25000 * FT, rel=1e-3) + assert runtime.simulation.simt < 2.0 + assert runtime.traffic.selalt[0] == pytest.approx(25000 * FT, rel=1e-3) - def test_scenario_waypoint_added(self, bs, run_cmd, step_until): + def test_scenario_waypoint_added(self, runtime, run_cmd, step_until): run_cmd("IC scenarios/kl204.scn", steps=2) # At t=1s the scenario adds waypoint RIVER - step_until(lambda: bs.sim.simt > 2.0, max_steps=20) - route = bs.traf.ap.route[0] + step_until(lambda: runtime.simulation.simt > 2.0, max_steps=20) + route = runtime.traffic.ap.route[0] assert any("RIVER" in name for name in route.wpname) class TestConvergingScenario: - def test_2ac_scenario_produces_conflict(self, bs, run_cmd, step_until): + def test_2ac_scenario_produces_conflict(self, runtime, run_cmd, step_until): run_cmd("IC scenarios/2ac_converging.scn", steps=2) - assert bs.traf.ntraf == 2 - step_until(lambda: len(bs.traf.cd.confpairs) > 0, max_steps=400) + assert runtime.traffic.ntraf == 2 + step_until(lambda: len(runtime.traffic.cd.confpairs) > 0, max_steps=400) diff --git a/tests/integration/test_stack.py b/tests/integration/test_stack.py index 30105e8..b1aee4c 100644 --- a/tests/integration/test_stack.py +++ b/tests/integration/test_stack.py @@ -4,99 +4,97 @@ import pytest -import minisky - FT = 0.3048 KTS = 0.514444 class TestQueueing: - def test_stack_only_queues(self, bs, sim): - minisky.stack.stack("CRE KL204,B744,52,4,45,FL250,350") - assert bs.traf.ntraf == 0 # not executed yet + def test_stack_only_queues(self, runtime, sim): + runtime.commands.stack("CRE KL204,B744,52,4,45,FL250,350") + assert runtime.traffic.ntraf == 0 # not executed yet - def test_command_executes_on_step(self, bs, sim): - minisky.stack.stack("CRE KL204,B744,52,4,45,FL250,350") - bs.sim.step() - assert bs.traf.ntraf == 1 - assert bs.traf.callsign[0] == "KL204" + def test_command_executes_on_step(self, runtime, sim): + runtime.commands.stack("CRE KL204,B744,52,4,45,FL250,350") + runtime.simulation.step() + assert runtime.traffic.ntraf == 1 + assert runtime.traffic.callsign[0] == "KL204" - def test_command_stacked_during_processing_is_kept(self, bs, sim): + def test_command_stacked_during_processing_is_kept(self, runtime, sim): # A stack() call that lands while process() is draining the stack # (e.g. from a plugin I/O thread) must not be lost: commands() detaches # the pending list up front, so late arrivals run on the next step. - minisky.stack.stack("ECHO first") - drain = minisky.stack.Stack.commands() + runtime.commands.stack("ECHO first") + drain = runtime.commands.commands() assert next(drain) == "ECHO first" - minisky.stack.stack("CRE KL204,B744,52,4,45,FL250,350") # racing append + runtime.commands.stack("CRE KL204,B744,52,4,45,FL250,350") # racing append with pytest.raises(StopIteration): next(drain) - bs.sim.step() - assert bs.traf.ntraf == 1 + runtime.simulation.step() + assert runtime.traffic.ntraf == 1 class TestCommands: - def test_cre_via_stack(self, bs, run_cmd): + def test_cre_via_stack(self, runtime, run_cmd): run_cmd("CRE KL204,B744,52,4,45,FL250,350") - assert bs.traf.ntraf == 1 - assert bs.traf.alt[0] == pytest.approx(25000 * FT, rel=1e-3) + assert runtime.traffic.ntraf == 1 + assert runtime.traffic.alt[0] == pytest.approx(25000 * FT, rel=1e-3) - def test_pos_outputs_callsign(self, bs, run_cmd): + def test_pos_outputs_callsign(self, runtime, run_cmd): run_cmd("CRE KL204,B744,52,4,45,FL250,350") output = run_cmd("POS KL204") assert "KL204" in output - def test_bare_callsign_defaults_to_pos(self, bs, run_cmd): + def test_bare_callsign_defaults_to_pos(self, runtime, run_cmd): run_cmd("CRE KL204,B744,52,4,45,FL250,350") output = run_cmd("KL204") assert "KL204" in output - def test_alt_sets_selected_altitude(self, bs, run_cmd): + def test_alt_sets_selected_altitude(self, runtime, run_cmd): run_cmd("CRE KL204,B744,52,4,45,FL250,350") run_cmd("ALT KL204 FL260") - assert bs.traf.selalt[0] == pytest.approx(26000 * FT, rel=1e-3) + assert runtime.traffic.selalt[0] == pytest.approx(26000 * FT, rel=1e-3) - def test_hdg_sets_autopilot_track(self, bs, run_cmd): + def test_hdg_sets_autopilot_track(self, runtime, run_cmd): run_cmd("CRE KL204,B744,52,4,45,FL250,350") run_cmd("HDG KL204 340") - assert bs.traf.ap.trk[0] == pytest.approx(340.0) - assert not bs.traf.swlnav[0] + assert runtime.traffic.ap.trk[0] == pytest.approx(340.0) + assert not runtime.traffic.swlnav[0] - def test_spd_sets_selected_speed(self, bs, run_cmd): + def test_spd_sets_selected_speed(self, runtime, run_cmd): run_cmd("CRE KL204,B744,52,4,45,FL250,350") run_cmd("SPD KL204 300") - assert bs.traf.selspd[0] == pytest.approx(300 * KTS, rel=1e-3) + assert runtime.traffic.selspd[0] == pytest.approx(300 * KTS, rel=1e-3) - def test_del_removes_aircraft(self, bs, run_cmd): + def test_del_removes_aircraft(self, runtime, run_cmd): run_cmd("CRE KL204,B744,52,4,45,FL250,350") - assert bs.traf.ntraf == 1 + assert runtime.traffic.ntraf == 1 run_cmd("DEL KL204") - assert bs.traf.ntraf == 0 + assert runtime.traffic.ntraf == 0 - def test_mcre_via_stack(self, bs, run_cmd): + def test_mcre_via_stack(self, runtime, run_cmd): run_cmd("MCRE 3") - assert bs.traf.ntraf == 3 + assert runtime.traffic.ntraf == 3 class TestReadscn: - def test_short_command_line_survives(self, bs): + def test_short_command_line_survives(self, runtime): # "0:00:00>OP" is only 10 characters; it used to be dropped by a # minimum-length check meant to skip empty lines. - lines = list(minisky.stack.readscn(StringIO("0:00:00>OP\n"))) + lines = list(runtime.commands.readscn(StringIO("0:00:00>OP\n"))) assert lines == [(0.0, "OP")] - def test_blank_and_comment_lines_skipped(self, bs): + def test_blank_and_comment_lines_skipped(self, runtime): scn = StringIO("# a comment\n\n0:00:01>HOLD\n") - lines = list(minisky.stack.readscn(scn)) + lines = list(runtime.commands.readscn(scn)) assert lines == [(1.0, "HOLD")] class TestHelp: - def test_help_writes_command_reference(self, bs, sim, tmp_path, monkeypatch): + def test_help_writes_command_reference(self, runtime, sim, tmp_path, monkeypatch): # HELP >filename writes the reference to ./docs/ monkeypatch.chdir(tmp_path) (tmp_path / "docs").mkdir() - success, msg = minisky.stack.showhelp(">ref.txt") + success, msg = runtime.commands.showhelp(">ref.txt") assert success ref = tmp_path / "docs" / "ref.txt" assert ref.exists(), msg @@ -106,57 +104,55 @@ def test_help_writes_command_reference(self, bs, sim, tmp_path, monkeypatch): class TestVarExplorer: - def test_variable_get_without_index(self, bs, run_cmd): + def test_variable_get_without_index(self, runtime, run_cmd): run_cmd("CRE KL204,B744,52,4,45,FL250,350") - v = minisky.core.varexplorer.findvar("traf.ntraf") + v = runtime.variables.findvar("traf.ntraf") assert v is not None assert v.get() == 1 assert v.get_type() == "int" - def test_variable_get_with_index(self, bs, run_cmd): + def test_variable_get_with_index(self, runtime, run_cmd): run_cmd("CRE KL204,B744,52,4,45,FL250,350") - v = minisky.core.varexplorer.findvar("traf.callsign[0]") + v = runtime.variables.findvar("traf.callsign[0]") assert v is not None assert v.get() == ["KL204"] class TestSynonyms: - def test_airway_synonyms_point_to_pos(self, bs): - cmddict = minisky.stack.Command.cmddict + def test_airway_synonyms_point_to_pos(self, runtime): + cmddict = runtime.commands.cmddict assert cmddict["AIRWAY"] is cmddict["POS"] assert cmddict["AIRWAYS"] is cmddict["POS"] class TestErrors: - def test_unknown_command_echoes_error(self, bs, run_cmd): + def test_unknown_command_echoes_error(self, runtime, run_cmd): output = run_cmd("BOGUSCMD 42") assert "unknown command" in output.lower() - def test_command_on_missing_aircraft_reports_error(self, bs, run_cmd): + def test_command_on_missing_aircraft_reports_error(self, runtime, run_cmd): output = run_cmd("ALT NOSUCH FL100") assert output # some error text is echoed - assert bs.traf.ntraf == 0 + assert runtime.traffic.ntraf == 0 - def test_sim_survives_bad_command(self, bs, run_cmd): + def test_sim_survives_bad_command(self, runtime, run_cmd): run_cmd("THISDOESNOTEXIST") run_cmd("CRE KL204,B744,52,4,45,FL250,350") - assert bs.traf.ntraf == 1 + assert runtime.traffic.ntraf == 1 class TestArgumentSpecs: - def test_all_registered_specs_resolve_to_parsers(self, bs): + def test_all_registered_specs_resolve_to_parsers(self, runtime): # Several commands (AT, DIRECT, AFTER, RESOOFF, ...) were registered # with argument specs containing whitespace or free-form help text; # their parameters were silently dropped, making the commands # unusable from the stack. Every annotation token must resolve to a # parser (or be a documented placeholder). - from minisky.stack import Command, current - - argparsers = current().argument_parser.parsers + argparsers = runtime.commands.argument_parser.parsers placeholders = {"...", "lon", "*"} # consumed by the preceding parser seen = set() bad = [] - for cmd in Command.cmddict.values(): + for cmd in runtime.commands.cmddict.values(): if id(cmd) in seen: continue seen.add(id(cmd)) diff --git a/tests/integration/test_streaming.py b/tests/integration/test_streaming.py index 48548d7..5311566 100644 --- a/tests/integration/test_streaming.py +++ b/tests/integration/test_streaming.py @@ -8,10 +8,11 @@ import pytest +from minisky.simulation import SimulationState from minisky.streaming import STREAM_MAX_HZ, StreamHub, build_snapshot -def test_snapshot_structure_and_units(runtime, bs, sim, run_cmd): +def test_snapshot_structure_and_units(runtime, sim, run_cmd): # Two steps: the first creates the aircraft, the second flips INIT -> OP. run_cmd("CRE KL001 A320 52.0 4.0 90 FL100 250", steps=2) @@ -29,7 +30,7 @@ def test_snapshot_structure_and_units(runtime, bs, sim, run_cmd): "scenname", } assert info["ntraf"] == 1 - assert info["state"] == bs.OP # running after a CRE + assert info["state"] == SimulationState.OP # running after a CRE assert isinstance(info["simutc"], str) ac = snap["acdata"] @@ -42,7 +43,7 @@ def test_snapshot_structure_and_units(runtime, bs, sim, run_cmd): assert ac["inconf"] == [False] -def test_snapshot_is_json_serialisable(runtime, bs, sim, run_cmd): +def test_snapshot_is_json_serialisable(runtime, sim, run_cmd): run_cmd("CRE KL001 A320 52.0 4.0 90 FL100 250") # Must not raise: no numpy scalars leak into the snapshot. json.dumps( @@ -50,20 +51,20 @@ def test_snapshot_is_json_serialisable(runtime, bs, sim, run_cmd): ) -def test_snapshot_empty_when_no_traffic(runtime, bs, sim): +def test_snapshot_empty_when_no_traffic(runtime, sim): snap = build_snapshot(runtime.simulation, runtime.traffic, runtime.runner, runtime.commands) assert snap["siminfo"]["ntraf"] == 0 assert snap["acdata"]["callsign"] == [] assert snap["acdata"]["alt"] == [] -def test_dtmult_sets_runner_speed(bs, sim, run_cmd): +def test_dtmult_sets_runner_speed(runtime, sim, run_cmd): run_cmd("DTMULT 8") - assert bs.runner.speed == 8.0 + assert runtime.runner.speed == 8.0 -def test_dtmult_rejects_non_positive(bs, sim): - ok, msg = bs.runner.setspeed(0) +def test_dtmult_rejects_non_positive(runtime, sim): + ok, msg = runtime.runner.setspeed(0) assert ok is False assert "positive" in msg.lower() diff --git a/tests/integration/test_tangram_bridge.py b/tests/integration/test_tangram_bridge.py index 5eb23c0..5c3f165 100644 --- a/tests/integration/test_tangram_bridge.py +++ b/tests/integration/test_tangram_bridge.py @@ -3,7 +3,6 @@ import json import time from collections.abc import Callable, Iterator -from types import ModuleType from typing import Any import fakeredis @@ -11,7 +10,8 @@ from redis.client import PubSub from example_plugins.tangram import TangramBridge -from minisky.simulation import Simulation +from minisky import MiniSky +from minisky.simulation import Simulation, SimulationState from minisky.streaming import build_snapshot Observer = tuple[fakeredis.FakeRedis, PubSub] @@ -25,7 +25,7 @@ def redis_server() -> fakeredis.FakeServer: @pytest.fixture def bridge( - runtime, bs: ModuleType, sim: Simulation, redis_server: fakeredis.FakeServer + runtime, sim: Simulation, redis_server: fakeredis.FakeServer ) -> Iterator[TangramBridge]: bridge = TangramBridge( "redis://fake", @@ -81,15 +81,15 @@ def wait_for( def test_snapshot_published( - bs: ModuleType, + runtime: MiniSky, sim: Simulation, bridge: TangramBridge, observer: Observer, step_until: StepUntil, ) -> None: _, pubsub = observer - bs.stack.stack("CRE KL204 B744 52 4 90 FL300 250") - step_until(lambda: bs.traf.ntraf == 1) + runtime.commands.stack("CRE KL204 B744 52 4 90 FL300 250") + step_until(lambda: runtime.traffic.ntraf == 1) bridge.tick() payload = wait_for(pubsub, ":new-data", lambda p: p["count"] == 1) @@ -100,38 +100,41 @@ def test_snapshot_published( def test_command_roundtrip( - bs: ModuleType, + runtime: MiniSky, sim: Simulation, bridge: TangramBridge, observer: Observer, step_until: StepUntil, ) -> None: client, _ = observer - bs.stack.stack("CRE KL204 B744 52 4 90 FL300 250") - step_until(lambda: int(bs.sim.state) == bs.OP) + runtime.commands.stack("CRE KL204 B744 52 4 90 FL300 250") + step_until(lambda: runtime.simulation.state == SimulationState.OP) client.publish("from:minisky:command", json.dumps({"command": "HOLD"})) # The bridge thread stacks the command; the sim applies it on a step. deadline = time.monotonic() + 5.0 - while time.monotonic() < deadline and int(bs.sim.state) != bs.HOLD: - bs.sim.step() + while ( + time.monotonic() < deadline + and runtime.simulation.state != SimulationState.HOLD + ): + runtime.simulation.step() time.sleep(0.02) - assert int(bs.sim.state) == bs.HOLD + assert runtime.simulation.state == SimulationState.HOLD def test_heartbeat_while_paused( - bs: ModuleType, + runtime: MiniSky, sim: Simulation, bridge: TangramBridge, observer: Observer, step_until: StepUntil, ) -> None: _, pubsub = observer - bs.stack.stack("CRE KL204 B744 52 4 90 FL300 250") - step_until(lambda: bs.traf.ntraf == 1) + runtime.commands.stack("CRE KL204 B744 52 4 90 FL300 250") + step_until(lambda: runtime.traffic.ntraf == 1) bridge.tick() - bs.stack.stack("HOLD") - bs.sim.step() + runtime.commands.stack("HOLD") + runtime.simulation.step() # With no further ticks, the bridge must still republish state on its own, # and the refreshed siminfo must reflect the pause. @@ -142,7 +145,7 @@ def test_heartbeat_while_paused( def test_heartbeat_before_any_traffic( - bs: ModuleType, sim: Simulation, bridge: TangramBridge, observer: Observer + runtime: MiniSky, sim: Simulation, bridge: TangramBridge, observer: Observer ) -> None: """A freshly started, idle simulator (INIT, no aircraft, no ticks yet) must still announce itself, or the frontend shows 'simulator offline'.""" @@ -155,9 +158,9 @@ def test_heartbeat_before_any_traffic( def test_console_relay( - bs: ModuleType, sim: Simulation, bridge: TangramBridge, observer: Observer + runtime: MiniSky, sim: Simulation, bridge: TangramBridge, observer: Observer ) -> None: _, pubsub = observer - bs.scr.echo("hello tangram") + runtime.console.echo("hello tangram") payload = wait_for(pubsub, ":console", lambda p: "hello tangram" in p["lines"]) assert payload["lines"] diff --git a/tests/integration/test_traffic.py b/tests/integration/test_traffic.py index d160a5c..f71915a 100644 --- a/tests/integration/test_traffic.py +++ b/tests/integration/test_traffic.py @@ -8,173 +8,173 @@ class TestCreate: - def test_cre_single(self, bs, sim): - ok, msg = bs.traf.cre("KL001", "A320", lat=52.0, lon=4.0, hdg=90, alt=3000, spd=150) + def test_cre_single(self, runtime, sim): + ok, msg = runtime.traffic.cre("KL001", "A320", lat=52.0, lon=4.0, hdg=90, alt=3000, spd=150) assert ok - assert bs.traf.ntraf == 1 - assert bs.traf.callsign[0] == "KL001" - assert bs.traf.lat[0] == pytest.approx(52.0) - assert bs.traf.lon[0] == pytest.approx(4.0) - assert bs.traf.hdg[0] == pytest.approx(90.0) - - def test_cre_lowercase_callsign_is_uppercased(self, bs, sim): - bs.traf.cre("kl002") - assert bs.traf.callsign[0] == "KL002" - - def test_cre_duplicate_callsign_rejected(self, bs, sim): - bs.traf.cre("KL001") - ok, msg = bs.traf.cre("KL001") + assert runtime.traffic.ntraf == 1 + assert runtime.traffic.callsign[0] == "KL001" + assert runtime.traffic.lat[0] == pytest.approx(52.0) + assert runtime.traffic.lon[0] == pytest.approx(4.0) + assert runtime.traffic.hdg[0] == pytest.approx(90.0) + + def test_cre_lowercase_callsign_is_uppercased(self, runtime, sim): + runtime.traffic.cre("kl002") + assert runtime.traffic.callsign[0] == "KL002" + + def test_cre_duplicate_callsign_rejected(self, runtime, sim): + runtime.traffic.cre("KL001") + ok, msg = runtime.traffic.cre("KL001") assert not ok - assert bs.traf.ntraf == 1 + assert runtime.traffic.ntraf == 1 - def test_mcre_multiple(self, bs, sim): - ok, _ = bs.traf.mcre(5) + def test_mcre_multiple(self, runtime, sim): + ok, _ = runtime.traffic.mcre(5) assert ok - assert bs.traf.ntraf == 5 - assert len(set(bs.traf.callsign)) == 5 + assert runtime.traffic.ntraf == 5 + assert len(set(runtime.traffic.callsign)) == 5 - def test_idx_lookup(self, bs, sim): - bs.traf.cre("KL001") - bs.traf.cre("KL002") - assert bs.traf.idx("KL002") == 1 - assert bs.traf.idx("kl001") == 0 - assert bs.traf.idx("MISSING") == -1 + def test_idx_lookup(self, runtime, sim): + runtime.traffic.cre("KL001") + runtime.traffic.cre("KL002") + assert runtime.traffic.idx("KL002") == 1 + assert runtime.traffic.idx("kl001") == 0 + assert runtime.traffic.idx("MISSING") == -1 - def test_cre_defaults_are_25000ft_300kts(self, bs, sim): + def test_cre_defaults_are_25000ft_300kts(self, runtime, sim): # Defaults used to be 25000 m / 300 m/s; they are meant as ft/kts. - bs.traf.cre("KL001") - assert bs.traf.alt[0] == pytest.approx(25000 * FT) - assert bs.traf.cas[0] == pytest.approx(300 * KTS) + runtime.traffic.cre("KL001") + assert runtime.traffic.alt[0] == pytest.approx(25000 * FT) + assert runtime.traffic.cas[0] == pytest.approx(300 * KTS) - def test_cre_via_stack_without_alt_spd_uses_defaults(self, bs, run_cmd): + def test_cre_via_stack_without_alt_spd_uses_defaults(self, runtime, run_cmd): run_cmd("CRE KL204,B744,52,4") - assert bs.traf.ntraf == 1 - assert bs.traf.alt[0] == pytest.approx(25000 * FT, rel=1e-3) - assert bs.traf.cas[0] == pytest.approx(300 * KTS, rel=1e-3) + assert runtime.traffic.ntraf == 1 + assert runtime.traffic.alt[0] == pytest.approx(25000 * FT, rel=1e-3) + assert runtime.traffic.cas[0] == pytest.approx(300 * KTS, rel=1e-3) - def test_cre_echoes_confirmation(self, bs, run_cmd): + def test_cre_echoes_confirmation(self, runtime, run_cmd): # Command results must reach the output buffer (scr.echo), not stdout only out = run_cmd("CRE KL204,B744,52,4,45,FL250,350") assert out == "Aircraft KL204 created" class TestArrays: - def test_array_sizes_consistent(self, bs, sim): - bs.traf.mcre(3) - n = bs.traf.ntraf + def test_array_sizes_consistent(self, runtime, sim): + runtime.traffic.mcre(3) + n = runtime.traffic.ntraf for attr in ("lat", "lon", "alt", "hdg", "tas", "cas", "gs", "vs"): - assert len(getattr(bs.traf, attr)) == n, attr - assert len(bs.traf.callsign) == n + assert len(getattr(runtime.traffic, attr)) == n, attr + assert len(runtime.traffic.callsign) == n - def test_speed_arrays_initialized(self, bs, sim): - bs.traf.cre("KL001", spd=150, alt=3000) - assert bs.traf.tas[0] > 0 - assert bs.traf.gs[0] == pytest.approx(bs.traf.tas[0]) + def test_speed_arrays_initialized(self, runtime, sim): + runtime.traffic.cre("KL001", spd=150, alt=3000) + assert runtime.traffic.tas[0] > 0 + assert runtime.traffic.gs[0] == pytest.approx(runtime.traffic.tas[0]) class TestDelete: - def test_delete_shrinks_arrays(self, bs, sim): - bs.traf.cre("KL001") - bs.traf.cre("KL002") - bs.traf.delete(0) - assert bs.traf.ntraf == 1 - assert bs.traf.callsign[0] == "KL002" - assert len(bs.traf.lat) == 1 + def test_delete_shrinks_arrays(self, runtime, sim): + runtime.traffic.cre("KL001") + runtime.traffic.cre("KL002") + runtime.traffic.delete(0) + assert runtime.traffic.ntraf == 1 + assert runtime.traffic.callsign[0] == "KL002" + assert len(runtime.traffic.lat) == 1 - def test_delete_all(self, bs, sim): - bs.traf.mcre(3) - bs.traf.delete([0, 1, 2]) - assert bs.traf.ntraf == 0 + def test_delete_all(self, runtime, sim): + runtime.traffic.mcre(3) + runtime.traffic.delete([0, 1, 2]) + assert runtime.traffic.ntraf == 0 class TestReset: - def test_sim_reset_clears_traffic(self, bs, sim): - bs.traf.mcre(4) - assert bs.traf.ntraf == 4 - bs.sim.reset() - assert bs.traf.ntraf == 0 - assert len(bs.traf.lat) == 0 - - def test_reset_clears_simtime(self, bs, sim): - bs.traf.cre("KL001") + def test_sim_reset_clears_traffic(self, runtime, sim): + runtime.traffic.mcre(4) + assert runtime.traffic.ntraf == 4 + runtime.simulation.reset() + assert runtime.traffic.ntraf == 0 + assert len(runtime.traffic.lat) == 0 + + def test_reset_clears_simtime(self, runtime, sim): + runtime.traffic.cre("KL001") for _ in range(5): - bs.sim.step() - assert bs.sim.simt > 0 - bs.sim.reset() - assert bs.sim.simt == 0 + runtime.simulation.step() + assert runtime.simulation.simt > 0 + runtime.simulation.reset() + assert runtime.simulation.simt == 0 class TestStep: - def test_step_advances_time_with_traffic(self, bs, sim): - bs.traf.cre("KL001") - bs.sim.step() # INIT -> OP transition + first update - t0 = bs.sim.simt - bs.sim.step() - assert bs.sim.simt == pytest.approx(t0 + bs.sim.simdt) - - def test_no_time_advance_without_traffic(self, bs, sim): - bs.sim.step() - assert bs.sim.simt == 0 - - def test_aircraft_moves_when_stepped(self, bs, sim): - bs.traf.cre("KL001", lat=52.0, lon=4.0, hdg=90, alt=10000 * FT, spd=250) + def test_step_advances_time_with_traffic(self, runtime, sim): + runtime.traffic.cre("KL001") + runtime.simulation.step() # INIT -> OP transition + first update + t0 = runtime.simulation.simt + runtime.simulation.step() + assert runtime.simulation.simt == pytest.approx(t0 + runtime.simulation.simdt) + + def test_no_time_advance_without_traffic(self, runtime, sim): + runtime.simulation.step() + assert runtime.simulation.simt == 0 + + def test_aircraft_moves_when_stepped(self, runtime, sim): + runtime.traffic.cre("KL001", lat=52.0, lon=4.0, hdg=90, alt=10000 * FT, spd=250) for _ in range(10): - bs.sim.step() + runtime.simulation.step() # eastbound: longitude increases, latitude nearly constant - assert bs.traf.lon[0] > 4.0 - assert bs.traf.lat[0] == pytest.approx(52.0, abs=0.05) + assert runtime.traffic.lon[0] > 4.0 + assert runtime.traffic.lat[0] == pytest.approx(52.0, abs=0.05) class TestCreCmd: - def test_clrcrecmd_with_pending_commands(self, bs, run_cmd): + def test_clrcrecmd_with_pending_commands(self, runtime, run_cmd): run_cmd("CRECMD SPD 250") - assert bs.traf.crecmdlist == ["SPD 250"] + assert runtime.traffic.crecmdlist == ["SPD 250"] out = run_cmd("CLRCRECMD") - assert bs.traf.crecmdlist == [] + assert runtime.traffic.crecmdlist == [] assert "All 1 crecmd commands deleted" in out - def test_clrcrecmd_with_empty_list(self, bs, run_cmd): + def test_clrcrecmd_with_empty_list(self, runtime, run_cmd): out = run_cmd("CLRCRECMD") - assert bs.traf.crecmdlist == [] + assert runtime.traffic.crecmdlist == [] assert "CLRCRECMD" in out class TestConditional: - def test_atspd_seeds_condition_with_cas(self, bs, sim): - bs.traf.cre("KL001", alt=25000 * FT, spd=150) - cas, tas = bs.traf.cas[0], bs.traf.tas[0] + def test_atspd_seeds_condition_with_cas(self, runtime, sim): + runtime.traffic.cre("KL001", alt=25000 * FT, spd=150) + cas, tas = runtime.traffic.cas[0], runtime.traffic.tas[0] assert tas > cas # TAS exceeds CAS at altitude # Target between current CAS and TAS: not crossed in CAS terms target = 0.5 * (cas + tas) - bs.traf.cond.atspdcmd(0, target, "KL001 LNAV ON") + runtime.traffic.cond.atspdcmd(0, target, "KL001 LNAV ON") # Seed must be based on CAS, like the comparison in update() - assert bs.traf.cond.lastdif[-1] == pytest.approx(target - cas) + assert runtime.traffic.cond.lastdif[-1] == pytest.approx(target - cas) # The speed did not cross the target, so nothing may trigger - ncond = bs.traf.cond.ncond - bs.traf.cond.update() - assert bs.traf.cond.ncond == ncond - - def test_renameac_updates_pending_conditions(self, bs, sim): - bs.traf.cre("KL001", alt=10000 * FT, spd=150) - bs.traf.cond.ataltcmd(0, 5000 * FT, "KL001 SPD 200") - bs.traf.cond.renameac("KL001", "KL999") - assert "KL999" in bs.traf.cond.id - assert "KL001" not in bs.traf.cond.id + ncond = runtime.traffic.cond.ncond + runtime.traffic.cond.update() + assert runtime.traffic.cond.ncond == ncond + + def test_renameac_updates_pending_conditions(self, runtime, sim): + runtime.traffic.cre("KL001", alt=10000 * FT, spd=150) + runtime.traffic.cond.ataltcmd(0, 5000 * FT, "KL001 SPD 200") + runtime.traffic.cond.renameac("KL001", "KL999") + assert "KL999" in runtime.traffic.cond.id + assert "KL001" not in runtime.traffic.cond.id # Unknown callsign takes the early-return path without errors - bs.traf.cond.renameac("MISSING", "XX123") - assert "XX123" not in bs.traf.cond.id + runtime.traffic.cond.renameac("MISSING", "XX123") + assert "XX123" not in runtime.traffic.cond.id class TestWind: - def test_wind_add_get_roundtrip(self, bs, sim): - wind = bs.traf.wind + def test_wind_add_get_roundtrip(self, runtime, sim): + wind = runtime.traffic.wind assert wind.add(52.0, 4.0, 270.0, 20.0) is True # from 270 deg, 20 kts vn, ve = wind.getdata(52.0, 4.0, 0.0) assert ve == pytest.approx(20 * KTS) # westerly wind blows eastward assert vn == pytest.approx(0.0, abs=1e-9) - def test_windfield_remove_keeps_lat_lon_paired(self, bs, sim): - wind = bs.traf.wind + def test_windfield_remove_keeps_lat_lon_paired(self, runtime, sim): + wind = runtime.traffic.wind wind.addpoint(52.0, 4.0, 270.0, 20.0) idx = wind.addpoint(54.0, 6.0, 180.0, 10.0) wind.remove(idx) @@ -182,79 +182,79 @@ def test_windfield_remove_keeps_lat_lon_paired(self, bs, sim): assert list(wind.lon) == [4.0] # used to become a copy of lat assert wind.winddim == 1 - def test_wind_del_clears_field(self, bs, sim): - wind = bs.traf.wind + def test_wind_del_clears_field(self, runtime, sim): + wind = runtime.traffic.wind wind.add(52.0, 4.0, 270.0, 20.0) assert wind.winddim > 0 assert wind.add(52.0, 4.0, "DEL") is True assert wind.winddim == 0 assert len(wind.lat) == 0 - def test_wind_del_not_shadowed_by_altitude_form(self, bs, sim): - wind = bs.traf.wind + def test_wind_del_not_shadowed_by_altitude_form(self, runtime, sim): + wind = runtime.traffic.wind wind.add(52.0, 4.0, 270.0, 20.0) # With 3+ winddata elements DEL used to fall into the alt/dir/spd branch assert wind.add(52.0, 4.0, "DEL", None, None) is True assert wind.winddim == 0 - def test_wind_via_stack_two_element_form(self, bs, run_cmd): + def test_wind_via_stack_two_element_form(self, runtime, run_cmd): # The WIND spec ran the direction through the altitude parser # (ft -> m), silently mangling WIND lat,lon,dir,spd out = run_cmd("WIND 52,4,270,20") assert "Error" not in out - vn, ve = bs.traf.wind.getdata(52.0, 4.0, 0.0) + vn, ve = runtime.traffic.wind.getdata(52.0, 4.0, 0.0) assert ve == pytest.approx(20 * KTS, rel=1e-6) assert vn == pytest.approx(0.0, abs=1e-9) - def test_wind_del_via_stack(self, bs, run_cmd): + def test_wind_del_via_stack(self, runtime, run_cmd): # WIND lat,lon,DEL used to be rejected by the altitude parser run_cmd("WIND 52,4,270,20") - assert bs.traf.wind.winddim > 0 + assert runtime.traffic.wind.winddim > 0 out = run_cmd("WIND 52,4,DEL") assert "Error" not in out - assert bs.traf.wind.winddim == 0 + assert runtime.traffic.wind.winddim == 0 class TestNoise: - def test_surveillance_noise_differs_per_aircraft(self, bs, sim): - bs.traf.mcre(3) - bs.traf.setnoise(True) - bs.traf.noise.lastupdate[:] = -1.0 # make every aircraft due for update - bs.traf.noise.update() - offsets = bs.traf.noise.lat - bs.traf.lat + def test_surveillance_noise_differs_per_aircraft(self, runtime, sim): + runtime.traffic.mcre(3) + runtime.traffic.setnoise(True) + runtime.traffic.noise.lastupdate[:] = -1.0 # make every aircraft due for update + runtime.traffic.noise.update() + offsets = runtime.traffic.noise.lat - runtime.traffic.lat # One noise sample used to be broadcast to all due aircraft - assert np.unique(offsets).size == bs.traf.ntraf + assert np.unique(offsets).size == runtime.traffic.ntraf - def test_turbulence_registered_in_traffic_tree(self, bs, sim): - assert bs.traf.turbulence in bs.traf._children + def test_turbulence_registered_in_traffic_tree(self, runtime, sim): + assert runtime.traffic.turbulence in runtime.traffic._children - def test_noise_on_via_stack_steps_without_crash(self, bs, run_cmd): + def test_noise_on_via_stack_steps_without_crash(self, runtime, run_cmd): run_cmd("CRE KL001,A320,52,4,90,FL250,300") run_cmd("NOISE ON") - assert bs.traf.turbulence.active + assert runtime.traffic.turbulence.active for _ in range(5): - bs.sim.step() - assert bs.traf.ntraf == 1 + runtime.simulation.step() + assert runtime.traffic.ntraf == 1 class TestTrails: - def test_fresh_trails_object_has_background_buffers(self, bs, sim): + def test_fresh_trails_object_has_background_buffers(self, runtime, sim): from minisky.traffic.trails import Trails - trails = Trails(bs.traf, lambda: bs.sim) + trails = Trails(runtime.traffic, lambda: runtime.simulation) try: assert trails.bgacid == [] # used to exist only after clearbg() assert not hasattr(trails, "pygame") finally: - bs.traf._children.remove(trails) + runtime.traffic._children.remove(trails) - def test_trail_on_update_and_buffer(self, bs, run_cmd): + def test_trail_on_update_and_buffer(self, runtime, run_cmd): run_cmd("CRE KL001,A320,52,4,90,FL250,300") run_cmd("TRAIL ON 1") - assert bs.traf.trails.active + assert runtime.traffic.trails.active for _ in range(5): - bs.sim.step() - assert len(bs.traf.trails.newlat0) > 0 # segments were recorded - bs.traf.trails.buffer() # must not crash on bgacid - assert "KL001" in bs.traf.trails.bgacid + runtime.simulation.step() + assert len(runtime.traffic.trails.newlat0) > 0 # segments were recorded + runtime.traffic.trails.buffer() # must not crash on bgacid + assert "KL001" in runtime.traffic.trails.bgacid run_cmd("TRAIL OFF") # clears all trail data diff --git a/tests/unit/test_areafilter.py b/tests/unit/test_areafilter.py index a537006..397d56b 100644 --- a/tests/unit/test_areafilter.py +++ b/tests/unit/test_areafilter.py @@ -7,75 +7,75 @@ import numpy as np import pytest -from minisky.tools import areafilter +from minisky.tools.areafilter import AreaFilter -@pytest.fixture(autouse=True) -def clean_shapes(): - areafilter.reset() - yield - areafilter.reset() +@pytest.fixture +def area_filter() -> AreaFilter: + return AreaFilter() -def check_single(name, lat, lon, alt=0.0): - return bool(areafilter.checkInside(name, np.array([lat]), np.array([lon]), np.array([alt]))[0]) +def check_single( + area_filter: AreaFilter, name: str, lat: float, lon: float, alt: float = 0.0 +) -> bool: + return bool(area_filter.checkInside(name, np.array([lat]), np.array([lon]), np.array([alt]))[0]) class TestDefineArea: - def test_define_box_and_has_area(self): - ok, msg = areafilter.define_area("BOX1", "BOX", [52.0, 4.0, 53.0, 5.0]) + def test_define_box_and_has_area(self, area_filter): + ok, msg = area_filter.define_area("BOX1", "BOX", [52.0, 4.0, 53.0, 5.0]) assert ok - assert areafilter.has_area("BOX1") + assert area_filter.has_area("BOX1") - def test_unknown_area_absent(self): - assert not areafilter.has_area("NOPE") + def test_unknown_area_absent(self, area_filter): + assert not area_filter.has_area("NOPE") - def test_checkinside_unknown_area_returns_false(self): - result = areafilter.checkInside("NOPE", np.array([52.0]), np.array([4.0]), np.array([0.0])) + def test_checkinside_unknown_area_returns_false(self, area_filter): + result = area_filter.checkInside("NOPE", np.array([52.0]), np.array([4.0]), np.array([0.0])) assert not result.any() - def test_reset_clears_areas(self): - areafilter.define_area("TMP", "BOX", [52.0, 4.0, 53.0, 5.0]) - areafilter.reset() - assert not areafilter.has_area("TMP") + def test_reset_clears_areas(self, area_filter): + area_filter.define_area("TMP", "BOX", [52.0, 4.0, 53.0, 5.0]) + area_filter.reset() + assert not area_filter.has_area("TMP") class TestBox: - def test_inside_and_outside(self): - areafilter.define_area("B", "BOX", [52.0, 4.0, 53.0, 5.0]) - assert check_single("B", 52.5, 4.5) - assert not check_single("B", 51.0, 4.5) - assert not check_single("B", 52.5, 6.0) - - def test_altitude_bounds(self): - areafilter.define_area("B", "BOX", [52.0, 4.0, 53.0, 5.0], top=3000.0, bottom=1000.0) - assert check_single("B", 52.5, 4.5, alt=2000.0) - assert not check_single("B", 52.5, 4.5, alt=500.0) - assert not check_single("B", 52.5, 4.5, alt=5000.0) - - def test_array_input(self): - areafilter.define_area("B", "BOX", [52.0, 4.0, 53.0, 5.0]) + def test_inside_and_outside(self, area_filter): + 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): + 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): + 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 = areafilter.checkInside("B", lat, lon, alt) + inside = area_filter.checkInside("B", lat, lon, alt) assert inside.tolist() == [True, False, True] class TestCircle: - def test_center_inside_far_point_outside(self): + def test_center_inside_far_point_outside(self, area_filter): # 50 NM radius around (52, 4) - areafilter.define_area("C", "CIRCLE", [52.0, 4.0, 50.0]) - assert check_single("C", 52.0, 4.0) + 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("C", 52.5, 4.0) + assert check_single(area_filter, "C", 52.5, 4.0) # 2 deg lat is about 120 NM: outside - assert not check_single("C", 54.0, 4.0) + assert not check_single(area_filter, "C", 54.0, 4.0) class TestPoly: - def test_triangle_centroid_inside(self): + def test_triangle_centroid_inside(self, area_filter): # Triangle (52,4) (53,4) (52.5,5) - areafilter.define_area("P", "POLY", [52.0, 4.0, 53.0, 4.0, 52.5, 5.0]) - assert check_single("P", 52.5, 4.3) - assert not check_single("P", 52.5, 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) From fdb334fc4dd564df8e90bc23b15408bb87c1d34d Mon Sep 17 00:00:00 2001 From: Abraham Cheung <58929011+abc8747@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:30:32 +0800 Subject: [PATCH 12/16] refactor: replace mutable `CASMACHTHR` and make random generators explicit - make `MiniSky` owns independent python/numpy generators, and `SEED` should only affect that runtime - use `typing.Final` for magnetic declination - suppress openap warnings for now --- example_plugins/example.py | 14 +++--- minisky/core/settings.py | 1 - minisky/runtime.py | 13 ++++-- minisky/simulation/simulation.py | 16 +++++-- minisky/stack/commands.py | 2 +- minisky/tools/aero.py | 70 ++++++++++++++-------------- minisky/tools/geo.py | 13 ++++-- minisky/traffic/autopilot.py | 12 ++++- minisky/traffic/performance/coeff.py | 24 +++++----- minisky/traffic/route.py | 22 ++++----- minisky/traffic/traffic.py | 55 ++++++++++++++++++---- minisky/traffic/turbulence.py | 12 +++-- minisky/traffic/uncertainty.py | 14 ++++-- tests/unit/test_aero.py | 4 +- 14 files changed, 168 insertions(+), 104 deletions(-) diff --git a/example_plugins/example.py b/example_plugins/example.py index 053860d..a43f249 100644 --- a/example_plugins/example.py +++ b/example_plugins/example.py @@ -9,7 +9,7 @@ from __future__ import annotations -from random import randint +from random import Random from typing import TYPE_CHECKING, Any import numpy as np @@ -39,7 +39,7 @@ def init_plugin( manager. """ # Instantiate the example entity on this runtime's traffic-array tree. - instance = Example(runtime.traffic) + instance = Example(runtime.traffic, runtime.python_random) # Configuration parameters and lifecycle callbacks. config = { @@ -69,9 +69,10 @@ class Example(plugin.Entity): passed to the constructor. """ - def __init__(self, traffic: Traffic) -> None: + def __init__(self, traffic: Traffic, random: Random) -> None: """Attach the entity to `traffic` and register its passenger array.""" super().__init__(traffic) + self.random = random # Register per-aircraft data arrays. These automatically resize when # aircraft are created or deleted in the owning runtime. @@ -88,16 +89,13 @@ def create(self, n: int = 1) -> None: n: Number of newly created aircraft. """ super().create(n) - self.npassengers[-n:] = [randint(50, 250) for _ in range(n)] + self.npassengers[-n:] = [self.random.randint(50, 250) for _ in range(n)] def update(self) -> None: """Periodic update function called every five simulation seconds.""" if self.traffic.ntraf > 0: total = int(sum(self.npassengers)) - print( - f"Example plugin: {self.traffic.ntraf} aircraft, " - f"{total} total passengers" - ) + print(f"Example plugin: {self.traffic.ntraf} aircraft, {total} total passengers") def passengers(self, callsign: str, count: int = -1) -> tuple[bool, str]: """Set or get the number of passengers on an aircraft. diff --git a/minisky/core/settings.py b/minisky/core/settings.py index af651c9..cf71460 100644 --- a/minisky/core/settings.py +++ b/minisky/core/settings.py @@ -21,7 +21,6 @@ class MiniSkySettings(BaseModel): asas_marh: Annotated[float, Field(), annotated_types.Gt(0)] = 1.05 asas_marv: Annotated[float, Field(), annotated_types.Gt(0)] = 1.05 plugin_path: Annotated[str, Field(), annotated_types.MinLen(1)] = "plugins" - # TODO(abraham): remove when we implement out-of-tree plugins enabled_plugins: tuple[str, ...] = () @classmethod diff --git a/minisky/runtime.py b/minisky/runtime.py index 3803e03..0146f33 100644 --- a/minisky/runtime.py +++ b/minisky/runtime.py @@ -4,6 +4,9 @@ import asyncio from contextlib import suppress +from random import Random + +import numpy as np from minisky.core.settings import MiniSkySettings, data from minisky.core.trafficarrays import ReplaceableManager @@ -24,14 +27,16 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No self.settings = settings self._run_task: asyncio.Task[None] | None = None self._closed = False - self.console = ConsoleIO( - lambda: self.simulation.state == SimulationState.OP - ) + self.python_random = Random() + 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.variables = VariableExplorer() self.traffic = Traffic( settings=settings, + python_random=self.python_random, + numpy_random=self.numpy_random, areas=self.areas, navigation=self.navigation, console=self.console, @@ -66,6 +71,8 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No self.simulation = Simulation( traffic=self.traffic, navigation=self.navigation, + python_random=self.python_random, + numpy_random=self.numpy_random, console=self.console, command_stack=self.commands, areas=self.areas, diff --git a/minisky/simulation/simulation.py b/minisky/simulation/simulation.py index 4687c22..6438d4d 100644 --- a/minisky/simulation/simulation.py +++ b/minisky/simulation/simulation.py @@ -12,7 +12,7 @@ import time from collections.abc import Callable from enum import IntEnum -from random import seed +from random import Random from typing import TYPE_CHECKING, Any import numpy as np @@ -26,6 +26,7 @@ from minisky.tools.navdata import Navdatabase from minisky.traffic import Traffic + class SimulationState(IntEnum): """Simulation lifecycle states.""" @@ -34,6 +35,7 @@ class SimulationState(IntEnum): OP = 2 END = 3 + # Minimum sleep interval MINSLEEP = 1e-3 @@ -65,6 +67,8 @@ def __init__( self, traffic: Traffic, navigation: Navdatabase, + python_random: Random, + numpy_random: np.random.RandomState, console: ConsoleIO, command_stack: CommandStack, areas: AreaFilter, @@ -75,6 +79,8 @@ def __init__( ) -> None: self.traffic = traffic self.navigation = navigation + self.python_random = python_random + self.numpy_random = numpy_random self.console = console self.commands = command_stack self.areas = areas @@ -333,12 +339,12 @@ def setutc(self, *args: str) -> tuple[bool, str]: def setseed(self, value: int) -> None: """Set the random seed for this simulation (stack SEED command). - Seeds both Python's `random` module and NumPy's random generator - so that stochastic scenario elements are reproducible. + Seeds this runtime's Python and NumPy generators so stochastic + scenario elements are reproducible without affecting other runtimes. Args: value: Integer seed value. """ - seed(value) - np.random.seed(value) + self.python_random.seed(value) + self.numpy_random.seed(value) self.console.echo("random seed set") diff --git a/minisky/stack/commands.py b/minisky/stack/commands.py index 18ec90a..584d800 100644 --- a/minisky/stack/commands.py +++ b/minisky/stack/commands.py @@ -147,7 +147,7 @@ def get_commands(command_stack: CommandStack) -> tuple: "Define a box-shaped area", ], "CASMACHTHR": [ - tools.aero.casmachthr, + command_stack.traffic.casmachthr, "float", "CASMACHTHR threshold", """Set a threshold below which speeds should be considered as Mach numbers diff --git a/minisky/tools/aero.py b/minisky/tools/aero.py index d1921d7..409dc1e 100644 --- a/minisky/tools/aero.py +++ b/minisky/tools/aero.py @@ -15,8 +15,8 @@ these use a simplified two-layer ISA (troposphere and lower stratosphere, valid up to approximately 22 km). The scalar variants without prefix use the full multi-layer ISA table. The casormach* functions interpret a -single speed input as either CAS or Mach number, depending on the -CAS/Mach threshold that can be set with the CASMACHTHR command. +single speed input as either CAS or Mach number, using an explicit +CAS/Mach threshold supplied by the owning runtime. """ import numpy as np @@ -45,28 +45,7 @@ beta = -0.0065 # [K/m] ISA temp gradient below tropopause Rearth = 6371000.0 # m Average earth radius a0 = np.sqrt(gamma * R * T0) # sea level speed of sound ISA -casmach_thr = 2 # Threshold below which speeds should -# be considered as Mach numbers in casormach* functions - - -def casmachthr(threshold: float | None = None) -> tuple[bool, str]: - """CASMACHTHR threshold - - Set a threshold below which speeds should be considered as Mach numbers - in CRE(ATE), ADDWPT, and SPD commands. Set to zero if speeds should - never be considered as Mach number (e.g., when simulating drones). - - Argument: - - threshold: CAS speed threshold [m/s] - """ - if threshold is None: - return ( - True, - f"CASMACHTHR: The current CAS/Mach threshold is {casmach_thr} m/s ({casmach_thr / kts} kts", - ) - - globals()["casmach_thr"] = threshold - return True, f"CASMACHTHR: Set CAS/Mach threshold to {threshold}" +DEFAULT_CASMACH_THRESHOLD = 2.0 # @@ -200,6 +179,7 @@ def vmach2tas(mach: np.ndarray, h: np.ndarray) -> np.ndarray: Arguments: - mach: Mach number [-] - h: Altitude [m] + - threshold: Upper bound below which positive speed values are Mach numbers. Returns: - tas: True airspeed [m/s] @@ -307,38 +287,48 @@ def vcas2mach(cas: np.ndarray, h: np.ndarray) -> np.ndarray: return M -def vcasormach(spd: np.ndarray, h: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: +def vcasormach( + spd: np.ndarray, + h: np.ndarray, + threshold: float, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Interpret input speed as either CAS or a Mach number, and return TAS, CAS, and Mach. Arguments: - spd: Airspeed. Interpreted as Mach number [-] when its value is below the CAS/Mach threshold. Otherwise interpreted as CAS [m/s]. - h: Altitude [m] + - threshold: Upper bound below which positive speed values are Mach numbers. Returns: - tas: True airspeed [m/s] - cas: Calibrated airspeed [m/s] - mach: Mach number [-] """ - ismach = np.logical_and(spd > 0.1, spd < casmach_thr) + ismach = np.logical_and(spd > 0.1, spd < threshold) tas = np.where(ismach, vmach2tas(spd, h), vcas2tas(spd, h)) cas = np.where(ismach, vtas2cas(tas, h), spd) mach = np.where(ismach, spd, vtas2mach(tas, h)) return tas, cas, mach -def vcasormach2tas(spd: np.ndarray, h: np.ndarray) -> np.ndarray: +def vcasormach2tas( + spd: np.ndarray, + h: np.ndarray, + threshold: float, +) -> np.ndarray: """Interpret input speed as either CAS or a Mach number, and return TAS. Arguments: - spd: Airspeed. Interpreted as Mach number [-] when its value is below the CAS/Mach threshold. Otherwise interpreted as CAS [m/s]. - h: Altitude [m] + - threshold: Upper bound below which positive speed values are Mach numbers. Returns: - tas: True airspeed [m/s] """ - ismach = np.logical_and(spd > 0.1, spd < casmach_thr) + ismach = np.logical_and(spd > 0.1, spd < threshold) return np.where(ismach, vmach2tas(spd, h), vcas2tas(spd, h)) @@ -681,21 +671,26 @@ def cas2mach(cas: float, h: float) -> float: return M -def casormach(spd: float, h: float) -> tuple[float, float, float]: +def casormach( + spd: float, + h: float, + threshold: float, +) -> tuple[float, float, float]: """Interpret input speed as either CAS or a Mach number (scalar version). - The speed is treated as a Mach number when 0.1 < spd < casmach_thr - (settable with the CASMACHTHR command), and as CAS otherwise. + The speed is treated as a Mach number when 0.1 < spd < threshold + supplied by the caller, and as CAS otherwise. Args: spd: Airspeed: Mach number [-] or calibrated airspeed [m/s]. h: Altitude [m]. + threshold: Upper bound below which positive speed values are Mach numbers. Returns: tuple: (tas, cas, m): true airspeed [m/s], calibrated airspeed [m/s], and Mach number [-]. """ - if 0.1 < spd < casmach_thr: + if 0.1 < spd < threshold: # Interpret spd as Mach number tas = mach2tas(spd, h) cas = mach2cas(spd, h) @@ -708,19 +703,24 @@ def casormach(spd: float, h: float) -> tuple[float, float, float]: return tas, cas, m -def casormach2tas(spd: float, h: float) -> float: +def casormach2tas( + spd: float, + h: float, + threshold: float, +) -> float: """Interpret input speed as either CAS or Mach, and return TAS (scalar version). Args: - spd: Airspeed: Mach number [-] when 0.1 < spd < casmach_thr, + spd: Airspeed: Mach number [-] when 0.1 < spd < threshold, otherwise calibrated airspeed [m/s]. h: Altitude [m]. + threshold: Upper bound below which positive speed values are Mach numbers. Returns: True airspeed [m/s]. """ # Interpret spd as Mach number inside the threshold band, otherwise as CAS - tas = mach2tas(spd, h) if 0.1 < spd < casmach_thr else cas2tas(spd, h) + tas = mach2tas(spd, h) if 0.1 < spd < threshold else cas2tas(spd, h) return tas diff --git a/minisky/tools/geo.py b/minisky/tools/geo.py index 952b987..fe92fd3 100644 --- a/minisky/tools/geo.py +++ b/minisky/tools/geo.py @@ -14,7 +14,7 @@ are in nautical miles unless stated otherwise. """ -from functools import cache +from typing import Final import numpy as np import pandas as pd @@ -27,6 +27,7 @@ # Constants nm = 1852.0 # m 1 nautical mile + def rwgs84(latd: FloatOrArray) -> FloatOrArray: """Calculate the earths radius with WGS'84 geoid definition. @@ -426,7 +427,9 @@ def qdrpos( return np.degrees(lat2), np.degrees(lon2) -def kwikdist(lata: FloatOrArray, lona: FloatOrArray, latb: FloatOrArray, lonb: FloatOrArray) -> FloatOrArray: +def kwikdist( + lata: FloatOrArray, lona: FloatOrArray, latb: FloatOrArray, lonb: FloatOrArray +) -> FloatOrArray: """Quick and dirty distance calculation. Equirectangular (flat-earth) approximation with the mean earth radius; @@ -621,7 +624,7 @@ def magdec(latd, lond) -> float: of the actual data. Axes were regularly spaced at one degree. The direct manual linear interpolation also 6 x times faster. """ - decl_lat_lon = load_magnetic_declination() + decl_lat_lon = MAGNETIC_DECLINATION # Use fact that whole degrees are used as ticks on both lat & lon axis i_lat = min(max(0, int(90.0 - latd)), 180) @@ -644,7 +647,6 @@ def magdec(latd, lond) -> float: return d_hdg -@cache def load_magnetic_declination() -> np.ndarray: """ Called by Init @@ -706,6 +708,9 @@ def load_magnetic_declination() -> np.ndarray: return decl_lat_lon +MAGNETIC_DECLINATION: Final[np.ndarray] = load_magnetic_declination() + + # Command MAGVAR to get magnetic variation at position lat,lon def magdeccmd(latdeg: float, londeg: float) -> tuple[bool, str]: """MAGVAR Get magnetic variation at position lat/lon. diff --git a/minisky/traffic/autopilot.py b/minisky/traffic/autopilot.py index b976485..f2f0f2d 100644 --- a/minisky/traffic/autopilot.py +++ b/minisky/traffic/autopilot.py @@ -580,7 +580,11 @@ def update(self) -> None: # Note that because nextspd comes from the stack, and can be either a mach number or # a calibrated airspeed, it can only be converted from Mach / CAS [kts] to TAS [m/s] # once the altitude is known. - nexttas = vcasormach2tas(self.traffic.actwp.nextspd, self.traffic.alt) + nexttas = vcasormach2tas( + self.traffic.actwp.nextspd, + self.traffic.alt, + self.traffic.casmach_threshold, + ) # dxspdconchg = distaccel(self.traffic.tas, nexttas, self.traffic.perf.axmax) @@ -670,7 +674,11 @@ def update(self) -> None: self.inturn = np.logical_or(useturnspd, inoldturn) # Below crossover altitude: CAS=const, above crossover altitude: Mach = const - self.tas = vcasormach2tas(self.traffic.selspd, self.traffic.alt) + self.tas = vcasormach2tas( + self.traffic.selspd, + self.traffic.alt, + self.traffic.casmach_threshold, + ) def ComputeVNAV(self, idx: int, toalt: Any, xtoalt: Any, torta: Any, xtorta: Any) -> None: """ diff --git a/minisky/traffic/performance/coeff.py b/minisky/traffic/performance/coeff.py index e8d697c..9e5a911 100644 --- a/minisky/traffic/performance/coeff.py +++ b/minisky/traffic/performance/coeff.py @@ -9,6 +9,7 @@ """ import json +import warnings import numpy as np from openap import WRAP, drag, prop @@ -50,24 +51,21 @@ class Coefficient: """ def __init__(self) -> None: - self.actypes_fixwing = prop.available_aircraft( - use_synonym=True - ) # fixed wing types from openap - self.acs_fixwing = self._load_all_fixwing_flavor() - self.limits_fixwing = self._load_all_fixwing_envelop() + with warnings.catch_warnings(action="ignore"): + self.actypes_fixwing = prop.available_aircraft( + use_synonym=True + ) # fixed wing types from openap + self.acs_fixwing = self._load_all_fixwing_flavor() + self.limits_fixwing = self._load_all_fixwing_envelop() - self.acs_rotor = self._load_all_rotor_flavor() - self.limits_rotor = self._load_all_rotor_envelop() - self.actypes_rotor = list(self.acs_rotor.keys()) + self.acs_rotor = self._load_all_rotor_flavor() + self.limits_rotor = self._load_all_rotor_envelop() + self.actypes_rotor = list(self.acs_rotor.keys()) - self.dragpolar_fixwing = self._load_fixedwing_dragpolar() + self.dragpolar_fixwing = self._load_fixedwing_dragpolar() def _load_all_fixwing_flavor(self) -> dict: """Load fixed-wing aircraft and default engine data from OpenAP.""" - import warnings - - warnings.simplefilter("ignore") - # load fixwing aircraft and engine from openap acs = {} # match acs_ with openap native data diff --git a/minisky/traffic/route.py b/minisky/traffic/route.py index 4e8d7b1..c7033ad 100644 --- a/minisky/traffic/route.py +++ b/minisky/traffic/route.py @@ -673,7 +673,9 @@ def calcfp(self) -> None: # Default to 10000 ft to minimize errors, when no alt constraints # are present alt = toalt if self.wptoalt[i] > 0.0 else 10000.0 * ft - legtas = casormach2tas(self.wpspd[i], alt) + legtas = casormach2tas( + self.wpspd[i], alt, self.traffic.casmach_threshold + ) # TODO: account for wind at this position vy adding wind vectors to waypoints? # xtorta stays the same! This leg will not be available for RTA scheduling, so distance @@ -769,9 +771,7 @@ def getnextqdr(self): # ---- following are functions managing the routes ---- -def get_available_name( - data: list, name_: str, callsigns: list[str], len_: int = 2 -) -> str: +def get_available_name(data: list, name_: str, callsigns: list[str], len_: int = 2) -> str: """Make a waypoint name unique by appending a zero-padded number. Checks if the name already exists in the given list (or matches an @@ -801,9 +801,7 @@ def get_available_name( return name_ -def change_wpt_mode( - traffic: Traffic, acidx: int, mode=None, value=None -) -> bool | None: +def change_wpt_mode(traffic: Traffic, acidx: int, mode=None, value=None) -> bool | None: """Change the mode with which ADDWPT adds new waypoints. Implements the ADDWPTMODE stack command. Available modes: FLYBY, @@ -1516,11 +1514,7 @@ def direct(traffic: Traffic, acidx: int, wpname: Wpt) -> bool: turnrad = traffic.tas[acidx] * 360.0 / (2 * math.pi * acrte.wpturnhdgr[wpidx]) else: # nothing specified, use default bank ang;e turnrad = ( - traffic.tas[acidx] - * traffic.tas[acidx] - / math.tan(math.radians(acrte.bank)) - / g0 - / nm + traffic.tas[acidx] * traffic.tas[acidx] / math.tan(math.radians(acrte.bank)) / g0 / nm ) # [nm]default bank angle e.g. 25 deg traffic.actwp.turndist[acidx] = ( @@ -1537,7 +1531,9 @@ def direct(traffic: Traffic, acidx: int, wpname: Wpt) -> bool: return True -def set_rta(traffic: Traffic, acidx: int, wpname: Wpt, time: Time) -> bool: # all arguments of setRTA +def set_rta( + traffic: Traffic, acidx: int, wpname: Wpt, time: Time +) -> bool: # all arguments of setRTA """Set a required time of arrival (RTA) at a route waypoint. Implements the RTA stack command: `RTA acid, wpname, time`. The RTA diff --git a/minisky/traffic/traffic.py b/minisky/traffic/traffic.py index 129b710..c12a295 100644 --- a/minisky/traffic/traffic.py +++ b/minisky/traffic/traffic.py @@ -15,7 +15,7 @@ from __future__ import annotations from collections.abc import Callable, Collection, Iterable, Mapping -from random import randint +from random import Random from typing import TYPE_CHECKING, overload import numpy as np @@ -24,6 +24,7 @@ from minisky.core.trafficarrays import TrafficArrays from minisky.tools import geo from minisky.tools.aero import ( + DEFAULT_CASMACH_THRESHOLD, Rearth, casormach, casormach2tas, @@ -78,6 +79,8 @@ class Traffic(TrafficArrays): Attributes: ntraf (int): Number of aircraft currently in the simulation. + casmach_threshold: Upper bound below which positive speed values are + interpreted as Mach numbers. callsign (list): Aircraft identifier (callsign) strings. typecode (list): ICAO aircraft type designators (e.g. "A320"). lat (ndarray): Latitude [deg]. @@ -131,6 +134,8 @@ class Traffic(TrafficArrays): def __init__( self, settings: MiniSkySettings, + python_random: Random, + numpy_random: np.random.RandomState, areas: AreaFilter, navigation: Navdatabase, console: ConsoleIO, @@ -141,6 +146,8 @@ def __init__( ) -> None: super().__init__() self.settings = settings + self.python_random = python_random + self.numpy_random = numpy_random self.areas = areas self.navigation = navigation self.console = console @@ -150,6 +157,7 @@ def __init__( self.select_implementation = select_implementation self.ntraf = 0 + self.casmach_threshold = DEFAULT_CASMACH_THRESHOLD self.cond = Condition(self, stack_command, console) # Conditional commands list self.wind = Wind() @@ -239,6 +247,23 @@ def __init__( # Default bank angles per flight phase self.bphase = np.deg2rad(np.array([15, 35, 35, 35, 15, 45])) + def casmachthr(self, threshold: float | None = None) -> tuple[bool, str]: + """Get or set this runtime's CAS/Mach interpretation threshold. + + Positive speed values below this threshold are interpreted as Mach + numbers by CRE, MOVE, route, and autopilot speed conversions. + """ + if threshold is None: + return ( + True, + "CASMACHTHR: The current CAS/Mach threshold is " + f"{self.casmach_threshold} m/s " + f"({self.casmach_threshold / kts} kts)", + ) + + self.casmach_threshold = threshold + return True, f"CASMACHTHR: Set CAS/Mach threshold to {threshold}" + @property def command_registry(self) -> Mapping[str, object]: """Return the command registry owned by this runtime.""" @@ -358,17 +383,27 @@ def mcre( """ # Generate random callsigns - idtmp = chr(randint(65, 90)) + chr(randint(65, 90)) + "{:>03}" + idtmp = ( + chr(self.python_random.randint(65, 90)) + + chr(self.python_random.randint(65, 90)) + + "{:>03}" + ) callsign = [idtmp.format(i) for i in range(n)] actype_ = np.array([actype] * n) # Generate random positions - aclat = np.random.rand(n) * (lat_max - lat_min) + lat_min - aclon = np.random.rand(n) * (lon_max - lon_min) + lon_min - achdg = np.random.randint(1, 360, n) - acalt_ = np.full(n, acalt) if acalt is not None else np.random.randint(2000, 39000, n) * ft - acspd_ = np.full(n, acspd) if acspd is not None else np.random.randint(250, 450, n) * kts + aclat = self.numpy_random.rand(n) * (lat_max - lat_min) + lat_min + aclon = self.numpy_random.rand(n) * (lon_max - lon_min) + lon_min + achdg = self.numpy_random.randint(1, 360, n) + acalt_ = ( + np.full(n, acalt) + if acalt is not None + else self.numpy_random.randint(2000, 39000, n) * ft + ) + acspd_ = ( + np.full(n, acspd) if acspd is not None else self.numpy_random.randint(250, 450, n) * kts + ) self.__create_aircraft(np.array(callsign), actype_, aclat, aclon, achdg, acalt_, acspd_) @@ -416,7 +451,7 @@ def __create_aircraft( self.trk[-n:] = hdg # Velocities - self.tas[-n:], self.cas[-n:], self.M[-n:] = vcasormach(spd, alt) + self.tas[-n:], self.cas[-n:], self.M[-n:] = vcasormach(spd, alt, self.casmach_threshold) self.gs[-n:] = self.tas[-n:] hdgrad = np.radians(hdg) self.gsnorth[-n:] = self.tas[-n:] * np.cos(hdgrad) @@ -540,7 +575,7 @@ def creconfs( if spd: # CAS or Mach provided: convert to groundspeed, assuming that # wind at intruder position is similar to wind at ownship position - tas = tasref if spd is None else casormach2tas(spd, acalt) + tas = tasref if spd is None else casormach2tas(spd, acalt, self.casmach_threshold) tasn, tase = tas * np.cos(trk), tas * np.sin(trk) wn, we = self.wind.getdata(latref, lonref, acalt) gsn, gse = tasn + wn, tase + we @@ -877,7 +912,7 @@ def move( if casmach is not None: h = alt if alt is not None else float(self.alt[idx]) - self.tas[idx], self.selspd[idx], _ = casormach(casmach, h) + self.tas[idx], self.selspd[idx], _ = casormach(casmach, h, self.casmach_threshold) if vspd is not None: self.vs[idx] = vspd diff --git a/minisky/traffic/turbulence.py b/minisky/traffic/turbulence.py index e84ec5b..3416eef 100644 --- a/minisky/traffic/turbulence.py +++ b/minisky/traffic/turbulence.py @@ -85,13 +85,19 @@ def update(self) -> None: timescale = np.sqrt(self._get_simulation().simdt) # Horizontal flight direction - turbhf = np.random.normal(0, self.sd[0] * timescale, self.traffic.ntraf) # [m] + turbhf = self.traffic.numpy_random.normal( + 0, self.sd[0] * timescale, self.traffic.ntraf + ) # [m] # Horizontal wing direction - turbhw = np.random.normal(0, self.sd[1] * timescale, self.traffic.ntraf) # [m] + turbhw = self.traffic.numpy_random.normal( + 0, self.sd[1] * timescale, self.traffic.ntraf + ) # [m] # Vertical direction - turbalt = np.random.normal(0, self.sd[2] * timescale, self.traffic.ntraf) # [m] + turbalt = self.traffic.numpy_random.normal( + 0, self.sd[2] * timescale, self.traffic.ntraf + ) # [m] trkrad = np.radians(self.traffic.trk) # Lateral, longitudinal direction diff --git a/minisky/traffic/uncertainty.py b/minisky/traffic/uncertainty.py index 60d05e2..af4fb05 100644 --- a/minisky/traffic/uncertainty.py +++ b/minisky/traffic/uncertainty.py @@ -96,7 +96,7 @@ def create(self, n: int = 1) -> None: """ super().create(n) - self.lastupdate[-n:] = -self.trunctime * np.random.rand(n) + self.lastupdate[-n:] = -self.trunctime * self.traffic.numpy_random.rand(n) self.lat[-n:] = self.traffic.lat[-n:] self.lon[-n:] = self.traffic.lon[-n:] self.alt[-n:] = self.traffic.alt[-n:] @@ -115,9 +115,15 @@ def update(self) -> None: up = np.where(self.lastupdate + self.trunctime < self._get_simulation().simt) nup = len(up[0]) if self.transnoise: - self.lat[up] = self.traffic.lat[up] + np.random.normal(0, self.transerror[0], nup) - self.lon[up] = self.traffic.lon[up] + np.random.normal(0, self.transerror[0], nup) - self.alt[up] = self.traffic.alt[up] + np.random.normal(0, self.transerror[1], nup) + self.lat[up] = self.traffic.lat[up] + self.traffic.numpy_random.normal( + 0, self.transerror[0], nup + ) + self.lon[up] = self.traffic.lon[up] + self.traffic.numpy_random.normal( + 0, self.transerror[0], nup + ) + self.alt[up] = self.traffic.alt[up] + self.traffic.numpy_random.normal( + 0, self.transerror[1], nup + ) else: self.lat[up] = self.traffic.lat[up] self.lon[up] = self.traffic.lon[up] diff --git a/tests/unit/test_aero.py b/tests/unit/test_aero.py index b7e1c93..c75e9e2 100644 --- a/tests/unit/test_aero.py +++ b/tests/unit/test_aero.py @@ -89,12 +89,12 @@ def test_mach_increases_with_altitude_at_constant_tas(self): assert aero.vtas2mach(200.0, 10000.0) > aero.vtas2mach(200.0, 0.0) def test_vcasormach_interprets_small_value_as_mach(self): - tas, cas, mach = aero.vcasormach(0.8, 10000.0) + tas, cas, mach = aero.vcasormach(0.8, 10000.0, aero.DEFAULT_CASMACH_THRESHOLD) assert mach == pytest.approx(0.8, rel=1e-6) assert tas > 200.0 def test_vcasormach_interprets_large_value_as_cas(self): - tas, cas, mach = aero.vcasormach(150.0, 5000.0) + tas, cas, mach = aero.vcasormach(150.0, 5000.0, aero.DEFAULT_CASMACH_THRESHOLD) assert cas == pytest.approx(150.0, rel=1e-6) assert tas > cas From 3fc3a87b2c7200b1624f22e818b5076bdaecf0b0 Mon Sep 17 00:00:00 2001 From: Abraham Cheung <58929011+abc8747@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:56:43 +0800 Subject: [PATCH 13/16] refactor: make runner task cleanup exception-safe --- minisky/runtime.py | 83 ++++++++++++++++++++++++++---------- minisky/simulation/runner.py | 37 +++++++++------- minisky/stack/__init__.py | 3 -- 3 files changed, 81 insertions(+), 42 deletions(-) diff --git a/minisky/runtime.py b/minisky/runtime.py index 0146f33..7b41e12 100644 --- a/minisky/runtime.py +++ b/minisky/runtime.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from contextlib import suppress from random import Random import numpy as np @@ -109,38 +108,76 @@ def start(self) -> asyncio.Task[None]: """Start the simulation runner in an owned asyncio task.""" if self._closed: raise RuntimeError("MiniSky runtime is closed") - if self._run_task is None or self._run_task.done(): - self._run_task = asyncio.create_task(self.run()) + task = self._run_task + if task is not None: + if not task.done(): + return task + self._run_task = None + task.result() + + self._run_task = asyncio.create_task(self.run()) return self._run_task + def _close_resources(self) -> list[Exception]: + if self._closed: + return [] + + errors: list[Exception] = [] + for cleanup in (self.runner.shutdown, self.streaming.close, self.plugins.shutdown): + try: + cleanup() + except Exception as exc: + errors.append(exc) + self._closed = True + return errors + + @staticmethod + def _raise_errors(message: str, errors: list[Exception]) -> None: + if len(errors) == 1: + raise errors[0] + if errors: + raise ExceptionGroup(message, errors) + def close(self) -> None: """Release synchronous resources owned by this runtime.""" - if self._closed: - return - self.runner.shutdown() - self.streaming.close() + errors = self._close_resources() + task = self._run_task try: - self.plugins.shutdown() - finally: - self._closed = True + current = asyncio.current_task() + except RuntimeError: + current = None + + if task is not None and task is not current: + if task.done(): + self._run_task = None + try: + task.result() + except asyncio.CancelledError: + pass + except Exception as exc: + errors.append(exc) + else: + task.cancel() + + self._raise_errors("MiniSky cleanup failed", errors) async def aclose(self) -> None: """Stop the runner task and release all runtime-owned resources.""" - error: BaseException | None = None - try: - self.close() - except BaseException as exc: # cleanup the runner task before re-raising - error = exc - + errors = self._close_resources() task = self._run_task - if task is not None and task is not asyncio.current_task() and not task.done(): - task.cancel() - with suppress(asyncio.CancelledError): + if task is not None and task is not asyncio.current_task(): + if not task.done(): + task.cancel() + try: await task - self._run_task = None - - if error is not None: - raise error + except asyncio.CancelledError: + pass + except Exception as exc: + errors.append(exc) + finally: + self._run_task = None + + self._raise_errors("MiniSky shutdown failed", errors) def __enter__(self) -> MiniSky: """Enter a synchronous runtime lifecycle context.""" diff --git a/minisky/simulation/runner.py b/minisky/simulation/runner.py index aa839e4..7555390 100644 --- a/minisky/simulation/runner.py +++ b/minisky/simulation/runner.py @@ -112,30 +112,35 @@ async def run(self) -> None: target simulation time is reached. The loop exits when `stop` sets `running` to False (and shutdown is allowed). """ + if self.running: + raise RuntimeError("Simulation runner is already running") + self.console.echo("Starting simulation") self.running = True + try: + while self.running: + # Check if jump is active + if self.jump > 0: + update_interval = MIN_UPDATE_INTERVAL - while self.running: - # Check if jump is active - if self.jump > 0: - update_interval = MIN_UPDATE_INTERVAL - - # Check if jump is completed - if self.jump_to <= self.simulation.simt: - self.jump = 0 - self.jump_to = 0 - else: - update_interval = 1 / self.speed + # Check if jump is completed + if self.jump_to <= self.simulation.simt: + self.jump = 0 + self.jump_to = 0 + else: + update_interval = 1 / self.speed - next_time = asyncio.get_event_loop().time() + update_interval + next_time = asyncio.get_event_loop().time() + update_interval - self.simulation.step() + self.simulation.step() - current_time = asyncio.get_event_loop().time() + current_time = asyncio.get_event_loop().time() - sleep_time = max(MIN_UPDATE_INTERVAL, next_time - current_time) + sleep_time = max(MIN_UPDATE_INTERVAL, next_time - current_time) - await asyncio.sleep(sleep_time) + await asyncio.sleep(sleep_time) + finally: + self.running = False self.console.echo("Simulation completed") diff --git a/minisky/stack/__init__.py b/minisky/stack/__init__.py index 105c3c7..4d1fe5f 100644 --- a/minisky/stack/__init__.py +++ b/minisky/stack/__init__.py @@ -370,9 +370,6 @@ def addcommand( f"reimplemented as a {command_type.__name__}" ) - if not inspect.ismethod(func): - func.__stack_cmd__ = cmdobj # type: ignore[reportFunctionMemberAccess] - def _reset_state(self) -> None: """Reset the runtime-owned command queue and scenario state.""" # Stack data From c60ab8198d716ae42e9e8178f068aec6cc81ae30 Mon Sep 17 00:00:00 2001 From: Abraham Cheung <58929011+abc8747@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:38:02 +0800 Subject: [PATCH 14/16] refactor: use cached magnetic declination loading reverts change in fdb334f also add some comments on deferred changes --- minisky/core/settings.py | 9 +++++---- minisky/core/trafficarrays.py | 2 ++ minisky/plugin/plugin.py | 8 ++++++-- minisky/runtime.py | 6 +++++- minisky/stack/__init__.py | 1 + minisky/tools/geo.py | 8 +++----- 6 files changed, 22 insertions(+), 12 deletions(-) diff --git a/minisky/core/settings.py b/minisky/core/settings.py index cf71460..639f8cf 100644 --- a/minisky/core/settings.py +++ b/minisky/core/settings.py @@ -11,8 +11,10 @@ class MiniSkySettings(BaseModel): - """Validated, immutable settings for the MiniSky runtime.""" + """Validated settings for the MiniSky runtime.""" + # TODO(abraham): when we work on issue #24 we should add a [plugin] + # namespace for plugin-specific config and disallow extras model_config = ConfigDict(frozen=True, extra="allow") asas_dtlookahead: Annotated[float, Field(), annotated_types.Ge(0)] = 300.0 @@ -29,13 +31,12 @@ def from_file(cls, path: str | Path) -> MiniSkySettings: with Path(path).expanduser().open("rb") as file: return cls.model_validate(tomllib.load(file)) - +# TODO(abraham): delete this, require users to pass in an explicit path and +# use platformdirs for default loading DEFAULT_SETTINGS_FILE = Path(__file__).parent.parent.parent / "settings.toml" PACKAGE_DATA_DIR = Path(__file__).parent.parent / "data" def data(path: str) -> Path: """Return an absolute path inside the package data directory.""" - # NOTE(abraham): in the case where we need to distribute as a wheel this - # should be removed. return PACKAGE_DATA_DIR / path diff --git a/minisky/core/trafficarrays.py b/minisky/core/trafficarrays.py index 135a674..8077c74 100644 --- a/minisky/core/trafficarrays.py +++ b/minisky/core/trafficarrays.py @@ -253,6 +253,8 @@ def replaceable_base(cls) -> type[TrafficArrays]: return candidate return cls + # TODO(abraham): replace process-wide subclass discovery with plugin declarations + # or ideally remove implementation inheritance altogether @classmethod def derived(cls): """Recursively find all derived classes.""" diff --git a/minisky/plugin/plugin.py b/minisky/plugin/plugin.py index 4828840..bee0c9f 100644 --- a/minisky/plugin/plugin.py +++ b/minisky/plugin/plugin.py @@ -36,6 +36,7 @@ from minisky.stack import CommandStack +# TODO(abraham): split discovered and loaded records so loaded state has no optionals @dataclass class Plugin: """Information about one plugin discovered for one runtime. @@ -139,8 +140,7 @@ def discover(self) -> None: self.console.echo(f"Plugin directory not found: {plugin_path}") return - # TODO(abraham): replace this process-wide sys.path mutation with a - # path-based importer that still supports plugin-local package imports. + # TODO(abraham): replace sys.path mutation with a path-based importer plugin_parent = str(plugin_path.parent) if plugin_parent not in sys.path: sys.path.insert(0, plugin_parent) @@ -210,10 +210,13 @@ def load(self, name: str) -> tuple[bool, str]: if plugin.loaded: return False, f"Plugin {plugin.plugin_name} already loaded" + # TODO(abraham): make loading transactional before adding unload or reload try: # Load and initialize the plugin for this runtime. + # TODO(abraham): isolate module namespaces before supporting unload module = importlib.import_module(plugin.fullname) result = module.init_plugin(self.runtime) + # TODO(abraham): replace dict and tuple returns with a typed plugin plan config = result if isinstance(result, dict) else result[0] stack_functions = ( result[1] if isinstance(result, (tuple, list)) and len(result) > 1 else None @@ -311,6 +314,7 @@ def hold(self) -> None: def shutdown(self) -> None: """Run shutdown callbacks and release all runtime-owned plugin state.""" errors: list[Exception] = [] + # TODO(abraham): own plugin tasks and handles through one async exit stack. for plugin in reversed(tuple(self.loaded_plugins.values())): try: callback = plugin.config.get("shutdown") diff --git a/minisky/runtime.py b/minisky/runtime.py index 7b41e12..072782e 100644 --- a/minisky/runtime.py +++ b/minisky/runtime.py @@ -94,6 +94,7 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No def _stop_runner(self) -> None: self.runner.stop() + # TODO(abraham): load configured plugins during async entry and make this private def load_plugins(self) -> None: """Load plugins enabled in this runtime's settings.""" self.plugins.load_enabled() @@ -104,6 +105,7 @@ async def run(self) -> None: raise RuntimeError("MiniSky runtime is closed") await self.runner.run() + # TODO(abraham): move background task ownership to callers and remove start() def start(self) -> asyncio.Task[None]: """Start the simulation runner in an owned asyncio task.""" if self._closed: @@ -138,8 +140,9 @@ def _raise_errors(message: str, errors: list[Exception]) -> None: if errors: raise ExceptionGroup(message, errors) + # TODO(abraham): remove sync close when teardown has one async ownership path def close(self) -> None: - """Release synchronous resources owned by this runtime.""" + """Release synchronous resources and request runner-task cancellation.""" errors = self._close_resources() task = self._run_task try: @@ -157,6 +160,7 @@ def close(self) -> None: except Exception as exc: errors.append(exc) else: + # only aclose() can await cancellation of an asyncio task task.cancel() self._raise_errors("MiniSky cleanup failed", errors) diff --git a/minisky/stack/__init__.py b/minisky/stack/__init__.py index 4d1fe5f..183cc38 100644 --- a/minisky/stack/__init__.py +++ b/minisky/stack/__init__.py @@ -752,6 +752,7 @@ def stack(self, *cmdlines: str, sender_id: bytes | None = None) -> None: commands separated by ";". sender_id: Optional network route/id of the command sender. """ + # TODO(abraham): replace this list with an owned mailbox? for cmdline in cmdlines: cmdline = cmdline.strip() if cmdline: diff --git a/minisky/tools/geo.py b/minisky/tools/geo.py index fe92fd3..2b7ea8f 100644 --- a/minisky/tools/geo.py +++ b/minisky/tools/geo.py @@ -14,7 +14,7 @@ are in nautical miles unless stated otherwise. """ -from typing import Final +from functools import cache import numpy as np import pandas as pd @@ -624,7 +624,7 @@ def magdec(latd, lond) -> float: of the actual data. Axes were regularly spaced at one degree. The direct manual linear interpolation also 6 x times faster. """ - decl_lat_lon = MAGNETIC_DECLINATION + decl_lat_lon = load_magnetic_declination() # Use fact that whole degrees are used as ticks on both lat & lon axis i_lat = min(max(0, int(90.0 - latd)), 180) @@ -647,6 +647,7 @@ def magdec(latd, lond) -> float: return d_hdg +@cache def load_magnetic_declination() -> np.ndarray: """ Called by Init @@ -708,9 +709,6 @@ def load_magnetic_declination() -> np.ndarray: return decl_lat_lon -MAGNETIC_DECLINATION: Final[np.ndarray] = load_magnetic_declination() - - # Command MAGVAR to get magnetic variation at position lat,lon def magdeccmd(latdeg: float, londeg: float) -> tuple[bool, str]: """MAGVAR Get magnetic variation at position lat/lon. From 14fc7a265853d1f3fb1d237a7c59819baeec37f5 Mon Sep 17 00:00:00 2001 From: Abraham Cheung <58929011+abc8747@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:38:57 +0800 Subject: [PATCH 15/16] test(types): enable pyright for most tests best effort, skipping test_aero.py and test_detection.py for now --- example_plugins/tangram.py | 1 + pyproject.toml | 8 +- tests/_types.py | 16 +++ tests/conftest.py | 8 +- tests/integration/test_conflict.py | 75 +++++++++---- tests/integration/test_navdata.py | 18 ++- tests/integration/test_plugin.py | 29 +++-- tests/integration/test_route_autopilot.py | 128 ++++++++++++++++------ tests/integration/test_scenario.py | 29 +++-- tests/integration/test_stack.py | 55 ++++++---- tests/integration/test_streaming.py | 26 +++-- tests/integration/test_tangram_bridge.py | 7 +- tests/integration/test_traffic.py | 88 +++++++++------ tests/test_api.py | 26 +++-- tests/unit/test_areafilter.py | 18 +-- tests/unit/test_convert.py | 38 +++---- tests/unit/test_geo.py | 36 +++--- tests/unit/test_phase.py | 44 ++++---- tests/unit/test_tangram_plugin.py | 10 +- 19 files changed, 418 insertions(+), 242 deletions(-) create mode 100644 tests/_types.py diff --git a/example_plugins/tangram.py b/example_plugins/tangram.py index 9183373..40c3be1 100644 --- a/example_plugins/tangram.py +++ b/example_plugins/tangram.py @@ -78,6 +78,7 @@ class TangramPluginSettings(BaseModel): # not advancing (paused/init), so the frontend still sees state changes. HEARTBEAT_SECS = 1.0 + def _state_name(state: int) -> str: """Return the enum member name for a serialized simulation state.""" try: diff --git a/pyproject.toml b/pyproject.toml index 7bd5f9f..da0a8e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,8 +75,12 @@ ignore = [ "minisky/plugin/*.py" = ["F401"] # plugin API surface re-exports [tool.pyright] -include = ["minisky", "example_plugins"] -exclude = ["**/__pycache__", "**/node_modules", ".venv", "build", "site"] +include = ["minisky", "example_plugins", "tests"] +exclude = [ + "**/.*", "**/__pycache__", "**/node_modules", ".venv", "build", "site", + # TODO(abraham): these files will need extensive typing, deferring for now + "tests/test_aero.py", "tests/test_detection.py" +] pythonVersion = "3.11" # "standard" catches useful bugs while staying practical for this numpy/scipy-heavy fork. typeCheckingMode = "standard" diff --git a/tests/_types.py b/tests/_types.py new file mode 100644 index 0000000..9f6a6c8 --- /dev/null +++ b/tests/_types.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Protocol + + +class RunCommand(Protocol): + """Execute a queued command after one or more simulation steps.""" + + def __call__(self, cmd: str, steps: int = 1) -> str: ... + + +class StepUntil(Protocol): + """Step the simulation until a predicate succeeds.""" + + def __call__(self, pred: Callable[[], bool], max_steps: int = 600) -> int: ... diff --git a/tests/conftest.py b/tests/conftest.py index 815afce..6dfb175 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,8 @@ from minisky import MiniSky from minisky.core.settings import DEFAULT_SETTINGS_FILE, MiniSkySettings +from minisky.simulation import Simulation +from tests._types import RunCommand, StepUntil @pytest.fixture(scope="session") @@ -24,7 +26,7 @@ def runtime() -> Iterator[MiniSky]: @pytest.fixture -def sim(runtime: MiniSky): +def sim(runtime: MiniSky) -> Simulation: """Fresh simulation state for each test.""" runtime.simulation.reset() runtime.console.read_output_buffer() # drain "Simulation reset" echo @@ -32,7 +34,7 @@ def sim(runtime: MiniSky): @pytest.fixture -def run_cmd(runtime: MiniSky, sim) -> Callable[..., str]: +def run_cmd(runtime: MiniSky, sim: Simulation) -> RunCommand: """Queue a stack command, step the sim, and return the last echoed output.""" def _run(cmd: str, steps: int = 1) -> str: @@ -45,7 +47,7 @@ def _run(cmd: str, steps: int = 1) -> str: @pytest.fixture -def step_until(runtime: MiniSky): +def step_until(runtime: MiniSky) -> StepUntil: """Step the simulation until a predicate holds, failing after max_steps.""" def _step(pred: Callable[[], bool], max_steps: int = 600) -> int: diff --git a/tests/integration/test_conflict.py b/tests/integration/test_conflict.py index f4b0eaf..5c5c1b5 100644 --- a/tests/integration/test_conflict.py +++ b/tests/integration/test_conflict.py @@ -1,12 +1,19 @@ """Integration tests for conflict detection and resolution (ASAS).""" +from __future__ import annotations + import pytest +from minisky import MiniSky +from minisky.simulation import Simulation +from minisky.traffic.asas import MVP +from tests._types import RunCommand, StepUntil + FT = 0.3048 @pytest.fixture -def converging(runtime, run_cmd): +def converging(runtime: MiniSky, run_cmd: RunCommand) -> None: """Two converging aircraft at the same flight level (from 2ac_converging.scn).""" run_cmd("ASAS ON") run_cmd("CRE FLIGHT1,B744,0.6655,0.0,180,FL200,290") @@ -15,22 +22,30 @@ def converging(runtime, run_cmd): class TestConflictDetection: - def test_converging_pair_detected(self, runtime, step_until, converging): + def test_converging_pair_detected( + self, runtime: MiniSky, step_until: StepUntil, converging: None + ) -> None: step_until(lambda: len(runtime.traffic.cd.confpairs) > 0, max_steps=400) callsigns = {ac for pair in runtime.traffic.cd.confpairs for ac in pair} assert callsigns == {"FLIGHT1", "FLIGHT2"} - def test_conflict_pairs_symmetric(self, runtime, step_until, converging): + def test_conflict_pairs_symmetric( + self, runtime: MiniSky, step_until: StepUntil, converging: None + ) -> None: step_until(lambda: len(runtime.traffic.cd.confpairs) > 0, max_steps=400) pairs = set(runtime.traffic.cd.confpairs) for a, b in pairs: assert (b, a) in pairs - def test_tcpa_positive_before_cpa(self, runtime, step_until, converging): + def test_tcpa_positive_before_cpa( + self, runtime: MiniSky, step_until: StepUntil, converging: None + ) -> None: step_until(lambda: len(runtime.traffic.cd.confpairs) > 0, max_steps=400) assert all(t > 0 for t in runtime.traffic.cd.tcpa) - def test_lookahead_metrics_present(self, runtime, step_until, converging): + def test_lookahead_metrics_present( + self, runtime: MiniSky, step_until: StepUntil, converging: None + ) -> None: step_until(lambda: len(runtime.traffic.cd.confpairs) > 0, max_steps=400) n = len(runtime.traffic.cd.confpairs) assert len(runtime.traffic.cd.tcpa) == n @@ -38,79 +53,87 @@ def test_lookahead_metrics_present(self, runtime, step_until, converging): class TestResolutionCommands: - def test_reso_off_via_stack(self, runtime, run_cmd): + def test_reso_off_via_stack(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("RESO MVP") assert runtime.traffic.cr.activate output = run_cmd("RESO OFF") assert not runtime.traffic.cr.activate assert "turned off" in output - def test_reso_status_reports_current_method(self, runtime, run_cmd): + def test_reso_status_reports_current_method( + self, runtime: MiniSky, run_cmd: RunCommand + ) -> None: run_cmd("RESO MVP") output = run_cmd("RESO") assert "Current CR method: MVP" in output - def test_reso_status_reports_off(self, runtime, run_cmd): + def test_reso_status_reports_off(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("RESO OFF") output = run_cmd("RESO") assert "Current CR method: OFF" in output - def test_rmethh_returns_success_tuple(self, runtime, run_cmd): + def test_rmethh_returns_success_tuple(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("RESO MVP") result = runtime.traffic.cr.setresometh("SPD") assert result == (True, "Horizontal resolution method set to SPD") - def test_rmethv_returns_success_tuple(self, runtime, run_cmd): + def test_rmethv_returns_success_tuple(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("RESO MVP") result = runtime.traffic.cr.setresometv("ON") assert result == (True, "Vertical resolution method set to ON") - def test_rmethh_via_stack(self, runtime, run_cmd): + def test_rmethh_via_stack(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("RESO MVP") output = run_cmd("RMETHH SPD") assert "Horizontal resolution method set to SPD" in output + assert isinstance(runtime.traffic.cr, MVP) assert runtime.traffic.cr.swresospd assert not runtime.traffic.cr.swresohdg - def test_rmethv_via_stack(self, runtime, run_cmd): + def test_rmethv_via_stack(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("RESO MVP") output = run_cmd("RMETHV ON") assert "Vertical resolution method set to ON" in output + assert isinstance(runtime.traffic.cr, MVP) assert runtime.traffic.cr.swresovert - def test_rmethh_requires_mvp(self, runtime, run_cmd): + def test_rmethh_requires_mvp(self, runtime: MiniSky, run_cmd: RunCommand) -> None: output = run_cmd("RMETHH SPD") assert "not available" in output - def test_resooff_report_mentions_resooff(self, runtime, sim): - success, message = runtime.traffic.cr.setresooff() + def test_resooff_report_mentions_resooff(self, runtime: MiniSky, sim: Simulation) -> None: + result = runtime.traffic.cr.setresooff() + assert isinstance(result, tuple) + success, message = result assert success assert "RESOOFF" in message assert "NORESO" not in message class TestDetectionCommands: - def test_zoner_status_query(self, runtime, run_cmd): + def test_zoner_status_query(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("CRE KL204,B744,52,4,45,FL250,350") output = run_cmd("ZONER") assert "Current default PZ radius" in output - def test_zonedh_status_query(self, runtime, run_cmd): + def test_zonedh_status_query(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("CRE KL204,B744,52,4,45,FL250,350") output = run_cmd("ZONEDH") assert "Current default PZ height" in output - def test_sethpz_status_uses_default(self, runtime, run_cmd): + def test_sethpz_status_uses_default(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("CRE KL204,B744,52,4,45,FL250,350") success, message = runtime.traffic.cd.sethpz() assert success assert f"{runtime.traffic.cd.hpz_def / FT:.2f} ft" in message - def test_hpz_default_consistent_after_reset(self, runtime, sim): + def test_hpz_default_consistent_after_reset(self, runtime: MiniSky, sim: Simulation) -> None: # reset() must restore the same default as __init__ assert runtime.traffic.cd.hpz_def == pytest.approx(runtime.settings.asas_pzh * FT) - def test_zoner_with_callsign_sets_aircraft_rpz(self, runtime, run_cmd): + def test_zoner_with_callsign_sets_aircraft_rpz( + self, runtime: MiniSky, run_cmd: RunCommand + ) -> None: # The ZONER/ZONEDH specs had an unparseable "callsign..." token, # so per-aircraft zone sizes could not be set from the stack run_cmd("CRE KL204,B744,52,4,45,FL250,350") @@ -118,7 +141,7 @@ def test_zoner_with_callsign_sets_aircraft_rpz(self, runtime, run_cmd): assert "Error" not in out assert runtime.traffic.cd.rpz[0] == pytest.approx(6.0 * 1852.0) - def test_resooff_with_callsign_sets_flag(self, runtime, run_cmd): + def test_resooff_with_callsign_sets_flag(self, runtime: MiniSky, run_cmd: RunCommand) -> None: # The RESOOFF/NORESO specs had an unparseable "callsign..." token, # so the per-aircraft variants of these commands never worked run_cmd("CRE KL204,B744,52,4,45,FL250,350") @@ -131,14 +154,16 @@ def test_resooff_with_callsign_sets_flag(self, runtime, run_cmd): class TestNoConflict: - def test_single_aircraft_no_conflicts(self, runtime, run_cmd): + def test_single_aircraft_no_conflicts(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("ASAS ON") run_cmd("CRE SOLO,A320,52,4,90,FL100,250") for _ in range(50): runtime.simulation.step() assert len(runtime.traffic.cd.confpairs) == 0 - def test_vertically_separated_aircraft_no_conflict(self, runtime, run_cmd): + def test_vertically_separated_aircraft_no_conflict( + self, runtime: MiniSky, run_cmd: RunCommand + ) -> None: run_cmd("ASAS ON") # Same converging geometry but 10000 ft apart vertically run_cmd("CRE HIGH1,B744,0.6655,0.0,180,FL300,290") @@ -147,7 +172,9 @@ def test_vertically_separated_aircraft_no_conflict(self, runtime, run_cmd): runtime.simulation.step() assert len(runtime.traffic.cd.confpairs) == 0 - def test_reset_clears_conflicts(self, runtime, step_until, converging): + def test_reset_clears_conflicts( + self, runtime: MiniSky, step_until: StepUntil, converging: None + ) -> None: step_until(lambda: len(runtime.traffic.cd.confpairs) > 0, max_steps=400) runtime.simulation.reset() assert len(runtime.traffic.cd.confpairs) == 0 diff --git a/tests/integration/test_navdata.py b/tests/integration/test_navdata.py index a4bdeb7..76fb727 100644 --- a/tests/integration/test_navdata.py +++ b/tests/integration/test_navdata.py @@ -4,9 +4,14 @@ simulation reset reloads the navigation database. """ +from __future__ import annotations + +from minisky import MiniSky +from minisky.simulation import Simulation + class TestDefwpt: - def test_defwpt_adds_waypoint(self, runtime, sim): + def test_defwpt_adds_waypoint(self, runtime: MiniSky, sim: Simulation) -> None: navdb = runtime.navigation n = len(navdb.wpid) ok, msg = navdb.defwpt("TSTWPT1", 52.0, 4.0, "FIX") @@ -19,7 +24,7 @@ def test_defwpt_adds_waypoint(self, runtime, sim): assert navdb.wplat[idx] == 52.0 assert navdb.wplon[idx] == 4.0 - def test_delwpt_removes_coordinates(self, runtime, sim): + def test_delwpt_removes_coordinates(self, runtime: MiniSky, sim: Simulation) -> None: # Regression: delwpt discarded the result of np.delete, so # wplat/wplon kept the deleted waypoint's coordinates navdb = runtime.navigation @@ -38,14 +43,15 @@ def test_delwpt_removes_coordinates(self, runtime, sim): assert navdb.wplat[idx] == 10.0 assert navdb.wplon[idx] == 20.0 - def test_defwpt_delete_via_lon_delete_keyword(self, runtime, sim): + def test_defwpt_delete_via_lon_delete_keyword(self, runtime: MiniSky, sim: Simulation) -> None: # Regression: `lon.upper == "DELETE"` (missing call parentheses) # made deletion via the DELETE keyword silently impossible navdb = runtime.navigation n = len(navdb.wpid) navdb.defwpt("TSTWPT2", 52.0, 4.0) - ok, msg = navdb.defwpt("TSTWPT2", 0.0, "delete") + # TODO(abraham): there may be an inherited bug in the following line, ignoring for now + ok, msg = navdb.defwpt("TSTWPT2", 0.0, "delete") # type: ignore assert ok assert "deleted" in msg assert "TSTWPT2" not in navdb.wpid @@ -53,14 +59,14 @@ def test_defwpt_delete_via_lon_delete_keyword(self, runtime, sim): assert len(navdb.wplat) == n assert len(navdb.wplon) == n - def test_defwpt_delete_via_wptype_del(self, runtime, sim): + def test_defwpt_delete_via_wptype_del(self, runtime: MiniSky, sim: Simulation) -> None: navdb = runtime.navigation navdb.defwpt("TSTWPT3", 52.0, 4.0) ok, msg = navdb.defwpt("TSTWPT3", 52.0, 4.0, "DEL") assert ok assert "TSTWPT3" not in navdb.wpid - def test_delwpt_accepts_lowercase_name(self, runtime, sim): + def test_delwpt_accepts_lowercase_name(self, runtime: MiniSky, sim: Simulation) -> None: # Regression: delwpt uppercased the name for the existence check but # searched wpid with the raw name, raising ValueError for lowercase input navdb = runtime.navigation diff --git a/tests/integration/test_plugin.py b/tests/integration/test_plugin.py index 7f77cae..88f6264 100644 --- a/tests/integration/test_plugin.py +++ b/tests/integration/test_plugin.py @@ -6,28 +6,33 @@ import pytest +from minisky import MiniSky +from minisky.plugin.plugin import Plugin +from minisky.simulation import Simulation +from tests._types import RunCommand + class TestDiscovery: - def test_discover_finds_example_plugins(self, runtime): + def test_discover_finds_example_plugins(self, runtime: MiniSky) -> None: runtime.plugins.discover() assert "EXAMPLE" in runtime.plugins.plugins - def test_discovery_does_not_import(self, runtime): + def test_discovery_does_not_import(self, runtime: MiniSky) -> None: plugin = runtime.plugins.plugins["EXAMPLE"] if not plugin.loaded: assert plugin.module is None - def test_manage_plugins_list(self, runtime): + def test_manage_plugins_list(self, runtime: MiniSky) -> None: ok, text = runtime.plugins.manage("LIST") assert ok assert "EXAMPLE" in text - def test_unknown_plugin_load_fails(self, runtime): + def test_unknown_plugin_load_fails(self, runtime: MiniSky) -> None: ok, message = runtime.plugins.load("NOSUCHPLUGIN") assert not ok assert "not found" in message.lower() - def test_discovery_emits_no_deprecation_warning(self, runtime): + def test_discovery_emits_no_deprecation_warning(self, runtime: MiniSky) -> None: with warnings.catch_warnings(): warnings.simplefilter("error", DeprecationWarning) runtime.plugins.discover() @@ -35,7 +40,7 @@ def test_discovery_emits_no_deprecation_warning(self, runtime): @pytest.fixture -def loaded_example(runtime): +def loaded_example(runtime: MiniSky) -> Plugin: """Load the EXAMPLE plugin once into the session runtime.""" plugin = runtime.plugins.plugins["EXAMPLE"] if not plugin.loaded: @@ -45,21 +50,25 @@ def loaded_example(runtime): class TestLoading: - def test_load_registers_plugin(self, runtime, loaded_example): + def test_load_registers_plugin(self, runtime: MiniSky, loaded_example: Plugin) -> None: assert loaded_example.loaded assert "EXAMPLE" in runtime.plugins.loaded_plugins - def test_double_load_rejected(self, runtime, loaded_example): + def test_double_load_rejected(self, runtime: MiniSky, loaded_example: Plugin) -> None: ok, message = runtime.plugins.load("EXAMPLE") assert not ok assert "already loaded" in message.lower() - def test_plugin_stack_command_registered(self, sim, loaded_example, run_cmd): + def test_plugin_stack_command_registered( + self, sim: Simulation, loaded_example: Plugin, run_cmd: RunCommand + ) -> None: run_cmd("CRE KL001,A320,52,4,90,FL100,250") output = run_cmd("PASSENGERS KL001 150") assert "150" in output - def test_plugin_entity_tracks_aircraft(self, sim, loaded_example, run_cmd): + def test_plugin_entity_tracks_aircraft( + self, sim: Simulation, loaded_example: Plugin, run_cmd: RunCommand + ) -> None: run_cmd("CRE KL001,A320,52,4,90,FL100,250") run_cmd("PASSENGERS KL001 42") output = run_cmd("PASSENGERS KL001") diff --git a/tests/integration/test_route_autopilot.py b/tests/integration/test_route_autopilot.py index f0f5329..ca5334c 100644 --- a/tests/integration/test_route_autopilot.py +++ b/tests/integration/test_route_autopilot.py @@ -1,16 +1,22 @@ """Integration tests for route management (ADDWPT/DEST) and autopilot guidance.""" +from __future__ import annotations + +import numpy as np import pytest +from minisky import MiniSky from minisky.tools import geo from minisky.traffic import route as route_commands +from minisky.traffic.route import Route +from tests._types import RunCommand, StepUntil FT = 0.3048 KTS = 0.514444 @pytest.fixture -def aircraft(runtime, run_cmd): +def aircraft(runtime: MiniSky, run_cmd: RunCommand) -> str: """A single aircraft at (52, 4) heading east at FL100.""" run_cmd("CRE KL001,A320,52,4,90,FL100,250") assert runtime.traffic.ntraf == 1 @@ -18,33 +24,41 @@ def aircraft(runtime, run_cmd): class TestAddwpt: - def test_addwpt_by_latlon(self, runtime, run_cmd, aircraft): + def test_addwpt_by_latlon(self, runtime: MiniSky, run_cmd: RunCommand, aircraft: str) -> None: run_cmd(f"ADDWPT {aircraft} 52.5,5.0") route = runtime.traffic.ap.route[0] assert len(route.wpname) == 1 assert route.wplat[0] == pytest.approx(52.5) assert route.wplon[0] == pytest.approx(5.0) - def test_addwpt_by_navdb_name(self, runtime, run_cmd, aircraft): + def test_addwpt_by_navdb_name( + self, runtime: MiniSky, run_cmd: RunCommand, aircraft: str + ) -> None: # SUGOL is a real waypoint near EHAM in the bundled navdata run_cmd(f"ADDWPT {aircraft} SUGOL") route = runtime.traffic.ap.route[0] assert len(route.wpname) == 1 assert "SUGOL" in route.wpname[0] - def test_addwpt_multiple_in_order(self, runtime, run_cmd, aircraft): + def test_addwpt_multiple_in_order( + self, runtime: MiniSky, run_cmd: RunCommand, aircraft: str + ) -> None: run_cmd(f"ADDWPT {aircraft} 52.5,5.0") run_cmd(f"ADDWPT {aircraft} 53.0,6.0") route = runtime.traffic.ap.route[0] assert len(route.wpname) == 2 assert route.wplat == [52.5, 53.0] - def test_addwpt_with_altitude_constraint(self, runtime, run_cmd, aircraft): + def test_addwpt_with_altitude_constraint( + self, runtime: MiniSky, run_cmd: RunCommand, aircraft: str + ) -> None: run_cmd(f"ADDWPT {aircraft} 52.5,5.0 FL150") route = runtime.traffic.ap.route[0] assert route.wpalt[0] == pytest.approx(15000 * FT, rel=1e-3) - def test_dest_resolves_airport(self, runtime, run_cmd, aircraft): + def test_dest_resolves_airport( + self, runtime: MiniSky, run_cmd: RunCommand, aircraft: str + ) -> None: run_cmd(f"DEST {aircraft} EHAM") route = runtime.traffic.ap.route[0] # EHAM (Schiphol) is at approximately (52.31, 4.76) @@ -53,19 +67,23 @@ def test_dest_resolves_airport(self, runtime, run_cmd, aircraft): class TestLnav: - def test_lnav_turns_toward_waypoint(self, runtime, run_cmd, step_until, aircraft): + def test_lnav_turns_toward_waypoint( + self, runtime: MiniSky, run_cmd: RunCommand, step_until: StepUntil, aircraft: str + ) -> None: # Waypoint to the north; aircraft initially heading east run_cmd(f"ADDWPT {aircraft} 54.0,4.0") run_cmd(f"LNAV {aircraft} ON") assert runtime.traffic.swlnav[0] - def heading_north(): + def heading_north() -> bool: hdg = runtime.traffic.hdg[0] % 360.0 return hdg > 350.0 or hdg < 10.0 step_until(heading_north, max_steps=300) - def test_lnav_off_keeps_heading(self, runtime, run_cmd, aircraft): + def test_lnav_off_keeps_heading( + self, runtime: MiniSky, run_cmd: RunCommand, aircraft: str + ) -> None: run_cmd(f"ADDWPT {aircraft} 54.0,4.0") run_cmd(f"LNAV {aircraft} OFF") for _ in range(30): @@ -74,12 +92,16 @@ def test_lnav_off_keeps_heading(self, runtime, run_cmd, aircraft): class TestVerticalGuidance: - def test_alt_command_captures_altitude(self, runtime, run_cmd, step_until, aircraft): + def test_alt_command_captures_altitude( + self, runtime: MiniSky, run_cmd: RunCommand, step_until: StepUntil, aircraft: str + ) -> None: target = 11000 * FT run_cmd(f"ALT {aircraft} FL110") step_until(lambda: abs(runtime.traffic.alt[0] - target) < 50 * FT, max_steps=600) - def test_vertical_speed_settles_after_capture(self, runtime, run_cmd, step_until, aircraft): + def test_vertical_speed_settles_after_capture( + self, runtime: MiniSky, run_cmd: RunCommand, step_until: StepUntil, aircraft: str + ) -> None: target = 11000 * FT run_cmd(f"ALT {aircraft} FL110") step_until(lambda: abs(runtime.traffic.alt[0] - target) < 20 * FT, max_steps=600) @@ -88,7 +110,9 @@ def test_vertical_speed_settles_after_capture(self, runtime, run_cmd, step_until assert runtime.traffic.vs[0] == pytest.approx(0.0, abs=0.5) assert runtime.traffic.alt[0] == pytest.approx(target, rel=1e-2) - def test_descent(self, runtime, run_cmd, step_until, aircraft): + def test_descent( + self, runtime: MiniSky, run_cmd: RunCommand, step_until: StepUntil, aircraft: str + ) -> None: target = 8000 * FT run_cmd(f"ALT {aircraft} FL080") step_until(lambda: abs(runtime.traffic.alt[0] - target) < 50 * FT, max_steps=600) @@ -97,7 +121,9 @@ def test_descent(self, runtime, run_cmd, step_until, aircraft): class TestRouteEditing: """Regression tests for route-editing bugs from docs/known-issues.md.""" - def test_addwpt_accepts_string_callsign(self, runtime, run_cmd, aircraft): + def test_addwpt_accepts_string_callsign( + self, runtime: MiniSky, run_cmd: RunCommand, aircraft: str + ) -> None: # addwpt() with a callsign string used to crash on the callsign lookup result = route_commands.addwpt(runtime.traffic, aircraft, "52.5,5.0") assert result is True @@ -105,7 +131,9 @@ def test_addwpt_accepts_string_callsign(self, runtime, run_cmd, aircraft): assert route.wplat[0] == pytest.approx(52.5) assert route.wplon[0] == pytest.approx(5.0) - def test_direct_switches_active_waypoint(self, runtime, run_cmd, aircraft): + def test_direct_switches_active_waypoint( + self, runtime: MiniSky, run_cmd: RunCommand, aircraft: str + ) -> None: run_cmd(f"ADDWPT {aircraft} 52.5,5.0") run_cmd(f"ADDWPT {aircraft} 53.0,6.0") route = runtime.traffic.ap.route[0] @@ -113,7 +141,9 @@ def test_direct_switches_active_waypoint(self, runtime, run_cmd, aircraft): assert route.iactwp == 1 assert runtime.traffic.actwp.lat[0] == pytest.approx(53.0) - def test_direct_with_turn_heading_rate(self, runtime, run_cmd, aircraft): + def test_direct_with_turn_heading_rate( + self, runtime: MiniSky, run_cmd: RunCommand, aircraft: str + ) -> None: # direct() used bare `pi` in the heading-rate branch (NameError) run_cmd(f"ADDWPT {aircraft} TURNHDG 3") run_cmd(f"ADDWPT {aircraft} 52.5,5.0") @@ -125,7 +155,9 @@ def test_direct_with_turn_heading_rate(self, runtime, run_cmd, aircraft): assert route.iactwp == 0 assert runtime.traffic.swlnav[0] - def test_delwpt_active_waypoint_redirects(self, runtime, run_cmd, aircraft): + def test_delwpt_active_waypoint_redirects( + self, runtime: MiniSky, run_cmd: RunCommand, aircraft: str + ) -> None: # delwpt() used to call the nonexistent Route.direct method run_cmd(f"ADDWPT {aircraft} 52.5,5.0") run_cmd(f"ADDWPT {aircraft} 53.0,6.0") @@ -137,7 +169,9 @@ def test_delwpt_active_waypoint_redirects(self, runtime, run_cmd, aircraft): assert route.iactwp == 0 assert runtime.traffic.actwp.lat[0] == pytest.approx(53.0) - def test_at_wpt_sets_alt_and_spd_constraints(self, runtime, run_cmd, aircraft): + def test_at_wpt_sets_alt_and_spd_constraints( + self, runtime: MiniSky, run_cmd: RunCommand, aircraft: str + ) -> None: # The alt/spd branch wrote the speed into the altitude constraint # and called the nonexistent Route.direct method run_cmd(f"ADDWPT {aircraft} 52.5,5.0") @@ -148,7 +182,9 @@ def test_at_wpt_sets_alt_and_spd_constraints(self, runtime, run_cmd, aircraft): assert route.wpalt[1] == pytest.approx(9000 * FT, rel=1e-3) assert route.wpspd[1] == pytest.approx(250 * KTS, rel=1e-3) - def test_lnav_reengage_issues_direct(self, runtime, run_cmd, aircraft): + def test_lnav_reengage_issues_direct( + self, runtime: MiniSky, run_cmd: RunCommand, aircraft: str + ) -> None: # setLNAV used to call the nonexistent Route.direct method run_cmd(f"ADDWPT {aircraft} 52.5,5.0") run_cmd(f"ADDWPT {aircraft} 53.0,6.0") @@ -158,7 +194,9 @@ def test_lnav_reengage_issues_direct(self, runtime, run_cmd, aircraft): assert "Error" not in out assert runtime.traffic.swlnav[0] - def test_at_via_stack_sets_constraints(self, runtime, run_cmd, aircraft): + def test_at_via_stack_sets_constraints( + self, runtime: MiniSky, run_cmd: RunCommand, aircraft: str + ) -> None: # The AT registration used help text as its argument spec, so the # command never reached at_wpt() from the stack run_cmd(f"ADDWPT {aircraft} 52.5,5.0") @@ -169,7 +207,7 @@ def test_at_via_stack_sets_constraints(self, runtime, run_cmd, aircraft): assert route.wpalt[1] == pytest.approx(9000 * FT, rel=1e-3) assert route.wpspd[1] == pytest.approx(250 * KTS, rel=1e-3) - def test_direct_via_stack(self, runtime, run_cmd, aircraft): + def test_direct_via_stack(self, runtime: MiniSky, run_cmd: RunCommand, aircraft: str) -> None: # The DIRECT argument spec had a stray space (" wpt"), dropping the # waypoint parameter so DIRECT always rejected its second argument run_cmd(f"ADDWPT {aircraft} 52.5,5.0") @@ -179,7 +217,9 @@ def test_direct_via_stack(self, runtime, run_cmd, aircraft): assert "Error" not in out assert route.iactwp == 1 - def test_after_and_before_via_stack(self, runtime, run_cmd, aircraft): + def test_after_and_before_via_stack( + self, runtime: MiniSky, run_cmd: RunCommand, aircraft: str + ) -> None: # AFTER/BEFORE specs contained unparseable tokens, and the ADDWPT # keyword parameter shadowed the addwpt() function run_cmd(f"ADDWPT {aircraft} EH007") @@ -193,7 +233,9 @@ def test_after_and_before_via_stack(self, runtime, run_cmd, aircraft): class TestStatusQueries: - def test_vnav_query_reports_state(self, runtime, run_cmd, aircraft): + def test_vnav_query_reports_state( + self, runtime: MiniSky, run_cmd: RunCommand, aircraft: str + ) -> None: # The VNAV query path referenced nonexistent traffic.id run_cmd(f"ADDWPT {aircraft} 52.5,5.0 FL110") run_cmd(f"ADDWPT {aircraft} 53.0,6.0") @@ -204,7 +246,9 @@ def test_vnav_query_reports_state(self, runtime, run_cmd, aircraft): out = run_cmd(f"VNAV {aircraft}") assert f"{aircraft}: VNAV is OFF" in out - def test_swtod_status_reflects_switch(self, runtime, run_cmd, aircraft): + def test_swtod_status_reflects_switch( + self, runtime: MiniSky, run_cmd: RunCommand, aircraft: str + ) -> None: # SWTOD status output used to read swtoc instead of swtod out = run_cmd(f"SWTOD {aircraft}") assert f"{aircraft}: SWTOD is ON" in out @@ -215,7 +259,9 @@ def test_swtod_status_reflects_switch(self, runtime, run_cmd, aircraft): class TestActiveWaypointDefaults: - def test_mcre_initialises_nextaltco_for_all(self, runtime, run_cmd): + def test_mcre_initialises_nextaltco_for_all( + self, runtime: MiniSky, run_cmd: RunCommand + ) -> None: # ActiveWaypoint.create() used nextaltco[-n] instead of [-n:], # leaving all but one new aircraft without the -999 sentinel run_cmd("MCRE 3") @@ -224,13 +270,19 @@ def test_mcre_initialises_nextaltco_for_all(self, runtime, run_cmd): class TestGuidanceGeometry: - def test_aircraft_approaches_waypoint_with_lnav(self, runtime, run_cmd, step_until, aircraft): + def test_aircraft_approaches_waypoint_with_lnav( + self, runtime: MiniSky, run_cmd: RunCommand, step_until: StepUntil, aircraft: str + ) -> None: wplat, wplon = 52.6, 4.0 run_cmd(f"ADDWPT {aircraft} {wplat},{wplon}") run_cmd(f"LNAV {aircraft} ON") - def dist_nm(): - return geo.kwikdist(runtime.traffic.lat[0], runtime.traffic.lon[0], wplat, wplon) + def dist_nm() -> float: + return float( + np.asarray( + geo.kwikdist(runtime.traffic.lat[0], runtime.traffic.lon[0], wplat, wplon) + ).item() + ) start = dist_nm() step_until(lambda: dist_nm() < start / 2, max_steps=600) @@ -249,14 +301,16 @@ class TestWaypointSwitching: WPTS = [(52.00, 4.05), (52.03, 4.10), (52.00, 4.15), (52.03, 4.20)] @pytest.fixture - def route(self, runtime, run_cmd, aircraft): + def route(self, runtime: MiniSky, run_cmd: RunCommand, aircraft: str) -> Route: for lat, lon in self.WPTS: run_cmd(f"ADDWPT {aircraft} {lat},{lon}") run_cmd(f"LNAV {aircraft} ON") run_cmd(f"VNAV {aircraft} ON") return runtime.traffic.ap.route[0] - def test_switches_through_route_and_disengages_at_end(self, runtime, step_until, route): + def test_switches_through_route_and_disengages_at_end( + self, runtime: MiniSky, step_until: StepUntil, route: Route + ) -> None: assert route.iactwp == 0 for target in range(1, len(self.WPTS)): step_until(lambda target=target: route.iactwp == target, max_steps=200) @@ -266,16 +320,22 @@ def test_switches_through_route_and_disengages_at_end(self, runtime, step_until, step_until(lambda: not runtime.traffic.swlnav[0], max_steps=200) assert not runtime.traffic.swvnav[0] - def test_next_qdr_matches_next_leg_bearing(self, runtime, step_until, route): + def test_next_qdr_matches_next_leg_bearing( + self, runtime: MiniSky, step_until: StepUntil, route: Route + ) -> None: step_until(lambda: route.iactwp == 1, max_steps=200) expected, _ = geo.qdrdist(*self.WPTS[1], *self.WPTS[2]) assert runtime.traffic.actwp.next_qdr[0] == pytest.approx(expected) - def test_next_qdr_sentinel_on_last_waypoint(self, runtime, step_until, route): + def test_next_qdr_sentinel_on_last_waypoint( + self, runtime: MiniSky, step_until: StepUntil, route: Route + ) -> None: step_until(lambda: route.iactwp == len(self.WPTS) - 1, max_steps=600) assert runtime.traffic.actwp.next_qdr[0] == -999.0 - def test_nextturn_data_tracks_upcoming_flyturn(self, runtime, run_cmd, step_until, aircraft): + def test_nextturn_data_tracks_upcoming_flyturn( + self, runtime: MiniSky, run_cmd: RunCommand, step_until: StepUntil, aircraft: str + ) -> None: # Waypoint 2 is a fly-turn waypoint with a turn speed; 0, 1 and 3 are fly-by run_cmd(f"ADDWPT {aircraft} {self.WPTS[0][0]},{self.WPTS[0][1]}") run_cmd(f"ADDWPT {aircraft} {self.WPTS[1][0]},{self.WPTS[1][1]}") @@ -302,6 +362,8 @@ def test_nextturn_data_tracks_upcoming_flyturn(self, runtime, run_cmd, step_unti step_until(lambda: route.iactwp == 3, max_steps=600) assert runtime.traffic.actwp.nextturnidx[0] == -999 - def test_no_flyturn_waypoints_gives_defaults(self, runtime, step_until, route): + def test_no_flyturn_waypoints_gives_defaults( + self, runtime: MiniSky, step_until: StepUntil, route: Route + ) -> None: step_until(lambda: route.iactwp == 1, max_steps=200) assert runtime.traffic.actwp.nextturnidx[0] == -999 diff --git a/tests/integration/test_scenario.py b/tests/integration/test_scenario.py index ac3ed3c..13b5e27 100644 --- a/tests/integration/test_scenario.py +++ b/tests/integration/test_scenario.py @@ -1,26 +1,31 @@ """Integration tests for scenario loading (IC) and timed command execution.""" +from __future__ import annotations + import pytest +from minisky import MiniSky +from tests._types import RunCommand, StepUntil + FT = 0.3048 class TestIcLoading: - def test_ic_kl204_creates_aircraft(self, runtime, run_cmd): + def test_ic_kl204_creates_aircraft(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("IC scenarios/kl204.scn", steps=2) assert runtime.traffic.ntraf == 1 assert runtime.traffic.callsign[0] == "KL204" - def test_ic_sets_scenario_name(self, runtime, run_cmd): + def test_ic_sets_scenario_name(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("IC scenarios/kl204.scn", steps=2) assert runtime.commands.get_scenname() == "kl204" - def test_ic_missing_file_reports_error(self, runtime, run_cmd): + def test_ic_missing_file_reports_error(self, runtime: MiniSky, run_cmd: RunCommand) -> None: output = run_cmd("IC scenarios/doesnotexist.scn") assert "not found" in output.lower() assert runtime.traffic.ntraf == 0 - def test_ic_resets_previous_state(self, runtime, run_cmd): + def test_ic_resets_previous_state(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("CRE OLD1,A320,50,3,90,FL100,250") assert runtime.traffic.ntraf == 1 run_cmd("IC scenarios/kl204.scn", steps=2) @@ -29,7 +34,9 @@ def test_ic_resets_previous_state(self, runtime, run_cmd): class TestTimedCommands: - def test_timed_commands_fire_at_simtime(self, runtime, run_cmd, step_until): + def test_timed_commands_fire_at_simtime( + self, runtime: MiniSky, run_cmd: RunCommand, step_until: StepUntil + ) -> None: run_cmd("IC scenarios/kl204.scn", steps=2) # The t=2s commands (ALT FL260, HDG 340) have been processed once # simt reaches 3; at t=3s ADDWPT re-enables LNAV, overriding HDG, @@ -39,13 +46,17 @@ def test_timed_commands_fire_at_simtime(self, runtime, run_cmd, step_until): # scenario wind makes the commanded track deviate a few degrees from 340 assert runtime.traffic.ap.trk[0] == pytest.approx(340.0, abs=5.0) - def test_future_commands_not_executed_early(self, runtime, run_cmd): + def test_future_commands_not_executed_early( + self, runtime: MiniSky, run_cmd: RunCommand + ) -> None: run_cmd("IC scenarios/kl204.scn", steps=2) # Before t=2s the FL260 command must not have fired yet assert runtime.simulation.simt < 2.0 assert runtime.traffic.selalt[0] == pytest.approx(25000 * FT, rel=1e-3) - def test_scenario_waypoint_added(self, runtime, run_cmd, step_until): + def test_scenario_waypoint_added( + self, runtime: MiniSky, run_cmd: RunCommand, step_until: StepUntil + ) -> None: run_cmd("IC scenarios/kl204.scn", steps=2) # At t=1s the scenario adds waypoint RIVER step_until(lambda: runtime.simulation.simt > 2.0, max_steps=20) @@ -54,7 +65,9 @@ def test_scenario_waypoint_added(self, runtime, run_cmd, step_until): class TestConvergingScenario: - def test_2ac_scenario_produces_conflict(self, runtime, run_cmd, step_until): + def test_2ac_scenario_produces_conflict( + self, runtime: MiniSky, run_cmd: RunCommand, step_until: StepUntil + ) -> None: run_cmd("IC scenarios/2ac_converging.scn", steps=2) assert runtime.traffic.ntraf == 2 step_until(lambda: len(runtime.traffic.cd.confpairs) > 0, max_steps=400) diff --git a/tests/integration/test_stack.py b/tests/integration/test_stack.py index b1aee4c..359e80f 100644 --- a/tests/integration/test_stack.py +++ b/tests/integration/test_stack.py @@ -1,25 +1,34 @@ """Integration tests for the command stack (queueing, processing, echo output).""" +from __future__ import annotations + from io import StringIO +from pathlib import Path import pytest +from minisky import MiniSky +from minisky.simulation import Simulation +from tests._types import RunCommand + FT = 0.3048 KTS = 0.514444 class TestQueueing: - def test_stack_only_queues(self, runtime, sim): + def test_stack_only_queues(self, runtime: MiniSky, sim: Simulation) -> None: runtime.commands.stack("CRE KL204,B744,52,4,45,FL250,350") assert runtime.traffic.ntraf == 0 # not executed yet - def test_command_executes_on_step(self, runtime, sim): + def test_command_executes_on_step(self, runtime: MiniSky, sim: Simulation) -> None: runtime.commands.stack("CRE KL204,B744,52,4,45,FL250,350") runtime.simulation.step() assert runtime.traffic.ntraf == 1 assert runtime.traffic.callsign[0] == "KL204" - def test_command_stacked_during_processing_is_kept(self, runtime, sim): + def test_command_stacked_during_processing_is_kept( + self, runtime: MiniSky, sim: Simulation + ) -> None: # A stack() call that lands while process() is draining the stack # (e.g. from a plugin I/O thread) must not be lost: commands() detaches # the pending list up front, so late arrivals run on the next step. @@ -34,63 +43,65 @@ def test_command_stacked_during_processing_is_kept(self, runtime, sim): class TestCommands: - def test_cre_via_stack(self, runtime, run_cmd): + def test_cre_via_stack(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("CRE KL204,B744,52,4,45,FL250,350") assert runtime.traffic.ntraf == 1 assert runtime.traffic.alt[0] == pytest.approx(25000 * FT, rel=1e-3) - def test_pos_outputs_callsign(self, runtime, run_cmd): + def test_pos_outputs_callsign(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("CRE KL204,B744,52,4,45,FL250,350") output = run_cmd("POS KL204") assert "KL204" in output - def test_bare_callsign_defaults_to_pos(self, runtime, run_cmd): + def test_bare_callsign_defaults_to_pos(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("CRE KL204,B744,52,4,45,FL250,350") output = run_cmd("KL204") assert "KL204" in output - def test_alt_sets_selected_altitude(self, runtime, run_cmd): + def test_alt_sets_selected_altitude(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("CRE KL204,B744,52,4,45,FL250,350") run_cmd("ALT KL204 FL260") assert runtime.traffic.selalt[0] == pytest.approx(26000 * FT, rel=1e-3) - def test_hdg_sets_autopilot_track(self, runtime, run_cmd): + def test_hdg_sets_autopilot_track(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("CRE KL204,B744,52,4,45,FL250,350") run_cmd("HDG KL204 340") assert runtime.traffic.ap.trk[0] == pytest.approx(340.0) assert not runtime.traffic.swlnav[0] - def test_spd_sets_selected_speed(self, runtime, run_cmd): + def test_spd_sets_selected_speed(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("CRE KL204,B744,52,4,45,FL250,350") run_cmd("SPD KL204 300") assert runtime.traffic.selspd[0] == pytest.approx(300 * KTS, rel=1e-3) - def test_del_removes_aircraft(self, runtime, run_cmd): + def test_del_removes_aircraft(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("CRE KL204,B744,52,4,45,FL250,350") assert runtime.traffic.ntraf == 1 run_cmd("DEL KL204") assert runtime.traffic.ntraf == 0 - def test_mcre_via_stack(self, runtime, run_cmd): + def test_mcre_via_stack(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("MCRE 3") assert runtime.traffic.ntraf == 3 class TestReadscn: - def test_short_command_line_survives(self, runtime): + def test_short_command_line_survives(self, runtime: MiniSky) -> None: # "0:00:00>OP" is only 10 characters; it used to be dropped by a # minimum-length check meant to skip empty lines. lines = list(runtime.commands.readscn(StringIO("0:00:00>OP\n"))) assert lines == [(0.0, "OP")] - def test_blank_and_comment_lines_skipped(self, runtime): + def test_blank_and_comment_lines_skipped(self, runtime: MiniSky) -> None: scn = StringIO("# a comment\n\n0:00:01>HOLD\n") lines = list(runtime.commands.readscn(scn)) assert lines == [(1.0, "HOLD")] class TestHelp: - def test_help_writes_command_reference(self, runtime, sim, tmp_path, monkeypatch): + def test_help_writes_command_reference( + self, runtime: MiniSky, sim: Simulation, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: # HELP >filename writes the reference to ./docs/ monkeypatch.chdir(tmp_path) (tmp_path / "docs").mkdir() @@ -104,14 +115,14 @@ def test_help_writes_command_reference(self, runtime, sim, tmp_path, monkeypatch class TestVarExplorer: - def test_variable_get_without_index(self, runtime, run_cmd): + def test_variable_get_without_index(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("CRE KL204,B744,52,4,45,FL250,350") v = runtime.variables.findvar("traf.ntraf") assert v is not None assert v.get() == 1 assert v.get_type() == "int" - def test_variable_get_with_index(self, runtime, run_cmd): + def test_variable_get_with_index(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("CRE KL204,B744,52,4,45,FL250,350") v = runtime.variables.findvar("traf.callsign[0]") assert v is not None @@ -119,30 +130,32 @@ def test_variable_get_with_index(self, runtime, run_cmd): class TestSynonyms: - def test_airway_synonyms_point_to_pos(self, runtime): + def test_airway_synonyms_point_to_pos(self, runtime: MiniSky) -> None: cmddict = runtime.commands.cmddict assert cmddict["AIRWAY"] is cmddict["POS"] assert cmddict["AIRWAYS"] is cmddict["POS"] class TestErrors: - def test_unknown_command_echoes_error(self, runtime, run_cmd): + def test_unknown_command_echoes_error(self, runtime: MiniSky, run_cmd: RunCommand) -> None: output = run_cmd("BOGUSCMD 42") assert "unknown command" in output.lower() - def test_command_on_missing_aircraft_reports_error(self, runtime, run_cmd): + def test_command_on_missing_aircraft_reports_error( + self, runtime: MiniSky, run_cmd: RunCommand + ) -> None: output = run_cmd("ALT NOSUCH FL100") assert output # some error text is echoed assert runtime.traffic.ntraf == 0 - def test_sim_survives_bad_command(self, runtime, run_cmd): + def test_sim_survives_bad_command(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("THISDOESNOTEXIST") run_cmd("CRE KL204,B744,52,4,45,FL250,350") assert runtime.traffic.ntraf == 1 class TestArgumentSpecs: - def test_all_registered_specs_resolve_to_parsers(self, runtime): + def test_all_registered_specs_resolve_to_parsers(self, runtime: MiniSky) -> None: # Several commands (AT, DIRECT, AFTER, RESOOFF, ...) were registered # with argument specs containing whitespace or free-form help text; # their parameters were silently dropped, making the commands diff --git a/tests/integration/test_streaming.py b/tests/integration/test_streaming.py index 5311566..e2e9431 100644 --- a/tests/integration/test_streaming.py +++ b/tests/integration/test_streaming.py @@ -4,15 +4,21 @@ the `DTMULT` stack command that sets the runner speed multiplier. """ +from __future__ import annotations + import json import pytest -from minisky.simulation import SimulationState +from minisky import MiniSky +from minisky.simulation import Simulation, SimulationState from minisky.streaming import STREAM_MAX_HZ, StreamHub, build_snapshot +from tests._types import RunCommand -def test_snapshot_structure_and_units(runtime, sim, run_cmd): +def test_snapshot_structure_and_units( + runtime: MiniSky, sim: Simulation, run_cmd: RunCommand +) -> None: # Two steps: the first creates the aircraft, the second flips INIT -> OP. run_cmd("CRE KL001 A320 52.0 4.0 90 FL100 250", steps=2) @@ -43,7 +49,9 @@ def test_snapshot_structure_and_units(runtime, sim, run_cmd): assert ac["inconf"] == [False] -def test_snapshot_is_json_serialisable(runtime, sim, run_cmd): +def test_snapshot_is_json_serialisable( + runtime: MiniSky, sim: Simulation, run_cmd: RunCommand +) -> None: run_cmd("CRE KL001 A320 52.0 4.0 90 FL100 250") # Must not raise: no numpy scalars leak into the snapshot. json.dumps( @@ -51,25 +59,25 @@ def test_snapshot_is_json_serialisable(runtime, sim, run_cmd): ) -def test_snapshot_empty_when_no_traffic(runtime, sim): +def test_snapshot_empty_when_no_traffic(runtime: MiniSky, sim: Simulation) -> None: snap = build_snapshot(runtime.simulation, runtime.traffic, runtime.runner, runtime.commands) assert snap["siminfo"]["ntraf"] == 0 assert snap["acdata"]["callsign"] == [] assert snap["acdata"]["alt"] == [] -def test_dtmult_sets_runner_speed(runtime, sim, run_cmd): +def test_dtmult_sets_runner_speed(runtime: MiniSky, sim: Simulation, run_cmd: RunCommand) -> None: run_cmd("DTMULT 8") assert runtime.runner.speed == 8.0 -def test_dtmult_rejects_non_positive(runtime, sim): +def test_dtmult_rejects_non_positive(runtime: MiniSky, sim: Simulation) -> None: ok, msg = runtime.runner.setspeed(0) assert ok is False assert "positive" in msg.lower() -def test_hub_skips_publish_without_subscribers(runtime): +def test_hub_skips_publish_without_subscribers(runtime: MiniSky) -> None: hub = StreamHub( lambda: build_snapshot( runtime.simulation, runtime.traffic, runtime.runner, runtime.commands @@ -83,7 +91,7 @@ def test_hub_skips_publish_without_subscribers(runtime): assert hub.active is True -def test_hub_rate_cap_gates_publishing(runtime): +def test_hub_rate_cap_gates_publishing(runtime: MiniSky) -> None: # A very low cap means the second immediate tick is dropped. hub = StreamHub( lambda: build_snapshot( @@ -99,5 +107,5 @@ def test_hub_rate_cap_gates_publishing(runtime): assert hub.generation == first_gen -def test_stream_max_hz_default_is_positive(): +def test_stream_max_hz_default_is_positive() -> None: assert STREAM_MAX_HZ > 0 diff --git a/tests/integration/test_tangram_bridge.py b/tests/integration/test_tangram_bridge.py index 5c3f165..95196ae 100644 --- a/tests/integration/test_tangram_bridge.py +++ b/tests/integration/test_tangram_bridge.py @@ -25,7 +25,7 @@ def redis_server() -> fakeredis.FakeServer: @pytest.fixture def bridge( - runtime, sim: Simulation, redis_server: fakeredis.FakeServer + runtime: MiniSky, sim: Simulation, redis_server: fakeredis.FakeServer ) -> Iterator[TangramBridge]: bridge = TangramBridge( "redis://fake", @@ -113,10 +113,7 @@ def test_command_roundtrip( client.publish("from:minisky:command", json.dumps({"command": "HOLD"})) # The bridge thread stacks the command; the sim applies it on a step. deadline = time.monotonic() + 5.0 - while ( - time.monotonic() < deadline - and runtime.simulation.state != SimulationState.HOLD - ): + while time.monotonic() < deadline and runtime.simulation.state != SimulationState.HOLD: runtime.simulation.step() time.sleep(0.02) assert runtime.simulation.state == SimulationState.HOLD diff --git a/tests/integration/test_traffic.py b/tests/integration/test_traffic.py index f71915a..6ab058f 100644 --- a/tests/integration/test_traffic.py +++ b/tests/integration/test_traffic.py @@ -1,14 +1,20 @@ """Integration tests for aircraft creation/deletion (minisky.traffic.Traffic).""" +from __future__ import annotations + import numpy as np import pytest +from minisky import MiniSky +from minisky.simulation import Simulation +from tests._types import RunCommand + FT = 0.3048 KTS = 0.514444 class TestCreate: - def test_cre_single(self, runtime, sim): + def test_cre_single(self, runtime: MiniSky, sim: Simulation) -> None: ok, msg = runtime.traffic.cre("KL001", "A320", lat=52.0, lon=4.0, hdg=90, alt=3000, spd=150) assert ok assert runtime.traffic.ntraf == 1 @@ -17,63 +23,65 @@ def test_cre_single(self, runtime, sim): assert runtime.traffic.lon[0] == pytest.approx(4.0) assert runtime.traffic.hdg[0] == pytest.approx(90.0) - def test_cre_lowercase_callsign_is_uppercased(self, runtime, sim): + def test_cre_lowercase_callsign_is_uppercased(self, runtime: MiniSky, sim: Simulation) -> None: runtime.traffic.cre("kl002") assert runtime.traffic.callsign[0] == "KL002" - def test_cre_duplicate_callsign_rejected(self, runtime, sim): + def test_cre_duplicate_callsign_rejected(self, runtime: MiniSky, sim: Simulation) -> None: runtime.traffic.cre("KL001") ok, msg = runtime.traffic.cre("KL001") assert not ok assert runtime.traffic.ntraf == 1 - def test_mcre_multiple(self, runtime, sim): + def test_mcre_multiple(self, runtime: MiniSky, sim: Simulation) -> None: ok, _ = runtime.traffic.mcre(5) assert ok assert runtime.traffic.ntraf == 5 assert len(set(runtime.traffic.callsign)) == 5 - def test_idx_lookup(self, runtime, sim): + def test_idx_lookup(self, runtime: MiniSky, sim: Simulation) -> None: runtime.traffic.cre("KL001") runtime.traffic.cre("KL002") assert runtime.traffic.idx("KL002") == 1 assert runtime.traffic.idx("kl001") == 0 assert runtime.traffic.idx("MISSING") == -1 - def test_cre_defaults_are_25000ft_300kts(self, runtime, sim): + def test_cre_defaults_are_25000ft_300kts(self, runtime: MiniSky, sim: Simulation) -> None: # Defaults used to be 25000 m / 300 m/s; they are meant as ft/kts. runtime.traffic.cre("KL001") assert runtime.traffic.alt[0] == pytest.approx(25000 * FT) assert runtime.traffic.cas[0] == pytest.approx(300 * KTS) - def test_cre_via_stack_without_alt_spd_uses_defaults(self, runtime, run_cmd): + def test_cre_via_stack_without_alt_spd_uses_defaults( + self, runtime: MiniSky, run_cmd: RunCommand + ) -> None: run_cmd("CRE KL204,B744,52,4") assert runtime.traffic.ntraf == 1 assert runtime.traffic.alt[0] == pytest.approx(25000 * FT, rel=1e-3) assert runtime.traffic.cas[0] == pytest.approx(300 * KTS, rel=1e-3) - def test_cre_echoes_confirmation(self, runtime, run_cmd): + def test_cre_echoes_confirmation(self, runtime: MiniSky, run_cmd: RunCommand) -> None: # Command results must reach the output buffer (scr.echo), not stdout only out = run_cmd("CRE KL204,B744,52,4,45,FL250,350") assert out == "Aircraft KL204 created" class TestArrays: - def test_array_sizes_consistent(self, runtime, sim): + def test_array_sizes_consistent(self, runtime: MiniSky, sim: Simulation) -> None: runtime.traffic.mcre(3) n = runtime.traffic.ntraf for attr in ("lat", "lon", "alt", "hdg", "tas", "cas", "gs", "vs"): assert len(getattr(runtime.traffic, attr)) == n, attr assert len(runtime.traffic.callsign) == n - def test_speed_arrays_initialized(self, runtime, sim): + def test_speed_arrays_initialized(self, runtime: MiniSky, sim: Simulation) -> None: runtime.traffic.cre("KL001", spd=150, alt=3000) assert runtime.traffic.tas[0] > 0 assert runtime.traffic.gs[0] == pytest.approx(runtime.traffic.tas[0]) class TestDelete: - def test_delete_shrinks_arrays(self, runtime, sim): + def test_delete_shrinks_arrays(self, runtime: MiniSky, sim: Simulation) -> None: runtime.traffic.cre("KL001") runtime.traffic.cre("KL002") runtime.traffic.delete(0) @@ -81,21 +89,21 @@ def test_delete_shrinks_arrays(self, runtime, sim): assert runtime.traffic.callsign[0] == "KL002" assert len(runtime.traffic.lat) == 1 - def test_delete_all(self, runtime, sim): + def test_delete_all(self, runtime: MiniSky, sim: Simulation) -> None: runtime.traffic.mcre(3) - runtime.traffic.delete([0, 1, 2]) + runtime.traffic.delete(np.array([0, 1, 2])) assert runtime.traffic.ntraf == 0 class TestReset: - def test_sim_reset_clears_traffic(self, runtime, sim): + def test_sim_reset_clears_traffic(self, runtime: MiniSky, sim: Simulation) -> None: runtime.traffic.mcre(4) assert runtime.traffic.ntraf == 4 runtime.simulation.reset() assert runtime.traffic.ntraf == 0 assert len(runtime.traffic.lat) == 0 - def test_reset_clears_simtime(self, runtime, sim): + def test_reset_clears_simtime(self, runtime: MiniSky, sim: Simulation) -> None: runtime.traffic.cre("KL001") for _ in range(5): runtime.simulation.step() @@ -105,18 +113,18 @@ def test_reset_clears_simtime(self, runtime, sim): class TestStep: - def test_step_advances_time_with_traffic(self, runtime, sim): + def test_step_advances_time_with_traffic(self, runtime: MiniSky, sim: Simulation) -> None: runtime.traffic.cre("KL001") runtime.simulation.step() # INIT -> OP transition + first update t0 = runtime.simulation.simt runtime.simulation.step() assert runtime.simulation.simt == pytest.approx(t0 + runtime.simulation.simdt) - def test_no_time_advance_without_traffic(self, runtime, sim): + def test_no_time_advance_without_traffic(self, runtime: MiniSky, sim: Simulation) -> None: runtime.simulation.step() assert runtime.simulation.simt == 0 - def test_aircraft_moves_when_stepped(self, runtime, sim): + def test_aircraft_moves_when_stepped(self, runtime: MiniSky, sim: Simulation) -> None: runtime.traffic.cre("KL001", lat=52.0, lon=4.0, hdg=90, alt=10000 * FT, spd=250) for _ in range(10): runtime.simulation.step() @@ -126,21 +134,21 @@ def test_aircraft_moves_when_stepped(self, runtime, sim): class TestCreCmd: - def test_clrcrecmd_with_pending_commands(self, runtime, run_cmd): + def test_clrcrecmd_with_pending_commands(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("CRECMD SPD 250") assert runtime.traffic.crecmdlist == ["SPD 250"] out = run_cmd("CLRCRECMD") assert runtime.traffic.crecmdlist == [] assert "All 1 crecmd commands deleted" in out - def test_clrcrecmd_with_empty_list(self, runtime, run_cmd): + def test_clrcrecmd_with_empty_list(self, runtime: MiniSky, run_cmd: RunCommand) -> None: out = run_cmd("CLRCRECMD") assert runtime.traffic.crecmdlist == [] assert "CLRCRECMD" in out class TestConditional: - def test_atspd_seeds_condition_with_cas(self, runtime, sim): + def test_atspd_seeds_condition_with_cas(self, runtime: MiniSky, sim: Simulation) -> None: runtime.traffic.cre("KL001", alt=25000 * FT, spd=150) cas, tas = runtime.traffic.cas[0], runtime.traffic.tas[0] assert tas > cas # TAS exceeds CAS at altitude @@ -154,7 +162,7 @@ def test_atspd_seeds_condition_with_cas(self, runtime, sim): runtime.traffic.cond.update() assert runtime.traffic.cond.ncond == ncond - def test_renameac_updates_pending_conditions(self, runtime, sim): + def test_renameac_updates_pending_conditions(self, runtime: MiniSky, sim: Simulation) -> None: runtime.traffic.cre("KL001", alt=10000 * FT, spd=150) runtime.traffic.cond.ataltcmd(0, 5000 * FT, "KL001 SPD 200") runtime.traffic.cond.renameac("KL001", "KL999") @@ -166,14 +174,14 @@ def test_renameac_updates_pending_conditions(self, runtime, sim): class TestWind: - def test_wind_add_get_roundtrip(self, runtime, sim): + def test_wind_add_get_roundtrip(self, runtime: MiniSky, sim: Simulation) -> None: wind = runtime.traffic.wind assert wind.add(52.0, 4.0, 270.0, 20.0) is True # from 270 deg, 20 kts vn, ve = wind.getdata(52.0, 4.0, 0.0) assert ve == pytest.approx(20 * KTS) # westerly wind blows eastward assert vn == pytest.approx(0.0, abs=1e-9) - def test_windfield_remove_keeps_lat_lon_paired(self, runtime, sim): + def test_windfield_remove_keeps_lat_lon_paired(self, runtime: MiniSky, sim: Simulation) -> None: wind = runtime.traffic.wind wind.addpoint(52.0, 4.0, 270.0, 20.0) idx = wind.addpoint(54.0, 6.0, 180.0, 10.0) @@ -182,22 +190,26 @@ def test_windfield_remove_keeps_lat_lon_paired(self, runtime, sim): assert list(wind.lon) == [4.0] # used to become a copy of lat assert wind.winddim == 1 - def test_wind_del_clears_field(self, runtime, sim): + def test_wind_del_clears_field(self, runtime: MiniSky, sim: Simulation) -> None: wind = runtime.traffic.wind wind.add(52.0, 4.0, 270.0, 20.0) assert wind.winddim > 0 - assert wind.add(52.0, 4.0, "DEL") is True + # TODO(abraham): possible bug! + assert wind.add(52.0, 4.0, "DEL") is True # type: ignore assert wind.winddim == 0 assert len(wind.lat) == 0 - def test_wind_del_not_shadowed_by_altitude_form(self, runtime, sim): + def test_wind_del_not_shadowed_by_altitude_form( + self, runtime: MiniSky, sim: Simulation + ) -> None: wind = runtime.traffic.wind wind.add(52.0, 4.0, 270.0, 20.0) # With 3+ winddata elements DEL used to fall into the alt/dir/spd branch - assert wind.add(52.0, 4.0, "DEL", None, None) is True + # TODO(abraham): possible bug! + assert wind.add(52.0, 4.0, "DEL", None, None) is True # type: ignore assert wind.winddim == 0 - def test_wind_via_stack_two_element_form(self, runtime, run_cmd): + def test_wind_via_stack_two_element_form(self, runtime: MiniSky, run_cmd: RunCommand) -> None: # The WIND spec ran the direction through the altitude parser # (ft -> m), silently mangling WIND lat,lon,dir,spd out = run_cmd("WIND 52,4,270,20") @@ -206,7 +218,7 @@ def test_wind_via_stack_two_element_form(self, runtime, run_cmd): assert ve == pytest.approx(20 * KTS, rel=1e-6) assert vn == pytest.approx(0.0, abs=1e-9) - def test_wind_del_via_stack(self, runtime, run_cmd): + def test_wind_del_via_stack(self, runtime: MiniSky, run_cmd: RunCommand) -> None: # WIND lat,lon,DEL used to be rejected by the altitude parser run_cmd("WIND 52,4,270,20") assert runtime.traffic.wind.winddim > 0 @@ -216,7 +228,9 @@ def test_wind_del_via_stack(self, runtime, run_cmd): class TestNoise: - def test_surveillance_noise_differs_per_aircraft(self, runtime, sim): + def test_surveillance_noise_differs_per_aircraft( + self, runtime: MiniSky, sim: Simulation + ) -> None: runtime.traffic.mcre(3) runtime.traffic.setnoise(True) runtime.traffic.noise.lastupdate[:] = -1.0 # make every aircraft due for update @@ -225,10 +239,12 @@ def test_surveillance_noise_differs_per_aircraft(self, runtime, sim): # One noise sample used to be broadcast to all due aircraft assert np.unique(offsets).size == runtime.traffic.ntraf - def test_turbulence_registered_in_traffic_tree(self, runtime, sim): + def test_turbulence_registered_in_traffic_tree(self, runtime: MiniSky, sim: Simulation) -> None: assert runtime.traffic.turbulence in runtime.traffic._children - def test_noise_on_via_stack_steps_without_crash(self, runtime, run_cmd): + def test_noise_on_via_stack_steps_without_crash( + self, runtime: MiniSky, run_cmd: RunCommand + ) -> None: run_cmd("CRE KL001,A320,52,4,90,FL250,300") run_cmd("NOISE ON") assert runtime.traffic.turbulence.active @@ -238,7 +254,9 @@ def test_noise_on_via_stack_steps_without_crash(self, runtime, run_cmd): class TestTrails: - def test_fresh_trails_object_has_background_buffers(self, runtime, sim): + def test_fresh_trails_object_has_background_buffers( + self, runtime: MiniSky, sim: Simulation + ) -> None: from minisky.traffic.trails import Trails trails = Trails(runtime.traffic, lambda: runtime.simulation) @@ -248,7 +266,7 @@ def test_fresh_trails_object_has_background_buffers(self, runtime, sim): finally: runtime.traffic._children.remove(trails) - def test_trail_on_update_and_buffer(self, runtime, run_cmd): + def test_trail_on_update_and_buffer(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("CRE KL001,A320,52,4,90,FL250,300") run_cmd("TRAIL ON 1") assert runtime.traffic.trails.active diff --git a/tests/test_api.py b/tests/test_api.py index fbeebe6..9079d3a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -12,51 +12,59 @@ here because it is flaky under `TestClient`. """ +from __future__ import annotations + +from collections.abc import Iterator + import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from minisky import MiniSky pytestmark = pytest.mark.api @pytest.fixture(scope="module") -def server_app(): +def server_app() -> FastAPI: from minisky.server import create_app return create_app() @pytest.fixture(scope="module") -def runtime(server_app): +def runtime(server_app: FastAPI) -> MiniSky: return server_app.state.runtime @pytest.fixture(scope="module") -def client(server_app): +def client(server_app: FastAPI) -> Iterator[TestClient]: fastapi_testclient = pytest.importorskip("fastapi.testclient") with fastapi_testclient.TestClient(server_app) as test_client: yield test_client -def test_root(client): +def test_root(client: TestClient) -> None: resp = client.get("/") assert resp.status_code == 200 assert "ready" in resp.json()["msg"].lower() -def test_simtime(client): +def test_simtime(client: TestClient) -> None: resp = client.get("/simtime") assert resp.status_code == 200 value = resp.json()["simulation time (seconds)"] assert isinstance(value, (int, float)) -def test_all_empty_traffic(client): +def test_all_empty_traffic(client: TestClient) -> None: resp = client.get("/all") assert resp.status_code == 200 assert isinstance(resp.json(), list) -def test_all_reflects_created_aircraft(client, runtime): +def test_all_reflects_created_aircraft(client: TestClient, runtime: MiniSky) -> None: runtime.traffic.cre("KL001", "A320", lat=52.0, lon=4.0, hdg=90, alt=3000, spd=150) resp = client.get("/all") assert resp.status_code == 200 @@ -64,12 +72,12 @@ def test_all_reflects_created_aircraft(client, runtime): assert "KL001" in callsigns -def test_speed_endpoint(client): +def test_speed_endpoint(client: TestClient) -> None: resp = client.get("/speed/10") assert resp.status_code == 200 assert "10" in resp.json()["msg"] -def test_plugins_endpoint(client): +def test_plugins_endpoint(client: TestClient) -> None: resp = client.get("/plugins") assert resp.status_code == 200 diff --git a/tests/unit/test_areafilter.py b/tests/unit/test_areafilter.py index 397d56b..f359f83 100644 --- a/tests/unit/test_areafilter.py +++ b/tests/unit/test_areafilter.py @@ -22,38 +22,38 @@ def check_single( class TestDefineArea: - def test_define_box_and_has_area(self, area_filter): + def test_define_box_and_has_area(self, area_filter: AreaFilter) -> None: ok, msg = area_filter.define_area("BOX1", "BOX", [52.0, 4.0, 53.0, 5.0]) assert ok assert area_filter.has_area("BOX1") - def test_unknown_area_absent(self, area_filter): + 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): + def test_checkinside_unknown_area_returns_false(self, area_filter: AreaFilter) -> None: result = area_filter.checkInside("NOPE", np.array([52.0]), np.array([4.0]), np.array([0.0])) assert not result.any() - def test_reset_clears_areas(self, area_filter): + 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") class TestBox: - def test_inside_and_outside(self, area_filter): + 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): + 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): + 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]) @@ -63,7 +63,7 @@ def test_array_input(self, area_filter): class TestCircle: - def test_center_inside_far_point_outside(self, area_filter): + 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) @@ -74,7 +74,7 @@ def test_center_inside_far_point_outside(self, area_filter): class TestPoly: - def test_triangle_centroid_inside(self, area_filter): + 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) diff --git a/tests/unit/test_convert.py b/tests/unit/test_convert.py index 886b647..e1d9b10 100644 --- a/tests/unit/test_convert.py +++ b/tests/unit/test_convert.py @@ -13,57 +13,57 @@ class TestAltitude: - def test_flight_level(self): + def test_flight_level(self) -> None: assert cv.txt2alt("FL300") == pytest.approx(30000 * FT) - def test_plain_feet(self): + def test_plain_feet(self) -> None: assert cv.txt2alt("2500") == pytest.approx(2500 * FT) - def test_invalid_raises(self): + def test_invalid_raises(self) -> None: with pytest.raises(ValueError): cv.txt2alt("NOTANALT") class TestTime: - def test_txt2tim_hms(self): + def test_txt2tim_hms(self) -> None: assert cv.txt2tim("00:01:30") == pytest.approx(90.0) - def test_txt2tim_seconds(self): + def test_txt2tim_seconds(self) -> None: assert cv.txt2tim("45") == pytest.approx(45.0) - def test_tim2txt_format(self): + def test_tim2txt_format(self) -> None: assert cv.tim2txt(90) == "00:01:30.00" - def test_roundtrip(self): + def test_roundtrip(self) -> None: assert cv.txt2tim(cv.tim2txt(3725.0)) == pytest.approx(3725.0) class TestLatLon: - def test_decimal_lat(self): + def test_decimal_lat(self) -> None: assert cv.txt2lat("52.3") == pytest.approx(52.3) - def test_decimal_lon(self): + def test_decimal_lon(self) -> None: assert cv.txt2lon("4.5") == pytest.approx(4.5) - def test_negative_lat(self): + def test_negative_lat(self) -> None: assert cv.txt2lat("-33.9") == pytest.approx(-33.9) - def test_hemisphere_lat(self): + def test_hemisphere_lat(self) -> None: # N52'18'00 == 52.3 degrees assert cv.txt2lat("N52'18'0") == pytest.approx(52.3, abs=1e-6) - def test_hemisphere_south_is_negative(self): + def test_hemisphere_south_is_negative(self) -> None: assert cv.txt2lat("S52'18'0") == pytest.approx(-52.3, abs=1e-6) class TestSpeed: - def test_knots_to_ms(self): + def test_knots_to_ms(self) -> None: assert cv.txt2spd("250") == pytest.approx(250 * KTS, rel=1e-3) - def test_mach_passthrough(self): + def test_mach_passthrough(self) -> None: assert cv.txt2spd(".8") == pytest.approx(0.8) - def test_invalid_raises(self): + def test_invalid_raises(self) -> None: with pytest.raises(ValueError): cv.txt2spd("FAST") @@ -73,10 +73,10 @@ class TestAngles: "angle,expected", [(190.0, -170.0), (-190.0, 170.0), (180.0, -180.0), (0.0, 0.0), (359.0, -1.0)], ) - def test_degto180_wraps(self, angle, expected): + def test_degto180_wraps(self, angle: float, expected: float) -> None: assert cv.degto180(angle) == pytest.approx(expected) - def test_deg180_is_alias_of_degto180(self): + def test_deg180_is_alias_of_degto180(self) -> None: # Regression: deg180 and degto180 were duplicate implementations assert cv.deg180 is cv.degto180 assert cv.deg180(190.0) == pytest.approx(-170.0) @@ -84,9 +84,9 @@ def test_deg180_is_alias_of_degto180(self): class TestBool: @pytest.mark.parametrize("txt", ["ON", "TRUE", "YES", "1"]) - def test_truthy(self, txt): + def test_truthy(self, txt: str) -> None: assert cv.txt2bool(txt) is True @pytest.mark.parametrize("txt", ["OFF", "FALSE", "NO", "0"]) - def test_falsy(self, txt): + def test_falsy(self, txt: str) -> None: assert cv.txt2bool(txt) is False diff --git a/tests/unit/test_geo.py b/tests/unit/test_geo.py index 7161a28..7a64617 100644 --- a/tests/unit/test_geo.py +++ b/tests/unit/test_geo.py @@ -13,40 +13,40 @@ class TestQdrDist: - def test_eastbound_along_equator(self): + def test_eastbound_along_equator(self) -> None: qdr, dist = geo.qdrdist(0.0, 0.0, 0.0, 1.0) assert qdr == pytest.approx(90.0, abs=0.1) assert dist == pytest.approx(60.1, abs=0.2) # 1 deg lon at equator - def test_northbound_along_meridian(self): + def test_northbound_along_meridian(self) -> None: qdr, dist = geo.qdrdist(0.0, 0.0, 1.0, 0.0) assert qdr == pytest.approx(0.0, abs=0.1) assert dist == pytest.approx(60.1, abs=0.5) - def test_reciprocal_bearing(self): + def test_reciprocal_bearing(self) -> None: qdr_fwd, dist_fwd = geo.qdrdist(52.0, 4.0, 53.0, 5.0) qdr_rev, dist_rev = geo.qdrdist(53.0, 5.0, 52.0, 4.0) assert dist_fwd == pytest.approx(dist_rev, rel=1e-6) assert (qdr_rev - qdr_fwd) % 360.0 == pytest.approx(180.0, abs=1.0) - def test_zero_distance_same_point(self): + def test_zero_distance_same_point(self) -> None: _, dist = geo.qdrdist(52.0, 4.0, 52.0, 4.0) assert dist == pytest.approx(0.0, abs=1e-6) class TestDistanceFunctions: - def test_latlondist_matches_qdrdist(self): + def test_latlondist_matches_qdrdist(self) -> None: _, dist_nm = geo.qdrdist(52.0, 4.0, 52.5, 4.5) dist_m = geo.latlondist(52.0, 4.0, 52.5, 4.5) assert dist_m / NM_IN_M == pytest.approx(dist_nm, rel=1e-3) - def test_kwikdist_approximates_latlondist(self): + def test_kwikdist_approximates_latlondist(self) -> None: # kwikdist is a fast flat-earth approximation, good at short range dist_kwik_nm = geo.kwikdist(52.0, 4.0, 52.1, 4.1) dist_m = geo.latlondist(52.0, 4.0, 52.1, 4.1) assert dist_kwik_nm == pytest.approx(dist_m / NM_IN_M, rel=0.01) - def test_kwikqdrdist_approximates_qdrdist(self): + def test_kwikqdrdist_approximates_qdrdist(self) -> None: qdr, dist = geo.qdrdist(52.0, 4.0, 52.1, 4.1) kqdr, kdist = geo.kwikqdrdist(52.0, 4.0, 52.1, 4.1) assert kqdr == pytest.approx(qdr, abs=1.0) @@ -59,7 +59,7 @@ class TestMatrixVariants: LAT2 = np.array([4.0, 6.0]) LON2 = np.array([5.0, 8.0]) - def test_latlondist_matrix_returns_metres_like_scalar(self): + def test_latlondist_matrix_returns_metres_like_scalar(self) -> None: # Regression: latlondist_matrix returned nm while latlondist returns m dist = geo.latlondist_matrix(self.LAT1, self.LON1, self.LAT2, self.LON2) assert dist.shape == (2, 2) @@ -68,7 +68,7 @@ def test_latlondist_matrix_returns_metres_like_scalar(self): expected_m = geo.latlondist(self.LAT1[i], self.LON1[i], self.LAT2[j], self.LON2[j]) assert dist[i, j] == pytest.approx(expected_m, rel=1e-3) - def test_latlondist_matrix_high_latitude_matches_scalar(self): + def test_latlondist_matrix_high_latitude_matches_scalar(self) -> None: # Regression: the matrix variants evaluated the earth radius at # lat1 + lat2 instead of 0.5 * (lat1 + lat2), skewing distances # at higher latitudes @@ -78,47 +78,45 @@ def test_latlondist_matrix_high_latitude_matches_scalar(self): expected_m = geo.latlondist(60.0, 10.0, 70.0, 20.0) assert dist[0, 0] == pytest.approx(expected_m, rel=1e-9) - def test_latlondist_matrix_returns_plain_ndarray(self): + def test_latlondist_matrix_returns_plain_ndarray(self) -> None: # Regression: np.asmatrix is deprecated; result must not be np.matrix dist = geo.latlondist_matrix(self.LAT1, self.LON1, self.LAT2, self.LON2) assert isinstance(dist, np.ndarray) assert not isinstance(dist, np.matrix) - def test_qdrdist_matrix_matches_scalar(self): + def test_qdrdist_matrix_matches_scalar(self) -> None: qdr, dist = geo.qdrdist_matrix(self.LAT1, self.LON1, self.LAT2, self.LON2) assert not isinstance(qdr, np.matrix) assert not isinstance(dist, np.matrix) for i in range(2): for j in range(2): - sqdr, sdist_nm = geo.qdrdist( - self.LAT1[i], self.LON1[i], self.LAT2[j], self.LON2[j] - ) + sqdr, sdist_nm = geo.qdrdist(self.LAT1[i], self.LON1[i], self.LAT2[j], self.LON2[j]) assert qdr[i, j] == pytest.approx(sqdr, abs=1e-9) assert dist[i, j] == pytest.approx(sdist_nm, rel=1e-3) class TestProjection: @pytest.mark.parametrize("qdr,dist", [(0.0, 60.0), (45.0, 100.0), (270.0, 30.0)]) - def test_qdrpos_roundtrip(self, qdr, dist): + def test_qdrpos_roundtrip(self, qdr: float, dist: float) -> None: lat2, lon2 = geo.qdrpos(52.0, 4.0, qdr, dist) qdr_back, dist_back = geo.qdrdist(52.0, 4.0, lat2, lon2) assert qdr_back % 360.0 == pytest.approx(qdr % 360.0, abs=0.5) assert dist_back == pytest.approx(dist, rel=1e-3) - def test_qdrpos_north_increases_latitude(self): + def test_qdrpos_north_increases_latitude(self) -> None: lat2, lon2 = geo.qdrpos(52.0, 4.0, 0.0, 60.0) assert lat2 > 52.0 assert lon2 == pytest.approx(4.0, abs=1e-6) class TestWgs84: - def test_equatorial_radius(self): + def test_equatorial_radius(self) -> None: assert geo.rwgs84(0.0) == pytest.approx(6378137.0, rel=1e-6) - def test_polar_radius(self): + def test_polar_radius(self) -> None: assert geo.rwgs84(90.0) == pytest.approx(6356752.3, rel=1e-6) - def test_radius_within_bounds(self): + def test_radius_within_bounds(self) -> None: for lat in (10.0, 30.0, 45.0, 60.0, 80.0): r = geo.rwgs84(lat) assert 6356752.0 < r < 6378138.0 diff --git a/tests/unit/test_phase.py b/tests/unit/test_phase.py index 5a7829c..8a2e12a 100644 --- a/tests/unit/test_phase.py +++ b/tests/unit/test_phase.py @@ -13,42 +13,40 @@ FPM = 0.00508 -def fixwing_phase(alt_ft, roc_fpm, spd_kts=150.0): - ph = phase.get_fixwing( - np.array([spd_kts]), np.array([roc_fpm]), np.array([alt_ft]), unit="EP" - ) - return ph[0] +def fixwing_phase(alt_ft: float, roc_fpm: float, spd_kts: float = 150.0) -> int: + ph = phase.get_fixwing(np.array([spd_kts]), np.array([roc_fpm]), np.array([alt_ft]), unit="EP") + return int(ph[0]) class TestFixwingBoundaries: - def test_exactly_75ft_climbing_is_ground(self): + def test_exactly_75ft_climbing_is_ground(self) -> None: assert fixwing_phase(75.0, 500.0) == phase.GD - def test_exactly_75ft_descending_is_ground(self): + def test_exactly_75ft_descending_is_ground(self) -> None: assert fixwing_phase(75.0, -500.0) == phase.GD - def test_just_above_75ft_climbing_is_initial_climb(self): + def test_just_above_75ft_climbing_is_initial_climb(self) -> None: assert fixwing_phase(76.0, 500.0) == phase.IC - def test_just_above_75ft_descending_is_approach(self): + def test_just_above_75ft_descending_is_approach(self) -> None: assert fixwing_phase(76.0, -500.0) == phase.AP - def test_exactly_1000ft_climbing_is_initial_climb(self): + def test_exactly_1000ft_climbing_is_initial_climb(self) -> None: assert fixwing_phase(1000.0, 500.0) == phase.IC - def test_exactly_1000ft_descending_is_approach(self): + def test_exactly_1000ft_descending_is_approach(self) -> None: assert fixwing_phase(1000.0, -500.0) == phase.AP - def test_just_above_1000ft_climbing_is_climb(self): + def test_just_above_1000ft_climbing_is_climb(self) -> None: assert fixwing_phase(1001.0, 500.0) == phase.CL - def test_just_above_1000ft_descending_is_descent(self): + def test_just_above_1000ft_descending_is_descent(self) -> None: assert fixwing_phase(1001.0, -500.0) == phase.DE - def test_level_above_10000ft_is_cruise(self): + def test_level_above_10000ft_is_cruise(self) -> None: assert fixwing_phase(30000.0, 0.0) == phase.CR - def test_boundary_conditions_assign_exactly_one_phase(self): + def test_boundary_conditions_assign_exactly_one_phase(self) -> None: # Each altitude/roc band must match exactly one condition alt = np.array([75.0, 75.0, 1000.0, 1000.0, 1001.0, 1001.0]) roc = np.array([500.0, -500.0, 500.0, -500.0, 500.0, -500.0]) @@ -63,15 +61,13 @@ def test_boundary_conditions_assign_exactly_one_phase(self): matches = np.sum(conditions, axis=0) assert np.all(matches == 1) - def test_si_units_exactly_75ft_is_ground(self): - ph = phase.get_fixwing( - np.array([80.0]), np.array([5.0]), np.array([75.0 * FT]), unit="SI" - ) + def test_si_units_exactly_75ft_is_ground(self) -> None: + ph = phase.get_fixwing(np.array([80.0]), np.array([5.0]), np.array([75.0 * FT]), unit="SI") assert ph[0] == phase.GD class TestGetDtype: - def test_get_returns_integer_dtype(self): + def test_get_returns_integer_dtype(self) -> None: lifttype = np.array([LIFT_FIXWING, LIFT_ROTOR]) ph = phase.get( lifttype, @@ -84,12 +80,10 @@ def test_get_returns_integer_dtype(self): assert ph[0] == phase.CR assert ph[1] == phase.NA - def test_get_fixwing_returns_integer_dtype(self): - ph = phase.get_fixwing( - np.array([150.0]), np.array([0.0]), np.array([2000.0]), unit="EP" - ) + def test_get_fixwing_returns_integer_dtype(self) -> None: + ph = phase.get_fixwing(np.array([150.0]), np.array([0.0]), np.array([2000.0]), unit="EP") assert np.issubdtype(ph.dtype, np.integer) - def test_get_rotor_returns_integer_dtype(self): + def test_get_rotor_returns_integer_dtype(self) -> None: ph = phase.get_rotor(np.array([50.0]), np.array([0.0]), np.array([500.0])) assert np.issubdtype(ph.dtype, np.integer) diff --git a/tests/unit/test_tangram_plugin.py b/tests/unit/test_tangram_plugin.py index b196f76..c17004a 100644 --- a/tests/unit/test_tangram_plugin.py +++ b/tests/unit/test_tangram_plugin.py @@ -38,7 +38,7 @@ def make_snapshot() -> Snapshot: } -def test_convert_snapshot_units_and_fields(): +def test_convert_snapshot_units_and_fields() -> None: payload = convert_snapshot(make_snapshot()) assert payload["count"] == 1 @@ -65,7 +65,7 @@ def test_convert_snapshot_units_and_fields(): assert ac["timestamp"] == 1767225600.0 -def test_convert_snapshot_no_traffic(): +def test_convert_snapshot_no_traffic() -> None: snapshot: Snapshot = { "siminfo": { "speed": 1.0, @@ -113,7 +113,7 @@ def test_convert_snapshot_no_traffic(): } -def test_convert_snapshot_naive_simutc_is_utc(): +def test_convert_snapshot_naive_simutc_is_utc() -> None: # A simutc without tzinfo must be interpreted as UTC, never local time. snapshot = make_snapshot() snapshot["siminfo"]["simutc"] = "2026-01-01T00:00:00" @@ -121,7 +121,7 @@ def test_convert_snapshot_naive_simutc_is_utc(): assert payload["aircraft"][0]["timestamp"] == 1767225600.0 -def test_convert_snapshot_bad_simutc(): +def test_convert_snapshot_bad_simutc() -> None: snapshot = make_snapshot() snapshot["siminfo"]["simutc"] = "not a date" payload = convert_snapshot(snapshot) @@ -144,5 +144,5 @@ def test_convert_snapshot_bad_simutc(): ("[1, 2]", None), ], ) -def test_extract_command(payload, expected): +def test_extract_command(payload: str | bytes, expected: str | None) -> None: assert extract_command(payload) == expected From 70ebb24a565e18e12d7184a63a58f988d581c4ef Mon Sep 17 00:00:00 2001 From: Abraham Cheung <58929011+abc8747@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:31:24 +0800 Subject: [PATCH 16/16] docs: update examples to new runtime ownership --- AGENTS.md | 16 +++++--------- docs/api/minisky.md | 2 +- docs/api/tools.md | 4 ++-- docs/architecture.md | 18 +++++++++------ docs/getting-started.md | 17 +++++++------- docs/guides/plugins.md | 29 ++++++++++++------------ docs/guides/python-api.md | 11 +++++----- docs/guides/rest-api.md | 2 ++ docs/reference/commands.md | 2 +- minisky/core/settings.py | 1 + readme.md | 45 +++++++++++++++++++------------------- 11 files changed, 74 insertions(+), 73 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7dc8769..65ae54e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,15 +16,9 @@ uv run pytest tests/unit # fast pure-function tests only uv run pytest tests/integration/test_stack.py::test_name # single test uv run pytest -m api tests/test_api.py # REST API tests — spawn a separate process, opt-in -uv run ruff check . # lint -uv run ruff format . # format (line-length 100) -uv run pyright # type check (standard mode; covers example_plugins EXCEPT example_plugins/tangram) - -# tangram frontend plugin — its own self-contained uv + pnpm workspace (separate .venv); -# all commands run from example_plugins/tangram/ (see its justfile) -cd example_plugins/tangram && just check # ruff + pyright + pnpm (eslint + vue-tsc + tsc) -cd example_plugins/tangram && just fmt # ruff --fix/format + pnpm lint:fix -cd example_plugins/tangram && pnpm build # bundle each plugin into dist-frontend/ +just check # ruff, pyright, frontend checks +just fmt # lint fixes and tangram/frontend formatting +pnpm build # tangram frontend bundle uv run minisky commands docs # regenerate docs/reference/commands.md after changing commands uv run minisky docs serve # docs live preview @@ -47,9 +41,9 @@ The FastAPI app lives in `minisky/server.py`; `minisky server` is the CLI entry Full details in `docs/architecture.md` — read it before making structural changes. The essentials: -**Singletons.** `minisky.init()` constructs module-level singletons everything else references: `sim` (clock/state machine), `traf` (all aircraft state + flight-dynamics update), `runner` (async loop stepping at a controllable rate), `scr` (`ConsoleIO` output buffer), `navdb` (waypoints/airports/airways from parquet). They are `None` until `init()` runs. Call `load_plugins()` after `init()` to activate plugins from `settings.toml`. +**Runtime ownership.** [`MiniSky`][minisky.runtime.MiniSky] owns one simulator object graph: settings, simulation, traffic, runner, console, navigation, command stack, plugins, replaceables, areas, variable explorer, random generators, and streaming hub. Unlike `bluesky`, there is no package-level `traf`, `sim`, `scr`, `runner`, or `navdb`. -**Import order in `minisky/__init__.py` is load-bearing.** `traffic` is imported last and separately because the performance model runs module-level code touching `minisky.data` (set up by the settings import). Reordering causes a circular import. +**Lifecycle.** Use `with MiniSky(settings)` for manually stepped synchronous work. Use `async with MiniSky(settings)` and `await runtime.run()` when running the async loop. The FastAPI lifespan owns its background runner task and awaits asynchronous cleanup. **Simulation loop.** `sim.step()` runs, in order: stack processing → time advance (only in `OP` state) → plugin `preupdate` → `traf.update()` (autopilot/FMS, conflict detection+resolution, performance limits, wind, position integration) → plugin `update`. States: `INIT`, `OP`, `HOLD`, `END`. Drive it either by calling `sim.step()` manually (embedding) or via `runner.run()` (wall-clock paced; `runner.speed` and `runner.forward()`). diff --git a/docs/api/minisky.md b/docs/api/minisky.md index 37351b1..b285586 100644 --- a/docs/api/minisky.md +++ b/docs/api/minisky.md @@ -1,7 +1,7 @@ # `minisky` The top-level package exposes the explicit runtime owner, validated settings, -immutable default-settings path, and simulation-state constants. +default settings path, and simulation-state constants. ## Runtime diff --git a/docs/api/tools.md b/docs/api/tools.md index 681f205..094af92 100644 --- a/docs/api/tools.md +++ b/docs/api/tools.md @@ -1,7 +1,7 @@ # `minisky.tools` -Aeronautics and geodesy utilities. These are pure functions, usable outside the -simulator. +Aeronautics and geodesy utilities, plus runtime-owned area, navigation, and +position helpers. ## Aeronautics (`aero`) diff --git a/docs/architecture.md b/docs/architecture.md index 5220596..fed246e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,8 +1,7 @@ # Architecture MiniSky keeps BlueSky's core simulation model but removes the GUI, networking, -and node management. What remains is a single-process simulator whose mutable -state is owned by one [`MiniSky`][minisky.MiniSky] runtime. +and node management. The mutabl state is owned by one [`MiniSky`][minisky.MiniSky] runtime. ## Runtime ownership @@ -17,6 +16,9 @@ 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 records, hooks, timers, and state | +| `runtime.areas` | [`AreaFilter`][minisky.tools.areafilter.AreaFilter] | Named geographic areas | +| `runtime.variables` | [`VariableExplorer`][minisky.core.varexplorer.VariableExplorer] | Runtime data inspection | +| `runtime.streaming` | `StreamHub` | Rate-capped snapshot fan-out | ```python from minisky import DEFAULT_SETTINGS_FILE, MiniSky, MiniSkySettings @@ -32,15 +34,17 @@ with MiniSky(settings) as runtime: The simulation advances in discrete timesteps of [`runtime.simulation.simdt`][minisky.simulation.simulation.Simulation] seconds (default 1 s). One call to [`Simulation.step`][minisky.simulation.simulation.Simulation.step] does, in order: -1. **Stack processing** — pending text commands are parsed and executed +1. `INIT` switches to `OP` when traffic or scenario work exists. +2. pending text commands are parsed and executed ([`CommandStack.process`][minisky.stack.CommandStack.process]). -2. **Time advance** — [`runtime.simulation.simt`][minisky.simulation.simulation.Simulation] and the simulated UTC clock move forward by `simdt` +3. [`runtime.simulation.simt`][minisky.simulation.simulation.Simulation] and the simulated UTC clock move forward by `simdt` (only in the `OP` state). -3. **Plugin pre-update** — timed plugin functions registered with the `preupdate` hook. -4. **Traffic update** — [`Traffic.update`][minisky.traffic.traffic.Traffic.update] +4. Timed plugin functions registered with the `preupdate` hook. +5. [`Traffic.update`][minisky.traffic.traffic.Traffic.update] integrates aircraft state: autopilot/FMS logic, conflict detection and resolution, aircraft performance limits, wind, and finally position integration. -5. **Plugin update** — timed plugin functions registered with the `update` hook. +6. Timed plugin functions registered with the `update` hook. +7. The runtime-owned hub publishes when subscribers are present. The simulation state machine uses [`SimulationState`][minisky.simulation.simulation.SimulationState]: `SimulationState.INIT` waits for traffic, `SimulationState.OP` runs, diff --git a/docs/getting-started.md b/docs/getting-started.md index a3e60fc..d8fc7f9 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -86,16 +86,17 @@ ownership and stepping the simulation yourself. ## Configuration Runtime settings live in `settings.toml` at the repository root, e.g. conflict-detection -lookahead time and protected-zone sizes, the plugin search directory, and which plugins to -load at startup: +lookahead time and protected-zone sizes, plus the current plugin search directory and startup list: -```yaml -asas_dtlookahead: 300 # ASAS lookahead time [sec] -asas_pzr: 5 # ASAS horizontal protected zone radius [nm] -asas_pzh: 1000 # ASAS vertical protected zone height [ft] +```toml +asas_dtlookahead = 300 +asas_pzr = 5 +asas_pzh = 1000 +asas_marh = 1.05 +asas_marv = 1.05 -plugin_path: example_plugins -# enabled_plugins: ['EXAMPLE'] +plugin_path = "example_plugins" +enabled_plugins = [] ``` ## Running the tests diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md index 985ad2f..31dab5c 100644 --- a/docs/guides/plugins.md +++ b/docs/guides/plugins.md @@ -59,9 +59,10 @@ The config dictionary supports these lifecycle entries: | `shutdown` | Callback when the owning runtime shuts down | | `state` | Optional plugin-owned object exposed through the variable explorer | -Plugin records, loaded state, timers, hooks, and returned state belong to -[`runtime.plugins`][minisky.plugin.plugin.PluginManager]. Loading the same plugin into two runtimes creates separate -records and hook sets. +Plugin records, timers, hooks, and returned state belong to +[`runtime.plugins`][minisky.plugin.plugin.PluginManager]. Each `init_plugin` call +must create runtime-specific state; imported Python modules and class declarations +are still process-wide. ## Per-aircraft data: `Entity` @@ -99,7 +100,7 @@ correct runtime: class Example(plugin.Entity): # ... - def passengers(self, callsign: str, count: int = -1): + def passengers(self, callsign: str, count: int = -1) -> tuple[bool, str]: callsign = callsign.upper() if callsign not in self.traffic.callsign: return False, f"Aircraft {callsign} not found" @@ -116,7 +117,7 @@ A command entry contains the callback, argument parser specification, brief usage text, and help text. Command handlers return `(success, message)`; returning `None` counts as success with no message. -The [`@stack.command`][minisky.plugin.plugin_decorators.command] decorator is +The [`@plugin.command`][minisky.plugin.plugin_decorators.command] decorator is also available for stateless module-level declarations. Importing a decorated function only stores metadata. The command is registered when the owning runtime loads that plugin module. @@ -128,26 +129,24 @@ discovery during construction. Load plugins in any of these ways: -- **At startup** — list names under `enabled_plugins`, then call - `runtime.load_plugins()`. -- **From the stack** — use `PLUGINS LIST` and `PLUGINS LOAD EXAMPLE`. -- **From Python** — call `runtime.plugins.load("EXAMPLE")`. -- **Over the REST API** — use `GET /plugins` and - `GET /plugins/load/EXAMPLE`. +- At startup: list names under `enabled_plugins`, then call `runtime.load_plugins()`. +- From the stack: use `PLUGINS LIST` and `PLUGINS LOAD EXAMPLE`. +- From Python: call [`runtime.plugins.load("EXAMPLE")`][minisky.plugin.plugin.PluginManager.load]. +- REST API: use `GET /plugins` and `GET /plugins/load/EXAMPLE`. ```python from minisky import MiniSky, MiniSkySettings settings = MiniSkySettings.from_file("settings.toml") -runtime = MiniSky(settings) -runtime.load_plugins() +with MiniSky(settings) as runtime: + runtime.load_plugins() ``` ## Replaceable implementations A plugin can declare a subclass of a replaceable traffic component, such as -[`Autopilot`][minisky.traffic.autopilot.Autopilot]. Importing the class adds it -to the shared declaration catalog, while selection belongs to each runtime: +[`Autopilot`][minisky.traffic.autopilot.Autopilot]. Python tracks the subclass declaration process-wide, while the selected instance +belongs to each runtime: ```python runtime.replaceables.select("AUTOPILOT", "CUSTOMAUTOPILOT") diff --git a/docs/guides/python-api.md b/docs/guides/python-api.md index 38845fd..7fbbd50 100644 --- a/docs/guides/python-api.md +++ b/docs/guides/python-api.md @@ -1,8 +1,7 @@ # Python library MiniSky can be embedded in your own Python code. Construct one explicit runtime, -step its simulation, read aircraft state directly from NumPy arrays, and close -the runtime when finished. +step its simulation, and read aircraft state directly from NumPy arrays. ## Minimal example @@ -44,11 +43,13 @@ runtime.streaming # per-runtime snapshot fan-out Pass a scenario to the constructor to queue it immediately: ```python -runtime = MiniSky(settings, scenario="scenarios/kl204.scn") +with MiniSky(settings, scenario="scenarios/kl204.scn") as runtime: + runtime.simulation.step() ``` -Use `with MiniSky(...)` or `async with MiniSky(...)` so plugin resources, -stream consumers, and the runner are closed deterministically. +Use `with MiniSky(...)` for manually stepped synchronous work. Use +`async with MiniSky(...)` when running `await runtime.run()` so asynchronous +runner cleanup is awaited. ## Creating and commanding aircraft diff --git a/docs/guides/rest-api.md b/docs/guides/rest-api.md index 0fd8a64..ca03cf9 100644 --- a/docs/guides/rest-api.md +++ b/docs/guides/rest-api.md @@ -25,6 +25,8 @@ FastAPI serves interactive OpenAPI docs at `http://localhost:8000/docs`. | GET | `/speed/{speed}` | Set the simulation speed multiplier | | GET | `/forward/{seconds}` | Fast-forward the simulation by a number of seconds | | GET | `/stack/{cmd}` | Execute any [stack command](../reference/commands.md) and return its output | +| GET | `/commands` | List canonical stack commands and usage strings | +| WebSocket | `/stream` | Receive rate-capped full simulation snapshots | | GET/POST | `/scn` | Upload and load a scenario file (GET serves a small upload form) | | GET | `/map` | Browser-based aircraft map viewer (served from `static/`) | | GET | `/plugins` | List available and loaded plugins | diff --git a/docs/reference/commands.md b/docs/reference/commands.md index f5cfb82..d6c3a2f 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -2,7 +2,7 @@ Every text command understood by the simulator — usable in scenario files, the [console](../guides/console.md), the REST [`stack/` endpoint](../guides/rest-api.md), -or `runtime.commands.stack()` from Python. Commands are +or [`runtime.commands.stack()`][minisky.stack.CommandStack.stack] from Python. Commands are case-insensitive. Argument conventions: optional arguments are enclosed in `[...]`; `callsign` is an diff --git a/minisky/core/settings.py b/minisky/core/settings.py index 639f8cf..cd35efe 100644 --- a/minisky/core/settings.py +++ b/minisky/core/settings.py @@ -22,6 +22,7 @@ class MiniSkySettings(BaseModel): asas_pzh: Annotated[float, Field(), annotated_types.Gt(0)] = 1000.0 asas_marh: Annotated[float, Field(), annotated_types.Gt(0)] = 1.05 asas_marv: Annotated[float, Field(), annotated_types.Gt(0)] = 1.05 + # TODO(abraham): delete this. plugin_path: Annotated[str, Field(), annotated_types.MinLen(1)] = "plugins" enabled_plugins: tuple[str, ...] = () diff --git a/readme.md b/readme.md index 89d6d49..19e4680 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,4 @@ -# MiniSky - A minimal command line air traffic simulator with REST API +# MiniSky MiniSky is a hackable air traffic control simulator, a fork of [BlueSky](https://github.com/TUDelft-CNS-ATM/bluesky). @@ -9,27 +9,26 @@ MiniSky is being optimized for: - use in command-line - interact with the simulator through REST API - call simulations in your own Python code +- running multiple independent simulations side by side in one process, with each runtime owning its own state and lifecycle ## Usage -### 1. Run a scenario file without interaction - -Run the simulator with a scenario file: +### 1. Run a scenario ```bash +uv sync uv run minisky run --scenario scenarios/kl204.scn +uv run minisky run --scenario scenarios/kl204.scn --speed 10 ``` -### 2. Run simulator with REST API server - -Start the simulator with a REST API endpoint for interactions: +### 2. Run the API server ```bash -uv run minisky server # serves on 0.0.0.0:8000 -uv run minisky server --reload # development server with auto-reload +uv run minisky server # serves on 0.0.0.0:8000 by default +uv run minisky server --reload ``` -#### Interaction with API +### 3. Interaction with API Once the fastapi server is running, some simple examples: @@ -100,19 +99,19 @@ Note that commands are case-insensitive. Use the simulator in your Python code: ```python -import minisky - -minisky.init() - -minisky.sim.reset() -minisky.traf.cre('KL315', lat=52.0, lon=4.0, hdg=45, alt=5000, spd=250) -minisky.stack.stack('KL315 ADDWPT HELEN FL100 250') - -minisky.sim.simdt = 10 - -for i in range(5): - minisky.sim.step() - print(f"time-{minisky.sim.simt}s, positions: {minisky.traf.lat} {minisky.traf.lon}") +from minisky import DEFAULT_SETTINGS_FILE, MiniSky, MiniSkySettings + +settings = MiniSkySettings.from_file(DEFAULT_SETTINGS_FILE) +with MiniSky(settings) as runtime: + runtime.traffic.cre( + "KL315", lat=52.0, lon=4.0, hdg=45, alt=5000, spd=250 + ) + runtime.commands.stack("KL315 ADDWPT HELEN FL100 250") + + runtime.simulation.simdt = 10 + for _ in range(5): + runtime.simulation.step() + print(runtime.simulation.simt, runtime.traffic.lat, runtime.traffic.lon) ``` ## Documentation