Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"

Expand Down
65 changes: 65 additions & 0 deletions src/opensatcom/interop.py
Original file line number Diff line number Diff line change
@@ -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))
}
43 changes: 43 additions & 0 deletions tests/unit/test_interop.py
Original file line number Diff line number Diff line change
@@ -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"
Loading