diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1bb70ec..50a6aa9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,15 +1,23 @@ -name: tests +name: ci on: - pull_request: push: - branches: [main, refactor] + branches: + - main + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - pytest: + test-python: + name: test python runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v7 - name: Install pnpm uses: pnpm/action-setup@v6 @@ -20,22 +28,34 @@ jobs: node-version: 24 cache: pnpm - - name: Build frontend plugin + - name: Install frontend dependencies + run: pnpm install --frozen-lockfile + + # NOTE(abraham): we must build the frontend assets otherwise tangram_minisky would fail + # with missing frontend assets. + # we should document this and potentially split up the action into repo-with-frontend + # just like tangram + - name: Check and build frontend plugin run: | - pnpm install --frozen-lockfile pnpm check pnpm build - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: enable-cache: true - name: Install Python dependencies - run: uv sync --locked --all-packages + run: uv sync --all-groups --all-packages + + - name: Check code quality + run: | + uv run ruff check packages + uv run ruff format packages --check + uv run pyright - name: Run unit and integration tests run: uv run pytest - name: Run API smoke tests - run: uv run pytest -m api tests/test_api.py + run: uv run pytest -m api packages/minisky/tests/test_api.py diff --git a/.gitignore b/.gitignore index b40d36f..69d210c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,187 +1,18 @@ - -# Automatically generated directories and files -output -cache - -# tangram frontend plugin builds (example_plugins/tangram/*) +# generated frontend artifacts node_modules/ dist-frontend/ -bluesky/resources/grib -bluesky/resources/netcdf - -.vscode -# Byte-compiled / optimized / DLL files +# python __pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# zensical documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc +/.pytest_cache/ +/.ruff_cache/ +/.venv/ +/dist/ + +# zensical +/.cache/ +/site/ + +# misc +.vscode/ +.DS_Store diff --git a/AGENTS.md b/AGENTS.md index aa10701..9db2c85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ minisky console # interactive console ``` The FastAPI app lives in `packages/minisky/minisky/server.py`; `minisky server` is the CLI entry point -(`MINISKY_HOST`/`MINISKY_PORT` env vars, default `0.0.0.0:8000`). +(default `0.0.0.0:8000`; configure `[server]` in TOML or override with `--host` and `--port`). ## Architecture diff --git a/docs/guides/cli.md b/docs/guides/cli.md index cfa514f..91a5a8a 100644 --- a/docs/guides/cli.md +++ b/docs/guides/cli.md @@ -12,7 +12,7 @@ uv run minisky --help | Command | Purpose | | --- | --- | | `minisky run --scenario FILE [--speed N] [--config FILE]` | Run a scenario file without interaction. | -| `minisky server [--host HOST] [--port PORT] [--reload] [--config FILE]` | Start the REST and WebSocket API server. | +| `minisky server [--host HOST] [--port PORT] [--reload] [--config FILE]` | Start the REST and WebSocket API server; CLI bind options override `[server]` config. | | `minisky console [--server URL] [--port PORT]` | Open an interactive console against a running server. | | `minisky stream [--url URL] [--raw]` | Print snapshots from the `/stream` WebSocket. | diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md index bd51d5b..e65745f 100644 --- a/docs/guides/plugins.md +++ b/docs/guides/plugins.md @@ -63,14 +63,18 @@ Use [`@plugin.command`][minisky.plugin.plugin_decorators.command] on an instance Use the `arguments` option when MiniSky's stack parser needs more information than the Python annotations provide: ```python +from minisky import Err, Ok, Result + @plugin_api.command(arguments="txt,[int]") -def passengers(self, callsign: str, count: int = -1) -> tuple[bool, str]: +def passengers(self, callsign: str, count: int = -1) -> Result[str, str]: """Set or get the passenger count for an aircraft.""" - ... + if count < 0: + return Ok("current passenger count") + if count > 500: + return Err("passenger count is too large") + return Ok("passenger count updated") ``` -A command handler can return `(success, message)`. Returning `None` means the command completed successfully without a message. - ## Add simulation hooks Use [`@plugin.hook`][minisky.plugin.plugin_decorators.hook] for work tied to the simulation cycle: @@ -138,17 +142,25 @@ async with MiniSky(config=config) as runtime: To load only one installed plugin, call `load()` with its plugin ID: ```python -ok, message = await runtime.plugins.load("example") -print(message) +from minisky import Err, Ok + +match await runtime.plugins.load("example"): + case Ok(message): + print(message) + case Err(error): + # ... handle the error ``` -Plugin IDs are case-insensitive. `load()` returns a success flag and a message, so you can decide how your application should handle a missing, invalid, or already loaded plugin. +Plugin IDs are case-insensitive. `load()` returns [`Result[str, str]`][minisky.result.Result]`. To inspect the plugins known to the runtime, use `listing()`: ```python -ok, text = runtime.plugins.listing() -print(text) +match runtime.plugins.listing(): + case Ok(text): + print(text) + case Err(error): + # ... handle the error ``` While the simulator is running, you can manage plugins through the stack instead: diff --git a/justfile b/justfile index 12dcb08..8584e59 100644 --- a/justfile +++ b/justfile @@ -4,13 +4,13 @@ sync: uv sync --all-packages fmt: - uv run ruff check packages tests --fix - uv run ruff format packages tests + uv run ruff check packages --fix + uv run ruff format packages pnpm lint:fix check: - uv run ruff check packages tests - uv run ruff format packages tests --check + uv run ruff check packages + uv run ruff format packages --check uv run pyright pnpm check @@ -20,11 +20,11 @@ test: # Run fast unit tests. test-unit: - uv run pytest tests/unit + uv run pytest packages/*/tests/unit # Run opt-in REST API tests. test-api: - uv run pytest -m api tests/test_api.py + uv run pytest -m api packages/minisky/tests/test_api.py docs-serve: uv run --group docs zensical serve diff --git a/packages/minisky-example-customautopilot/tests/test_customautopilot_plugin.py b/packages/minisky-example-customautopilot/tests/test_customautopilot_plugin.py new file mode 100644 index 0000000..080cf74 --- /dev/null +++ b/packages/minisky-example-customautopilot/tests/test_customautopilot_plugin.py @@ -0,0 +1,30 @@ +"""Integration tests owned by the custom-autopilot example package.""" + +import pytest +from minisky import MiniSky, MiniSkyConfig +from minisky.traffic.autopilot import Autopilot +from minisky_example_customautopilot import CustomAutoPilot + + +@pytest.mark.anyio +async def test_replacement_is_runtime_local_and_removed_on_shutdown() -> None: + runtime_a = MiniSky(MiniSkyConfig()) + runtime_b = MiniSky(MiniSkyConfig()) + try: + assert runtime_a.replaceables.select("AUTOPILOT", "CUSTOMAUTOPILOT").is_err() + assert runtime_b.replaceables.select("AUTOPILOT", "CUSTOMAUTOPILOT").is_err() + + alt_callback = runtime_a.commands.cmddict["ALT"].callback + result = await runtime_a.plugins.load("CUSTOMAUTOPILOT") + assert result.is_ok(), result.err() + assert runtime_a.replaceables.select("AUTOPILOT", "CUSTOMAUTOPILOT").is_ok() + assert type(runtime_a.traffic.ap) is CustomAutoPilot + assert runtime_b.replaceables.select("AUTOPILOT", "CUSTOMAUTOPILOT").is_err() + + await runtime_a.plugins.aclose() + assert type(runtime_a.traffic.ap) is Autopilot + assert runtime_a.replaceables.select("AUTOPILOT", "CUSTOMAUTOPILOT").is_err() + assert runtime_a.commands.cmddict["ALT"].callback is alt_callback + finally: + await runtime_a.aclose() + await runtime_b.aclose() diff --git a/packages/minisky-example/src/minisky_example/__init__.py b/packages/minisky-example/src/minisky_example/__init__.py index 093d803..780521b 100644 --- a/packages/minisky-example/src/minisky_example/__init__.py +++ b/packages/minisky-example/src/minisky_example/__init__.py @@ -5,8 +5,8 @@ from random import Random import numpy as np - from minisky import plugin as plugin_api +from minisky.result import Err, Ok, Result # --8<-- [start:declaration] @@ -40,16 +40,16 @@ def update(self) -> None: self.updates += 1 @plugin_api.command(arguments="txt,[int]") - def passengers(self, callsign: str, count: int = -1) -> tuple[bool, str]: + def passengers(self, callsign: str, count: int = -1) -> Result[str, str]: """Set or get the number of passengers on an aircraft.""" callsign = callsign.upper() if callsign not in self.traffic.callsign: - return False, f"Aircraft {callsign} not found" + return Err(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" + return Ok(f"Aircraft {callsign} has {int(self.npassengers[index])} passengers") self.npassengers[index] = count - return True, f"Set {callsign} passengers to {count}" + return Ok(f"Set {callsign} passengers to {count}") # --8<-- [end:entity] diff --git a/packages/minisky-example/tests/test_example_plugin.py b/packages/minisky-example/tests/test_example_plugin.py new file mode 100644 index 0000000..a39b2e3 --- /dev/null +++ b/packages/minisky-example/tests/test_example_plugin.py @@ -0,0 +1,50 @@ +"""Integration tests owned by the example plugin package.""" + +from typing import cast + +import pytest +from minisky import Err, MiniSky, MiniSkyConfig +from minisky_example import Example + + +def run_command(runtime: MiniSky, command: str) -> str: + runtime.commands.stack(command) + runtime.simulation.step() + return runtime.console.read_output_buffer() + + +@pytest.mark.anyio +async def test_commands_and_entity_are_runtime_owned() -> None: + runtime = MiniSky(MiniSkyConfig()) + try: + result = await runtime.plugins.load("EXAMPLE") + assert result.is_ok(), result.err() + record = runtime.plugins.plugins["EXAMPLE"] + assert record.loaded + assert tuple(runtime.plugins.loaded_plugins) == ("EXAMPLE",) + + run_command(runtime, "CRE KL001,A320,52,4,90,FL100,250") + assert "150" in run_command(runtime, "PASSENGERS KL001 150") + assert "150" in run_command(runtime, "PASSENGERS KL001") + + again = await runtime.plugins.load("EXAMPLE") + assert again == Err("Plugin EXAMPLE already loaded") + finally: + await runtime.aclose() + + +@pytest.mark.anyio +async def test_entity_sizes_existing_traffic_and_retires() -> None: + runtime = MiniSky(MiniSkyConfig()) + runtime.traffic.cre("KL001", "A320", lat=52.0, lon=4.0, hdg=90, alt=3000, spd=150) + result = await runtime.plugins.load("EXAMPLE") + assert result.is_ok(), result.err() + record = runtime.plugins.plugins["EXAMPLE"] + entity = cast(Example, record.entities[0]) + assert record.entities == (entity,) + assert len(entity.npassengers) == 1 + assert entity._traffic is runtime.traffic + + await runtime.aclose() + assert entity._retired + assert entity._traffic is None diff --git a/packages/minisky-tangram/src/minisky_tangram/__init__.py b/packages/minisky-tangram/src/minisky_tangram/__init__.py index 797a51a..6e3f069 100644 --- a/packages/minisky-tangram/src/minisky_tangram/__init__.py +++ b/packages/minisky-tangram/src/minisky_tangram/__init__.py @@ -49,12 +49,12 @@ from datetime import UTC, datetime from typing import Any, TypedDict, cast -from pydantic import BaseModel, ConfigDict - from minisky import plugin as plugin_api +from minisky.result import Err, Ok, Result from minisky.simulation import SimulationState from minisky.streaming import Snapshot from minisky.tools.aero import fpm, ft, kts +from pydantic import BaseModel, ConfigDict # --8<-- [start:configuration] @@ -237,7 +237,7 @@ def __init__( self._thread: threading.Thread | None = None self.ready = threading.Event() - def start(self, runtime: plugin_api.PluginRuntime) -> tuple[bool, str]: + def start(self, runtime: plugin_api.PluginRuntime) -> Result[str, str]: """Bind runtime capabilities and start the Redis thread.""" try: if self.redis_factory is None: @@ -248,7 +248,7 @@ def start(self, runtime: plugin_api.PluginRuntime) -> tuple[bool, str]: redis.Redis.from_url, # pyright: ignore[reportUnknownMemberType] ) except ImportError: - return False, ( + return Err( "TANGRAM plugin needs the redis package; run `just sync` from the " "MiniSky repository root" ) @@ -260,7 +260,7 @@ def start(self, runtime: plugin_api.PluginRuntime) -> tuple[bool, str]: self.ready.clear() self._thread = threading.Thread(target=self._run, name="tangram-bridge", daemon=True) self._thread.start() - return True, f"Tangram bridge publishing to to:{self.channel}:* at {self.redis_url}" + return Ok(f"Tangram bridge publishing to to:{self.channel}:* at {self.redis_url}") def stop(self) -> None: """Stop Redis I/O and release runtime callbacks.""" @@ -277,7 +277,7 @@ def stop(self) -> None: self._stack_command = None @plugin_api.command(name="TANGRAM") - def status(self) -> tuple[bool, str]: + def status(self) -> Result[str, str]: """Show the status of the tangram Redis bridge.""" status = "connected" if self.connected else "disconnected" text = ( @@ -286,7 +286,7 @@ def status(self) -> tuple[bool, str]: ) if self.last_error: text += f"\nLast error: {self.last_error}" - return True, text + return Ok(text) @plugin_api.hook("update") def tick(self) -> None: @@ -418,10 +418,12 @@ def build(context: plugin_api.PluginContext[TangramConfig]) -> plugin_api.Plugin @asynccontextmanager async def lifespan(runtime: plugin_api.PluginRuntime) -> AsyncGenerator[None]: runtime.subscribe_console(bridge.capture_console) - success, message = bridge.start(runtime) - runtime.echo(message) - if not success: - raise RuntimeError(message) + match bridge.start(runtime): + case Ok(message): + runtime.echo(message) + case Err(message): + runtime.echo(message) + raise RuntimeError(message) try: yield finally: diff --git a/packages/minisky-tangram/tests/conftest.py b/packages/minisky-tangram/tests/conftest.py new file mode 100644 index 0000000..807b6b3 --- /dev/null +++ b/packages/minisky-tangram/tests/conftest.py @@ -0,0 +1,35 @@ +"""Runtime fixtures for the MiniSky tangram bridge tests.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator + +import pytest +from minisky import MiniSky, MiniSkyConfig +from minisky.simulation import Simulation + + +@pytest.fixture(scope="session") +def runtime() -> Iterator[MiniSky]: + instance = MiniSky(MiniSkyConfig()) + yield instance + instance.close() + + +@pytest.fixture +def sim(runtime: MiniSky) -> Simulation: + runtime.simulation.reset() + runtime.console.read_output_buffer() + return runtime.simulation + + +@pytest.fixture +def step_until(runtime: MiniSky) -> Callable[[Callable[[], bool]], int]: + def _step(pred: Callable[[], bool], max_steps: int = 600) -> int: + for index in range(max_steps): + runtime.simulation.step() + if pred(): + return index + pytest.fail(f"condition not met within {max_steps} simulation steps") + + return _step diff --git a/tests/integration/test_tangram_bridge.py b/packages/minisky-tangram/tests/integration/test_tangram_bridge.py similarity index 98% rename from tests/integration/test_tangram_bridge.py rename to packages/minisky-tangram/tests/integration/test_tangram_bridge.py index fc15afe..ab9c5c3 100644 --- a/tests/integration/test_tangram_bridge.py +++ b/packages/minisky-tangram/tests/integration/test_tangram_bridge.py @@ -7,11 +7,10 @@ import fakeredis import pytest -from redis.client import PubSub - from minisky import MiniSky from minisky.simulation import Simulation, SimulationState from minisky_tangram import TangramBridge +from redis.client import PubSub Observer = tuple[fakeredis.FakeRedis, PubSub] StepUntil = Callable[[Callable[[], bool]], int] @@ -43,8 +42,8 @@ def bridge( plugin_runtime = runtime.plugins._plugin_runtime() plugin_runtime._activate() plugin_runtime.subscribe_console(bridge.capture_console) - ok, msg = bridge.start(plugin_runtime) - assert ok, msg + result = bridge.start(plugin_runtime) + assert result.is_ok(), result.err() # The I/O thread subscribes asynchronously; commands published before the # subscription is live would be silently lost (pub/sub has no replay). assert bridge.ready.wait(timeout=5.0), "bridge did not subscribe in time" diff --git a/tests/unit/test_tangram_plugin.py b/packages/minisky-tangram/tests/unit/test_tangram_plugin.py similarity index 99% rename from tests/unit/test_tangram_plugin.py rename to packages/minisky-tangram/tests/unit/test_tangram_plugin.py index e28aa63..bdf62f2 100644 --- a/tests/unit/test_tangram_plugin.py +++ b/packages/minisky-tangram/tests/unit/test_tangram_plugin.py @@ -1,7 +1,6 @@ """Unit tests for the pure functions of the tangram bridge plugin.""" import pytest - from minisky.streaming import Snapshot from minisky_tangram import convert_snapshot, extract_command diff --git a/packages/minisky/minisky/__init__.py b/packages/minisky/minisky/__init__.py index 004f1c2..3e9d5e3 100644 --- a/packages/minisky/minisky/__init__.py +++ b/packages/minisky/minisky/__init__.py @@ -10,6 +10,7 @@ default_user_config_dir, default_user_config_toml_path, ) +from minisky.result import Err, Ok, Result, UnwrapError from minisky.runtime import MiniSky from minisky.simulation import SimulationState @@ -23,9 +24,13 @@ "BS_CMDERR", "BS_FUNERR", "BS_OK", - "default_user_config_dir", - "default_user_config_toml_path", + "Err", "MiniSky", "MiniSkyConfig", + "Ok", + "Result", "SimulationState", + "UnwrapError", + "default_user_config_dir", + "default_user_config_toml_path", ) diff --git a/packages/minisky/minisky/cli.py b/packages/minisky/minisky/cli.py index e19bc23..714b2dd 100644 --- a/packages/minisky/minisky/cli.py +++ b/packages/minisky/minisky/cli.py @@ -19,7 +19,7 @@ from prompt_toolkit.history import FileHistory from pydantic import ValidationError -from minisky.core.config import MiniSkyConfig +from minisky.core.config import MiniSkyConfig, default_user_config_toml_path if TYPE_CHECKING: from minisky.runtime import MiniSky @@ -31,19 +31,18 @@ typer.Option(help="Config TOML file. Overrides the default user config path."), ] -history_file = os.path.expanduser("/tmp/hacksky_console_history") +history_file = Path("/tmp/hacksky_console_history").expanduser() path_completer = PathCompleter() completer = NestedCompleter.from_nested_dict({"load": path_completer, "/load": path_completer}) -def _load_config(path: Path | None) -> MiniSkyConfig | None: - if path is None: - return None - - selected = path.expanduser() +def _load_config(path: Path | None) -> MiniSkyConfig: + selected = path.expanduser() if path is not None else default_user_config_toml_path() try: return MiniSkyConfig.from_path(selected) except FileNotFoundError as exc: + if path is None: + return MiniSkyConfig() raise typer.BadParameter( f"config file not found: {selected}", param_hint="--config", @@ -55,16 +54,16 @@ def _load_config(path: Path | None) -> MiniSkyConfig | None: ) from exc -def _new_runtime(config_path: Path | None, scenario: str | None = None) -> MiniSky: - """Construct a runtime from explicit or default configuration.""" +def _new_runtime(config: MiniSkyConfig, scenario: str | None = None) -> MiniSky: + """Construct a runtime from validated configuration.""" from minisky import MiniSky - return MiniSky(config=_load_config(config_path), scenario=scenario) + return MiniSky(config=config, scenario=scenario) async def _run_scenario(scenario: str, speed: int, config_path: Path | None) -> None: """Initialise the simulator with a scenario and run it to completion.""" - async with _new_runtime(config_path, scenario) as runtime: + async with _new_runtime(_load_config(config_path), scenario) as runtime: await runtime.plugins.load_configured() runtime.runner.speed = speed await runtime.run() @@ -82,12 +81,8 @@ def run_cmd( @app.command("server") def server_cmd( - host: Annotated[str, typer.Option(help="Host address to bind.")] = os.environ.get( - "MINISKY_HOST", "0.0.0.0" - ), - port: Annotated[int, typer.Option(help="TCP port to bind.")] = int( - os.environ.get("MINISKY_PORT", "8000") - ), + host: Annotated[str | None, typer.Option(help="Host address to bind.")] = None, + port: Annotated[int | None, typer.Option(help="TCP port to bind.")] = None, reload: Annotated[bool, typer.Option(help="Enable uvicorn auto-reload.")] = False, config: _ConfigOption = None, ) -> None: @@ -101,6 +96,10 @@ def server_cmd( param_hint="--config", ) + loaded_config = _load_config(config) + host = host if host is not None else loaded_config.server.host + port = port if port is not None else loaded_config.server.port + if reload: uvicorn.run( "minisky.server:create_app", @@ -114,7 +113,7 @@ def server_cmd( from minisky.server import create_app uvicorn.run( - create_app(_new_runtime(config)), + create_app(_new_runtime(loaded_config)), host=host, port=port, ) @@ -132,7 +131,7 @@ def console_cmd( while True: print(Fore.LIGHTGREEN_EX + Style.BRIGHT, end="") - cmd = prompt("> ", completer=completer, history=FileHistory(history_file)) + cmd = prompt("> ", completer=completer, history=FileHistory(str(history_file))) print(Style.RESET_ALL, end="") if cmd == "": @@ -145,12 +144,12 @@ def console_cmd( os.system("clear") continue - if cmd.startswith("/load ") or cmd.startswith("load "): - file_path = cmd.split(" ", maxsplit=1)[1] + if cmd.startswith(("/load ", "load ")): + file_path = Path(cmd.split(" ", maxsplit=1)[1]) - if os.path.isfile(file_path): - with open(file_path, "rb") as f: - files = {"file": (os.path.basename(file_path), f)} + if file_path.is_file(): + with file_path.open("rb") as f: + files = {"file": (file_path.name, f)} response = requests.post(f"{root_url}/scn", files=files, timeout=30) typer.echo(response.json()) else: diff --git a/packages/minisky/minisky/core/config.py b/packages/minisky/minisky/core/config.py index d59728d..9f12c48 100644 --- a/packages/minisky/minisky/core/config.py +++ b/packages/minisky/minisky/core/config.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Annotated, Any, TypeAlias -import annotated_types +from annotated_types import Ge, Gt, Le from pydantic import BaseModel, ConfigDict, Field from pydantic.functional_validators import BeforeValidator @@ -16,16 +16,24 @@ PluginId: TypeAlias = Annotated[str, BeforeValidator(validate_plugin_id)] +class ServerConfig(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + host: str = "0.0.0.0" + port: Annotated[int, Ge(0), Le(65535)] = 8000 + + class MiniSkyConfig(BaseModel): """Validated configuration for a MiniSky runtime.""" model_config = ConfigDict(frozen=True, extra="forbid") - 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 + asas_dtlookahead: Annotated[float, Ge(0)] = 300.0 + asas_pzr: Annotated[float, Gt(0)] = 5.0 + asas_pzh: Annotated[float, Gt(0)] = 1000.0 + asas_marh: Annotated[float, Gt(0)] = 1.05 + asas_marv: Annotated[float, Gt(0)] = 1.05 + server: ServerConfig = Field(default_factory=ServerConfig) plugins: dict[PluginId, dict[str, Any]] = Field(default_factory=dict) @classmethod diff --git a/packages/minisky/minisky/core/trafficarrays.py b/packages/minisky/minisky/core/trafficarrays.py index 92dc998..6efcd92 100644 --- a/packages/minisky/minisky/core/trafficarrays.py +++ b/packages/minisky/minisky/core/trafficarrays.py @@ -32,6 +32,7 @@ import numpy as np from minisky.identifiers import normalize_public_name +from minisky.result import Err, Ok, Result defaults = MappingProxyType({"float": 0.0, "int": 0, "uint": 0, "bool": False, "S": "", "str": ""}) @@ -57,7 +58,7 @@ class _ComponentSlot: def current(self) -> TrafficArrays: component = getattr(self.traffic, self.attribute) if not isinstance(component, self.base): - raise RuntimeError(f"replaceable slot {self.attribute} has an invalid component") + raise TypeError(f"replaceable slot {self.attribute} has an invalid component") return component def bind(self, callback: Callable[..., Any]) -> Callable[..., Any]: @@ -178,18 +179,18 @@ def remove(self, replacements: tuple[PreparedReplacement, ...]) -> None: if implementations.get(replacement.name) is replacement.implementation: del implementations[replacement.name] - def select(self, basename: str = "", implname: str = "") -> tuple[bool, str]: + def select(self, basename: str = "", implname: str = "") -> Result[str, str]: if not basename: - return True, "Replaceable classes in MiniSky:\n" + ", ".join(sorted(self._bases)) + return Ok("Replaceable classes in MiniSky:\n" + ", ".join(sorted(self._bases))) base = self._bases.get(basename.upper()) if base is None: - return False, f"Replaceable {basename} not found." + return Err(f"Replaceable {basename} not found.") implementations = self._implementations[base] slot = self._slots[base] current = type(slot.current) if not implname: - return True, ( + return Ok( f"Current implementation for {basename}: {current.__name__}\n" f"Available implementations: {', '.join(sorted(implementations))}" ) @@ -197,10 +198,10 @@ def select(self, basename: str = "", implname: str = "") -> tuple[bool, str]: requested = base.__name__.upper() if implname.upper() == "BASE" else implname.upper() implementation = implementations.get(requested) if implementation is None: - return False, f"Implementation {implname} not found for {basename}." + return Err(f"Implementation {implname} not found for {basename}.") if current is not implementation: slot.replace(implementation) - return True, f"Selected {implname} for {basename}" + return Ok(f"Selected {implname} for {basename}") def reset(self) -> None: for base, slot in self._slots.items(): @@ -225,7 +226,6 @@ def __init__(self, parent: TrafficArrays) -> None: def __enter__(self) -> None: """No-op: the attribute snapshot is already taken in __init__.""" - pass def __exit__(self, exc_type, exc_value, tb) -> None: """Register all attributes created inside the with-block as traffic arrays.""" diff --git a/packages/minisky/minisky/core/varexplorer.py b/packages/minisky/minisky/core/varexplorer.py index d965a37..137c6a3 100644 --- a/packages/minisky/minisky/core/varexplorer.py +++ b/packages/minisky/minisky/core/varexplorer.py @@ -20,6 +20,7 @@ import numpy as np from minisky.core import TrafficArrays +from minisky.result import Err, Ok, Result class VariableExplorer: @@ -62,13 +63,13 @@ def unregister_data_parent(self, name: str, *, expected: object | None = None) - if current is not None and (expected is None or current[0] is expected): del self.varlist[name] - def lsvar(self, varname: str = "") -> tuple[bool, str]: + def lsvar(self, varname: str = "") -> Result[str, 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)) + return Ok("\n" + str.join(", ", list(self.varlist))) # Find the variable in the variable list v = self.findvar(varname) @@ -85,8 +86,8 @@ def lsvar(self, varname: str = "") -> tuple[bool, str]: txt += f"Parent: {v.parentname}" if attrs: txt += "\nAttributes: " + str.join(", ", attrs) + "\n" - return True, "\n" + txt - return False, f"Variable {varname} not found" + return Ok("\n" + txt) + return Err(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 @@ -135,8 +136,8 @@ def findvar(self, varname: str) -> Variable | None: 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 + except (AttributeError, IndexError, KeyError, TypeError, ValueError): + return None return None diff --git a/packages/minisky/minisky/plugin/plugin.py b/packages/minisky/minisky/plugin/plugin.py index b30e756..b5439bb 100644 --- a/packages/minisky/minisky/plugin/plugin.py +++ b/packages/minisky/minisky/plugin/plugin.py @@ -28,6 +28,7 @@ declared_hooks, declared_replacement, ) +from minisky.result import Err, Ok, Result from minisky.streaming import Snapshot, build_snapshot if TYPE_CHECKING: @@ -40,7 +41,7 @@ ConfigT = TypeVar("ConfigT") ComponentT = TypeVar("ComponentT") -CommandReply = tuple[bool, str] +CommandReply = Result[str, str] class PluginError(RuntimeError): @@ -328,19 +329,19 @@ def discover(self) -> None: if existing is None or not existing.loaded: self.plugins.pop(plugin_name, None) - async def load(self, name: str) -> CommandReply: + async def load(self, name: str) -> Result[str, str]: """Load a discovered plugin by name.""" async with self._lock: if self._state is not _ManagerState.OPEN: - return False, "Plugin manager is closed" + return Err("Plugin manager is closed") plugin = self.plugins.get(name.upper()) if plugin is None: - return False, f"Error loading plugin: plugin {name} not found." + return Err(f"Error loading plugin: plugin {name} not found.") if plugin.loaded: - return False, f"Plugin {plugin.plugin_name} already loaded" + return Err(f"Plugin {plugin.plugin_name} already loaded") return await self._load(plugin) - async def _load(self, plugin: _PluginRecord) -> CommandReply: + async def _load(self, plugin: _PluginRecord) -> Result[str, str]: prepared: _PreparedPlugin | None = None plugin_runtime: PluginRuntime | None = None lifespan: AbstractAsyncContextManager[None] | None = None @@ -375,7 +376,7 @@ async def _load(self, plugin: _PluginRecord) -> CommandReply: plugin.lifespan = lifespan plugin.runtime = plugin_runtime self.loaded_plugins[plugin.plugin_name] = plugin - return True, f"Successfully loaded plugin {plugin.plugin_name}" + return Ok(f"Successfully loaded plugin {plugin.plugin_name}") except BaseException as exc: if prepared is not None: prepared.abort() @@ -384,12 +385,12 @@ async def _load(self, plugin: _PluginRecord) -> CommandReply: if entered and lifespan is not None: try: await lifespan.__aexit__(type(exc), exc, exc.__traceback__) - except BaseException as cleanup_error: + except BaseException as cleanup_error: # ruff: ignore[BLE001] lifespan cleanup is arbitrary traceback.print_exception(cleanup_error) if not isinstance(exc, Exception): raise traceback.print_exception(exc) - return False, f"Error loading {plugin.plugin_name}: {exc}" + return Err(f"Error loading {plugin.plugin_name}: {exc}") def _build(self, key: str, declaration: Plugin) -> PluginSpec: raw = deepcopy(self.config.plugins.get(key, {})) @@ -421,7 +422,7 @@ def _prepare(self, key: str, spec: PluginSpec) -> _PreparedPlugin: aliases=bound.aliases, arguments=bound.declaration.arguments, brief=bound.brief, - help=bound.help, + help_text=bound.help, ) overlap = command_names.intersection(prepared.names) if overlap: @@ -542,13 +543,15 @@ async def load_configured(self) -> tuple[str, ...]: """Attempt every configured plugin and return those loaded successfully.""" loaded: list[str] = [] for plugin_name in self.config.plugins: - ok, message = await self.load(plugin_name) - self.console.echo(message) - if ok: - loaded.append(plugin_name.upper()) + match await self.load(plugin_name): + case Ok(message): + self.console.echo(message) + loaded.append(plugin_name.upper()) + case Err(message): + self.console.echo(message) return tuple(loaded) - def listing(self) -> CommandReply: + def listing(self) -> Result[str, str]: running = set(self.loaded_plugins) available = set(self.plugins) - running text = f"\nLoaded plugins: {', '.join(sorted(running)) if running else '(none)'}" @@ -556,7 +559,7 @@ def listing(self) -> CommandReply: text += f"\nAvailable plugins: {', '.join(sorted(available))}" else: text += "\nNo additional plugins available." - return True, text + return Ok(text) def manage( self, command: str = "LIST", plugin_name: str = "" @@ -567,11 +570,11 @@ def manage( return self.listing() if operation == "LOAD": if not plugin_name.strip(): - return False, "plugin name is required" + return Err("plugin name is required") return self.load(plugin_name) if not plugin_name: return self.load(command) - return False, f"Unknown command: {command}" + return Err(f"Unknown command: {command}") def preupdate(self) -> None: self._run_hooks("preupdate") @@ -604,7 +607,7 @@ def _run_hooks(self, phase: HookName) -> None: hook.callback(dt=elapsed) else: hook.callback() - except Exception as exc: + except Exception as exc: # ruff: ignore[BLE001] plugin hooks are arbitrary hook.enabled = False traceback.print_exception(exc) self.console.echo( @@ -624,13 +627,13 @@ async def aclose(self) -> None: plugin.runtime._revoke() try: self._remove(plugin) - except Exception as exc: + except Exception as exc: # ruff: ignore[BLE001] aggregate removal failures errors.append(exc) if plugin.lifespan is not None: try: await plugin.lifespan.__aexit__(None, None, None) - except Exception as exc: + except Exception as exc: # ruff: ignore[BLE001] plugin lifespan is arbitrary errors.append(exc) self._clear(plugin) @@ -656,7 +659,7 @@ def _remove(self, plugin: _PluginRecord) -> None: for cleanup in cleanups: try: cleanup() - except Exception as exc: + except Exception as exc: # ruff: ignore[BLE001] aggregate cleanup failures errors.append(exc) if errors: raise ExceptionGroup(f"Plugin {plugin.plugin_name} removal failed", errors) diff --git a/packages/minisky/minisky/result.py b/packages/minisky/minisky/result.py new file mode 100644 index 0000000..879795a --- /dev/null +++ b/packages/minisky/minisky/result.py @@ -0,0 +1,88 @@ +"""Typed success/error result values. + +See: +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Generic, Literal, Never, TypeAlias, TypeVar + +ValueT_co = TypeVar("ValueT_co", covariant=True) +ErrorT_co = TypeVar("ErrorT_co", covariant=True) + + +@dataclass(frozen=True, slots=True) +class Ok(Generic[ValueT_co]): + """A successful result containing a value.""" + + _value: ValueT_co + + def ok(self) -> ValueT_co: + """Return the success value.""" + return self._value + + def err(self) -> None: + """Return no error value for a successful result.""" + return + + def is_ok(self) -> Literal[True]: + """Return whether this result is successful.""" + return True + + def is_err(self) -> Literal[False]: + """Return whether this result is an error.""" + return False + + def unwrap(self) -> ValueT_co: + """Return the success value.""" + return self._value + + def unwrap_err(self) -> Never: + """Raise because this result does not contain an error.""" + raise UnwrapError( + self, f"called `Result.unwrap_err()` on successful result: {self._value!r}" + ) + + +@dataclass(frozen=True, slots=True) +class Err(Generic[ErrorT_co]): + """An unsuccessful result containing an error value.""" + + _value: ErrorT_co + + def ok(self) -> None: + """Return no success value for an unsuccessful result.""" + return + + def err(self) -> ErrorT_co: + """Return the error value.""" + return self._value + + def is_ok(self) -> Literal[False]: + """Return whether this result is successful.""" + return False + + def is_err(self) -> Literal[True]: + """Return whether this result is an error.""" + return True + + def unwrap(self) -> Never: + """Raise because this result does not contain a success value.""" + raise UnwrapError(self, f"called `Result.unwrap()` on errored result: {self._value!r}") + + def unwrap_err(self) -> ErrorT_co: + """Return the error value.""" + return self._value + + +class UnwrapError(Exception): + """Raised when unwrapping the absent side of a result.""" + + def __init__(self, result: Any, message: str) -> None: + self.result = result + super().__init__(message) + + +Result: TypeAlias = Ok[ValueT_co] | Err[ErrorT_co] +"""A value that is either successful (`Ok`) or unsuccessful (`Err`).""" diff --git a/packages/minisky/minisky/runtime.py b/packages/minisky/minisky/runtime.py index a351acf..856a8e0 100644 --- a/packages/minisky/minisky/runtime.py +++ b/packages/minisky/minisky/runtime.py @@ -3,6 +3,7 @@ from __future__ import annotations from random import Random +from typing import Self import numpy as np @@ -137,7 +138,7 @@ def close(self) -> None: for cleanup in (self.runner.shutdown, self.streaming.close, self.plugins.close): try: cleanup() - except Exception as exc: + except Exception as exc: # ruff: ignore[BLE001] aggregate resource failures errors.append(exc) self._closed = True @@ -152,21 +153,21 @@ async def aclose(self) -> None: try: await self.commands.aclose() - except Exception as exc: + except Exception as exc: # ruff: ignore[BLE001] aggregate resource failures errors.append(exc) try: await self.plugins.aclose() - except Exception as exc: + except Exception as exc: # ruff: ignore[BLE001] aggregate resource failures errors.append(exc) try: self.streaming.close() - except Exception as exc: + except Exception as exc: # ruff: ignore[BLE001] aggregate resource failures errors.append(exc) self._closed = True self._raise_errors("MiniSky shutdown failed", errors) - def __enter__(self) -> MiniSky: + def __enter__(self) -> Self: """Enter a synchronous runtime lifecycle context.""" return self @@ -174,7 +175,7 @@ def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> No """Close the runtime when leaving a synchronous context.""" self.close() - async def __aenter__(self) -> MiniSky: + async def __aenter__(self) -> Self: """Enter an asynchronous runtime lifecycle context.""" return self diff --git a/packages/minisky/minisky/server.py b/packages/minisky/minisky/server.py index 71e5def..9f2eb82 100644 --- a/packages/minisky/minisky/server.py +++ b/packages/minisky/minisky/server.py @@ -22,10 +22,10 @@ from __future__ import annotations import asyncio -import os from contextlib import asynccontextmanager, suppress from io import StringIO -from typing import Annotated, Any, cast +from pathlib import Path +from typing import Annotated, Any, Literal, TypeAlias, TypedDict, cast import pandas as pd from fastapi import ( @@ -43,6 +43,7 @@ from fastapi.staticfiles import StaticFiles from minisky import MiniSky +from minisky.result import Err, Ok, Result from minisky.tools import aero @@ -53,6 +54,34 @@ def _get_runtime(request: Request) -> MiniSky: Runtime = Annotated[MiniSky, Depends(_get_runtime)] +# we are using adjacently tagged enums for compatability +# TODO(abraham): use externally tagged once we remove the html + + +class OkResultResponse(TypedDict): + """JSON representation of a successful string result.""" + + ok: Literal[True] + value: str + + +class ErrResultResponse(TypedDict): + """JSON representation of an unsuccessful string result.""" + + ok: Literal[False] + error: str + + +ResultResponse: TypeAlias = OkResultResponse | ErrResultResponse + + +def _result_response(result: Result[str, str]) -> ResultResponse: + match result: + case Ok(value): + return {"ok": True, "value": value} + case Err(error): + return {"ok": False, "error": error} + @asynccontextmanager async def lifespan(app: FastAPI): @@ -68,11 +97,11 @@ async def lifespan(app: FastAPI): with suppress(asyncio.CancelledError): try: await runner_task - except Exception as exc: + except Exception as exc: # ruff: ignore[BLE001] aggregate server cleanup failures errors.append(exc) try: await runtime.aclose() - except Exception as exc: + except Exception as exc: # ruff: ignore[BLE001] aggregate server cleanup failures errors.append(exc) if len(errors) == 1: raise errors[0] @@ -91,8 +120,8 @@ def create_app(runtime: MiniSky | None = None) -> FastAPI: # TODO(abraham): package static assets inside minisky and resolve them # with importlib.resources for wheel installs - static_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static") - os.makedirs(static_dir, exist_ok=True) + static_dir = Path(__file__).parent.parent / "static" + static_dir.mkdir(parents=True, exist_ok=True) app.mount("/static", StaticFiles(directory=static_dir), name="static") return app @@ -102,7 +131,7 @@ def root() -> dict[str, str]: return {"msg": "MiniSky API endpoint ready"} -def all(runtime: Runtime) -> list[dict[str, Any]]: +def all_aircraft(runtime: Runtime) -> list[dict[str, Any]]: """Get all aircraft states.""" traffic = runtime.traffic df = pd.DataFrame( @@ -245,7 +274,7 @@ def upload_form() -> Response: return Response(content=content, media_type="text/html") -async def scn(runtime: Runtime, file: UploadFile = File(...)) -> dict[str, str]: +async def scn(runtime: Runtime, file: Annotated[UploadFile, File()]) -> dict[str, str]: """Load an uploaded scenario file into the running simulation.""" runtime.console.event.clear() contents = await file.read() @@ -260,21 +289,23 @@ def show_map() -> RedirectResponse: return RedirectResponse(url="/static/display.html") -def list_plugins(runtime: Runtime) -> Any: +def list_plugins(runtime: Runtime) -> ResultResponse: """List available and loaded plugins.""" - return runtime.plugins.manage("LIST") + result = runtime.plugins.listing() + return _result_response(result) -async def load_plugin(name: str, runtime: Runtime) -> Any: +async def load_plugin(name: str, runtime: Runtime) -> ResultResponse: """Load a plugin by name.""" - return await runtime.plugins.load(name) + result = await runtime.plugins.load(name) + return _result_response(result) def create_router() -> APIRouter: """Create the API router for a FastAPI application.""" router = APIRouter() router.add_api_route("/", root, methods=["GET"]) - router.add_api_route("/all", all, methods=["GET"]) + router.add_api_route("/all", all_aircraft, 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"]) @@ -288,20 +319,3 @@ def create_router() -> APIRouter: 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. - - 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("minisky.server:create_app", factory=True, host=host, port=port) - - -if __name__ == "__main__": - main() diff --git a/packages/minisky/minisky/simulation/console.py b/packages/minisky/minisky/simulation/console.py index 0a97f4e..1b67900 100644 --- a/packages/minisky/minisky/simulation/console.py +++ b/packages/minisky/minisky/simulation/console.py @@ -7,6 +7,7 @@ import sys import traceback from collections.abc import Callable +from typing import Self from colorama import Fore, Style @@ -27,7 +28,7 @@ def close(self) -> None: self._closed = True self._console._unsubscribe(self._token) - def __enter__(self) -> ConsoleSubscription: + def __enter__(self) -> Self: return self def __exit__(self, *_args: object) -> None: @@ -95,7 +96,7 @@ def echo(self, text: str = "", flag: int = 0) -> None: for token, callback in tuple(self._subscribers.items()): try: callback(text) - except Exception as exc: + except Exception as exc: # ruff: ignore[BLE001] subscribers are arbitrary self._subscribers.pop(token, None) traceback.print_exception(exc) @@ -115,11 +116,9 @@ def getviewctr(self) -> tuple[float, float]: def addnavwpt(self, name: str, lat: float, lon: float) -> None: """Add a waypoint marker. Stub for non-GUI mode.""" - pass def removenavwpt(self, name: str) -> None: """Remove a waypoint marker. Stub for non-GUI mode.""" - pass def read_output_buffer(self) -> str: """Return and clear buffered console output.""" diff --git a/packages/minisky/minisky/simulation/runner.py b/packages/minisky/minisky/simulation/runner.py index 98b2409..9fa03ef 100644 --- a/packages/minisky/minisky/simulation/runner.py +++ b/packages/minisky/minisky/simulation/runner.py @@ -13,6 +13,8 @@ import os from typing import TYPE_CHECKING +from minisky.result import Err, Ok, Result + if TYPE_CHECKING: from minisky.simulation.console import ConsoleIO from minisky.simulation.simulation import Simulation @@ -73,7 +75,7 @@ def forward(self, seconds: float) -> None: self.jump_to = self.simulation.simt + seconds - 2 # -2 for the action margin self.jump = seconds - def setspeed(self, mult: float) -> tuple[bool, str]: + def setspeed(self, mult: float) -> Result[str, str]: """Set the simulation speed multiplier (stack DTMULT command). The loop targets a simulation step every `1 / speed` wall-clock @@ -84,15 +86,11 @@ def setspeed(self, mult: float) -> tuple[bool, str]: Args: mult: Simulation speed factor relative to real time; must be positive. - - Returns: - Tuple of (success flag, message reporting the new speed, or an - error message when the multiplier is not positive). """ if mult <= 0: - return False, "DTMULT: speed multiplier must be positive" + return Err("DTMULT: speed multiplier must be positive") self.speed = mult - return True, f"Simulation speed set to {mult}x" + return Ok(f"Simulation speed set to {mult}x") def prevent_shutdown(self) -> None: """Disable shutdown so that `stop` requests are ignored. diff --git a/packages/minisky/minisky/simulation/simulation.py b/packages/minisky/minisky/simulation/simulation.py index 846d399..e830a0a 100644 --- a/packages/minisky/minisky/simulation/simulation.py +++ b/packages/minisky/minisky/simulation/simulation.py @@ -17,6 +17,8 @@ import numpy as np +from minisky.result import Err, Ok, Result + if TYPE_CHECKING: from minisky.core.trafficarrays import ReplaceableManager from minisky.plugin import PluginManager @@ -218,7 +220,7 @@ def reset(self) -> None: self.plugins.reset() self.console.echo("Simulation reset") - def realtime(self, flag: bool | None = None) -> tuple[bool, str]: + def realtime(self, flag: bool | None = None) -> Result[str, str]: """Get or set realtime mode (stack REALTIME command). In realtime mode the timestep may be varied to keep the simulation @@ -227,15 +229,11 @@ def realtime(self, flag: bool | None = None) -> tuple[bool, str]: Args: 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 - on or off). """ if flag is not None: self.rtmode = flag - return True, "Realtime mode is o" + ("n" if self.rtmode else "ff") + return Ok("Realtime mode is o" + ("n" if self.rtmode else "ff")) def event(self, eventname: bytes, eventdata: Any, sender_rte: Any) -> bool: """Handle events coming from the network. @@ -274,7 +272,7 @@ def event(self, eventname: bytes, eventdata: Any, sender_rte: Any) -> bool: return event_processed - def setutc(self, *args: str) -> tuple[bool, str]: + def setutc(self, *args: str) -> Result[str, str]: """Set the simulated UTC clock time (stack UTC/DATE command). Usage: UTC [RUN | REAL | UTC | HH:MM:SS[.ff] | day month year [HH:MM:SS[.ff]]] @@ -291,10 +289,6 @@ def setutc(self, *args: str) -> tuple[bool, str]: Args: *args: Zero, one, three, or four arguments as described above. - - Returns: - Tuple of (success flag, message with the resulting simulation UTC - time, or an error message when parsing failed). """ if not args: pass # avoid error message, just give time @@ -317,14 +311,14 @@ def setutc(self, *args: str) -> tuple[bool, str]: args[0], "%H:%M:%S.%f" if "." in args[0] else "%H:%M:%S" ).replace(tzinfo=datetime.UTC) except ValueError: - return False, "Input time invalid" + return Err("Input time invalid") elif len(args) == 3: day, month, year = args try: self.utc = datetime.datetime(int(year), int(month), int(day), tzinfo=datetime.UTC) except ValueError: - return False, "Input date invalid." + return Err("Input date invalid.") elif len(args) == 4: day, month, year, timestring = args try: @@ -333,11 +327,11 @@ def setutc(self, *args: str) -> tuple[bool, str]: ("%Y,%m,%d,%H:%M:%S.%f" if "." in timestring else "%Y,%m,%d,%H:%M:%S"), ).replace(tzinfo=datetime.UTC) except ValueError: - return False, "Input date invalid." + return Err("Input date invalid.") else: - return False, "Syntax error" + return Err("Syntax error") - return True, "Simulation UTC " + str(self.utc) + return Ok("Simulation UTC " + str(self.utc)) def setseed(self, value: int) -> None: """Set the random seed for this simulation (stack SEED command). diff --git a/packages/minisky/minisky/stack/__init__.py b/packages/minisky/minisky/stack/__init__.py index a22074a..dec041e 100644 --- a/packages/minisky/minisky/stack/__init__.py +++ b/packages/minisky/minisky/stack/__init__.py @@ -25,7 +25,6 @@ import asyncio import inspect -import os import traceback from collections.abc import Awaitable, Callable, Iterator from contextlib import suppress @@ -38,7 +37,7 @@ import numpy as np -from minisky.plugin.plugin_decorators import command +from minisky.result import Err, Ok, Result from minisky.stack import argparser, commands from minisky.stack.argparser import ArgumentError, Parameter, String, Time, Txt, getnextarg @@ -52,11 +51,6 @@ from minisky.traffic import Traffic -class CommandResult(NamedTuple): - success: bool - echotext: str - - class Command: """Stack command object. @@ -97,7 +91,7 @@ def __init__( self.params = [] self.callback = func - def __call__(self, argstring: str) -> CommandResult | Awaitable[CommandResult]: + def __call__(self, argstring: str) -> Result[str, str] | Awaitable[Result[str, str]]: """Parse arguments and execute the callback.""" args: list[Any] = [] param = None @@ -128,19 +122,22 @@ def __call__(self, argstring: str) -> CommandResult | Awaitable[CommandResult]: return self._result(result) @staticmethod - async def _await_result(result: Awaitable[Any]) -> CommandResult: + async def _await_result(result: Awaitable[Any]) -> Result[str, str]: return Command._result(await result) @staticmethod - def _result(result: Any) -> CommandResult: + def _result(result: Any) -> Result[str, str]: + if isinstance(result, (Ok, Err)): + return result if result is None: - return CommandResult(True, "") + return Ok("") if isinstance(result, (tuple, list)): if len(result) > 1: - return CommandResult(bool(result[0]), str(result[1])) + text = str(result[1]) + return Ok(text) if bool(result[0]) else Err(text) if len(result) == 1: result = result[0] - return CommandResult(bool(result), "") + return Ok("") if bool(result) else Err("") def __repr__(self) -> str: if self.valid: @@ -223,7 +220,7 @@ def helptext(self, subcmd: str = "") -> str: 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("/") + fname_stripped = fname.replace(str(Path.cwd()), "").lstrip("/") firstline = self._callback_source.__code__.co_firstlineno msg += f"{fname_stripped} on line {firstline}" else: @@ -235,12 +232,20 @@ def brieftext(self) -> str: """Return the brief usage text.""" return self.brief - def _get_arguments(self, arguments) -> tuple: - """Get arguments from string, or tuple/list.""" + class ArgumentSpec(NamedTuple): + annotation: str + """Parser annotation.""" + optional: bool + """Whether the argument may be omitted.""" + + def _get_arguments( + self, arguments: str | list[ArgumentSpec] | tuple[ArgumentSpec, ...] + ) -> tuple[ArgumentSpec, ...]: + """Get arguments from string, or typed argument specifications.""" if isinstance(arguments, (tuple, list)): return tuple(arguments) # Assume it is a comma-separated string - argtypes = [] + argtypes: list[Command.ArgumentSpec] = [] # Process and reduce annotation string from left to right # First cut at square brackets, then take separate argument types @@ -255,8 +260,7 @@ def _get_arguments(self, arguments) -> tuple: ) types = [t.strip() for t in arguments[:cut].strip("[,] ").split(",")] - # Returned argtypes are tuples of type and optional status - argtypes += [(t, opt or t == "...") for t in types if t] + argtypes.extend(self.ArgumentSpec(t, opt or t == "...") for t in types if t) arguments = arguments[cut:].lstrip(",]") return tuple(argtypes) @@ -272,7 +276,7 @@ class PreparedCommand: @dataclass(slots=True) class _PendingCommand: - task: asyncio.Future[CommandResult] + task: asyncio.Future[Result[str, str]] name: str argstring: str command: Command @@ -346,7 +350,7 @@ def prepare_command( aliases: tuple[str, ...] = (), arguments: str = "", brief: str = "", - help: str = "", + help_text: str = "", ) -> PreparedCommand: """Construct and parse a command without registering it.""" callback = func.__func__ if isinstance(func, (staticmethod, classmethod)) else func @@ -363,7 +367,7 @@ def prepare_command( aliases=alias_names, arguments=arguments, brief=brief, - help=help, + help=help_text, ) return PreparedCommand(command_obj, names) @@ -433,7 +437,7 @@ def init(self) -> None: aliases=catalog.aliases.get(name, ()), arguments=definition.arguments, brief=definition.brief, - help=definition.help, + help_text=definition.help, ) for name, definition in catalog.definitions.items() ) @@ -514,7 +518,7 @@ def process(self) -> bool: header = "" if not argstring else exc.args[0] if exc.args else "Argument error." self.console.echo(f"{header}\nUsage:\n{cmdobj.brieftext()}") continue - except Exception as exc: + except Exception as exc: # ruff: ignore[BLE001] commands are arbitrary callbacks self._echo_command_exception(cmdu, argstring, exc) continue @@ -549,7 +553,7 @@ def _finish_pending_command(self) -> bool: result = pending.task.result() except asyncio.CancelledError: return True - except Exception as exc: + except Exception as exc: # ruff: ignore[BLE001] commands are arbitrary callbacks self._echo_command_exception(pending.name, pending.argstring, exc) else: self._echo_command_result(pending.command, pending.argstring, result) @@ -562,14 +566,16 @@ def _prepend_commands(self, commands: list[tuple[str, bytes | None]]) -> None: self.cmdstack[0:0] = commands def _echo_command_result( - self, command_obj: Command, argstring: str, result: CommandResult + self, command_obj: Command, argstring: str, result: Result[str, str] ) -> None: - success, text = result - if not success: - if not argstring: - text = text or command_obj.brieftext() - else: - text = f"Error: {text or command_obj.brieftext()}" + match result: + case Ok(text): + pass + case Err(text): + if not argstring: + text = text or command_obj.brieftext() + else: + text = f"Error: {text or command_obj.brieftext()}" if text: self.console.echo(text) @@ -621,7 +627,7 @@ def readscn(self, scn: str | Path | StringIO) -> Iterator[tuple[float, str]]: # ensure .scn suffix if necessary scn_path = Path(scn).with_suffix(".scn") - with open(scn_path) as fscen: + with scn_path.open() as fscen: scn_input = StringIO(fscen.read()) elif isinstance(scn, StringIO): scn_input = scn @@ -658,7 +664,7 @@ def readscn(self, scn: str | Path | StringIO) -> Iterator[tuple[float, str]]: 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]: + def ic(self, scn: str) -> Result[str, str]: """IC: Load a scenario file. Resets the simulation, reads the scenario file, and buffers its @@ -667,16 +673,13 @@ def ic(self, scn: str) -> tuple[bool, str]: 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}" + return Err(f"IC: File not found: {scn_path}") lines = self.readscn(scn_path) @@ -685,9 +688,9 @@ def ic(self, scn: str) -> tuple[bool, str]: self.scencmd.append(cmd) self.scenname = scn_path.stem - return True, f"scenario {scn_path} loaded." + return Ok(f"scenario {scn_path} loaded.") - def ic_StringIO(self, scn: StringIO, scn_name: str | None = None) -> tuple[bool, str]: + def ic_StringIO(self, scn: StringIO, scn_name: str | None = None) -> Result[str, str]: """IC: Load a scenario from a StringIO object. Resets the simulation, reads scenario lines from the StringIO object, @@ -696,9 +699,6 @@ def ic_StringIO(self, scn: StringIO, scn_name: str | None = None) -> tuple[bool, Args: scn: StringIO object containing scenario lines. scn_name: The name of the scenario (optional). - - Returns: - tuple: (success (bool), message (str)). """ # reset sim always @@ -711,19 +711,16 @@ def ic_StringIO(self, scn: StringIO, scn_name: str | None = None) -> tuple[bool, self.scencmd.append(cmd) self.scenname = scn_name or "" - return True, f"scenario {scn_name} loaded." + return Ok(f"scenario {scn_name} loaded.") - def scenario(self, name: String) -> tuple[bool, str]: + def scenario(self, name: String) -> Result[str, 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 + return Ok("Starting scenario " + name) def schedule(self, time: Time, cmdline: String) -> bool: """SCHEDULE: Schedule a stack command at a specific simulation time. @@ -765,7 +762,7 @@ def delay(self, time: Time, cmdline: String) -> bool: self.scencmd.insert(idx, cmdline) return True - def showhelp(self, cmd: Txt = "", subcmd: Txt = "") -> tuple[bool, str]: + def showhelp(self, cmd: Txt = "", subcmd: Txt = "") -> Result[str, str]: """HELP: Display general help text or help text for a specific command, or dump command reference in file when command is >filename. @@ -774,15 +771,12 @@ def showhelp(self, cmd: Txt = "", subcmd: Txt = "") -> tuple[bool, str]: 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) + return Ok(cmdobj.helptext(subcmd)) # Write command reference to tab-delimited text file if cmd[0] == ">": @@ -803,13 +797,13 @@ def showhelp(self, cmd: Txt = "", subcmd: Txt = "") -> tuple[bool, str]: # Sort & write table table.sort() - with open(fname, "w") as f: + with Path(fname).open("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 Ok("Writing command reference in " + fname) - return False, "HELP: Unknown command: " + cmd + return Err("HELP: Unknown command: " + cmd) def checkscen(self) -> None: """Check if commands from the scenario buffer need to be stacked. @@ -862,14 +856,15 @@ def get_scenname(self) -> str: 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. + class ScenarioData(NamedTuple): + times: list[float] + """Buffered command execution times [s].""" + commands: list[str] + """Buffered scenario command lines.""" - Returns: - tuple: (scentime, scencmd), the lists of command times [s] and - command lines still buffered for execution. - """ - return self.scentime, self.scencmd + def get_scendata(self) -> ScenarioData: + """Return the scenario data that was loaded from a scenario file.""" + return self.ScenarioData(self.scentime, self.scencmd) def set_scendata(self, newtime, newcmd) -> None: """Set the scenario data. This is used by the batch logic.""" diff --git a/packages/minisky/minisky/stack/argparser.py b/packages/minisky/minisky/stack/argparser.py index 0a1af71..8da2f69 100644 --- a/packages/minisky/minisky/stack/argparser.py +++ b/packages/minisky/minisky/stack/argparser.py @@ -21,8 +21,9 @@ import re from collections.abc import Callable from types import SimpleNamespace, UnionType -from typing import TYPE_CHECKING, Annotated, Any, Union, get_args, get_origin +from typing import TYPE_CHECKING, Annotated, Any, Protocol, TypeAlias, Union, get_args, get_origin +from minisky.result import Err, Ok from minisky.tools.convert import ( txt2alt, txt2bool, @@ -40,6 +41,22 @@ from minisky.tools.navdata import Navdatabase from minisky.traffic import Traffic + +# TODO(abraham): while we would like to migrate all bare tuple[X, Y, Z] to +# namedtuples/proper dataclasses, right now each Parser subclass returns a +# different type. +# we would like to eventually remove implementation inheritance altogether when +# we work on issue #37, so we just keep types simple for now. + +ParserResult: TypeAlias = tuple[Any, ...] + + +class ParserProtocol(Protocol): + size: int + + def parse(self, argstring: str) -> ParserResult: ... + + # Regular expression for argument parser # Reading the regular expression: # [\'"]? : skip potential opening quote @@ -53,10 +70,10 @@ def _match_groups(argstring: str) -> tuple[str, str]: """Match argstring against re_getarg (which always matches) and return groups.""" m = re_getarg.match(argstring) assert m is not None - return m.groups() # type: ignore[return-value] + return m.group(1), m.group(2) -def getnextarg(cmdstring: str) -> tuple: +def getnextarg(cmdstring: str) -> tuple[str, str]: """Return first argument and remainder of command string from cmdstring. Arguments are separated by whitespace and/or a comma; quoted arguments @@ -94,7 +111,7 @@ class Parameter: def __init__( self, param: inspect.Parameter, - parsers: dict[str, Parser | None], + parsers: dict[str, ParserProtocol | None], annotation: str = "", isopt: bool | None = None, ) -> None: @@ -196,8 +213,6 @@ def canwrap(param: inspect.Parameter) -> bool: class ArgumentError(Exception): """This error is raised when stack argument parsing fails.""" - pass - class Parser: """Base implementation of argument parsers @@ -217,10 +232,10 @@ class Parser: # Output size of this parser size = 1 - def __init__(self, parsefun: Callable[..., Any] | None = None) -> None: + def __init__(self, parsefun: Callable[[str], Any] | None = None) -> None: self.parsefun = parsefun - def parse(self, argstring: str) -> tuple: + def parse(self, argstring: str) -> ParserResult: """Parse the next argument from argstring. Args: @@ -237,7 +252,7 @@ def parse(self, argstring: str) -> tuple: class StringArg(Parser): """Argument parser that simply consumes the entire remaining text string.""" - def parse(self, argstring: str) -> tuple: + def parse(self, argstring: str) -> tuple[str, str]: """Return the complete remaining text as a single string argument.""" return argstring, "" @@ -249,7 +264,7 @@ def __init__(self, argument_parser: ArgumentParser) -> None: super().__init__() self.argument_parser = argument_parser - def parse(self, argstring: str) -> tuple: + def parse(self, argstring: str) -> tuple[Any, str]: """Parse a callsign or group name into traffic index/indices. For an aircraft callsign the traffic index is returned and the @@ -263,7 +278,11 @@ def parse(self, argstring: str) -> tuple: callsign = arg.upper() traffic = self.argument_parser.traffic if callsign in traffic.groups: - idx = traffic.groups.listgroup(callsign) + match traffic.groups.listgroup(callsign): + case Ok(group): + idx = group + case Err(error): + raise ArgumentError(error) else: idx = traffic.idx(callsign) if idx < 0: @@ -292,7 +311,7 @@ def __init__(self, argument_parser: ArgumentParser) -> None: super().__init__() self.argument_parser = argument_parser - def parse(self, argstring: str) -> tuple: + def parse(self, argstring: str) -> tuple[str, str]: """Combine one or two arguments into a single waypoint position text. Aircraft ids are translated to a "lat,lon" text; lat/lon pairs and @@ -340,7 +359,7 @@ def __init__(self, argument_parser: ArgumentParser) -> None: super().__init__() self.argument_parser = argument_parser - def parse(self, argstring: str) -> tuple: + def parse(self, argstring: str) -> tuple[float, float, str]: """Parse one or two arguments into a lat/lon position. Also updates the parser reference position to the parsed location. @@ -398,7 +417,7 @@ def parse(self, argstring: str) -> tuple: class PandirArg(Parser): """Parse pan direction commands.""" - def parse(self, argstring: str) -> tuple: + def parse(self, argstring: str) -> tuple[str, str]: """Parse a screen pan direction (LEFT, RIGHT, UP/ABOVE, or DOWN). Raises: @@ -435,7 +454,7 @@ def __init__(self, traffic: Traffic, navigation: Navdatabase, console: ConsoleIO # 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] = { + self.parsers: dict[str, ParserProtocol | None] = { "*": None, "txt": Parser(str.upper), "word": Parser(str), diff --git a/packages/minisky/minisky/tools/__init__.py b/packages/minisky/minisky/tools/__init__.py index 059db78..34bf525 100644 --- a/packages/minisky/minisky/tools/__init__.py +++ b/packages/minisky/minisky/tools/__init__.py @@ -7,4 +7,9 @@ parsing (position). """ -from . import aero, areafilter, convert, geo, navdata, position +from . import aero as aero +from . import areafilter as areafilter +from . import convert as convert +from . import geo as geo +from . import navdata as navdata +from . import position as position diff --git a/packages/minisky/minisky/tools/aero.py b/packages/minisky/minisky/tools/aero.py index 409dc1e..e96decc 100644 --- a/packages/minisky/minisky/tools/aero.py +++ b/packages/minisky/minisky/tools/aero.py @@ -19,6 +19,8 @@ CAS/Mach threshold supplied by the owning runtime. """ +from typing import NamedTuple + import numpy as np # International standard atmpshere only up to 72000 ft / 22 km @@ -78,16 +80,20 @@ # ------------------------------------------------------------------------------ # Vectorized aero functions # ------------------------------------------------------------------------------ -def vatmos(h: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: +class VectorAtmosphere(NamedTuple): + pressure: np.ndarray + """Atmospheric pressure [Pa].""" + density: np.ndarray + """Air density [kg/m³].""" + temperature: np.ndarray + """Air temperature [K].""" + + +def vatmos(h: np.ndarray) -> VectorAtmosphere: """Calculate atmospheric pressure, density, and temperature for a given altitude. Arguments: - h: Altitude [m] - - Returns: - - p: Pressure [Pa] - - rho: Density [kg / m3] - - T: Temperature [K] """ # Temp T = vtemp(h) @@ -100,7 +106,7 @@ def vatmos(h: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: # Pressure p = rho * R * T - return p, rho, T + return VectorAtmosphere(p, rho, T) def vtemp(h: np.ndarray) -> np.ndarray: @@ -287,11 +293,20 @@ def vcas2mach(cas: np.ndarray, h: np.ndarray) -> np.ndarray: return M +class VectorAirspeeds(NamedTuple): + true: np.ndarray + """True airspeed [m/s].""" + calibrated: np.ndarray + """Calibrated airspeed [m/s].""" + mach: np.ndarray + """Mach number [-].""" + + def vcasormach( spd: np.ndarray, h: np.ndarray, threshold: float, -) -> tuple[np.ndarray, np.ndarray, np.ndarray]: +) -> VectorAirspeeds: """Interpret input speed as either CAS or a Mach number, and return TAS, CAS, and Mach. Arguments: @@ -299,17 +314,12 @@ def vcasormach( 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 < 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 + return VectorAirspeeds(tas, cas, mach) def vcasormach2tas( @@ -359,7 +369,16 @@ def crossoveralt(cas: float, mach: float) -> float: # ------------------------------------------------------------------------------ # Scalar aero functions # ------------------------------------------------------------------------------ -def atmos(h: float) -> tuple[float, float, float]: +class ScalarAtmosphere(NamedTuple): + pressure: float + """Atmospheric pressure [Pa].""" + density: float + """Air density [kg/m³].""" + temperature: float + """Air temperature [K].""" + + +def atmos(h: float) -> ScalarAtmosphere: """International Standard Atmosphere calculator (scalar version). Uses the full multi-layer ISA table up to the mesosphere, with base @@ -370,10 +389,6 @@ def atmos(h: float) -> tuple[float, float, float]: Args: h: Altitude [m], 0.0 < h < 84852.0 (clipped when outside range, integer input allowed). - - Returns: - tuple: (p, rho, T): pressure [Pa], density [kg/m3], and - temperature [K]. """ # Constants @@ -434,7 +449,7 @@ def atmos(h: float) -> tuple[float, float, float]: p = p0[i] * ((T / T0[i]) ** (-g0 / (a[i] * R))) rho = p / (R * T) - return p, rho, T + return ScalarAtmosphere(p, rho, T) def temp(h: float) -> float: @@ -500,7 +515,7 @@ def pressure(h: float) -> float: # h [m] Returns: Pressure [Pa]. """ - p, r, T = atmos(h) + p, _r, _T = atmos(h) return p @@ -513,7 +528,7 @@ def density(h: float) -> float: # air density at given altitude h [m] Returns: Density [kg/m3]. """ - p, r, T = atmos(h) + _p, r, _T = atmos(h) return r @@ -613,7 +628,7 @@ def cas2tas(cas: float, h: float) -> float: Returns: True airspeed [m/s]. """ - p, rho, T = atmos(h) + p, rho, _T = atmos(h) qdyn = p0 * ((1.0 + rho0 * cas * cas / (7.0 * p0)) ** 3.5 - 1.0) tas = np.sqrt(7.0 * p / rho * ((1.0 + qdyn / p) ** (2.0 / 7.0) - 1.0)) tas = -1 * tas if cas < 0 else tas @@ -634,7 +649,7 @@ def tas2cas(tas: float, h: float) -> float: Returns: Calibrated airspeed [m/s]. """ - p, rho, T = atmos(h) + p, rho, _T = atmos(h) qdyn = p * ((1.0 + rho * tas * tas / (7.0 * p)) ** 3.5 - 1.0) cas = np.sqrt(7.0 * p0 / rho0 * ((qdyn / p0 + 1.0) ** (2.0 / 7.0) - 1.0)) cas = -1 * cas if tas < 0 else cas @@ -671,11 +686,22 @@ def cas2mach(cas: float, h: float) -> float: return M +class ScalarAirspeeds(NamedTuple): + true: float + """True airspeed [m/s].""" + calibrated: float + """Calibrated airspeed [m/s].""" + mach: float + """Mach number [-].""" + + +# TODO(abraham): eventually we want to get rid of the concept of "threshold-encoded" floats +# not typing it properly for now. see issue #40 def casormach( spd: float, h: float, threshold: float, -) -> tuple[float, float, float]: +) -> ScalarAirspeeds: """Interpret input speed as either CAS or a Mach number (scalar version). The speed is treated as a Mach number when 0.1 < spd < threshold @@ -685,10 +711,6 @@ def casormach( 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 < threshold: # Interpret spd as Mach number @@ -700,7 +722,7 @@ def casormach( tas = cas2tas(spd, h) cas = spd m = cas2mach(spd, h) - return tas, cas, m + return ScalarAirspeeds(tas, cas, m) def casormach2tas( @@ -729,7 +751,7 @@ def metres_to_feet_rounded(metres: float) -> int: Converts metres to feet. Returns feet as rounded integer. """ - return int(round(metres / ft)) + return round(metres / ft) def metric_spd_to_knots_rounded(speed: float) -> int: @@ -737,4 +759,4 @@ def metric_spd_to_knots_rounded(speed: float) -> int: Converts speed in m/s to knots. Returns knots as rounded integer. """ - return int(round(speed / kts)) + return round(speed / kts) diff --git a/packages/minisky/minisky/tools/areafilter.py b/packages/minisky/minisky/tools/areafilter.py index 3c71cd4..61b331f 100644 --- a/packages/minisky/minisky/tools/areafilter.py +++ b/packages/minisky/minisky/tools/areafilter.py @@ -17,6 +17,8 @@ import numpy as np from matplotlib.path import Path +from minisky.result import Err, Ok, Result + try: from rtree.index import Index # type: ignore[assignment] except (ImportError, OSError): @@ -94,7 +96,7 @@ def define_area( coordinates: tuple[float, ...] | list[float], top: float = 1e9, bottom: float = -1e9, - ) -> tuple[bool, str]: + ) -> Result[str, str]: """Define a new area, or list/inspect existing areas. Args: @@ -105,20 +107,17 @@ def define_area( 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." + return Ok("No shapes are currently defined.") else: - return True, "Currently defined shapes:\n" + ", ".join(self.basic_shapes) + return Ok("Currently defined shapes:\n" + ", ".join(self.basic_shapes)) if not coordinates: if areaname in self.basic_shapes: - return True, str(self.basic_shapes[areaname]) + return Ok(str(self.basic_shapes[areaname])) else: - return False, f"Unknown shape: {areaname}" + return Err(f"Unknown shape: {areaname}") old_shape = self.basic_shapes.get(areaname) if old_shape is not None: @@ -133,12 +132,12 @@ def define_area( elif areatype == "LINE": shape = Line(self, areaname, coordinates) else: - return False, f"Unknown shape type: {areatype}" + return Err(f"Unknown shape type: {areatype}") self.basic_shapes[areaname] = shape - return True, f"Created {areatype} {areaname}" + return Ok(f"Created {areatype} {areaname}") - def define_box_area(self, name: str, *coords: float) -> tuple[bool, str]: + def define_box_area(self, name: str, *coords: float) -> Result[str, str]: """BOX: Define a box-shaped area. Args: @@ -148,7 +147,7 @@ def define_box_area(self, name: str, *coords: float) -> tuple[bool, str]: """ return self.define_area(name, "BOX", coords[:4], *coords[4:]) - def define_circle_area(self, name: str, *coords: float) -> tuple[bool, str]: + def define_circle_area(self, name: str, *coords: float) -> Result[str, str]: """CIRCLE: Define a circle-shaped area. Args: @@ -158,7 +157,7 @@ def define_circle_area(self, name: str, *coords: float) -> tuple[bool, str]: """ return self.define_area(name, "CIRCLE", coords[:3], *coords[3:]) - def define_line_area(self, name: str, *coords: float) -> tuple[bool, str]: + def define_line_area(self, name: str, *coords: float) -> Result[str, str]: """LINE: Draw a line between two positions on the radar screen. Args: @@ -167,7 +166,7 @@ def define_line_area(self, name: str, *coords: float) -> tuple[bool, str]: """ return self.define_area(name, "LINE", coords) - def define_poly_area(self, name: str, *coords: float) -> tuple[bool, str]: + def define_poly_area(self, name: str, *coords: float) -> Result[str, str]: """POLY: Define a polygon-shaped area. Args: @@ -178,7 +177,7 @@ def define_poly_area(self, name: str, *coords: float) -> tuple[bool, str]: def define_polyalt_area( self, name: str, top: float, bottom: float, *coords: float - ) -> tuple[bool, str]: + ) -> Result[str, str]: """POLYALT: Define a polygon-shaped area in 3D, between two altitudes. Args: @@ -189,7 +188,7 @@ def define_polyalt_area( """ return self.define_area(name, "POLYALT", coords, top, bottom) - def define_polyline_area(self, name: str, *coords: float) -> tuple[bool, str]: + def define_polyline_area(self, name: str, *coords: float) -> Result[str, str]: """POLYLINE: Draw a multi-segment line on the radar screen. Args: @@ -228,20 +227,17 @@ def reset(self) -> None: self.areatree = Index() self.max_area_id = 0 - def deleteArea(self, name: str) -> tuple[bool, str]: + def deleteArea(self, name: str) -> Result[str, 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}." + return Ok(f"Area {name} deleted.") + return Err(f"No area found with name {name}.") def get_intersecting(self, lat0: float, lon0: float, lat1: float, lon1: float) -> list[Shape]: """Return all shapes that intersect with a specified rectangular area. diff --git a/packages/minisky/minisky/tools/convert.py b/packages/minisky/minisky/tools/convert.py index 4b761b1..124135b 100644 --- a/packages/minisky/minisky/tools/convert.py +++ b/packages/minisky/minisky/tools/convert.py @@ -9,6 +9,7 @@ """ from time import gmtime, strftime +from typing import NamedTuple import numpy as np @@ -220,15 +221,21 @@ def txt2tas(txt: str, h: float) -> float: return acspd -def col2rgb(txt: str) -> tuple[int, int, int]: +class RGB(NamedTuple): + red: int + """Red channel [0-255].""" + green: int + """Green channel [0-255].""" + blue: int + """Blue channel [0-255].""" + + +def col2rgb(txt: str) -> RGB: """Convert named color to R,G,B values (integer per component, 0-255). Args: txt: Colour name (e.g. "red", "amber"); unknown names default to white. - - Returns: - tuple: (R, G, B) integer components in the range 0-255. """ cols = { "black": (0, 0, 0), @@ -246,7 +253,7 @@ def col2rgb(txt: str) -> tuple[int, int, int]: except KeyError: rgb = cols["white"] # default - return rgb + return RGB(*rgb) def degto180(angle: float | np.ndarray) -> float | np.ndarray: @@ -385,16 +392,22 @@ def latlon2txt(lat: float, lon: float) -> str: return lat2txt(lat) + " " + lon2txt(lon) -def float2degminsec(x: float) -> tuple[int, float, float]: +class DegreesMinutesSeconds(NamedTuple): + degrees: int + """Whole degrees [deg].""" + minutes: float + """Whole arcminutes [arcmin].""" + seconds: float + """Whole arcseconds [arcsec].""" + + +def float2degminsec(x: float) -> DegreesMinutesSeconds: """Split a positive angle in degrees into whole degrees, minutes, and seconds. Args: x: Angle [deg] (positive). - - Returns: - tuple: (degrees, minutes, seconds) of the angle. """ deg = int(x) minutes = int(x * 60.0) - deg * 60.0 sec = int(x * 3600.0) - deg * 3600.0 - minutes * 60.0 - return deg, minutes, sec + return DegreesMinutesSeconds(deg, minutes, sec) diff --git a/packages/minisky/minisky/tools/geo.py b/packages/minisky/minisky/tools/geo.py index 2f56b8d..564b8c3 100644 --- a/packages/minisky/minisky/tools/geo.py +++ b/packages/minisky/minisky/tools/geo.py @@ -20,6 +20,7 @@ import pandas as pd from minisky.core.config import data +from minisky.result import Ok, Result # Type alias for values that may be a scalar or a numpy array FloatOrArray = float | np.ndarray @@ -710,23 +711,19 @@ def load_magnetic_declination() -> np.ndarray: # Command MAGVAR to get magnetic variation at position lat,lon -def magdeccmd(latdeg: float, londeg: float) -> tuple[bool, str]: +def magdeccmd(latdeg: float, londeg: float) -> Result[str, str]: """MAGVAR Get magnetic variation at position lat/lon. Args: latdeg: Latitude [deg]. londeg: Longitude [deg]. - - Returns: - tuple: (True, message stating the magnetic variation [deg]). """ - return ( - True, + return Ok( "Magnetic variation at " + str(latdeg) + "," + str(londeg) + " = " + str(magdec(latdeg, londeg)) - + " deg", + + " deg" ) diff --git a/packages/minisky/minisky/tools/navdata.py b/packages/minisky/minisky/tools/navdata.py index 24f1d2d..042a2ff 100644 --- a/packages/minisky/minisky/tools/navdata.py +++ b/packages/minisky/minisky/tools/navdata.py @@ -17,6 +17,7 @@ import numpy as np import pandas as pd +from minisky.result import Err, Ok, Result from minisky.tools import geo from minisky.tools.aero import nm @@ -125,9 +126,9 @@ def reset(self) -> None: awydata = pd.read_parquet(nav_data_path / "airway.parquet") codata = pd.read_parquet(nav_data_path / "country.parquet") - with open(nav_data_path / "fir.json") as f: + with (nav_data_path / "fir.json").open() as f: firdata = json.load(f) - with open(nav_data_path / "runway_thresholds.json") as f: + with (nav_data_path / "runway_thresholds.json").open() as f: rwythresholds = json.load(f) # Get waypoint data @@ -184,7 +185,7 @@ def defwpt( lat: float | None = None, lon: float | None = None, wptype: str | None = None, - ) -> tuple[bool, str]: + ) -> Result[str, str]: """DEFWPT: Define, inspect, or delete a scenario-specific waypoint. Without lat/lon, information about the existing waypoint is @@ -198,15 +199,12 @@ def defwpt( lon: Longitude [deg]. wptype: Optional waypoint type (e.g. FIX, VOR, DME, NDB), or DEL/DELETE to remove the waypoint. - - Returns: - tuple: (success (bool), message (str)). """ # Prevent polluting the database: check arguments if name == None or name == "": - return False, "Insufficient arguments" + return Err("Insufficient arguments") elif name.isdigit(): - return False, "Name needs to start with an alphabetical character" + return Err("Name needs to start with an alphabetical character") # DEL command: give info on waypoint (shudl work wit or without lat,lon, may be clicked by accident elif (wptype != None and (wptype.upper() == "DEL" or wptype.upper() == "DELETE")) or ( @@ -222,11 +220,11 @@ def defwpt( txt = self.wpid[i] + " : " + str(self.wplat[i]) + "," + str(self.wplon[i]) if len(self.wptype[i]) > 0: txt = txt + " " + self.wptype[i] - return True, txt + return Ok(txt) # Waypoint name is free else: - return True, "Waypoint " + name.upper() + " does not yet exist." + return Ok("Waypoint " + name.upper() + " does not yet exist.") # Still here? So there is data, then we add this waypoint self.wpid.append(name.upper()) @@ -246,24 +244,21 @@ def defwpt( # Update screen info self.console.addnavwpt(name.upper(), lat, lon) - return True, name.upper() + " added to navdb." + return Ok(name.upper() + " added to navdb.") - def delwpt(self, name: str | None = None) -> tuple[bool, str]: + def delwpt(self, name: str | None = None) -> Result[str, str]: """Delete a waypoint from the database. The last-added occurrence of the name is removed. Args: name: Waypoint name. - - Returns: - tuple: (success (bool), message (str)). """ if name is None: - return False, "No waypoint name given" + return Err("No waypoint name given") if self.wpid.count(name.upper()) <= 0: - return False, "Waypoint " + name.upper() + " does not exist." + return Err("Waypoint " + name.upper() + " does not exist.") idx = len(self.wpid) - self.wpid[::-1].index(name.upper()) - 1 # Search from back of list @@ -281,7 +276,7 @@ def delwpt(self, name: str | None = None) -> tuple[bool, str]: # Update screen info 9delete necessary there?) self.console.removenavwpt(name.upper()) - return True, name.upper() + " deleted from navdb." + return Ok(name.upper() + " deleted from navdb.") def getwpidx(self, txt: str, reflat: float = 999999.0, reflon: float = 999999) -> int: """Get waypoint index to access data. diff --git a/packages/minisky/minisky/tools/position.py b/packages/minisky/minisky/tools/position.py index 7231033..55a8d1e 100644 --- a/packages/minisky/minisky/tools/position.py +++ b/packages/minisky/minisky/tools/position.py @@ -10,6 +10,8 @@ from typing import TYPE_CHECKING +from minisky.result import Err, Ok, Result + from .convert import txt2lat, txt2lon if TYPE_CHECKING: @@ -23,7 +25,7 @@ def txt2pos( reflon: float, navigation: Navdatabase, traffic: Traffic, -) -> tuple[bool, Position | str]: +) -> Result[Position, str]: """Parse a position text into a Position object. Args: @@ -31,14 +33,11 @@ def txt2pos( (e.g. "EHAM/RW06"), or aircraft callsign. reflat: Reference latitude [deg], used to resolve ambiguous names. reflon: Reference longitude [deg], used to resolve ambiguous names. - - Returns: - tuple: (True, Position) on success, or (False, error message). """ pos = Position(name.upper().strip(), reflat, reflon, navigation, traffic) if not pos.error: - return True, pos - return False, name + " not found in database" + return Ok(pos) + return Err(name + " not found in database") def islat(txt: str) -> bool: diff --git a/packages/minisky/minisky/traffic/activewpdata.py b/packages/minisky/minisky/traffic/activewpdata.py index eb79cd0..1d26b1a 100644 --- a/packages/minisky/minisky/traffic/activewpdata.py +++ b/packages/minisky/minisky/traffic/activewpdata.py @@ -10,7 +10,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, NamedTuple import numpy as np @@ -248,6 +248,13 @@ def reached( # Return indices for which condition is True/1.0 for a/c where we have reached waypoint return swreached + # TODO(abraham): maybe make this Generic over float/np/any array? + class TurnGeometry(NamedTuple): + distance: Any + """Turn-initiation distance [m].""" + radius: Any + """Turn radius [m].""" + # Calculate turn distance for array or scalar def calcturn( self, @@ -258,7 +265,7 @@ def calcturn( turnrad: Any = -999.0, turnhdgr: Any = -999.0, flyturn: Any = False, - ) -> tuple: + ) -> TurnGeometry: """Calculate the turn-initiation distance and turn radius. Works on scalars as well as numpy arrays. The turn radius follows, @@ -277,9 +284,6 @@ def calcturn( turnhdgr: Specified turn heading rate [deg/s] (<0 = not specified). flyturn: Fly-turn switch (use the specified turn parameters). - - Returns: - tuple: (turn distance [m], turn radius [m]). """ # Tas is also used ti @@ -304,4 +308,4 @@ def calcturn( turndist = np.abs( turnrad * np.tan(np.radians(0.5 * np.abs(degto180(wpqdr % 360.0 - next_wpqdr % 360.0)))) ) - return turndist, turnrad + return self.TurnGeometry(turndist, turnrad) diff --git a/packages/minisky/minisky/traffic/asas/__init__.py b/packages/minisky/minisky/traffic/asas/__init__.py index 9bde372..92f465e 100644 --- a/packages/minisky/minisky/traffic/asas/__init__.py +++ b/packages/minisky/minisky/traffic/asas/__init__.py @@ -16,8 +16,8 @@ # isort: off # Import order matters: MVP subclasses ConflictResolution, so resolution must # be importable before mvp to avoid a partially-initialised circular import. -from .detection import ConflictDetection -from .resolution import ConflictResolution -from .mvp import MVP +from .detection import ConflictDetection as ConflictDetection +from .resolution import ConflictResolution as ConflictResolution +from .mvp import MVP as MVP # isort: on diff --git a/packages/minisky/minisky/traffic/asas/detection.py b/packages/minisky/minisky/traffic/asas/detection.py index 6c22120..fc5a70a 100644 --- a/packages/minisky/minisky/traffic/asas/detection.py +++ b/packages/minisky/minisky/traffic/asas/detection.py @@ -20,13 +20,14 @@ from __future__ import annotations from collections.abc import Callable -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, NamedTuple import numpy as np from scipy.spatial import KDTree from minisky.core.config import MiniSkyConfig from minisky.core.trafficarrays import TrafficArrays +from minisky.result import Ok, Result from minisky.stack.argparser import Time, Txt from minisky.tools.aero import ft, nm @@ -37,10 +38,34 @@ RE = 6371000.0 -def _noconflicts(ntraf: int) -> tuple: +# TODO(abraham): model callsign pairs as a named ConflictPair record. +class ConflictDetectionResult(NamedTuple): + confpairs: list[tuple[str, str]] + """Conflicting callsign pairs, in both directions.""" + lospairs: list[tuple[str, str]] + """Callsign pairs in loss of separation.""" + inconf: np.ndarray + """Per-aircraft in-conflict flags [-].""" + tcpamax: np.ndarray + """Per-aircraft maximum time to closest point of approach [s].""" + qdr: np.ndarray + """Bearing from ownship to intruder per conflict [deg].""" + dist: np.ndarray + """Current horizontal distance per conflict [m].""" + dcpa: np.ndarray + """Horizontal distance at closest point of approach per conflict [m].""" + tcpa: np.ndarray + """Time to closest point of approach per conflict [s].""" + tLOS: np.ndarray + """Time until loss of separation per conflict [s].""" + dalt: np.ndarray + """Current altitude difference per conflict [m].""" + + +def _noconflicts(ntraf: int) -> ConflictDetectionResult: """Detection result for a timestep without any conflicts or LoS.""" empty = np.array([]) - return ( + return ConflictDetectionResult( [], [], np.zeros(ntraf, dtype=bool), @@ -205,7 +230,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") -> Result[str, str]: """Turn Conflict Detection (CD) ON / OFF. Switching off also clears the current conflict database. @@ -213,9 +238,6 @@ def switch(self, name: Txt = "ON") -> tuple | None: Args: name (str): Either "ON" or "OFF". - Returns: - tuple: (success (bool), message (str)) for the command stack. - Raises: AssertionError: If `name` is not "ON" or "OFF". """ @@ -224,13 +246,12 @@ def switch(self, name: Txt = "ON") -> tuple | None: if name == "OFF": self.clearconfdb() self.activate = False - return True, "Conflict Detection turned off." + return Ok("Conflict Detection turned off.") - if name == "ON": - self.activate = True - return True, "Conflict Detection is on." + self.activate = True + return Ok("Conflict Detection is on.") - def setrpz(self, radius: float = -1.0, *acidx: int) -> tuple: + def setrpz(self, radius: float = -1.0, *acidx: int) -> Result[str, str]: """Set the horizontal separation distance (i.e., the radius of the protected zone) in nautical miles. @@ -244,20 +265,16 @@ def setrpz(self, radius: float = -1.0, *acidx: int) -> tuple: *acidx: Aircraft index/indices or group. When not provided, the default PZ radius is changed. Otherwise the PZ radius for the passed aircraft is changed. - - Returns: - tuple: (success (bool), message (str)) for the command stack. """ if radius < 0.0: - return ( - True, - f"ZONER [radius(nm), acid(s)/ac group]\nCurrent default PZ radius: {self.rpz_def / nm:.2f} NM", + return Ok( + f"ZONER [radius(nm), acid(s)/ac group]\nCurrent default PZ radius: {self.rpz_def / nm:.2f} NM" ) if len(acidx) > 0: idx: Any = acidx[0] if isinstance(acidx[0], np.ndarray) else acidx self.rpz[idx] = radius * nm self.global_rpz = False - return True, f"Setting PZ radius to {radius} NM for {len(idx)} aircraft" + return Ok(f"Setting PZ radius to {radius} NM for {len(idx)} aircraft") oldradius = self.rpz_def self.rpz_def = radius * nm if self.global_rpz: @@ -265,9 +282,9 @@ def setrpz(self, radius: float = -1.0, *acidx: int) -> tuple: # Adjust factors for reso zone if those were set with an absolute value 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" + return Ok(f"Setting default PZ radius to {radius} NM") - def sethpz(self, height: float = -1.0, *acidx: int) -> tuple: + def sethpz(self, height: float = -1.0, *acidx: int) -> Result[str, str]: """Set the vertical separation distance (i.e., half of the protected zone height) in feet. @@ -281,20 +298,16 @@ def sethpz(self, height: float = -1.0, *acidx: int) -> tuple: *acidx: Aircraft index/indices or group. When not provided, the default PZ height is changed. Otherwise the PZ height for the passed aircraft is changed. - - Returns: - tuple: (success (bool), message (str)) for the command stack. """ if height < 0.0: - return ( - True, - f"ZONEDH [height (ft), acid(s)/ac group]\nCurrent default PZ height: {self.hpz_def / ft:.2f} ft", + return Ok( + f"ZONEDH [height (ft), acid(s)/ac group]\nCurrent default PZ height: {self.hpz_def / ft:.2f} ft" ) if len(acidx) > 0: idx: Any = acidx[0] if isinstance(acidx[0], np.ndarray) else acidx self.hpz[idx] = height * ft self.global_hpz = False - return True, f"Setting PZ height to {height} ft for {len(idx)} aircraft" + return Ok(f"Setting PZ height to {height} ft for {len(idx)} aircraft") oldhpz = self.hpz_def self.hpz_def = height * ft if self.global_hpz: @@ -302,9 +315,9 @@ def sethpz(self, height: float = -1.0, *acidx: int) -> tuple: # Adjust factors for reso zone if those were set with an absolute value 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" + return Ok(f"Setting default PZ height to {height} ft") - def setdtlook(self, time: Time = -1.0, *acidx: int) -> tuple: + def setdtlook(self, time: Time = -1.0, *acidx: int) -> Result[str, str]: """Set the lookahead time (in [hh:mm:]sec) for conflict detection. Implements the DTLOOK stack command. @@ -315,23 +328,20 @@ def setdtlook(self, time: Time = -1.0, *acidx: int) -> tuple: *acidx: Aircraft index/indices or group. When not provided, the default lookahead time is changed. Otherwise the lookahead time for the passed aircraft is changed. - - Returns: - tuple: (success (bool), message (str)) for the command stack. """ if time < 0.0: - return True, f"DTLOOK[time]\nCurrent value: {self.dtlookahead_def: .1f} sec" + return Ok(f"DTLOOK[time]\nCurrent value: {self.dtlookahead_def: .1f} sec") if len(acidx) > 0: idx: Any = acidx[0] if isinstance(acidx[0], np.ndarray) else acidx self.dtlookahead[idx] = time self.global_dtlook = False - return True, f"Setting CD lookahead to {time} sec for {len(idx)} aircraft" + return Ok(f"Setting CD lookahead to {time} sec for {len(idx)} aircraft") self.dtlookahead_def = time if self.global_dtlook: self.dtlookahead[:] = time - return True, f"Setting default CD lookahead to {time} sec" + return Ok(f"Setting default CD lookahead to {time} sec") - def setdtnolook(self, time: Time = -1.0, *acidx: int) -> tuple: + def setdtnolook(self, time: Time = -1.0, *acidx: int) -> Result[str, str]: """Set the interval (in [hh:mm:]sec) in which conflict detection is skipped after a conflict resolution. @@ -343,21 +353,18 @@ def setdtnolook(self, time: Time = -1.0, *acidx: int) -> tuple: *acidx: Aircraft index/indices or group. When not provided, the default interval is changed. Otherwise the interval for the passed aircraft is changed. - - Returns: - tuple: (success (bool), message (str)) for the command stack. """ if time < 0.0: - return True, f"DTNOLOOK[time]\nCurrent value: {self.dtnolook_def: .1f} sec" + return Ok(f"DTNOLOOK[time]\nCurrent value: {self.dtnolook_def: .1f} sec") if len(acidx) > 0: idx: Any = acidx[0] if isinstance(acidx[0], np.ndarray) else acidx self.dtnolook[idx] = time self.global_dtnolook = False - return True, f"Setting CD no-look to {time} sec for {len(idx)} aircraft" + return Ok(f"Setting CD no-look to {time} sec for {len(idx)} aircraft") self.dtnolook_def = time if self.global_dtnolook: self.dtnolook[:] = time - return True, f"Setting default CD no-look to {time} sec" + return Ok(f"Setting default CD no-look to {time} sec") def update(self, ownship: Any, intruder: Any) -> None: """Perform an update step of the Conflict Detection implementation. @@ -375,18 +382,18 @@ def update(self, ownship: Any, intruder: Any) -> None: if not self.activate: return - ( - self.confpairs, - self.lospairs, - self.inconf, - self.tcpamax, - self.qdr, - self.dist, - self.dcpa, - self.tcpa, - self.tLOS, - self.dalt, - ) = self.detect(ownship, intruder, self.rpz, self.hpz, self.dtlookahead) + result = self.detect(ownship, intruder, self.rpz, self.hpz, self.dtlookahead) + # TODO(abraham): consider storing the entire result + self.confpairs = result.confpairs + self.lospairs = result.lospairs + self.inconf = result.inconf + self.tcpamax = result.tcpamax + self.qdr = result.qdr + self.dist = result.dist + self.dcpa = result.dcpa + self.tcpa = result.tcpa + self.tLOS = result.tLOS + self.dalt = result.dalt # confpairs has conflicts observed from both sides (a, b) and (b, a) # confpairs_unique keeps only one of these @@ -400,6 +407,12 @@ def update(self, ownship: Any, intruder: Any) -> None: self.confpairs_unique = confpairs_unique self.lospairs_unique = lospairs_unique + class VerticalInterval(NamedTuple): + entry: np.ndarray + """Vertical conflict entry time [s].""" + exit: np.ndarray + """Vertical conflict exit time [s].""" + def detect( self, ownship: Any, @@ -407,7 +420,7 @@ def detect( rpz: np.ndarray, hpz: np.ndarray, dtlookahead: np.ndarray, - ) -> tuple: + ) -> ConflictDetectionResult: """Conflict detection between ownship (traf) and intruder (traf/adsb). State-based detection with spatial candidate pruning: a KD-tree on @@ -433,19 +446,6 @@ def detect( rpz (ndarray): Per-aircraft horizontal separation minimum [m]. hpz (ndarray): Per-aircraft vertical separation minimum [m]. dtlookahead (ndarray): Per-aircraft lookahead time [s]. - - Returns: - tuple: The detection results: - - confpairs (list): Conflicting callsign pairs, both directions. - - lospairs (list): Callsign pairs in loss of separation. - - inconf (ndarray): Per-aircraft in-conflict flag [-]. - - tcpamax (ndarray): Per-aircraft maximum tCPA [s]. - - qdr (ndarray): Bearing ownship to intruder per conflict [deg]. - - dist (ndarray): Current horizontal distance per conflict [m]. - - dcpa (ndarray): Horizontal distance at CPA per conflict [m]. - - tcpa (ndarray): Time to CPA per conflict [s]. - - tinconf (ndarray): Time to start of LoS per conflict [s]. - - dalt (ndarray): Current altitude difference per conflict [m]. """ ntraf = ownship.ntraf if ntraf < 2: @@ -539,12 +539,14 @@ def detect( # flags a conflict). The horizontal geometry is fully symmetric. dvs = intruder.vs[jj] - ownship.vs[ii] - def vertical_interval(da: np.ndarray, dw: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + def vertical_interval(da: np.ndarray, dw: np.ndarray) -> ConflictDetection.VerticalInterval: """Vertical crossing interval of the disk (-hpz, +hpz).""" dw = np.where(np.abs(dw) < 1e-6, 1e-6, dw) # prevent division by zero tcrosshi = (da + hpz) / -dw tcrosslo = (da - hpz) / -dw - return np.minimum(tcrosshi, tcrosslo), np.maximum(tcrosshi, tcrosslo) + return self.VerticalInterval( + np.minimum(tcrosshi, tcrosslo), np.maximum(tcrosshi, tcrosslo) + ) tinver_ij, toutver_ij = vertical_interval(dalt, dvs) tinver_ji, toutver_ji = vertical_interval(-dalt, -dvs) @@ -608,7 +610,7 @@ def vertical_interval(da: np.ndarray, dw: np.ndarray) -> tuple[np.ndarray, np.nd for i, j in zip(ilos[losorder], jlos[losorder], strict=False) ] - return ( + return ConflictDetectionResult( confpairs, lospairs, inconf, diff --git a/packages/minisky/minisky/traffic/asas/mvp.py b/packages/minisky/minisky/traffic/asas/mvp.py index e82d0ed..39eb2aa 100644 --- a/packages/minisky/minisky/traffic/asas/mvp.py +++ b/packages/minisky/minisky/traffic/asas/mvp.py @@ -17,13 +17,14 @@ from __future__ import annotations from collections.abc import Callable -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, NamedTuple import numpy as np from minisky.core.config import MiniSkyConfig +from minisky.result import Err, Ok, Result from minisky.stack.argparser import Txt -from minisky.traffic.asas import ConflictResolution +from minisky.traffic.asas.resolution import ConflictResolution if TYPE_CHECKING: from minisky.traffic import Traffic @@ -55,7 +56,7 @@ def __init__( self, config: MiniSkyConfig, traffic: Traffic, - select_implementation: Callable[[str, str], tuple[bool, str]], + select_implementation: Callable[[str, str], Result[str, str]], ) -> None: super().__init__(config, traffic, select_implementation) # [-] switch to limit resolution to the horizontal direction @@ -67,7 +68,7 @@ def __init__( # [-] 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="") -> Result[str, str]: """Set the prio switch and the type of prio. Implements the PRIORULES stack command for MVP. Validates the @@ -77,13 +78,9 @@ def setprio(self, flag=None, priocode="") -> bool | tuple: flag (bool): True to enable priority rules, False to disable. When None, the available priority codes are reported. priocode (str): One of "FF1", "FF2", "FF3", "LAY1", "LAY2". - - Returns: - True on success, or (success (bool), message (str)) tuple. """ if flag is None: - return ( - True, + return Ok( "PRIORULES [ON/OFF] [PRIOCODE]" + "\nAvailable priority codes: " + "\n FF1: Free Flight Primary (No Prio) " @@ -94,14 +91,14 @@ def setprio(self, flag=None, priocode="") -> bool | tuple: + "\nPriority is currently " + ("ON" if self.swprio else "OFF") + "\nPriority code is currently: " - + str(self.priocode), + + str(self.priocode) ) options = ["FF1", "FF2", "FF3", "LAY1", "LAY2"] if priocode not in options: - return False, "Priority code Not Understood. Available Options: " + str(options) + return Err("Priority code Not Understood. Available Options: " + str(options)) return super().setprio(flag, priocode) - def setresometh(self, value: Txt = "") -> tuple: + def setresometh(self, value: Txt = "") -> Result[str, str]: """Processes the RMETHH command. Sets swresovert = False. Selects which horizontal degrees of freedom MVP may use for @@ -111,28 +108,21 @@ def setresometh(self, value: Txt = "") -> tuple: Args: value (str): One of "BOTH", "SPD", "HDG", "NONE", "ON", "OFF", "OF". When empty, the current settings are reported. - - Returns: - tuple: (success (bool), message (str)) for the command stack. """ # Acceptable arguments for this command options = ["BOTH", "SPD", "HDG", "NONE", "ON", "OFF", "OF"] if not value: - return ( - True, + return Ok( "RMETHH [ON / BOTH / OFF / NONE / SPD / HDG]" + "\nHorizontal resolution limitation is currently " + ("ON" if self.swresohoriz else "OFF") + "\nSpeed resolution limitation is currently " + ("ON" if self.swresospd else "OFF") + "\nHeading resolution limitation is currently " - + ("ON" if self.swresohdg else "OFF"), + + ("ON" if self.swresohdg else "OFF") ) if value not in options: - return ( - False, - "RMETH Not Understood" + "\nRMETHH [ON / BOTH / OFF / NONE / SPD / HDG]", - ) + return Err("RMETH Not Understood" + "\nRMETHH [ON / BOTH / OFF / NONE / SPD / HDG]") else: if value == "ON" or value == "BOTH": self.swresohoriz = True @@ -154,9 +144,9 @@ def setresometh(self, value: Txt = "") -> tuple: self.swresospd = False self.swresohdg = True self.swresovert = False - return True, f"Horizontal resolution method set to {value}" + return Ok(f"Horizontal resolution method set to {value}") - def setresometv(self, value: Txt = "") -> tuple: + def setresometv(self, value: Txt = "") -> Result[str, str]: """Processes the RMETHV command. Sets swresohoriz = False. Enables (ON/"V/S") or disables (OFF/NONE) vertical-speed-only @@ -165,24 +155,17 @@ def setresometv(self, value: Txt = "") -> tuple: Args: value (str): One of "ON", "V/S", "OFF", "OF", "NONE". When empty, the current setting is reported. - - Returns: - tuple: (success (bool), message (str)) for the command stack. """ # Acceptable arguments for this command options = ["NONE", "ON", "OFF", "OF", "V/S"] if not value: - return ( - True, + return Ok( "RMETHV [ON / V/S / OFF / NONE]" + "\nVertical resolution limitation is currently " - + ("ON" if self.swresovert else "OFF"), + + ("ON" if self.swresovert else "OFF") ) if value not in options: - return ( - False, - f"RMETHV '{value}' Not Understood\nRMETHV [ON / V/S / OFF / NONE]", - ) + return Err(f"RMETHV '{value}' Not Understood\nRMETHV [ON / V/S / OFF / NONE]") if value == "ON" or value == "V/S": self.swresovert = True @@ -192,7 +175,13 @@ def setresometv(self, value: Txt = "") -> tuple: elif value == "OFF" or value == "OF" or value == "NONE": # Do NOT swtich off self.swresohoriz if value == OFF self.swresovert = False - return True, f"Vertical resolution method set to {value}" + return Ok(f"Vertical resolution method set to {value}") + + class PriorityResolution(NamedTuple): + ownship: np.ndarray + """Updated ownship resolution vector [m/s].""" + intruder: np.ndarray + """Updated intruder resolution vector [m/s].""" def applyprio( self, @@ -201,7 +190,7 @@ def applyprio( dv2: np.ndarray, vs1: float, vs2: float, - ) -> tuple: + ) -> PriorityResolution: """Apply the desired priority setting to the resolution. Distributes the pairwise MVP resolution vector over the two aircraft @@ -217,9 +206,6 @@ def applyprio( dv2 (ndarray): Accumulated resolution vector of aircraft 2 [m/s]. vs1 (float): Vertical speed of aircraft 1 [m/s]. vs2 (float): Vertical speed of aircraft 2 [m/s]. - - Returns: - tuple: Updated (dv1, dv2) resolution vectors [m/s]. """ # Primary Free Flight prio rules (no priority) @@ -284,9 +270,11 @@ def applyprio( dv1 = dv1 - dv_mvp dv2 = dv2 + dv_mvp - return dv1, dv2 + return self.PriorityResolution(dv1, dv2) - def resolve(self, conf: Any, ownship: Any, intruder: Any) -> tuple: + def resolve( + self, conf: Any, ownship: Any, intruder: Any + ) -> ConflictResolution.ResolutionAdvisories: """Resolve all current conflicts. Loops over all detected conflict pairs, computes the MVP resolution @@ -304,14 +292,6 @@ def resolve(self, conf: Any, ownship: Any, intruder: Any) -> tuple: ownship: Traffic object with ownship states. intruder: Traffic object with intruder states. - Returns: - tuple: Per-aircraft advisories: - - newtrack (ndarray): Resolution track [deg]. - - newgscapped (ndarray): Resolution ground speed, capped to - the performance envelope [m/s]. - - vscapped (ndarray): Resolution vertical speed, capped to - the performance envelope [m/s]. - - alt (ndarray): Resolution altitude [m]. """ # Initialize an array to store the resolution velocity vector for all A/C dv = np.zeros((ownship.ntraf, 3)) @@ -329,15 +309,18 @@ def resolve(self, conf: Any, ownship: Any, intruder: Any) -> tuple: # If A/C indexes are found, then apply MVP on this conflict pair # Because ADSB is ON, this is done for each aircraft separately if idx1 > -1 and idx2 > -1: - dv_mvp, tsolV = self.MVP(ownship, intruder, conf, qdr, dist, tcpa, tLOS, idx1, idx2) - if tsolV < timesolveV[idx1]: - timesolveV[idx1] = tsolV + pair_resolution = self.MVP( + ownship, intruder, conf, qdr, dist, tcpa, tLOS, idx1, idx2 + ) + dv_mvp = pair_resolution.velocity_delta + timesolveV[idx1] = min(timesolveV[idx1], pair_resolution.vertical_time) # Use priority rules if activated if self.swprio: - dv[idx1], _ = self.applyprio( + priority = self.applyprio( dv_mvp, dv[idx1], dv[idx2], ownship.vs[idx1], intruder.vs[idx2] ) + dv[idx1] = priority.ownship else: # since cooperative, the vertical resolution component can be halved, and then dv_mvp can be added dv_mvp[2] = 0.5 * dv_mvp[2] @@ -417,7 +400,13 @@ def resolve(self, conf: Any, ownship: Any, intruder: Any) -> tuple: # using the auto pilot vertical speed (ownship.avs) using the code in line 106 (asasalttemp) when only # horizontal resolutions are allowed. alt = alt * (1 - self.swresohoriz) + ownship.selalt * self.swresohoriz - return newtrack, newgscapped, vscapped, alt + return self.ResolutionAdvisories(newtrack, newgscapped, vscapped, alt) + + class MvpResolution(NamedTuple): + velocity_delta: np.ndarray + """Resolution velocity change, east/north/up [m/s].""" + vertical_time: float + """Time needed to resolve the conflict vertically [s].""" def MVP( self, @@ -430,7 +419,7 @@ def MVP( tLOS: float, idx1: int, idx2: int, - ) -> tuple: + ) -> MvpResolution: """Modified Voltage Potential (MVP) resolution method. Computes the velocity change that displaces the predicted closest @@ -456,11 +445,6 @@ def MVP( tLOS (float): Time until loss of separation starts [s]. idx1 (int): Index of the ownship aircraft. idx2 (int): Index of the intruder aircraft. - - Returns: - tuple: (dv, tsolV) where dv is the resolution velocity change - (east, north, up) [m/s] and tsolV the time needed to resolve - the conflict vertically [s]. """ # Preliminary calculations------------------------------------------------- # Determine largest RPZ and HPZ of the conflict pair, use lookahead of ownship @@ -546,4 +530,4 @@ def MVP( # combine the dv components dv = np.array([dv1, dv2, dv3]) - return dv, tsolV + return self.MvpResolution(dv, tsolV) diff --git a/packages/minisky/minisky/traffic/asas/resolution.py b/packages/minisky/minisky/traffic/asas/resolution.py index e6e2b8e..b7dcdb1 100644 --- a/packages/minisky/minisky/traffic/asas/resolution.py +++ b/packages/minisky/minisky/traffic/asas/resolution.py @@ -16,12 +16,13 @@ from __future__ import annotations from collections.abc import Callable -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, NamedTuple import numpy as np from minisky.core.config import MiniSkyConfig from minisky.core.trafficarrays import TrafficArrays +from minisky.result import Err, Ok, Result from minisky.stack.argparser import Txt from minisky.tools.aero import ft, nm from minisky.traffic import route @@ -71,7 +72,7 @@ def __init__( self, config: MiniSkyConfig, traffic: Traffic, - select_implementation: Callable[[str, str], tuple[bool, str]], + select_implementation: Callable[[str, str], Result[str, str]], ) -> None: super().__init__() self.config = config @@ -170,7 +171,17 @@ def tasactive(self) -> np.ndarray: """ return self.active - def resolve(self, conf: Any, ownship: Any, intruder: Any) -> tuple: + class ResolutionAdvisories(NamedTuple): + track: np.ndarray + """Per-aircraft track advisory [deg].""" + tas: np.ndarray + """Per-aircraft true airspeed advisory [m/s].""" + vertical_speed: np.ndarray + """Per-aircraft vertical speed advisory [m/s].""" + altitude: np.ndarray + """Per-aircraft altitude advisory [m].""" + + def resolve(self, conf: Any, ownship: Any, intruder: Any) -> ResolutionAdvisories: """Resolve all current conflicts. This function should be reimplemented in a subclass for actual @@ -182,15 +193,13 @@ def resolve(self, conf: Any, ownship: Any, intruder: Any) -> tuple: conf: The ConflictDetection instance with the current conflicts. ownship: Traffic object with ownship states. intruder: Traffic object with intruder states. - - Returns: - tuple: Per-aircraft advisories (newtrk [deg], newtas [m/s], - newvs [m/s], newalt [m]). """ # If resolution is off, and detection is on, and a conflict is detected # then asas will be active for that airplane. Since resolution is off, it # should then follow the auto pilot instructions. - return ownship.ap.trk, ownship.ap.tas, ownship.ap.vs, ownship.ap.alt + return self.ResolutionAdvisories( + ownship.ap.trk, ownship.ap.tas, ownship.ap.vs, ownship.ap.alt + ) def update(self, conf: Any, ownship: Any, intruder: Any) -> None: """Perform an update step of the Conflict Resolution implementation. @@ -206,7 +215,12 @@ def update(self, conf: Any, ownship: Any, intruder: Any) -> None: """ if self.activate: if conf.confpairs: - self.trk, self.tas, self.vs, self.alt = self.resolve(conf, ownship, intruder) + advisories = self.resolve(conf, ownship, intruder) + # TODO(abraham): consider storing the entire advisories result + self.trk = advisories.track + self.tas = advisories.tas + self.vs = advisories.vertical_speed + self.alt = advisories.altitude self.resumenav(conf, ownship, intruder) def resumenav(self, conf: Any, ownship: Any, intruder: Any) -> None: @@ -322,7 +336,7 @@ def anglediff(a: float, b: float) -> float: # 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="") -> Result[str, str]: """Define priority rules (right of way) for conflict resolution. Implements the PRIORULES stack command. The base class only stores @@ -333,23 +347,19 @@ def setprio(self, flag: bool | None = None, priocode="") -> bool | tuple: flag (bool): True to enable priority rules, False to disable. When None, an informational message is returned. priocode (str): Identifier of the priority rule set to use. - - Returns: - True on success, or (False, message) when not applicable. """ if flag is None: if self.__class__ is ConflictResolution: - return False, "No conflict resolution enabled." - return ( - False, - f"Resolution algorithm {self.__class__.__name__} hasn't implemented priority.", + return Err("No conflict resolution enabled.") + return Err( + f"Resolution algorithm {self.__class__.__name__} hasn't implemented priority." ) self.swprio = flag self.priocode = priocode - return True + return Ok("") - def setnoreso(self, *idx: int) -> bool | tuple: + def setnoreso(self, *idx: int) -> Result[str, str]: """ADD or Remove aircraft that nobody will avoid. Multiple aircraft can be sent to this function at once. @@ -360,22 +370,18 @@ def setnoreso(self, *idx: int) -> bool | tuple: Args: *idx: Aircraft indices to toggle. When empty, the current list of flagged aircraft is reported. - - Returns: - True on success, or (True, message) when reporting. """ if not idx: - return ( - True, + return Ok( "NORESO [ACID, ... ] OR NORESO [GROUPID]" + "\nCurrent list of aircraft nobody will avoid:" - + ", ".join(np.array(self.traffic.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 + return Ok("") - def setresooff(self, *idx: int) -> bool | tuple: + def setresooff(self, *idx: int) -> Result[str, str]: """ADD or Remove aircraft that will not avoid anybody else. Multiple aircraft can be sent to this function at once. @@ -386,23 +392,19 @@ def setresooff(self, *idx: int) -> bool | tuple: Args: *idx: Aircraft indices to toggle. When empty, the current list of flagged aircraft is reported. - - Returns: - True on success, or (True, message) when reporting. """ if not idx: - return ( - True, + return Ok( "RESOOFF [ACID, ... ] OR RESOOFF [GROUPID]" + "\nCurrent list of aircraft will not avoid anybody:" - + ", ".join(np.array(self.traffic.callsign)[self.resooffac]), + + ", ".join(np.array(self.traffic.callsign)[self.resooffac]) ) else: indices = list(idx) self.resooffac[indices] = np.logical_not(self.resooffac[indices]) - return True + return Ok("") - def setresofach(self, factor: float | None = None) -> tuple: + def setresofach(self, factor: float | None = None) -> Result[str, str]: """Set resolution factor horizontal (to maneuver only a fraction of a resolution vector). @@ -414,23 +416,17 @@ def setresofach(self, factor: float | None = None) -> tuple: Args: factor (float): Horizontal resolution factor [-]. When None, the current factor is reported. - - Returns: - tuple: (success (bool), message (str)) for the command stack. """ if factor is None: - return ( - True, - f"RFACH [FACTOR]\nCurrent horizontal resolution factor is: {self.resofach}", - ) + return Ok(f"RFACH [FACTOR]\nCurrent horizontal resolution factor is: {self.resofach}") else: self.resofach = factor self.resorrelative = ( True # Size of resolution zone r, vertically, set relative to CD zone ) - return True, f"Horizontal resolution factor set to {self.resofach}" + return Ok(f"Horizontal resolution factor set to {self.resofach}") - def setresofacv(self, factor: float | None = None) -> tuple: + def setresofacv(self, factor: float | None = None) -> Result[str, str]: """Set resolution factor vertical (to maneuver only a fraction of a resolution vector). Implements the RFACV stack command. The vertical resolution zone @@ -439,21 +435,15 @@ def setresofacv(self, factor: float | None = None) -> tuple: Args: factor (float): Vertical resolution factor [-]. When None, the current factor is reported. - - Returns: - tuple: (success (bool), message (str)) for the command stack. """ if factor is None: - return ( - True, - f"RFACV [FACTOR]\nCurrent vertical resolution factor is: {self.resofacv}", - ) + return Ok(f"RFACV [FACTOR]\nCurrent vertical resolution factor is: {self.resofacv}") self.resofacv = factor # Size of resolution zone dh, vertically, set relative to CD zone self.resodhrelative = True - return True, f"Vertical resolution factor set to {self.resofacv}" + return Ok(f"Vertical resolution factor set to {self.resofacv}") - def setresozoner(self, zoner: float | None = None) -> tuple: + def setresozoner(self, zoner: float | None = None) -> Result[str, str]: """Set resolution factor horizontal, but then with absolute value (to maneuver only a fraction of a resolution vector). @@ -465,31 +455,25 @@ def setresozoner(self, zoner: float | None = None) -> tuple: Args: zoner (float): Resolution zone radius [NM]. When None, the current factor and resulting radius are reported. - - Returns: - tuple: (success (bool), message (str)) for the command stack. """ if not self.traffic.cd.global_rpz: self.resorrelative = True - return ( - False, - "RSZONER [radiusnm]\nCan only set resolution factor when simulation contains aircraft with different RPZ,\nUse RFACH instead.", + return Err( + "RSZONER [radiusnm]\nCan only set resolution factor when simulation contains aircraft with different RPZ,\nUse RFACH instead." ) if zoner is None: - return ( - True, - f"RSZONER [radiusnm]\nCurrent horizontal resolution factor is: {self.resofach}, resulting in radius: {self.resofach * self.traffic.cd.rpz_def / nm} nm", + return Ok( + 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 / self.traffic.cd.rpz_def * nm # Size of resolution zone r, vertically, no longer relative to CD zone self.resorrelative = False - return ( - True, - f"Horizontal resolution factor updated to {self.resofach}, resulting in radius: {zoner} nm", + return Ok( + f"Horizontal resolution factor updated to {self.resofach}, resulting in radius: {zoner} nm" ) - def setresozonedh(self, zonedh: float | None = None) -> tuple: + def setresozonedh(self, zonedh: float | None = None) -> Result[str, str]: """Set resolution factor vertical (to maneuver only a fraction of a resolution vector), but then with absolute value. @@ -501,31 +485,25 @@ def setresozonedh(self, zonedh: float | None = None) -> tuple: Args: zonedh (float): Resolution zone height [ft]. When None, the current factor and resulting height are reported. - - Returns: - tuple: (success (bool), message (str)) for the command stack. """ if not self.traffic.cd.global_hpz: self.resodhrelative = True - return ( - False, - "RSZONEH [zonedhft]\nCan only set resolution factor when simulation contains aircraft with different HPZ,\nUse RFACV instead.", + return Err( + "RSZONEH [zonedhft]\nCan only set resolution factor when simulation contains aircraft with different HPZ,\nUse RFACV instead." ) if zonedh is None: - return ( - True, - f"RSZONEDH [zonedhft]\nCurrent vertical resolution factor is: {self.resofacv}, resulting in height: {self.resofacv * self.traffic.cd.hpz_def / ft} ft", + return Ok( + 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 / self.traffic.cd.hpz_def * ft # Size of resolution zone dh, vertically, no longer relative to CD zone self.resodhrelative = False - return ( - True, - f"Vertical resolution factor updated to {self.resofacv}, resulting in height: {zonedh} ft", + return Ok( + f"Vertical resolution factor updated to {self.resofacv}, resulting in height: {zonedh} ft" ) - def setmethod(self, name: Txt = "") -> tuple: + def setmethod(self, name: Txt = "") -> Result[str, str]: """Select a Conflict Resolution method. Implements the RESO stack command. Selecting "MVP" replaces the @@ -534,37 +512,34 @@ def setmethod(self, name: Txt = "") -> tuple: Args: name (str): "OFF", "MVP", or empty to report available methods. - - Returns: - tuple: (success (bool), message (str)) for the command stack. """ names = ["OFF", "MVP"] if not name: 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)}", + return Ok( + f"Current CR method: {curname}" + f"\nAvailable CR methods: {', '.join(names)}" ) if name == "OFF": self.traffic.cr.switch(False) - return True, "Conflict Resolution turned off." + return Ok("Conflict Resolution turned off.") if name == "MVP": - success, message = self.select_implementation("CONFLICTRESOLUTION", name) - if not success: - return success, message - self.traffic.cr.switch(True) - return True, "Selected MVP as Conflict Resolution method." + match self.select_implementation("CONFLICTRESOLUTION", name): + case Err() as error: + return error + case Ok(): + self.traffic.cr.switch(True) + return Ok("Selected MVP as Conflict Resolution method.") - return False, f"Unknown method: {name}. Available: {', '.join(names)}" + return Err(f"Unknown method: {name}. Available: {', '.join(names)}") - def setresometh(self, value: Txt = "") -> tuple: + def setresometh(self, value: Txt = "") -> Result[str, str]: """Report that horizontal method selection requires the MVP implementation.""" - return False, f"RMETHH is not available for CR method {type(self).__name__}" + return Err(f"RMETHH is not available for CR method {type(self).__name__}") - def setresometv(self, value: Txt = "") -> tuple: + def setresometv(self, value: Txt = "") -> Result[str, str]: """Report that vertical method selection requires the MVP implementation.""" - return False, f"RMETHV is not available for CR method {type(self).__name__}" + return Err(f"RMETHV is not available for CR method {type(self).__name__}") diff --git a/packages/minisky/minisky/traffic/autopilot.py b/packages/minisky/minisky/traffic/autopilot.py index f2f0f2d..af19b1a 100644 --- a/packages/minisky/minisky/traffic/autopilot.py +++ b/packages/minisky/minisky/traffic/autopilot.py @@ -23,6 +23,7 @@ import numpy as np from minisky.core.trafficarrays import TrafficArrays +from minisky.result import Err, Ok, Result from minisky.stack.argparser import Acid, Alt, Hdg, OnOff, Spd, Vspd, Wpt from minisky.tools import geo from minisky.tools.aero import ( @@ -36,7 +37,7 @@ vcasormach2tas, ) from minisky.tools.convert import degto180 -from minisky.tools.position import Position, txt2pos +from minisky.tools.position import txt2pos from .route import Route, direct @@ -585,7 +586,6 @@ def update(self) -> None: self.traffic.alt, self.traffic.casmach_threshold, ) - # dxspdconchg = distaccel(self.traffic.tas, nexttas, self.traffic.perf.axmax) qdrturn, dist2turn = geo.qdrdist( @@ -769,7 +769,6 @@ def ComputeVNAV(self, idx: int, toalt: Any, xtoalt: Any, torta: Any, xtorta: Any # - Descend at the latest when necessary for next altitude constraint # which can be many waypoints beyond current actual waypoint epsalt = 2.0 * ft # deadzone - # 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. @@ -935,7 +934,7 @@ def setspeedforRTA(self, idx: int, torta: Any, xtorta: float) -> float | bool: def selaltcmd( self, idx: int | np.ndarray, alt: Alt, vspd: Vspd | None = None - ) -> tuple[bool, str]: + ) -> Result[str, str]: """Select the autopilot altitude, optionally with a vertical speed. Implements the ALT stack command: `ALT acid, alt, [vspd]`. @@ -948,9 +947,6 @@ def selaltcmd( idx: Aircraft index (or collection of indices). alt: Selected altitude [m] (stack input in ft/FL). vspd: Optional vertical speed [m/s] (stack input in fpm). - - Returns: - tuple: (True, confirmation message). """ self.traffic.selalt[idx] = alt self.traffic.swvnav[idx] = False @@ -969,9 +965,9 @@ def selaltcmd( ) self.traffic.selvs[idxarr[oppositevs]] = 0.0 - return True, f"altitude set to {alt / ft} ft" + return Ok(f"altitude set to {alt / ft} ft") - def selvspdcmd(self, idx: int, vspd: Vspd) -> tuple[bool, str]: + def selvspdcmd(self, idx: int, vspd: Vspd) -> Result[str, str]: """Select the autopilot vertical speed. Implements the VS stack command: `VS acid, vspd (ft/min)`. @@ -980,15 +976,12 @@ def selvspdcmd(self, idx: int, vspd: Vspd) -> tuple[bool, str]: Args: idx: Aircraft index. vspd: Selected vertical speed [m/s] (stack input in fpm). - - Returns: - tuple: (True, confirmation message). """ self.traffic.selvs[idx] = vspd self.traffic.swvnav[idx] = False - return True, f"vertical speed set to {vspd / fpm} ft/min" + return Ok(f"vertical speed set to {vspd / fpm} ft/min") - def selhdgcmd(self, idx: int, hdg: Hdg) -> tuple[bool, str]: # HDG command + def selhdgcmd(self, idx: int, hdg: Hdg) -> Result[str, str]: # HDG command """Select the autopilot heading. Implements the HDG stack command: `HDG acid, hdg (deg)`. When a @@ -1000,9 +993,6 @@ def selhdgcmd(self, idx: int, hdg: Hdg) -> tuple[bool, str]: # HDG command Args: idx: Aircraft index. hdg: Selected heading [deg]. - - Returns: - tuple: (True, confirmation message). """ if self.traffic.wind.winddim > 0: @@ -1023,9 +1013,9 @@ def selhdgcmd(self, idx: int, hdg: Hdg) -> tuple[bool, str]: # HDG command self.trk[idx] = hdg self.traffic.swlnav[idx] = False - return True, f"heading set to {hdg} deg" + return Ok(f"heading set to {hdg} deg") - def selspdcmd(self, idx: int, casmach: Spd) -> tuple[bool, str]: # SPD command + def selspdcmd(self, idx: int, casmach: Spd) -> Result[str, str]: # SPD command """Select the autopilot speed. Implements the SPD stack command: `SPD acid, casmach`. Switches @@ -1037,9 +1027,6 @@ def selspdcmd(self, idx: int, casmach: Spd) -> tuple[bool, str]: # SPD command idx: Aircraft index. casmach: Selected speed: CAS [m/s] or Mach [-] (values above 1.0 are interpreted as CAS; stack input in kts or Mach). - - Returns: - tuple: (True, confirmation message). """ # Depending on or position relative to crossover altitude, # we will maintain CAS or Mach when altitude changes @@ -1054,11 +1041,11 @@ def selspdcmd(self, idx: int, casmach: Spd) -> tuple[bool, str]: # SPD command else: msg = f"speed set to Mach {casmach}" - return True, msg + return Ok(msg) def setdest( self, acidx: Acid, wpname: Wpt | None = None, casmach: Spd | None = None - ) -> tuple[bool, str]: + ) -> Result[str, str]: """Set (or show) the destination of an aircraft. Implements the DEST stack command: `DEST acid, latlon/airport`. @@ -1073,12 +1060,9 @@ def setdest( current destination is reported. casmach: Optional speed constraint at the destination, CAS [m/s] or Mach [-]. - - Returns: - tuple: (success flag, message). """ if wpname is None: - return True, "DEST " + self.traffic.callsign[acidx] + ": " + self.dest[acidx] + return Ok("DEST " + self.traffic.callsign[acidx] + ": " + self.dest[acidx]) route = self.route[acidx] @@ -1091,19 +1075,18 @@ def setdest( reflat = self.traffic.lat[acidx] reflon = self.traffic.lon[acidx] - success, posobj = txt2pos( + match txt2pos( wpname, float(reflat), float(reflon), self.navigation, self.traffic, - ) - if success: - assert isinstance(posobj, Position) - lat = posobj.lat - lon = posobj.lon - else: - return False, "DEST: Position " + wpname + " not found." + ): + case Ok(posobj): + lat = posobj.lat + lon = posobj.lon + case Err(): + return Err("DEST: Position " + wpname + " not found.") else: lat = self.navigation.aptlat[apidx] @@ -1128,11 +1111,11 @@ def setdest( # If not found, say so elif iwp < 0: - return False, ("DEST position" + self.dest[acidx] + " not found.") + return Err("DEST position" + self.dest[acidx] + " not found.") - return True, f"destination set to {wpname}" + return Ok(f"destination set to {wpname}") - def setorig(self, acidx: int, wpname: Wpt | None = None) -> tuple[bool, str]: + def setorig(self, acidx: int, wpname: Wpt | None = None) -> Result[str, str]: """Set (or show) the origin of an aircraft. Implements the ORIG stack command: `ORIG acid, latlon/airport`. @@ -1143,12 +1126,9 @@ def setorig(self, acidx: int, wpname: Wpt | None = None) -> tuple[bool, str]: acidx: Aircraft index. wpname: Airport identifier or position text; when omitted, the current origin is reported. - - Returns: - tuple: (success flag, message). """ if wpname is None: - return True, "ORIG " + self.traffic.callsign[acidx] + ": " + self.orig[acidx] + return Ok("ORIG " + self.traffic.callsign[acidx] + ": " + self.orig[acidx]) route = self.route[acidx] @@ -1162,19 +1142,18 @@ def setorig(self, acidx: int, wpname: Wpt | None = None) -> tuple[bool, str]: reflat = self.traffic.lat[acidx] reflon = self.traffic.lon[acidx] - success, posobj = txt2pos( + match txt2pos( wpname, float(reflat), float(reflon), self.navigation, self.traffic, - ) - if success: - assert isinstance(posobj, Position) - lat = posobj.lat - lon = posobj.lon - else: - return False, ("ORIG: Position " + wpname + " not found.") + ): + case Ok(posobj): + lat = posobj.lat + lon = posobj.lon + case Err(): + return Err("ORIG: Position " + wpname + " not found.") else: lat = self.navigation.aptlat[apidx] @@ -1186,11 +1165,11 @@ def setorig(self, acidx: int, wpname: Wpt | None = None) -> tuple[bool, str]: acidx, self.orig[acidx], route.orig, lat, lon, 0.0, self.traffic.cas[acidx] ) if iwp < 0: - return False, (self.orig[acidx] + " not found.") + return Err(self.orig[acidx] + " not found.") - return True, f"origin set to {wpname}" + return Ok(f"origin set to {wpname}") - def setVNAV(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: + def setVNAV(self, idx: Any, flag: OnOff | None = None) -> Result[str, str]: """Switch VNAV (vertical FMS guidance) on or off, or show its state. Implements the VNAV stack command: `VNAV acid, [ON/OFF]`. VNAV can @@ -1202,9 +1181,6 @@ def setVNAV(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: idx: Aircraft index, collection of indices, or None for all aircraft. flag: True/False to switch on/off; None to report the state. - - Returns: - tuple: (success flag, status message). """ if not isinstance(idx, Collection): if idx is None: @@ -1231,7 +1207,7 @@ def setVNAV(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: elif flag: if not self.traffic.swlnav[i]: - return False, (self.traffic.callsign[i] + ": VNAV ON requires LNAV to be ON") + return Err(self.traffic.callsign[i] + ": VNAV ON requires LNAV to be ON") route = self.route[i] if len(route.wpname) > 0: @@ -1249,7 +1225,7 @@ def setVNAV(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: self.traffic.actwp.nextaltco[i] = self.route[i].wptoalt[actwpidx] else: - return False, ( + return Err( "VNAV " + self.traffic.callsign[i] + ": no waypoints or destination specified" @@ -1258,11 +1234,11 @@ def setVNAV(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: self.traffic.swvnav[i] = False self.traffic.swvnavspd[i] = False if flag == None: - return True, "\n".join(output) + return Ok("\n".join(output)) - return True, f"VNAV {'ON' if flag else 'OFF'}" + return Ok(f"VNAV {'ON' if flag else 'OFF'}") - def setLNAV(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: + def setLNAV(self, idx: Any, flag: OnOff | None = None) -> Result[str, str]: """Switch LNAV (lateral FMS guidance) on or off, or show its state. Implements the LNAV stack command: `LNAV acid, [ON/OFF]`. LNAV can @@ -1274,9 +1250,6 @@ def setLNAV(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: idx: Aircraft index, collection of indices, or None for all aircraft. flag: True/False to switch on/off; None to report the state. - - Returns: - tuple: (success flag, status message). """ if not isinstance(idx, Collection): if idx is None: @@ -1300,7 +1273,7 @@ def setLNAV(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: elif flag: route = self.route[i] if len(route.wpname) <= 0: - return False, ( + return Err( "LNAV " + self.traffic.callsign[i] + ": no waypoints or destination specified" @@ -1311,11 +1284,11 @@ def setLNAV(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: else: self.traffic.swlnav[i] = False if flag is None: - return True, "\n".join(output) + return Ok("\n".join(output)) - return True, f"LNAV {'ON' if flag else 'OFF'}" + return Ok(f"LNAV {'ON' if flag else 'OFF'}") - def setswtoc(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: + def setswtoc(self, idx: Any, flag: OnOff | None = None) -> Result[str, str]: """Switch the Top-of-Climb logic on or off, or show its state. Implements the SWTOC stack command: `SWTOC acid, [ON/OFF]`. With @@ -1327,9 +1300,6 @@ def setswtoc(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: idx: Aircraft index, collection of indices, or None for all aircraft. flag: True/False to switch on/off; None to report the state. - - Returns: - tuple: (True, status message). """ if not isinstance(idx, Collection): @@ -1354,11 +1324,11 @@ def setswtoc(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: else: self.swtoc[i] = False if flag is None: - return True, "\n".join(output) + return Ok("\n".join(output)) - return True, f"SWTOC {'ON' if flag else 'OFF'}" + return Ok(f"SWTOC {'ON' if flag else 'OFF'}") - def setswtod(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: + def setswtod(self, idx: Any, flag: OnOff | None = None) -> Result[str, str]: """Switch the Top-of-Descent logic on or off, or show its state. Implements the SWTOD stack command: `SWTOD acid, [ON/OFF]`. With @@ -1370,9 +1340,6 @@ def setswtod(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: idx: Aircraft index, collection of indices, or None for all aircraft. flag: True/False to switch on/off; None to report the state. - - Returns: - tuple: (True, status message). """ if not isinstance(idx, Collection): if idx is None: @@ -1396,9 +1363,9 @@ def setswtod(self, idx: Any, flag: OnOff | None = None) -> tuple[bool, str]: else: self.swtod[i] = False if flag is None: - return True, "\n".join(output) + return Ok("\n".join(output)) - return True, f"SWTOD {'ON' if flag else 'OFF'}" + return Ok(f"SWTOD {'ON' if flag else 'OFF'}") def calcvrta(v0: float, dx: float, deltime: float, trafax: float) -> float: diff --git a/packages/minisky/minisky/traffic/conditional.py b/packages/minisky/minisky/traffic/conditional.py index c74323a..1724b76 100644 --- a/packages/minisky/minisky/traffic/conditional.py +++ b/packages/minisky/minisky/traffic/conditional.py @@ -109,7 +109,7 @@ def update(self) -> None: ) # Invalid number which never triggers anything is extremely large for j in range(self.ncond): if self.condtype[j] == postype: - qdr, dist = qdrdist( + _qdr, dist = qdrdist( self.traffic.lat[acidxlst[j]], self.traffic.lon[acidxlst[j]], self.posdata[j][0], @@ -213,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(self.traffic.lat[acidx], self.traffic.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 @@ -255,7 +255,6 @@ def addcondition( self.ncond = self.ncond + 1 # print("addcondition: self.ncond",self.ncond) - return def renameac(self, oldid: str, newid: str) -> None: """Update stored callsigns after an aircraft has been renamed. diff --git a/packages/minisky/minisky/traffic/performance/__init__.py b/packages/minisky/minisky/traffic/performance/__init__.py index 47b8036..b2bbba3 100644 --- a/packages/minisky/minisky/traffic/performance/__init__.py +++ b/packages/minisky/minisky/traffic/performance/__init__.py @@ -14,5 +14,5 @@ The active performance model instance is [`runtime.traffic.perf`][minisky.traffic.performance.perfoap.OpenAP]. """ -import minisky.traffic.performance.coeff -import minisky.traffic.performance.phase +from . import coeff as coeff +from . import phase as phase diff --git a/packages/minisky/minisky/traffic/performance/coeff.py b/packages/minisky/minisky/traffic/performance/coeff.py index 5207a5d..7108bba 100644 --- a/packages/minisky/minisky/traffic/performance/coeff.py +++ b/packages/minisky/minisky/traffic/performance/coeff.py @@ -84,7 +84,7 @@ def _load_all_fixwing_flavor(self) -> dict: def _load_all_rotor_flavor(self) -> dict: """Load rotorcraft data from the local JSON database.""" # read rotor aircraft - with open(OPENAP_DIR / "rotor/aircraft.json") as f: + with (OPENAP_DIR / "rotor/aircraft.json").open() as f: acs = json.load(f) acs.pop("__comment") acs_ = {} diff --git a/packages/minisky/minisky/traffic/performance/perfoap.py b/packages/minisky/minisky/traffic/performance/perfoap.py index 3b23285..d0276b5 100644 --- a/packages/minisky/minisky/traffic/performance/perfoap.py +++ b/packages/minisky/minisky/traffic/performance/perfoap.py @@ -10,11 +10,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, NamedTuple import numpy as np from minisky.core.trafficarrays import TrafficArrays +from minisky.result import Ok, Result from minisky.tools import aero from minisky.tools.aero import fpm, ft, kts @@ -168,7 +169,7 @@ def create(self, n: int = 1) -> None: # populate fuel flow model es = self.coeff.acs_fixwing[actype]["engines"] - e = es[list(es.keys())[0]] + e = es[next(iter(es.keys()))] coeff_a, coeff_b, coeff_c = thrust.compute_eng_ff_coeff( e["ff_idl"], e["ff_app"], e["ff_co"], e["ff_to"] ) @@ -343,13 +344,21 @@ def update(self, dt: float = 1) -> None: self.bank, ) + class PerformanceLimits(NamedTuple): + tas: np.ndarray + """Allowed true airspeed [m/s].""" + vertical_speed: np.ndarray + """Allowed vertical speed [m/s].""" + altitude: np.ndarray + """Allowed altitude [m].""" + def limits( self, intent_v_tas: np.ndarray, intent_vs: np.ndarray, intent_h: np.ndarray, ax: np.ndarray, - ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + ) -> PerformanceLimits: """apply limits on indent speed, vertical speed, and altitude (called in pilot module) Clips the intended state to the aircraft flight envelope: altitude to @@ -364,10 +373,6 @@ def limits( intent_vs (float or 1D-array): intent vertical speed [m/s] intent_h (float or 1D-array): intent altitude [m] ax (float or 1D-array): acceleration [m/s^2] - - Returns: - floats or 1D-arrays: Allowed TAS [m/s], Allowed vertical - rate [m/s], Allowed altitude [m] """ allow_h = np.where(intent_h > self.hmax, self.hmax, intent_h) @@ -404,9 +409,20 @@ def limits( allow_vs[ir] = np.where((intent_vs[ir] < self.vsmin[ir]), self.vsmin[ir], intent_vs[ir]) allow_vs[ir] = np.where((intent_vs[ir] > self.vsmax[ir]), self.vsmax[ir], allow_vs[ir]) - return allow_v_tas, allow_vs, allow_h + return self.PerformanceLimits(allow_v_tas, allow_vs, allow_h) + + # TODO(abraham): maybe make this Generic over float/np/any array? + class CurrentPerformanceLimits(NamedTuple): + minimum_tas: float | np.ndarray + """Minimum true airspeed [m/s].""" + maximum_tas: float | np.ndarray + """Maximum true airspeed [m/s].""" + minimum_vertical_speed: float | np.ndarray + """Minimum vertical speed [m/s].""" + maximum_vertical_speed: float | np.ndarray + """Maximum vertical speed [m/s].""" - def currentlimits(self, id: Any = None) -> tuple: + def currentlimits(self, idx: Any = None) -> CurrentPerformanceLimits: """Get current kinematic performance envelop. Converts the phase-dependent CAS limits to TAS at the current @@ -414,11 +430,7 @@ def currentlimits(self, id: Any = None) -> tuple: operating Mach number. Args: - id (int or 1D-array): Aircraft ID(s). Defualt to None (all aircraft). - - Returns: - floats or 1D-arrays: Min TAS [m/s], Max TAS [m/s], - Min VS [m/s], Max VS [m/s] + idx (int or 1D-array): Aircraft index or indices. Defaults to all aircraft. """ vtasmin = aero.vcas2tas(self.vmin, self.traffic.alt) @@ -427,12 +439,19 @@ def currentlimits(self, id: Any = None) -> tuple: aero.vmach2tas(self.mmo, self.traffic.alt), ) - if id is not None: - return vtasmin[id], vtasmax[id], self.vsmin[id], self.vsmax[id] - else: - return vtasmin, vtasmax, self.vsmin, self.vsmax + if idx is not None: + return self.CurrentPerformanceLimits( + vtasmin[idx], vtasmax[idx], self.vsmin[idx], self.vsmax[idx] + ) + return self.CurrentPerformanceLimits(vtasmin, vtasmax, self.vsmin, self.vsmax) + + class SpeedLimits(NamedTuple): + minimum: np.ndarray + """Minimum calibrated airspeed [m/s].""" + maximum: np.ndarray + """Maximum calibrated airspeed [m/s].""" - def _construct_v_limits(self, mask: Any = True) -> tuple[np.ndarray, np.ndarray]: + def _construct_v_limits(self, mask: Any = True) -> SpeedLimits: """Compute speed limist base on aircraft model and flight phases For fixed-wing aircraft the applicable minimum and maximum calibrated @@ -442,9 +461,6 @@ def _construct_v_limits(self, mask: Any = True) -> tuple[np.ndarray, np.ndarray] Args: mask: Indices (boolean) for aircraft to construct speed limits for. When no indices are passed, all aircraft are updated. - - Returns: - 2D-array: vmin, vmax (CAS limits per aircraft [m/s]) """ n = len(self.actype) vmin = np.zeros(n) @@ -490,8 +506,8 @@ def _construct_v_limits(self, mask: Any = True) -> tuple[np.ndarray, np.ndarray] vmax[ir] = vmaxr if isinstance(mask, bool): - return vmin, vmax - return vmin[mask], vmax[mask] + return self.SpeedLimits(vmin, vmax) + return self.SpeedLimits(vmin[mask], vmax[mask]) def calc_axmax(self) -> np.ndarray: """Compute the maximum longitudinal acceleration per aircraft. @@ -522,7 +538,7 @@ def calc_axmax(self) -> np.ndarray: return axmax - def show_performance(self, acid: int) -> tuple: + def show_performance(self, acid: int) -> Result[str, str]: """Report the current performance state of one aircraft. Implements the PERFSTATS stack command output: flight phase, thrust, @@ -531,17 +547,13 @@ def show_performance(self, acid: int) -> tuple: Args: acid (int): Aircraft index. - - Returns: - tuple: (True, message (str)) for the command stack. """ - return ( - True, + return Ok( f"Flight phase: {ph.readable_phase(self.phase[acid])}\n" f"Thrust: {self.thrust[acid] / 1000:.0f} kN\n" f"Drag: {self.drag[acid] / 1000:.0f} kN\n" f"Fuel flow: {self.fuelflow[acid]:.2f} kg/s\n" f"Speed envelope: [{self.vmin[acid] / kts:.0f}, {self.vmax[acid] / kts:.0f}] kts\n" f"Vertical speed envelope: [{self.vsmin[acid] / fpm:.0f}, {self.vsmax[acid] / fpm:.0f}] fpm\n" - f"Ceiling: {self.hmax[acid] / ft:.0f} ft", + f"Ceiling: {self.hmax[acid] / ft:.0f} ft" ) diff --git a/packages/minisky/minisky/traffic/performance/thrust.py b/packages/minisky/minisky/traffic/performance/thrust.py index 742bd40..0454709 100644 --- a/packages/minisky/minisky/traffic/performance/thrust.py +++ b/packages/minisky/minisky/traffic/performance/thrust.py @@ -8,7 +8,7 @@ engine emission databank fuel-flow points as a function of thrust ratio. """ -from typing import Any +from typing import NamedTuple import numpy as np @@ -170,9 +170,18 @@ def mfunc(vratio, roc): return ratio_F0 +class FuelFlowCoefficients(NamedTuple): + quadratic: float | np.ndarray + """Quadratic fuel-flow coefficient [kg/s].""" + linear: float | np.ndarray + """Linear fuel-flow coefficient [kg/s].""" + constant: float | np.ndarray + """Constant fuel-flow coefficient [kg/s].""" + + def compute_eng_ff_coeff( ffidl: float, ffapp: float, ffco: float, ffto: float -) -> tuple[Any, Any, Any]: +) -> FuelFlowCoefficients: """Compute fuel flow based on engine icao fuel flow model Fits a quadratic polynomial through the four fuel-flow measurement @@ -185,9 +194,6 @@ def compute_eng_ff_coeff( ffapp (float or 1D-array): fuel flow at approach thrust (30%) [kg/s] ffco (float or 1D-array): fuel flow at climb-out thrust (85%) [kg/s] ffto (float or 1D-array): fuel flow at takeoff thrust (100%) [kg/s] - - Returns: - list of coeff: [a, b, c], fuel flow calc: ax^2 + bx + c """ # standard fuel flow at test thrust ratios @@ -196,4 +202,4 @@ def compute_eng_ff_coeff( a, b, c = np.polyfit(x, y, 2) - return a, b, c + return FuelFlowCoefficients(a, b, c) diff --git a/packages/minisky/minisky/traffic/route.py b/packages/minisky/minisky/traffic/route.py index c7033ad..d634c3f 100644 --- a/packages/minisky/minisky/traffic/route.py +++ b/packages/minisky/minisky/traffic/route.py @@ -16,16 +16,18 @@ from __future__ import annotations import math -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NamedTuple import numpy as np +from minisky.result import Err, Ok, Result + # from minisky.core import Replaceable 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 +from minisky.tools.position import txt2pos if TYPE_CHECKING: from minisky.traffic import Traffic @@ -388,7 +390,46 @@ def getnextturnwp(self) -> list: trnidx, ] - def getnextwp(self) -> tuple: + # TODO(abraham): split this large transition record into constraints, turn, + # and next-leg records + # TODO(abraham): replace -999.0 sentinels with explicit optional/validity state (see issue #40) + class WaypointTransition(NamedTuple): + latitude: float + """Active waypoint latitude [deg].""" + longitude: float + """Active waypoint longitude [deg].""" + altitude: float + """Altitude constraint [m].""" + speed: float + """Speed constraint, calibrated airspeed [m/s] or Mach number [-].""" + distance_to_altitude: float + """Distance to the next altitude constraint [m].""" + next_altitude: float + """Next altitude constraint [m].""" + distance_to_rta: float + """Distance to the next required time of arrival [m].""" + next_rta: float + """Next required time of arrival [s].""" + lnav_enabled: bool + """Whether lateral navigation remains enabled.""" + fly_by: bool + """Whether the waypoint uses fly-by switching.""" + fly_turn: bool + """Whether the waypoint uses an explicit turn.""" + turn_radius: float + """Turn radius [m].""" + turn_speed: float + """Turn calibrated airspeed [m/s].""" + turn_heading_rate: float + """Turn heading rate [deg/s].""" + next_leg_latitude: float + """Next-leg endpoint latitude [deg], or -999.0 when there is no next leg.""" + next_leg_longitude: float + """Next-leg endpoint longitude [deg], or -999.0 when there is no next leg.""" + last_waypoint: bool + """Whether this is the final waypoint.""" + + def getnextwp(self) -> WaypointTransition: """Activate the next waypoint in the route and return its data. Called by the autopilot when the active waypoint has been passed. @@ -397,16 +438,6 @@ def getnextwp(self) -> tuple: a runway used for landing, a fixed runway heading is commanded and deceleration plus deletion of the aircraft are scheduled via the stack. - - Returns: - tuple: (lat [deg], lon [deg], altitude constraint [m], speed - constraint (CAS [m/s] or Mach), distance to next altitude - constraint [m], next altitude constraint [m], distance to next - RTA [m], next RTA [s], lnavon switch, fly-by switch, fly-turn - switch, turn radius, turn speed (CAS), turn heading rate - [deg/s], next-leg endpoint lat [deg], next-leg endpoint lon - [deg] (-999.0 pair when there is no next leg), last-waypoint - switch). """ n_wpt = len(self.wpname) @@ -449,7 +480,7 @@ def getnextwp(self) -> tuple: swlastwp = self.iactwp == n_wpt - 1 - return ( + return self.WaypointTransition( self.wplat[self.iactwp], self.wplon[self.iactwp], self.wpalt[self.iactwp], @@ -502,7 +533,7 @@ def getnextwp(self) -> tuple: # print ("getnextwp:",self.wpname[self.iactwp]," torta = ",self.wptorta[self.iactwp]) - return ( + return self.WaypointTransition( self.wplat[self.iactwp], self.wplon[self.iactwp], self.wpalt[self.iactwp], @@ -532,7 +563,6 @@ def runactwpstack(self) -> None: self.traffic.stack_command(cmdline) # debug # stack.stack("ECHO "+self.acid+" AT "+self.wpname[self.iactwp]+" command issued:"+cmdline) - return def insertcalcwp(self, i: int, name: str) -> None: """Insert an empty calculated waypoint (T/C, T/D) at location i.""" @@ -599,7 +629,7 @@ def calcfp(self) -> None: # Calculate lateral leg data # LNAV: Calculate leg distances and directions - for i in range(0, n_wpt - 1): + for i in range(n_wpt - 1): qdr, dist = geo.qdrdist( self.wplat[i], self.wplon[i], self.wplat[i + 1], self.wplon[i + 1] ) @@ -613,7 +643,7 @@ def calcfp(self) -> None: qdr, dist = geo.qdrdist( 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 + self.wpdirto = [qdr, *self.wpdirfrom[0:-1]] # [deg] Direction to waypoints # Continue flying in the saem direction if n_wpt > 1: @@ -757,7 +787,7 @@ def getnextqdr(self): """ # get qdr for next leg if -1 < self.iactwp < len(self.wpname) - 1: - nextqdr, dist = geo.qdrdist( + nextqdr, _dist = geo.qdrdist( self.wplat[self.iactwp], self.wplon[self.iactwp], self.wplat[self.iactwp + 1], @@ -858,7 +888,9 @@ def change_wpt_mode(traffic: Traffic, acidx: int, mode=None, value=None) -> bool return True -def addwpt(traffic: Traffic, ac: str | int, *args) -> bool | tuple: # args: all arguments of addwpt +def addwpt( + traffic: Traffic, ac: str | int, *args +) -> Result[str, str]: # args: all arguments of addwpt """Add a waypoint to the route of an aircraft. Implements the ADDWPT stack command: @@ -881,9 +913,6 @@ def addwpt(traffic: Traffic, ac: str | int, *args) -> bool | tuple: # args: all Args: ac: Aircraft callsign or index. *args: Remaining ADDWPT arguments as described above. - - Returns: - bool or tuple: True on success, or (success flag, message). """ # First get the appropriate ac route @@ -904,17 +933,17 @@ def addwpt(traffic: Traffic, ac: str | int, *args) -> bool | tuple: # args: all if swwpmode == "FLYBY": acrte.swflyby = True acrte.swflyturn = False - return True + return Ok("") elif swwpmode == "FLYOVER": acrte.swflyby = False acrte.swflyturn = False - return True + return Ok("") elif swwpmode == "FLYTURN": acrte.swflyby = False acrte.swflyturn = True - return True + return Ok("") elif len(args) == 2: swwpmode = args[0].replace("-", "") @@ -925,14 +954,14 @@ def addwpt(traffic: Traffic, ac: str | int, *args) -> bool | tuple: # args: all acrte.turnrad = -999 else: acrte.turnrad = float(args[1] / ft * nm) # arg was originally parsed as wpalt - except Exception: - return False, "Error in processing value of turn radius" + except (TypeError, ValueError): + return Err("Error in processing value of turn radius") # Switch flyturn automatically when this is set acrte.swflyby = False acrte.swflyturn = True - return True + return Ok("") elif swwpmode == "TURNSPD" or swwpmode == "TURNSPEED": try: @@ -942,8 +971,8 @@ def addwpt(traffic: Traffic, ac: str | int, *args) -> bool | tuple: # args: all acrte.turnspd = ( args[1] * kts / ft ) # [m/s] Arg was wpalt Keep it as IAS/CAS orig in kts, now in m/s - except Exception: - return False, "Error in processing value of turn speed" + except (TypeError, ValueError): + return Err("Error in processing value of turn speed") # Switch flyturn automatically when this is set acrte.swflyby = False @@ -955,14 +984,14 @@ def addwpt(traffic: Traffic, ac: str | int, *args) -> bool | tuple: # args: all acrte.turnhdgr = -999 else: acrte.turnhdgr = args[1] / ft # [deg/s] turn rate - except Exception: - return False, "Error in processing value of turn heading rate" + except (TypeError, ValueError): + return Err("Error in processing value of turn heading rate") # Switch flyturn automatically when this is set acrte.swflyby = False acrte.swflyturn = True - return True + return Ok("") # Convert to positions name = args[0].upper().strip() @@ -996,36 +1025,35 @@ def addwpt(traffic: Traffic, ac: str | int, *args) -> bool | tuple: # args: all # Normal waypoint (no take-off waypoint => see else) if not takeoffwpt: # Get waypoint position - success, posobj = txt2pos(name, reflat, reflon, traffic.navigation, traffic) - if success: - assert isinstance(posobj, Position) - lat = posobj.lat - lon = posobj.lon + match txt2pos(name, reflat, reflon, traffic.navigation, traffic): + case Ok(posobj): + lat = posobj.lat + lon = posobj.lon - if posobj.type == "nav" or posobj.type == "apt": - wptype = Route.wpnav + if posobj.type == "nav" or posobj.type == "apt": + wptype = Route.wpnav - elif posobj.type == "rwy": - wptype = Route.runway + elif posobj.type == "rwy": + wptype = Route.runway - else: # treat as lat/lon - name = callsign - wptype = Route.wplatlon + else: # treat as lat/lon + name = callsign + wptype = Route.wplatlon - if len(args) > 1 and args[1]: - alt = args[1] + if len(args) > 1 and args[1]: + alt = args[1] - if len(args) > 2 and args[2]: - spd = args[2] + if len(args) > 2 and args[2]: + spd = args[2] - if len(args) > 3 and args[3]: - afterwp = args[3] + if len(args) > 3 and args[3]: + afterwp = args[3] - if len(args) > 4 and args[4]: - beforewp = args[4] + if len(args) > 4 and args[4]: + beforewp = args[4] - else: - return False, "Waypoint " + name + " not found." + case Err(): + return Err("Waypoint " + name + " not found.") # Take off waypoint: positioned 20% of the runway length after the runway else: @@ -1058,7 +1086,7 @@ def addwpt(traffic: Traffic, ac: str | int, *args) -> bool | tuple: # args: all rwylon = traffic.lon[acidx] rwyhdg = traffic.trk[acidx] - elif args[1].count("/") > 0 or len(args) > 2 and args[2]: # we need apt,rwy + 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) if args[1].count("/") > 0: aptid, rwyname = args[1].split("/") @@ -1073,29 +1101,28 @@ def addwpt(traffic: Traffic, ac: str | int, *args) -> bool | tuple: # args: all # Try to get it from the database try: rwyhdg = traffic.navigation.rwythresholds[aptid][rwyid][2] - except Exception: + except (IndexError, KeyError, TypeError): rwydir = rwyid.replace("L", "").replace("R", "").replace("C", "") try: rwyhdg = float(rwydir) * 10.0 except ValueError: - return False, name + " not found." + return Err(name + " not found.") - success, posobj = txt2pos( + match txt2pos( aptid + "/RW" + rwyid, reflat, reflon, traffic.navigation, traffic, - ) - if success: - assert isinstance(posobj, Position) - rwylat, rwylon = posobj.lat, posobj.lon - else: - rwylat = traffic.lat[acidx] - rwylon = traffic.lon[acidx] + ): + case Ok(posobj): + rwylat, rwylon = posobj.lat, posobj.lon + case Err(): + rwylat = traffic.lat[acidx] + rwylon = traffic.lon[acidx] else: - return False, "Use ADDWPT TAKEOFF,AIRPORTID,RWYNAME" + return Err("Use ADDWPT TAKEOFF,AIRPORTID,RWYNAME") # Create a waypoint 2 nm away from current point rwydist = 2.0 # [nm] use default distance away from threshold @@ -1123,7 +1150,7 @@ def addwpt(traffic: Traffic, ac: str | int, *args) -> bool | tuple: # args: all # Check for success by checking inserted location in flight plan >= 0 if wpidx < 0: - return False, "Waypoint " + name + " not added." + return Err("Waypoint " + name + " not added.") # check for presence of orig/dest norig = int(traffic.ap.orig[acidx] != "") # 1 if orig is present in route @@ -1137,12 +1164,9 @@ def addwpt(traffic: Traffic, ac: str | int, *args) -> bool | tuple: # args: all traffic.swlnav[acidx] = True if afterwp and acrte.wpname.count(afterwp) == 0: - return ( - True, - "Waypoint " + afterwp + " not found\n" + "waypoint added at end of route", - ) + return Ok("Waypoint " + afterwp + " not found\n" + "waypoint added at end of route") else: - return True + return Ok("") def addwpt_before( @@ -1153,7 +1177,7 @@ def addwpt_before( waypoint, alt: Alt | None = None, spd: Spd | None = None, -) -> bool | tuple: +) -> Result[str, str]: """Add a waypoint to a route before an existing waypoint. Implements the BEFORE stack command: @@ -1167,9 +1191,6 @@ def addwpt_before( waypoint: Waypoint name or lat/lon text of the new waypoint. alt: Optional altitude constraint [m]. spd: Optional speed constraint, CAS [m/s] or Mach [-]. - - Returns: - bool or tuple: Result of addwpt(). """ return addwpt(traffic, acidx, waypoint, alt, spd, None, beforewp) @@ -1182,7 +1203,7 @@ def addwpt_after( waypoint, alt: Alt | None = None, spd: Spd | None = None, -) -> bool | tuple: +) -> Result[str, str]: """Add a waypoint to a route after an existing waypoint. Implements the AFTER stack command: @@ -1196,14 +1217,11 @@ def addwpt_after( waypoint: Waypoint name or lat/lon text of the new waypoint. alt: Optional altitude constraint [m]. spd: Optional speed constraint, CAS [m/s] or Mach [-]. - - Returns: - bool or tuple: Result of addwpt(). """ return addwpt(traffic, acidx, waypoint, alt, spd, afterwp) -def at_wpt(traffic: Traffic, acidx: int, atwp: Wpt, *args) -> bool | tuple: +def at_wpt(traffic: Traffic, acidx: int, atwp: Wpt, *args) -> Result[str, str]: """Show, set or delete constraints and commands at a route waypoint. Implements the AT stack command: @@ -1226,9 +1244,6 @@ def at_wpt(traffic: Traffic, acidx: int, atwp: Wpt, *args) -> bool | tuple: acidx: Aircraft index. atwp: Name of the waypoint in the route. *args: Remaining AT arguments as described above. - - Returns: - bool or tuple: True on success, or (success flag, message). """ acid = traffic.callsign[acidx] acrte = traffic.ap.route[acidx] @@ -1264,11 +1279,11 @@ def at_wpt(traffic: Traffic, acidx: int, atwp: Wpt, *args) -> bool | tuple: txt += "-----" elif acrte.wpalt[wpidx] > 4500 * ft: - fl = int(round(acrte.wpalt[wpidx] / (100.0 * ft))) + fl = round(acrte.wpalt[wpidx] / (100.0 * ft)) txt += "FL" + str(fl) else: - txt += str(int(round(acrte.wpalt[wpidx] / ft))) + txt += str(round(acrte.wpalt[wpidx] / ft)) if swspd: txt += "/" @@ -1278,7 +1293,7 @@ def at_wpt(traffic: Traffic, acidx: int, atwp: Wpt, *args) -> bool | tuple: if acrte.wpspd[wpidx] < 0: txt += "---" else: - txt += str(int(round(acrte.wpspd[wpidx] / kts))) + txt += str(round(acrte.wpspd[wpidx] / kts)) # Type if swalt and swspd: @@ -1294,7 +1309,7 @@ def at_wpt(traffic: Traffic, acidx: int, atwp: Wpt, *args) -> bool | tuple: for stackedtxt in acrte.wpstack[wpidx]: txt = txt + stackedtxt + "\n" - return True, txt + return Ok(txt) elif args[0].count("/") == 1: # Set both alt & speed at this waypoint @@ -1324,7 +1339,7 @@ def at_wpt(traffic: Traffic, acidx: int, atwp: Wpt, *args) -> bool | tuple: success = False if not success: - return False, "Could not parse " + args[0] + " as alt / spd" + return Err("Could not parse " + args[0] + " as alt / spd") # If success: update flight plan and guidance acrte.calcfp() @@ -1349,14 +1364,14 @@ def at_wpt(traffic: Traffic, acidx: int, atwp: Wpt, *args) -> bool | tuple: try: acrte.wpalt[wpidx] = txt2alt(args[1]) except ValueError as e: - return False, e.args[0] + return Err(e.args[0]) # Edit waypoint speed constraint elif swspd: try: acrte.wpspd[wpidx] = txt2spd(args[1]) except ValueError as e: - return False, e.args[0] + return Err(e.args[0]) # add stack command: args[1] is DO or STACK, args[2:] contains a command elif swat: @@ -1382,11 +1397,8 @@ def at_wpt(traffic: Traffic, acidx: int, atwp: Wpt, *args) -> bool | tuple: else: # This command does not need an acid or it is already first argument acrte.wpstack[wpidx].append(" ".join(args[1:])) - except Exception: - return ( - False, - "Stacked command " + cmd + " unknown or syntax error", - ) + except (AttributeError, IndexError, KeyError, TypeError): + return Err("Stacked command " + cmd + " unknown or syntax error") else: # Command line starts with an aircraft id at the beginning of the command line, stack it acrte.wpstack[wpidx].append(" ".join(args[1:])) @@ -1408,7 +1420,7 @@ def at_wpt(traffic: Traffic, acidx: int, atwp: Wpt, *args) -> bool | tuple: acrte.wpstack[wpidx] = [] else: - return False, "No " + args[0] + " at ", atwp + return Err(f"No {args[0]} at {atwp}") # If success: update flight plan and guidance acrte.calcfp() @@ -1416,9 +1428,9 @@ def at_wpt(traffic: Traffic, acidx: int, atwp: Wpt, *args) -> bool | tuple: # Waypoint not found in route else: - return False, atwp + " not found in route " + acid + return Err(atwp + " not found in route " + acid) - return True + return Ok("") def direct(traffic: Traffic, acidx: int, wpname: Wpt) -> bool: @@ -1559,7 +1571,7 @@ def set_rta( return True -def listrte(traffic: Traffic, acidx: int, ipagetxt: str = "0") -> tuple | None: +def listrte(traffic: Traffic, acidx: int, ipagetxt: str = "0") -> Result[None, str]: """Show the route of an aircraft in the console, page by page. Implements the LISTRTE stack command: `LISTRTE acid, [pagenr]`. @@ -1573,7 +1585,7 @@ def listrte(traffic: Traffic, acidx: int, ipagetxt: str = "0") -> tuple | None: ipagetxt: Page number as text (default "0"). Returns: - tuple or None: (False, message) when the aircraft has no route. + Result: `Ok(None)` after listing the route, or `Err` when no route exists. """ # First get the appropriate ac route ipage = int(ipagetxt) @@ -1582,7 +1594,7 @@ def listrte(traffic: Traffic, acidx: int, ipagetxt: str = "0") -> tuple | None: n_wpt = len(acrte.wpname) if n_wpt <= 0: - return False, "Aircraft has no route." + return Err("Aircraft has no route.") for i in range(ipage * 7, ipage * 7 + 7): if 0 <= i < n_wpt: @@ -1597,17 +1609,17 @@ def listrte(traffic: Traffic, acidx: int, ipagetxt: str = "0") -> tuple | None: txt += "-----/" elif acrte.wpalt[i] > 4500 * ft: - fl = int(round(acrte.wpalt[i] / (100.0 * ft))) + fl = round(acrte.wpalt[i] / (100.0 * ft)) txt += "FL" + str(fl) + "/" else: - txt += str(int(round(acrte.wpalt[i] / ft))) + "/" + txt += str(round(acrte.wpalt[i] / ft)) + "/" # Speed if acrte.wpspd[i] < 0.0: txt += "---" elif acrte.wpspd[i] > 2.0: - txt += str(int(round(acrte.wpspd[i] / kts))) + txt += str(round(acrte.wpspd[i] / kts)) else: txt += "M" + str(acrte.wpspd[i]) @@ -1626,8 +1638,10 @@ def listrte(traffic: Traffic, acidx: int, ipagetxt: str = "0") -> tuple | None: # Display message traffic.console.echo(txt) + return Ok(None) -def delrte(traffic: Traffic, acidx: int | None = None) -> bool | tuple: + +def delrte(traffic: Traffic, acidx: int | None = None) -> Result[str, str]: """Delete the complete route (including origin/destination) of an aircraft. @@ -1637,15 +1651,12 @@ def delrte(traffic: Traffic, acidx: int | None = None) -> bool | tuple: Args: acidx: Aircraft index; may be None when only one aircraft exists. - - Returns: - bool or tuple: True on success, or (False, error message). """ if acidx is None: if traffic.ntraf == 0: - return False, "No aircraft in simulation" + return Err("No aircraft in simulation") if traffic.ntraf > 1: - return False, "Specify callsign of aircraft to delete route of" + return Err("Specify callsign of aircraft to delete route of") acidx = 0 # Simple re-initialize this route as empty acid = traffic.callsign[acidx] @@ -1657,10 +1668,10 @@ def delrte(traffic: Traffic, acidx: int | None = None) -> bool | tuple: traffic.swvnav[acidx] = False traffic.swvnavspd[acidx] = False - return True + return Ok("") -def delwpt(traffic: Traffic, acidx: int, wpname: Wpt) -> bool | tuple: +def delwpt(traffic: Traffic, acidx: int, wpname: Wpt) -> Result[str, str]: """Delete a single waypoint from the route of an aircraft. Implements the DELWPT stack command: `DELWPT acid, wpname`. When the @@ -1671,9 +1682,6 @@ def delwpt(traffic: Traffic, acidx: int, wpname: Wpt) -> bool | tuple: Args: acidx: Aircraft index. wpname: Name of the waypoint to delete. - - Returns: - bool or tuple: True on success, or (False, error message). """ # Look up waypoint @@ -1683,7 +1691,7 @@ def delwpt(traffic: Traffic, acidx: int, wpname: Wpt) -> bool | tuple: try: wpidx = acrte.wpname.index(wpname.upper()) except ValueError: - return False, "Waypoint " + wpname + " not found" + return Err("Waypoint " + wpname + " not found") # 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 @@ -1717,4 +1725,4 @@ def delwpt(traffic: Traffic, acidx: int, wpname: Wpt) -> bool | tuple: traffic.swvnav[acidx] = False traffic.swvnavspd[acidx] = False - return True + return Ok("") diff --git a/packages/minisky/minisky/traffic/traffic.py b/packages/minisky/minisky/traffic/traffic.py index f18fead..7b9882d 100644 --- a/packages/minisky/minisky/traffic/traffic.py +++ b/packages/minisky/minisky/traffic/traffic.py @@ -22,6 +22,7 @@ from minisky.core.config import MiniSkyConfig from minisky.core.trafficarrays import TrafficArrays +from minisky.result import Err, Ok, Result from minisky.tools import geo from minisky.tools.aero import ( DEFAULT_CASMACH_THRESHOLD, @@ -142,7 +143,7 @@ def __init__( get_simulation: Callable[[], Simulation], stack_command: Callable[..., None], get_command_registry: Callable[[], Mapping[str, object]], - select_implementation: Callable[[str, str], tuple[bool, str]], + select_implementation: Callable[[str, str], Result[str, str]], ) -> None: super().__init__() self.config = config @@ -247,22 +248,21 @@ 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]: + def casmachthr(self, threshold: float | None = None) -> Result[str, 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, + return Ok( "CASMACHTHR: The current CAS/Mach threshold is " f"{self.casmach_threshold} m/s " - f"({self.casmach_threshold / kts} kts)", + f"({self.casmach_threshold / kts} kts)" ) self.casmach_threshold = threshold - return True, f"CASMACHTHR: Set CAS/Mach threshold to {threshold}" + return Ok(f"CASMACHTHR: Set CAS/Mach threshold to {threshold}") @property def command_registry(self) -> Mapping[str, object]: @@ -312,7 +312,7 @@ def cre( hdg: float = 45.0, alt: float = 25000 * ft, spd: float = 300 * kts, - ) -> tuple[bool, str]: + ) -> Result[str, str]: """Create a single aircraft and add it to the traffic database. Implements the CRE stack command. After creation, any commands stored @@ -329,13 +329,10 @@ def cre( defaults to 25000 ft. spd: Initial speed: CAS [m/s] or Mach [-] (stack input in kts); defaults to 300 kts. - - Returns: - tuple: (success flag, confirmation or error message). """ if callsign in self.callsign: - return False, f"aircraft {callsign} already exists" + return Err(f"aircraft {callsign} already exists") # covert to array with 1 element acid_ = np.array([callsign.upper()]) @@ -348,7 +345,7 @@ def cre( self.__create_aircraft(acid_, actype_, lat_, lon_, hdg_, alt_, spd_) - return True, f"Aircraft {callsign} created" + return Ok(f"Aircraft {callsign} created") def mcre( self, @@ -360,7 +357,7 @@ def mcre( actype: str = "A320", acalt: int | None = None, acspd: int | None = None, - ) -> tuple[bool, str]: + ) -> Result[str, str]: """Create multiple aircraft at random positions in a lat/lon box. Implements the MCRE stack command. Callsigns are generated randomly @@ -377,9 +374,6 @@ def mcre( actype: ICAO aircraft type designator for all aircraft. acalt: Optional fixed altitude [m]; random when None. acspd: Optional fixed speed, CAS [m/s] or Mach; random when None. - - Returns: - tuple: (True, confirmation message). """ # Generate random callsigns @@ -407,7 +401,7 @@ def mcre( self.__create_aircraft(np.array(callsign), actype_, aclat, aclon, achdg, acalt_, acspd_) - return True, f"{n} aircraft created" + return Ok(f"{n} aircraft created") def __create_aircraft( self, @@ -845,7 +839,7 @@ def idx(self, callsign: str | Iterable[str]) -> int | list: except ValueError: return -1 - def setnoise(self, noise: bool | None = None) -> bool | tuple[bool, str]: + def setnoise(self, noise: bool | None = None) -> Result[str, str]: """Switch trajectory noise models on or off, or report their state. Implements the NOISE stack command. Controls both the turbulence @@ -854,16 +848,13 @@ def setnoise(self, noise: bool | None = None) -> bool | tuple[bool, str]: Args: noise: True/False to enable/disable noise; None to report the current state. - - Returns: - bool or tuple: True on set, or (True, status message) on query. """ if noise is None: - return True, "Noise is currently " + ("on" if self.turbulence.active else "off") + return Ok("Noise is currently " + ("on" if self.turbulence.active else "off")) self.turbulence.setnoise(noise) self.noise.setnoise(noise) - return True + return Ok("") def engchange(self, acid: int, engid: str) -> None: """Change the engine type of an aircraft in the performance model. @@ -873,7 +864,6 @@ def engchange(self, acid: int, engid: str) -> None: engid: New engine type identifier. """ self.perf.engchange(acid, engid) # type: ignore[attr-defined] - return def move( self, @@ -918,7 +908,7 @@ def move( self.vs[idx] = vspd self.swvnav[idx] = False - def position(self, id_or_name: int | str) -> tuple[bool, str]: + def position(self, id_or_name: int | str) -> Result[str, str]: """Show information on an aircraft, airport, waypoint or navaid. Implements the POS stack command. Dispatches to @@ -930,9 +920,6 @@ def position(self, id_or_name: int | str) -> tuple[bool, str]: Args: id_or_name: Aircraft index (int) or the name of an aircraft, airport, waypoint, navaid or airway (str). - - Returns: - tuple: (success flag, multi-line information text). """ if isinstance(id_or_name, int): @@ -940,7 +927,7 @@ def position(self, id_or_name: int | str) -> tuple[bool, str]: else: return self.position_by_name(id_or_name) - def position_aircraft(self, idx: int) -> tuple[bool, str]: + def position_aircraft(self, idx: int) -> Result[str, str]: """Generate a position report for a single aircraft. The report includes position, heading/track [deg], altitude [ft], @@ -949,9 +936,6 @@ def position_aircraft(self, idx: int) -> tuple[bool, str]: Args: idx: Aircraft index. - - Returns: - tuple: (True, multi-line position report). """ acid = self.callsign[idx] @@ -998,9 +982,9 @@ def position_aircraft(self, idx: int) -> tuple[bool, str]: if self.ap.dest[idx] != "": info = info + " to " + self.ap.dest[idx] - return True, info + return Ok(info) - def position_by_name(self, name: str) -> tuple[bool, str]: + def position_by_name(self, name: str) -> Result[str, str]: """Look up a name and generate an information report for it. Searches, in order: airports, aircraft callsigns, waypoints/navaids, @@ -1010,9 +994,6 @@ def position_by_name(self, name: str) -> tuple[bool, str]: Args: name: Name/identifier to look up (case-insensitive). - - Returns: - tuple: (success flag, multi-line information text). """ name = name.upper() @@ -1037,7 +1018,7 @@ def position_by_name(self, name: str) -> tuple[bool, str]: lines += ( f"{aptname} is a {airport_size} airport in {country_name} ({country_code}):\n" f"Position: {latlon2txt(aptlat, aptlon)}\n" - f"Elevation: {int(round(aptelev / ft))} ft \n" + f"Elevation: {round(aptelev / ft)} ft \n" ) if self.navigation.aptid[idx_airport] in self.navigation.rwythresholds: @@ -1045,7 +1026,7 @@ def position_by_name(self, name: str) -> tuple[bool, str]: if runways: lines += f"Runways: {', '.join(runways)}\n" - return True, lines + return Ok(lines) # try aircraft idx_ac = self.idx(name) @@ -1113,7 +1094,7 @@ def position_by_name(self, name: str) -> tuple[bool, str]: lines += f"Connected to airways: {'-'.join(awset)}\n" - return True, lines + return Ok(lines) # Try airway id else: # airway @@ -1123,36 +1104,32 @@ def position_by_name(self, name: str) -> tuple[bool, str]: lines = "" for segment in airway: lines += f"Airway {awid}: {' - '.join(segment)}\n" - return True, lines + return Ok(lines) # nothing matched - return False, f"{name} not found as aircraft, airport, navaid, or waypoint" + return Err(f"{name} not found as aircraft, airport, navaid, or waypoint") # Show what we found on airport and navaid/waypoint - def settrans(self, alt: float = -999.0) -> bool | tuple[bool, str]: + def settrans(self, alt: float = -999.0) -> Result[str, str]: """Set or show the transition level. Args: alt: New transition level [m] (stack input in ft/FL). With the default sentinel value the current level is reported instead. - - Returns: - bool or tuple: True on set, (True, message) on query, or - (False, error message) for invalid values. """ # in case a valid value is ginve set it if alt > -900.0: if alt > 0.0: self.translvl = alt - return True - return False, "Transition level needs to be ft/FL and larger than zero" + return Ok("") + return Err("Transition level needs to be ft/FL and larger than zero") # In case no value is given, show it - tlvl = int(round(self.translvl / ft)) - return True, f"Transition level = {tlvl}/FL{int(round(tlvl / 100.0))}" + tlvl = round(self.translvl / ft) + return Ok(f"Transition level = {tlvl}/FL{round(tlvl / 100.0)}") - def setbanklim(self, idx: int, bankangle: float | None = None) -> bool | tuple[bool, str]: + def setbanklim(self, idx: int, bankangle: float | None = None) -> Result[str, str]: """Set or show the bank angle limit for a given aircraft. Implements the BANK stack command. The limit is used by the autopilot @@ -1162,19 +1139,15 @@ def setbanklim(self, idx: int, bankangle: float | None = None) -> bool | tuple[b idx: Aircraft index. bankangle: New bank limit [deg]; when omitted, the current limit is reported. - - Returns: - bool or tuple: True on set, or (True, status message) on query. """ if bankangle: self.ap.bankdef[idx] = np.radians(bankangle) # [rad] - return True - return ( - True, - f"Banklimit of {self.callsign[idx]} is {int(np.degrees(self.ap.bankdef[idx]))} deg", + return Ok("") + return Ok( + f"Banklimit of {self.callsign[idx]} is {int(np.degrees(self.ap.bankdef[idx]))} deg" ) - def setthrottle(self, idx: int, throttle: str = "") -> bool | tuple[bool, str]: + def setthrottle(self, idx: int, throttle: str = "") -> Result[str, str]: """Set the throttle of an aircraft, or report the autothrottle state. Implements the THR stack command. "AUTO"/"OFF" re-engages the @@ -1185,10 +1158,6 @@ def setthrottle(self, idx: int, throttle: str = "") -> bool | tuple[bool, str]: Args: idx: Aircraft index. throttle: Throttle argument string; empty to query the state. - - Returns: - bool or tuple: True on set, (True, status message) on query, or - (False, error message) for invalid input. """ if throttle: @@ -1212,26 +1181,23 @@ def setthrottle(self, idx: int, throttle: str = "") -> bool | tuple[bool, str]: try: x = factor * float(throttle) except ValueError: - return False, "THR invalid argument " + throttle + return Err("THR invalid argument " + throttle) # Check whether value makes sense if x < 0.0 or x > 1.0: - return ( - False, - "THR invalid value " + throttle + ". Needs to be [0.0 , 1.0]", - ) + return Err("THR invalid value " + throttle + ". Needs to be [0.0 , 1.0]") # Valid value, set throttle and disable autothrottle self.swats[idx] = False self.thr[idx] = x - return True + return Ok("") if self.swats[idx]: - return True, "ATS of " + self.callsign[idx] + " is ON" - return True, "ATS of " + self.callsign[idx] + " is OFF. THR is " + str(self.thr[idx]) + return Ok("ATS of " + self.callsign[idx] + " is ON") + return Ok("ATS of " + self.callsign[idx] + " is OFF. THR is " + str(self.thr[idx])) - def crecmd(self, cmdline: str) -> tuple[bool, str]: + def crecmd(self, cmdline: str) -> Result[str, str]: """Add a command to the list issued for every newly created aircraft. Implements the CRECMD stack command. Each stored command line is @@ -1241,9 +1207,6 @@ def crecmd(self, cmdline: str) -> tuple[bool, str]: Args: cmdline: Command line (without callsign) to add to the list, or ""/"?" to show the current list. - - Returns: - tuple: (True, message). """ # Help text need or info on current list? if cmdline == "" or cmdline == "?": @@ -1254,29 +1217,23 @@ def crecmd(self, cmdline: str) -> tuple[bool, str]: allcmds = "[acid] " + txt else: allcmds += "; [acid] " + txt - return True, "CRECMD list: " + allcmds + return Ok("CRECMD list: " + allcmds) else: - return ( - True, - "CRECMD will add a/c specific commands to an aircraft after creation", - ) + return Ok("CRECMD will add a/c specific commands to an aircraft after creation") # Command to be added to list else: self.crecmdlist.append(cmdline) - return True, "" + return Ok("") - def clrcrecmd(self) -> tuple[bool, str]: + def clrcrecmd(self) -> Result[str, str]: """Clear the list of commands issued for newly created aircraft. Implements the CLRCRECMD stack command, removing all command lines previously added with CRECMD. - - Returns: - tuple: (True, message). """ ncrecmd = len(self.crecmdlist) if ncrecmd == 0: - return True, "CLRCRECMD deletes all commands on clears command" + return Ok("CLRCRECMD deletes all commands on clears command") else: self.crecmdlist = [] - return True, f"All {ncrecmd} crecmd commands deleted." + return Ok(f"All {ncrecmd} crecmd commands deleted.") diff --git a/packages/minisky/minisky/traffic/trafficgroups.py b/packages/minisky/minisky/traffic/trafficgroups.py index b696ad0..54853bc 100644 --- a/packages/minisky/minisky/traffic/trafficgroups.py +++ b/packages/minisky/minisky/traffic/trafficgroups.py @@ -12,11 +12,12 @@ from __future__ import annotations from collections.abc import Callable -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Self import numpy as np from minisky.core import TrafficArrays +from minisky.result import Err, Ok, Result if TYPE_CHECKING: from minisky.tools.areafilter import AreaFilter @@ -32,7 +33,7 @@ class GroupArray(np.ndarray): """ # 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) -> Self: ret = np.array(*args, **kwargs).view(cls) ret.groupname = groupname return ret @@ -71,7 +72,7 @@ def __contains__(self, groupname: str) -> bool: # Check if a group with a name exists return groupname in self.groups or groupname == "*" - def group(self, groupname: str = "", *args: Any) -> tuple[bool, str]: + def group(self, groupname: str = "", *args: Any) -> Result[str, str]: """Add aircraft to a group, list its members, or list all groups. Implements the GROUP stack command. Without arguments the existing @@ -83,21 +84,18 @@ def group(self, groupname: str = "", *args: Any) -> tuple[bool, str]: Args: groupname: Name of the group; empty to list all groups. *args: Aircraft indices, or a single area name. - - Returns: - tuple: (success flag, message). """ # Return list of groups if no groupname is given if not groupname: if not self.groups: - return True, "There are currently no traffic groups defined." + return Ok("There are currently no traffic groups defined.") else: - return True, "Defined traffic groups:\n" + ", ".join(self.groups) + return Ok("Defined traffic groups:\n" + ", ".join(self.groups)) if len(self.groups) >= 64: - return False, "Maximum number of 64 groups reached" + return Err("Maximum number of 64 groups reached") if groupname not in self.groups: if not args: - return False, f"Group {groupname} doesn't exist" + return Err(f"Group {groupname} doesn't exist") # Get first unused group mask for i in range(64): groupmask = 1 << i @@ -107,8 +105,12 @@ def group(self, groupname: str = "", *args: Any) -> tuple[bool, str]: break elif not args: - acnames = np.array(self.traffic.callsign)[self.listgroup(groupname)] - return True, "Aircraft in group {}:\n{}".format(groupname, ", ".join(acnames)) + match self.listgroup(groupname): + case Ok(group): + acnames = np.array(self.traffic.callsign)[group] + return Ok("Aircraft in group {}:\n{}".format(groupname, ", ".join(acnames))) + case Err(error): + return Err(error) # Add aircraft to group if self.areas.has_area(args[0]): @@ -121,7 +123,7 @@ def group(self, groupname: str = "", *args: Any) -> tuple[bool, str]: idx = list(args) self.ingroup[idx] |= self.groups[groupname] acnames = np.array(self.traffic.callsign)[idx] - return True, "Aircraft added to group {}:\n{}".format(groupname, ", ".join(acnames)) + return Ok("Aircraft added to group {}:\n{}".format(groupname, ", ".join(acnames))) def delgroup(self, grouparray: Any) -> None: """Delete a group, and all aircraft in that group. @@ -141,7 +143,7 @@ def delgroup(self, grouparray: Any) -> None: if grouparray.groupname != "*": self.allmasks ^= self.groups.pop(grouparray.groupname) - def ungroup(self, groupname: str, *args: Any) -> tuple[bool, str] | None: + def ungroup(self, groupname: str, *args: Any) -> Result[None, str]: """Remove members from a group by aircraft index. Implements the UNGROUP stack command. @@ -151,15 +153,15 @@ def ungroup(self, groupname: str, *args: Any) -> tuple[bool, str] | None: *args: Indices of the aircraft to remove from the group. Returns: - tuple or None: (False, error message) when the group does not - exist. + Result: `Ok(None)` after removal, or `Err` when the group is unknown. """ groupmask = self.groups.get(groupname, None) if groupmask is None: - return False, f"Group {groupname} doesn't exist" + return Err(f"Group {groupname} doesn't exist") self.ingroup[list(args)] ^= groupmask + return Ok(None) - def listgroup(self, groupname: str) -> Any: + def listgroup(self, groupname: str) -> Result[GroupArray, str]: """Return the aircraft indices of all aircraft in a group. When "*" is passed as group name, all aircraft in the simulation @@ -167,15 +169,10 @@ def listgroup(self, groupname: str) -> Any: Args: groupname: Name of the group, or "*" for all aircraft. - - Returns: - GroupArray: Indices of the group members (with the group name - attached), or (False, error message) when the group does not - exist. """ if groupname == "*": - return GroupArray(range(self.traffic.ntraf), groupname="*") + return Ok(GroupArray(range(self.traffic.ntraf), groupname="*")) groupmask = self.groups.get(groupname, None) if groupmask is None: - return False, f"Group {groupname} doesn't exist" - return GroupArray(np.where((self.ingroup & groupmask) > 0)[0], groupname=groupname) + return Err(f"Group {groupname} doesn't exist") + return Ok(GroupArray(np.where((self.ingroup & groupmask) > 0)[0], groupname=groupname)) diff --git a/packages/minisky/minisky/traffic/trails.py b/packages/minisky/minisky/traffic/trails.py index aa1f97e..53b7b75 100644 --- a/packages/minisky/minisky/traffic/trails.py +++ b/packages/minisky/minisky/traffic/trails.py @@ -14,6 +14,7 @@ import numpy as np from minisky.core import TrafficArrays +from minisky.result import Err, Ok, Result if TYPE_CHECKING: from minisky.simulation import Simulation @@ -102,8 +103,6 @@ def __init__( self.clearnew() - return - 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) @@ -215,7 +214,6 @@ def buffer(self) -> None: self.bgacid = self.bgacid + self.acid self.clearfg() # Clear foreground trails - return def clearnew(self) -> None: """Clear the pipeline of new line segments used for the QtGL GUI.""" @@ -233,7 +231,6 @@ def clearfg(self) -> None: # Foreground self.lon1 = np.array([]) self.time = np.array([]) self.col = np.array([]) - return def clearbg(self) -> None: # Background """Clear the background trail segment buffers.""" @@ -243,7 +240,6 @@ def clearbg(self) -> None: # Background self.bglon1 = np.array([]) self.bgtime = np.array([]) self.bgacid = [] - return def clear(self) -> None: """Clear all trail data: foreground, background and new-line buffers.""" @@ -252,9 +248,8 @@ def clear(self) -> None: self.clearfg() self.clearbg() self.clearnew() - return - def setTrails(self, *args: Any) -> bool | tuple[bool, str]: + def setTrails(self, *args: Any) -> Result[str, str]: """Switch trails on/off, or change the trail color of an aircraft. Implements the TRAIL stack command: @@ -266,16 +261,13 @@ def setTrails(self, *args: Any) -> bool | tuple[bool, str]: *args: Either a bool (on/off) optionally followed by the segment time resolution [s], or an aircraft index followed by a color name (BLUE/RED/YELLOW). - - Returns: - bool or tuple: True on success, or (success flag, message). """ if len(args) == 0: msg = "TRAIL ON/OFF, [dt] / TRAIL acid color\n" msg = msg + "TRAILS ARE ON" if self.active else msg + "TRAILS ARE OFF" - return True, msg + return Ok(msg) # Switch on/off elif type(args[0]) == bool: @@ -290,13 +282,10 @@ def setTrails(self, *args: Any) -> bool | tuple[bool, str]: else: # Change trail color if len(args) < 2 or args[1] not in ["BLUE", "RED", "YELLOW"]: - return ( - False, - "Set aircraft trail color with: TRAIL acid BLUE/RED/YELLOW", - ) + return Err("Set aircraft trail color with: TRAIL acid BLUE/RED/YELLOW") self.changeTrailColor(args[1], args[0]) - return True + return Ok("") def changeTrailColor(self, color: str, idx: int) -> None: """Change the trail color of one aircraft. @@ -307,7 +296,6 @@ def changeTrailColor(self, color: str, idx: int) -> None: idx: Aircraft index. """ self.accolor[idx] = self.colorList[color] - return def reset(self) -> None: """Clear all trail data and switch trails off upon simulation reset.""" diff --git a/packages/minisky/minisky/traffic/wind.py b/packages/minisky/minisky/traffic/wind.py index 0677bc1..d54c936 100644 --- a/packages/minisky/minisky/traffic/wind.py +++ b/packages/minisky/minisky/traffic/wind.py @@ -18,6 +18,7 @@ from scipy.interpolate import LinearNDInterpolator, interp1d from minisky.core.trafficarrays import TrafficArrays +from minisky.result import Err, Ok, Result from minisky.stack.argparser import Alt, Lat, Lon from minisky.tools.aero import ft, kts @@ -71,7 +72,6 @@ def __init__(self) -> None: # Clear actual field self.clear() - return def clear(self) -> None: # Clear actual field """Remove all wind vectors, leaving a windless (winddim 0) field.""" @@ -86,7 +86,6 @@ def clear(self) -> None: # Clear actual field self.nvec = 0 self.fe = None self.fn = None - return def addpointvne( self, @@ -156,7 +155,7 @@ def addpointvne( bounds_error=False, fill_value=0.0, ) - except Exception: + except Exception: # ruff: ignore[BLE001] scipy interpolation may fail broadly # Create vn, ve if RGI is not possible vnaxis = fnorth(self.altaxis).T veaxis = feast(self.altaxis).T @@ -404,8 +403,6 @@ def remove(self, idx: int) -> None: # remove a point using the returned index w if self.winddim < 3 or len(self.iprof) == 0 or len(self.lat) == 0: self.winddim = min(2, len(self.lat)) # Check for 0, 1D, 2D or 3D - return - class Wind(TrafficArrays, Windfield): """Wind field with the stack-command interface of the simulation. @@ -416,7 +413,7 @@ class Wind(TrafficArrays, Windfield): 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) -> Result[str, str]: """Define a wind vector as part of the 2D or 3D wind field. Implements the WIND stack command. @@ -447,7 +444,7 @@ def add(self, lat: Lat, lon: Lon, *winddata: float) -> bool | tuple[bool, str]: # No altitude or just one: same wind for all altitudes at this position elif ndata == 2 or (ndata == 3 and winddata[0] is None): # only one point, ignore altitude if winddata[-2] is None or winddata[-1] is None: - return False, "Wind direction and speed needed." + return Err("Wind direction and speed needed.") self.addpoint(lat, lon, winddata[-2], winddata[-1] * kts) @@ -461,11 +458,11 @@ def add(self, lat: Lat, lon: Lon, *winddata: float) -> bool | tuple[bool, str]: self.addpoint(lat, lon, dirarr, spdarr, altarr) else: # Something is wrong - return False, "Winddata not recognized" + return Err("Winddata not recognized") - return True + return Ok("") - 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) -> Result[str, str]: """Get wind at a specified position (and optionally at altitude) Implements the GETWIND stack command. The result is reported as @@ -475,15 +472,12 @@ def get(self, lat: Lat, lon: Lon, alt: Alt | None = None) -> tuple[bool, str]: - lat, lon: Horizontal position where wind should be determined [deg] - alt: Altitude at which wind should be determined [m] (stack input in ft) - - Returns: - tuple: (True, text with wind direction [deg] and speed [kts]). """ vn, ve = self.getdata(lat, lon, alt) wdir = (np.degrees(np.arctan2(ve, vn)) + 180.0) % 360.0 wspd = np.sqrt(vn * vn + ve * ve) - txt = f"WIND AT {lat:.5f}, {lon:.5f}: {int(round(wdir)):03d}/{int(round(wspd / kts))}" + txt = f"WIND AT {lat:.5f}, {lon:.5f}: {round(wdir):03d}/{round(wspd / kts)}" - return True, txt + return Ok(txt) diff --git a/tests/_types.py b/packages/minisky/tests/_types.py similarity index 100% rename from tests/_types.py rename to packages/minisky/tests/_types.py diff --git a/tests/conftest.py b/packages/minisky/tests/conftest.py similarity index 99% rename from tests/conftest.py rename to packages/minisky/tests/conftest.py index b8d8754..7e8a84e 100644 --- a/tests/conftest.py +++ b/packages/minisky/tests/conftest.py @@ -10,7 +10,6 @@ from collections.abc import Callable, Iterator import pytest - from minisky import MiniSky from minisky.core.config import MiniSkyConfig from minisky.simulation import Simulation diff --git a/tests/integration/test_conflict.py b/packages/minisky/tests/integration/test_conflict.py similarity index 91% rename from tests/integration/test_conflict.py rename to packages/minisky/tests/integration/test_conflict.py index 66bb171..efd8372 100644 --- a/tests/integration/test_conflict.py +++ b/packages/minisky/tests/integration/test_conflict.py @@ -3,7 +3,6 @@ from __future__ import annotations import pytest - from minisky import MiniSky from minisky.simulation import Simulation from minisky.traffic.asas import MVP @@ -72,15 +71,17 @@ def test_reso_status_reports_off(self, runtime: MiniSky, run_cmd: RunCommand) -> output = run_cmd("RESO") assert "Current CR method: OFF" in output - def test_rmethh_returns_success_tuple(self, runtime: MiniSky, run_cmd: RunCommand) -> None: + def test_rmethh_returns_ok_result(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") + assert result.is_ok() + assert result.unwrap() == "Horizontal resolution method set to SPD" - def test_rmethv_returns_success_tuple(self, runtime: MiniSky, run_cmd: RunCommand) -> None: + def test_rmethv_returns_ok_result(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") + assert result.is_ok() + assert result.unwrap() == "Vertical resolution method set to ON" def test_rmethh_via_stack(self, runtime: MiniSky, run_cmd: RunCommand) -> None: run_cmd("RESO MVP") @@ -103,9 +104,8 @@ def test_rmethh_requires_mvp(self, runtime: MiniSky, run_cmd: RunCommand) -> Non 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 result.is_ok() + message = result.unwrap() assert "RESOOFF" in message assert "NORESO" not in message @@ -123,9 +123,9 @@ def test_zonedh_status_query(self, runtime: MiniSky, run_cmd: RunCommand) -> Non 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 + result = runtime.traffic.cd.sethpz() + assert result.is_ok() + assert f"{runtime.traffic.cd.hpz_def / FT:.2f} ft" in result.unwrap() def test_hpz_default_consistent_after_reset(self, runtime: MiniSky, sim: Simulation) -> None: # reset() must restore the same default as __init__ diff --git a/tests/integration/test_navdata.py b/packages/minisky/tests/integration/test_navdata.py similarity index 83% rename from tests/integration/test_navdata.py rename to packages/minisky/tests/integration/test_navdata.py index 76fb727..e378729 100644 --- a/tests/integration/test_navdata.py +++ b/packages/minisky/tests/integration/test_navdata.py @@ -14,9 +14,9 @@ class TestDefwpt: 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") - assert ok - assert "TSTWPT1" in msg + result = navdb.defwpt("TSTWPT1", 52.0, 4.0, "FIX") + assert result.is_ok() + assert "TSTWPT1" in result.unwrap() assert len(navdb.wpid) == n + 1 assert len(navdb.wplat) == n + 1 assert len(navdb.wplon) == n + 1 @@ -32,8 +32,8 @@ def test_delwpt_removes_coordinates(self, runtime: MiniSky, sim: Simulation) -> navdb.defwpt("TSTWPTA", 52.0, 4.0, "FIX") navdb.defwpt("TSTWPTB", 10.0, 20.0, "FIX") - ok, _ = navdb.delwpt("TSTWPTA") - assert ok + result = navdb.delwpt("TSTWPTA") + assert result.is_ok() assert "TSTWPTA" not in navdb.wpid assert len(navdb.wpid) == n + 1 assert len(navdb.wplat) == n + 1 @@ -51,9 +51,9 @@ def test_defwpt_delete_via_lon_delete_keyword(self, runtime: MiniSky, sim: Simul navdb.defwpt("TSTWPT2", 52.0, 4.0) # 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 + result = navdb.defwpt("TSTWPT2", 0.0, "delete") # type: ignore + assert result.is_ok() + assert "deleted" in result.unwrap() assert "TSTWPT2" not in navdb.wpid assert len(navdb.wpid) == n assert len(navdb.wplat) == n @@ -62,8 +62,8 @@ def test_defwpt_delete_via_lon_delete_keyword(self, runtime: MiniSky, sim: Simul 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 + result = navdb.defwpt("TSTWPT3", 52.0, 4.0, "DEL") + assert result.is_ok() assert "TSTWPT3" not in navdb.wpid def test_delwpt_accepts_lowercase_name(self, runtime: MiniSky, sim: Simulation) -> None: @@ -71,6 +71,6 @@ def test_delwpt_accepts_lowercase_name(self, runtime: MiniSky, sim: Simulation) # searched wpid with the raw name, raising ValueError for lowercase input navdb = runtime.navigation navdb.defwpt("TSTWPT4", 52.0, 4.0, "FIX") - ok, _ = navdb.delwpt("tstwpt4") - assert ok + result = navdb.delwpt("tstwpt4") + assert result.is_ok() assert "TSTWPT4" not in navdb.wpid diff --git a/tests/integration/test_plugin.py b/packages/minisky/tests/integration/test_plugin.py similarity index 79% rename from tests/integration/test_plugin.py rename to packages/minisky/tests/integration/test_plugin.py index 6150261..b309c88 100644 --- a/tests/integration/test_plugin.py +++ b/packages/minisky/tests/integration/test_plugin.py @@ -12,13 +12,12 @@ import numpy as np import pytest -from pydantic import BaseModel - -from minisky import MiniSky, MiniSkyConfig +from minisky import Err, MiniSky, MiniSkyConfig, Ok, Result from minisky import plugin as plugin_api from minisky.simulation import Simulation from minisky.traffic import Traffic from minisky.traffic.autopilot import Autopilot +from pydantic import BaseModel @pytest.fixture @@ -43,15 +42,15 @@ def load(self) -> object: runtime.close() def test_listing(self, runtime: MiniSky) -> None: - ok, text = runtime.plugins.listing() - assert ok - assert "EXAMPLE" in text + result = runtime.plugins.listing() + assert result.is_ok() + assert "EXAMPLE" in result.unwrap() @pytest.mark.anyio async def test_unknown_plugin_load_fails(self, runtime: MiniSky) -> None: - ok, message = await runtime.plugins.load("NOSUCHPLUGIN") - assert not ok - assert "not found" in message.lower() + result = await runtime.plugins.load("NOSUCHPLUGIN") + assert result.is_err() + assert "not found" in result.unwrap_err().lower() def test_discovery_emits_no_deprecation_warning(self, runtime: MiniSky) -> None: with warnings.catch_warnings(): @@ -74,51 +73,8 @@ def install(monkeypatch: pytest.MonkeyPatch, *entries: FakeEntryPoint) -> None: monkeypatch.setattr(module.metadata, "entry_points", lambda *, group: entries) -def run_command(runtime: MiniSky, command: str) -> str: - runtime.commands.stack(command) - runtime.simulation.step() - return runtime.console.read_output_buffer() - - @pytest.mark.anyio -async def test_example_commands_and_entity_are_runtime_owned() -> None: - runtime = MiniSky(MiniSkyConfig()) - try: - ok, message = await runtime.plugins.load("EXAMPLE") - assert ok, message - record = runtime.plugins.plugins["EXAMPLE"] - assert record.loaded - assert tuple(runtime.plugins.loaded_plugins) == ("EXAMPLE",) - - run_command(runtime, "CRE KL001,A320,52,4,90,FL100,250") - assert "150" in run_command(runtime, "PASSENGERS KL001 150") - assert "150" in run_command(runtime, "PASSENGERS KL001") - - again = await runtime.plugins.load("EXAMPLE") - assert again == (False, "Plugin EXAMPLE already loaded") - finally: - await runtime.aclose() - - @pytest.mark.anyio -async def test_example_entity_sizes_existing_traffic_and_retires() -> None: - from minisky_example import Example - - runtime = MiniSky(MiniSkyConfig()) - runtime.traffic.cre("KL001", "A320", lat=52.0, lon=4.0, hdg=90, alt=3000, spd=150) - ok, message = await runtime.plugins.load("EXAMPLE") - assert ok, message - record = runtime.plugins.plugins["EXAMPLE"] - entity = cast(Example, record.entities[0]) - assert record.entities == (entity,) - assert len(entity.npassengers) == 1 - assert entity._traffic is runtime.traffic - - await runtime.aclose() - assert entity._retired - assert entity._traffic is None - - @pytest.mark.anyio async def test_entity_backfill_follows_lifespan_startup( monkeypatch: pytest.MonkeyPatch, @@ -155,8 +111,8 @@ def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: await entered.wait() runtime.traffic.cre("KL001", alt=3000.0, spd=150.0) release.set() - ok, message = await load_task - assert ok, message + result = await load_task + assert result.is_ok(), result.err() assert entity.names.tolist() == ["KL001"] finally: release.set() @@ -186,8 +142,8 @@ def build(context: plugin_api.PluginContext[Config]) -> plugin_api.PluginSpec: ) runtime = MiniSky(MiniSkyConfig(plugins={"typed": {"value": 7}})) try: - ok, message = await runtime.plugins.load("TYPED") - assert ok, message + result = await runtime.plugins.load("TYPED") + assert result.is_ok(), result.err() state = State(7, runtime.python_random) assert runtime.variables.varlist["typed"] == (state, ["value", "random"]) assert runtime.plugins.plugins["TYPED"].spec == plugin_api.PluginSpec((state,), state) @@ -204,10 +160,10 @@ def __init__(self) -> None: self.values: list[int] = [] @plugin_api.command(arguments="int") - def record(self, value: int) -> tuple[bool, str]: + def record(self, value: int) -> Result[str, str]: """Record an integer.""" self.values.append(value) - return True, f"recorded {value}" + return Ok(f"recorded {value}") component = Component() @@ -219,8 +175,8 @@ def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: runtime = MiniSky(MiniSkyConfig()) try: assert "RECORD" not in runtime.commands.cmddict - ok, message = await runtime.plugins.load("MOUNTED") - assert ok, message + result = await runtime.plugins.load("MOUNTED") + assert result.is_ok(), result.err() command = runtime.commands.cmddict["RECORD"] assert command.callback.__self__ is component assert command.brief == "RECORD value" @@ -254,8 +210,8 @@ def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: install(monkeypatch, FakeEntryPoint("hooks", plugin_api.Plugin(build=build))) runtime = MiniSky(MiniSkyConfig()) try: - ok, message = await runtime.plugins.load("HOOKS") - assert ok, message + result = await runtime.plugins.load("HOOKS") + assert result.is_ok(), result.err() runtime.plugins.preupdate() runtime.plugins.update() runtime.plugins.preupdate() @@ -290,8 +246,8 @@ def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: install(monkeypatch, FakeEntryPoint("hooks", plugin_api.Plugin(build=build))) runtime = MiniSky(MiniSkyConfig()) try: - ok, message = await runtime.plugins.load("HOOKS") - assert ok, message + result = await runtime.plugins.load("HOOKS") + assert result.is_ok(), result.err() runtime.plugins.update() runtime.plugins.update() assert calls == {"broken": 1, "healthy": 2} @@ -301,31 +257,6 @@ def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: @pytest.mark.anyio -async def test_replacement_visibility_is_runtime_local_and_removed_on_shutdown() -> None: - from minisky_example_customautopilot import CustomAutoPilot - - runtime_a = MiniSky(MiniSkyConfig()) - runtime_b = MiniSky(MiniSkyConfig()) - try: - assert runtime_a.replaceables.select("AUTOPILOT", "CUSTOMAUTOPILOT")[0] is False - assert runtime_b.replaceables.select("AUTOPILOT", "CUSTOMAUTOPILOT")[0] is False - - alt_callback = runtime_a.commands.cmddict["ALT"].callback - ok, message = await runtime_a.plugins.load("CUSTOMAUTOPILOT") - assert ok, message - assert runtime_a.replaceables.select("AUTOPILOT", "CUSTOMAUTOPILOT")[0] is True - assert type(runtime_a.traffic.ap) is CustomAutoPilot - assert runtime_b.replaceables.select("AUTOPILOT", "CUSTOMAUTOPILOT")[0] is False - - await runtime_a.plugins.aclose() - assert type(runtime_a.traffic.ap) is Autopilot - assert runtime_a.replaceables.select("AUTOPILOT", "CUSTOMAUTOPILOT")[0] is False - assert runtime_a.commands.cmddict["ALT"].callback is alt_callback - finally: - await runtime_a.aclose() - await runtime_b.aclose() - - @pytest.mark.anyio async def test_replacement_arrays_size_existing_traffic( monkeypatch: pytest.MonkeyPatch, @@ -349,10 +280,10 @@ def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: runtime = MiniSky(MiniSkyConfig()) try: runtime.traffic.cre("KL001", alt=3000.0, spd=150.0) - ok, message = await runtime.plugins.load("ARRAYS") - assert ok, message + result = await runtime.plugins.load("ARRAYS") + assert result.is_ok(), result.err() alt_callback = runtime.commands.cmddict["ALT"].callback - assert runtime.replaceables.select("AUTOPILOT", "ARRAYAUTOPILOT")[0] is True + assert runtime.replaceables.select("AUTOPILOT", "ARRAYAUTOPILOT").is_ok() selected = cast(ArrayAutopilot, runtime.traffic.ap) runtime.commands.stack("ALT KL001 FL100") runtime.simulation.step() @@ -396,8 +327,8 @@ def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: install(monkeypatch, FakeEntryPoint("lifecycle", plugin_api.Plugin(build=build))) runtime = MiniSky(MiniSkyConfig()) - ok, message = await runtime.plugins.load("LIFECYCLE") - assert ok, message + result = await runtime.plugins.load("LIFECYCLE") + assert result.is_ok(), result.err() assert events == [("enter", False)] assert "LIFECYCLE" in runtime.commands.cmddict assert capability is not None @@ -436,8 +367,8 @@ def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: install(monkeypatch, FakeEntryPoint("blocked", plugin_api.Plugin(build=build))) runtime = MiniSky(MiniSkyConfig()) - ok, message = await runtime.plugins.load("BLOCKED") - assert ok, message + result = await runtime.plugins.load("BLOCKED") + assert result.is_ok(), result.err() runtime.commands.stack("BLOCK") assert not runtime.simulation.step() await asyncio.sleep(0) @@ -474,10 +405,10 @@ def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: install(monkeypatch, FakeEntryPoint("failedstart", plugin_api.Plugin(build=build))) runtime = MiniSky(MiniSkyConfig()) - ok, message = await runtime.plugins.load("FAILEDSTART") + result = await runtime.plugins.load("FAILEDSTART") - assert not ok - assert "startup failed" in message + assert result.is_err() + assert "startup failed" in result.unwrap_err() assert "FAILEDSTART" not in runtime.commands.cmddict assert "failedstart" not in runtime.variables.varlist assert not runtime.plugins.plugins["FAILEDSTART"].loaded @@ -543,8 +474,8 @@ def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: FakeEntryPoint("second", declaration("second")), ) runtime = MiniSky(MiniSkyConfig()) - assert (await runtime.plugins.load("FIRST"))[0] - assert (await runtime.plugins.load("SECOND"))[0] + assert (await runtime.plugins.load("FIRST")).is_ok() + assert (await runtime.plugins.load("SECOND")).is_ok() with pytest.raises(ExceptionGroup) as exc_info: await runtime.aclose() @@ -565,10 +496,10 @@ async def test_concurrent_duplicate_loads_are_serialized() -> None: runtime.plugins.load("EXAMPLE"), runtime.plugins.load("EXAMPLE"), ) - assert sorted(results) == [ - (False, "Plugin EXAMPLE already loaded"), - (True, "Successfully loaded plugin EXAMPLE"), - ] + assert set(results) == { + Err("Plugin EXAMPLE already loaded"), + Ok("Successfully loaded plugin EXAMPLE"), + } finally: await runtime.aclose() diff --git a/tests/integration/test_route_autopilot.py b/packages/minisky/tests/integration/test_route_autopilot.py similarity index 98% rename from tests/integration/test_route_autopilot.py rename to packages/minisky/tests/integration/test_route_autopilot.py index ca5334c..b04067e 100644 --- a/tests/integration/test_route_autopilot.py +++ b/packages/minisky/tests/integration/test_route_autopilot.py @@ -2,9 +2,10 @@ from __future__ import annotations +from typing import ClassVar + import numpy as np import pytest - from minisky import MiniSky from minisky.tools import geo from minisky.traffic import route as route_commands @@ -126,7 +127,7 @@ def test_addwpt_accepts_string_callsign( ) -> 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 + assert result.is_ok() route = runtime.traffic.ap.route[0] assert route.wplat[0] == pytest.approx(52.5) assert route.wplon[0] == pytest.approx(5.0) @@ -178,7 +179,7 @@ def test_at_wpt_sets_alt_and_spd_constraints( run_cmd(f"ADDWPT {aircraft} 53.0,6.0") route = runtime.traffic.ap.route[0] result = route_commands.at_wpt(runtime.traffic, 0, route.wpname[1], "FL090/250") - assert result is True + assert result.is_ok() assert route.wpalt[1] == pytest.approx(9000 * FT, rel=1e-3) assert route.wpspd[1] == pytest.approx(250 * KTS, rel=1e-3) @@ -298,7 +299,12 @@ class TestWaypointSwitching: """ # Zig-zag legs of ~2 nm force a real heading change at every waypoint - WPTS = [(52.00, 4.05), (52.03, 4.10), (52.00, 4.15), (52.03, 4.20)] + WPTS: ClassVar[list[tuple[float, float]]] = [ + (52.00, 4.05), + (52.03, 4.10), + (52.00, 4.15), + (52.03, 4.20), + ] @pytest.fixture def route(self, runtime: MiniSky, run_cmd: RunCommand, aircraft: str) -> Route: diff --git a/tests/integration/test_scenario.py b/packages/minisky/tests/integration/test_scenario.py similarity index 99% rename from tests/integration/test_scenario.py rename to packages/minisky/tests/integration/test_scenario.py index 13b5e27..6303b45 100644 --- a/tests/integration/test_scenario.py +++ b/packages/minisky/tests/integration/test_scenario.py @@ -3,7 +3,6 @@ from __future__ import annotations import pytest - from minisky import MiniSky from tests._types import RunCommand, StepUntil diff --git a/tests/integration/test_stack.py b/packages/minisky/tests/integration/test_stack.py similarity index 96% rename from tests/integration/test_stack.py rename to packages/minisky/tests/integration/test_stack.py index acecdbe..847a3b0 100644 --- a/tests/integration/test_stack.py +++ b/packages/minisky/tests/integration/test_stack.py @@ -7,7 +7,6 @@ from pathlib import Path import pytest - from minisky import MiniSky from minisky.simulation import Simulation from tests._types import RunCommand @@ -158,10 +157,10 @@ def test_help_writes_command_reference( # HELP >filename writes the reference to ./docs/ monkeypatch.chdir(tmp_path) (tmp_path / "docs").mkdir() - success, msg = runtime.commands.showhelp(">ref.txt") - assert success + result = runtime.commands.showhelp(">ref.txt") + assert result.is_ok(), result.err() ref = tmp_path / "docs" / "ref.txt" - assert ref.exists(), msg + assert ref.exists(), result.ok() content = ref.read_text() assert content.startswith("Command\tDescription\tUsage") assert "\nCRE\t" in content @@ -223,9 +222,9 @@ def test_all_registered_specs_resolve_to_parsers(self, runtime: MiniSky) -> None continue seen.add(id(cmd)) for annot, _isopt in cmd.arguments: - assert annot == annot.strip() and annot, ( - f"{cmd.name}: whitespace/empty annotation token {annot!r}" - ) + message = f"{cmd.name}: whitespace/empty annotation token {annot!r}" + assert annot == annot.strip(), message + assert annot, message if annot in placeholders: continue if all(argparsers.get(part) is None for part in annot.split("/")): diff --git a/tests/integration/test_streaming.py b/packages/minisky/tests/integration/test_streaming.py similarity index 96% rename from tests/integration/test_streaming.py rename to packages/minisky/tests/integration/test_streaming.py index 5ed787a..5ae771c 100644 --- a/tests/integration/test_streaming.py +++ b/packages/minisky/tests/integration/test_streaming.py @@ -9,7 +9,6 @@ import json import pytest - from minisky import MiniSky from minisky.simulation import Simulation, SimulationState from minisky.streaming import STREAM_MAX_HZ, StreamHub, build_snapshot @@ -75,9 +74,9 @@ def test_dtmult_sets_runner_speed(runtime: MiniSky, sim: Simulation, run_cmd: Ru 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() + result = runtime.runner.setspeed(0) + assert result.is_err() + assert "positive" in result.unwrap_err().lower() def test_hub_skips_publish_without_subscribers(runtime: MiniSky) -> None: diff --git a/tests/integration/test_traffic.py b/packages/minisky/tests/integration/test_traffic.py similarity index 95% rename from tests/integration/test_traffic.py rename to packages/minisky/tests/integration/test_traffic.py index 6ab058f..9c96c35 100644 --- a/tests/integration/test_traffic.py +++ b/packages/minisky/tests/integration/test_traffic.py @@ -4,7 +4,6 @@ import numpy as np import pytest - from minisky import MiniSky from minisky.simulation import Simulation from tests._types import RunCommand @@ -15,8 +14,8 @@ class TestCreate: 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 + result = runtime.traffic.cre("KL001", "A320", lat=52.0, lon=4.0, hdg=90, alt=3000, spd=150) + assert result.is_ok() assert runtime.traffic.ntraf == 1 assert runtime.traffic.callsign[0] == "KL001" assert runtime.traffic.lat[0] == pytest.approx(52.0) @@ -29,13 +28,13 @@ def test_cre_lowercase_callsign_is_uppercased(self, runtime: MiniSky, sim: Simul 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 + result = runtime.traffic.cre("KL001") + assert result.is_err() assert runtime.traffic.ntraf == 1 def test_mcre_multiple(self, runtime: MiniSky, sim: Simulation) -> None: - ok, _ = runtime.traffic.mcre(5) - assert ok + result = runtime.traffic.mcre(5) + assert result.is_ok() assert runtime.traffic.ntraf == 5 assert len(set(runtime.traffic.callsign)) == 5 @@ -176,7 +175,8 @@ def test_renameac_updates_pending_conditions(self, runtime: MiniSky, sim: Simula class TestWind: 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 + result = wind.add(52.0, 4.0, 270.0, 20.0) # from 270 deg, 20 kts + assert result.is_ok() 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) @@ -195,7 +195,8 @@ def test_wind_del_clears_field(self, runtime: MiniSky, sim: Simulation) -> None: wind.add(52.0, 4.0, 270.0, 20.0) assert wind.winddim > 0 # TODO(abraham): possible bug! - assert wind.add(52.0, 4.0, "DEL") is True # type: ignore + result = wind.add(52.0, 4.0, "DEL") # type: ignore + assert result.is_ok() assert wind.winddim == 0 assert len(wind.lat) == 0 @@ -206,7 +207,8 @@ def test_wind_del_not_shadowed_by_altitude_form( wind.add(52.0, 4.0, 270.0, 20.0) # With 3+ winddata elements DEL used to fall into the alt/dir/spd branch # TODO(abraham): possible bug! - assert wind.add(52.0, 4.0, "DEL", None, None) is True # type: ignore + result = wind.add(52.0, 4.0, "DEL", None, None) # type: ignore + assert result.is_ok() assert wind.winddim == 0 def test_wind_via_stack_two_element_form(self, runtime: MiniSky, run_cmd: RunCommand) -> None: diff --git a/tests/test_api.py b/packages/minisky/tests/test_api.py similarity index 99% rename from tests/test_api.py rename to packages/minisky/tests/test_api.py index be08a1e..6539c2b 100644 --- a/tests/test_api.py +++ b/packages/minisky/tests/test_api.py @@ -13,7 +13,6 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient - from minisky import MiniSky, MiniSkyConfig pytestmark = pytest.mark.api diff --git a/tests/unit/test_aero.py b/packages/minisky/tests/unit/test_aero.py similarity index 96% rename from tests/unit/test_aero.py rename to packages/minisky/tests/unit/test_aero.py index c75e9e2..a9f0485 100644 --- a/tests/unit/test_aero.py +++ b/packages/minisky/tests/unit/test_aero.py @@ -5,7 +5,6 @@ import numpy as np import pytest - from minisky.tools import aero @@ -89,12 +88,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, aero.DEFAULT_CASMACH_THRESHOLD) + 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, aero.DEFAULT_CASMACH_THRESHOLD) + 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 diff --git a/tests/unit/test_areafilter.py b/packages/minisky/tests/unit/test_areafilter.py similarity index 96% rename from tests/unit/test_areafilter.py rename to packages/minisky/tests/unit/test_areafilter.py index f359f83..332d9d5 100644 --- a/tests/unit/test_areafilter.py +++ b/packages/minisky/tests/unit/test_areafilter.py @@ -6,7 +6,6 @@ import numpy as np import pytest - from minisky.tools.areafilter import AreaFilter @@ -23,8 +22,8 @@ def check_single( class TestDefineArea: 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 + result = area_filter.define_area("BOX1", "BOX", [52.0, 4.0, 53.0, 5.0]) + assert result.is_ok() assert area_filter.has_area("BOX1") def test_unknown_area_absent(self, area_filter: AreaFilter) -> None: diff --git a/tests/unit/test_convert.py b/packages/minisky/tests/unit/test_convert.py similarity index 93% rename from tests/unit/test_convert.py rename to packages/minisky/tests/unit/test_convert.py index e1d9b10..8671f85 100644 --- a/tests/unit/test_convert.py +++ b/packages/minisky/tests/unit/test_convert.py @@ -5,7 +5,6 @@ """ import pytest - from minisky.tools import convert as cv FT = 0.3048 @@ -20,7 +19,7 @@ def test_plain_feet(self) -> None: assert cv.txt2alt("2500") == pytest.approx(2500 * FT) def test_invalid_raises(self) -> None: - with pytest.raises(ValueError): + with pytest.raises(ValueError, match=r'Could not parse "NOTANALT" as altitude'): cv.txt2alt("NOTANALT") @@ -64,13 +63,13 @@ def test_mach_passthrough(self) -> None: assert cv.txt2spd(".8") == pytest.approx(0.8) def test_invalid_raises(self) -> None: - with pytest.raises(ValueError): + with pytest.raises(ValueError, match=r"Could not parse FAST as speed\."): cv.txt2spd("FAST") class TestAngles: @pytest.mark.parametrize( - "angle,expected", + ("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: float, expected: float) -> None: diff --git a/tests/unit/test_detection.py b/packages/minisky/tests/unit/test_detection.py similarity index 97% rename from tests/unit/test_detection.py rename to packages/minisky/tests/unit/test_detection.py index e1ef486..f52658c 100644 --- a/tests/unit/test_detection.py +++ b/packages/minisky/tests/unit/test_detection.py @@ -17,7 +17,6 @@ import numpy as np import pytest - from minisky.tools import geo from minisky.tools.aero import ft, nm from minisky.traffic.asas.detection import ConflictDetection @@ -227,12 +226,15 @@ def test_no_traffic(self): traf = make_traffic(0, 9) cd = ConflictDetection.__new__(ConflictDetection) result = cd.detect(traf, traf, *default_params(0)) - assert result[0] == [] and result[1] == [] - assert len(result[2]) == 0 and len(result[3]) == 0 + assert result[0] == [] + assert result[1] == [] + assert len(result[2]) == 0 + assert len(result[3]) == 0 def test_single_aircraft(self): traf = make_traffic(1, 10) cd = ConflictDetection.__new__(ConflictDetection) result = cd.detect(traf, traf, *default_params(1)) - assert result[0] == [] and result[1] == [] + assert result[0] == [] + assert result[1] == [] assert not result[2].any() diff --git a/tests/unit/test_geo.py b/packages/minisky/tests/unit/test_geo.py similarity index 98% rename from tests/unit/test_geo.py rename to packages/minisky/tests/unit/test_geo.py index 7a64617..6317eb1 100644 --- a/tests/unit/test_geo.py +++ b/packages/minisky/tests/unit/test_geo.py @@ -6,7 +6,6 @@ import numpy as np import pytest - from minisky.tools import geo NM_IN_M = 1852.0 @@ -96,7 +95,7 @@ def test_qdrdist_matrix_matches_scalar(self) -> None: class TestProjection: - @pytest.mark.parametrize("qdr,dist", [(0.0, 60.0), (45.0, 100.0), (270.0, 30.0)]) + @pytest.mark.parametrize(("qdr", "dist"), [(0.0, 60.0), (45.0, 100.0), (270.0, 30.0)]) 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) diff --git a/tests/unit/test_phase.py b/packages/minisky/tests/unit/test_phase.py similarity index 99% rename from tests/unit/test_phase.py rename to packages/minisky/tests/unit/test_phase.py index 8a2e12a..2e86c94 100644 --- a/tests/unit/test_phase.py +++ b/packages/minisky/tests/unit/test_phase.py @@ -5,7 +5,6 @@ """ import numpy as np - from minisky.traffic.performance import phase from minisky.traffic.performance.coeff import LIFT_FIXWING, LIFT_ROTOR diff --git a/pyproject.toml b/pyproject.toml index 98c35d6..54803a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,52 +5,35 @@ members = ["packages/*"] dev = [ "ipykernel>=6.29.5", "pytest>=9.1.1", - "ruff>=0.8.0", + "ruff>=0.16.0", "pyright>=1.1.390", "fakeredis>=2.26", ] -docs = [ - "zensical>=0.0.47", - "mkdocstrings-python>=2.0.5", -] +docs = ["zensical>=0.0.47", "mkdocstrings-python>=2.0.5"] [tool.pytest.ini_options] -testpaths = ["tests"] +testpaths = [ + "packages/minisky/tests", + "packages/minisky-example/tests", + "packages/minisky-example-customautopilot/tests", + "packages/minisky-tangram/tests", +] pythonpath = [".", "packages/minisky"] addopts = "-ra -m 'not api'" -markers = [ - "api: FastAPI endpoint tests, run separately with just test-api", -] +markers = ["api: FastAPI endpoint tests, run separately with just test-api"] [tool.ruff] line-length = 100 target-version = "py311" [tool.ruff.lint] -# E/W: pycodestyle, F: pyflakes, I: isort, UP: pyupgrade, -# B: flake8-bugbear, SIM: flake8-simplify, C4: flake8-comprehensions, -# DTZ: flake8-datetimez (no naive datetimes; sim.utc is timezone-aware UTC) -select = ["E", "W", "F", "I", "UP", "B", "SIM", "C4", "DTZ"] -ignore = [ - "E501", # line length is handled by the formatter - "B008", # FastAPI relies on function calls (Depends/File) in argument defaults - "E711", # `== None` on numpy arrays is elementwise, not equivalent to `is None` - "E712", # `== True/False` on numpy arrays is elementwise and intentional - "E721", # `type(x) == y` comparisons are intentional in a few numeric paths -] - -[tool.ruff.lint.per-file-ignores] -"__init__.py" = ["F401"] # re-exports -"packages/minisky/minisky/plugin/*.py" = ["F401"] # plugin API surface re-exports - -[tool.ruff.lint.isort] -known-first-party = [ - "minisky", - "minisky_example", - "minisky_example_customautopilot", - "minisky_tangram", - "example_plugins", - "tests", +extend-select = [ + "NPY", + "PT", + "A", # shadowing + "PTH", # prefer pathlib over os.path and built-in open() + "RUF006", # keep references to created asyncio tasks + "RUF021", # parenthesize mixed and/or expressions ] [tool.pyright] @@ -59,12 +42,18 @@ include = [ "packages/minisky-example*/src", "packages/minisky-tangram/src", "packages/tangram-minisky/src", - "tests", + "packages/*/tests", ] exclude = [ - "**/.*", "**/__pycache__", "**/node_modules", ".venv", "build", "site", + "**/.*", + "**/__pycache__", + "**/node_modules", + ".venv", + "build", + "site", # TODO(abraham): these files will need extensive typing, deferring for now - "tests/unit/test_aero.py", "tests/unit/test_detection.py" + "packages/minisky/tests/unit/test_aero.py", + "packages/minisky/tests/unit/test_detection.py", ] extraPaths = [ "packages/minisky", diff --git a/uv.lock b/uv.lock index 04d50ce..031c6e0 100644 --- a/uv.lock +++ b/uv.lock @@ -31,7 +31,7 @@ dev = [ { name = "ipykernel", specifier = ">=6.29.5" }, { name = "pyright", specifier = ">=1.1.390" }, { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.8.0" }, + { name = "ruff", specifier = ">=0.16.0" }, ] docs = [ { name = "mkdocstrings-python", specifier = ">=2.0.5" }, @@ -2517,27 +2517,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, - { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, - { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, - { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, - { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, - { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, - { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, - { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, - { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, - { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, - { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, ] [[package]]