diff --git a/pyproject.toml b/pyproject.toml index 34ead38..ed703a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "opensatcom" -version = "0.7.0" +version = "0.8.0" description = "Open-source Python toolkit for satellite communications engineering" readme = "README.md" license = "MIT" @@ -53,6 +53,9 @@ sensitivity = ["SALib>=1.4"] notebooks = ["jupyterlab>=4.0", "ipywidgets>=8.0"] docs = ["mkdocs>=1.6", "mkdocs-material>=9.5", "mkdocstrings[python]>=0.27"] +[project.entry-points."sysml2kit.engines"] +opensatcom-link = "opensatcom.interop:run_link" + [project.scripts] opensatcom = "opensatcom.cli:main" diff --git a/src/opensatcom/interop.py b/src/opensatcom/interop.py new file mode 100644 index 0000000..2156cc3 --- /dev/null +++ b/src/opensatcom/interop.py @@ -0,0 +1,65 @@ +"""sysml2kit verification engine: one snapshot link budget per call. + +``run_link(payload)`` is registered under the ``sysml2kit.engines`` +entry-point group as ``opensatcom-link``, so a SysML v2 analysis case +carrying a ``verificationBinding`` that names this engine evaluates a +link budget and checks requirements against the returned metrics. + +The payload is a ProjectConfig-shaped mapping (the same sections +``load_config`` reads from YAML) plus a ``snapshot`` section: + +- ``elev_deg`` (required): link elevation angle, +- ``az_deg`` (default 0.0), +- ``range_m`` (default: slant range from terminal altitudes and elevation), +- ``rain_rate_mm_per_hr`` (default: clear sky). + +The snapshot's ``margin_db`` is returned as ``link_margin_db`` to match +the metric key phased-array-systems emits, so one requirement can be +verified by either engine. Non-scalar outputs are dropped: the sysml2kit +engine contract is a flat mapping of scalars. +""" + +from dataclasses import asdict +from typing import Any + + +def run_link(payload: dict[str, Any]) -> dict[str, Any]: + """Evaluate one snapshot link budget from a config payload.""" + from opensatcom.cli.builders import build_link_inputs_from_config + from opensatcom.core.models import PropagationConditions + from opensatcom.geometry.slant import slant_range_m + from opensatcom.io.config_loader import ProjectConfig + from opensatcom.link.engine import DefaultLinkEngine + + payload = dict(payload) + snapshot = dict(payload.pop("snapshot", None) or {}) + if "elev_deg" not in snapshot: + raise ValueError("payload must carry snapshot.elev_deg") + elev_deg = float(snapshot["elev_deg"]) + + cfg = ProjectConfig.model_validate(payload) + inputs = build_link_inputs_from_config(cfg) + + range_m = snapshot.get("range_m") + if range_m is None: + range_m = slant_range_m(inputs.rx_terminal.alt_m, inputs.tx_terminal.alt_m, elev_deg) + + rain = snapshot.get("rain_rate_mm_per_hr") + cond = PropagationConditions(rain_rate_mm_per_hr=float(rain) if rain is not None else None) + out = DefaultLinkEngine().evaluate_snapshot( + elev_deg=elev_deg, + az_deg=float(snapshot.get("az_deg", 0.0)), + range_m=float(range_m), + inputs=inputs, + cond=cond, + ) + + result = asdict(out) + result["link_margin_db"] = result.pop("margin_db") + result["elev_deg"] = elev_deg + result["range_m"] = float(range_m) + return { + key: value + for key, value in result.items() + if value is None or isinstance(value, (int, float, str, bool)) + } diff --git a/tests/unit/test_interop.py b/tests/unit/test_interop.py new file mode 100644 index 0000000..fee4e40 --- /dev/null +++ b/tests/unit/test_interop.py @@ -0,0 +1,43 @@ +"""The sysml2kit engine surface: run_link over a config payload.""" + +import pytest +import yaml + +from opensatcom.interop import run_link + + +@pytest.fixture +def payload(): + with open("examples/example_config.yaml") as fh: + config = yaml.safe_load(fh) + config["snapshot"] = {"elev_deg": 40.0} + return config + + +def test_run_link_returns_flat_scalars(payload): + metrics = run_link(payload) + assert isinstance(metrics["link_margin_db"], float) + assert "margin_db" not in metrics + for value in metrics.values(): + assert value is None or isinstance(value, (int, float, str, bool)) + + +def test_run_link_requires_elevation(payload): + del payload["snapshot"] + with pytest.raises(ValueError, match="elev_deg"): + run_link(payload) + + +def test_run_link_rain_moves_margin(payload): + payload["propagation"]["components"].append({"type": "rain"}) + clear = run_link(payload)["link_margin_db"] + payload["snapshot"]["rain_rate_mm_per_hr"] = 20.0 + rainy = run_link(payload)["link_margin_db"] + assert rainy < clear + + +def test_engine_entry_point_registered(): + from importlib.metadata import entry_points + + eps = {ep.name: ep.value for ep in entry_points(group="sysml2kit.engines")} + assert eps["opensatcom-link"] == "opensatcom.interop:run_link"