feat(simulation): add provider-neutral worker profile

This commit is contained in:
DCCONSTRUCTIONS
2026-07-25 00:29:00 +03:00
parent 5abd07d2bf
commit 37c24b5fa8
21 changed files with 1275 additions and 121 deletions
+14
View File
@@ -31,6 +31,14 @@ from k1link.simulation.process_supervisor import (
ProcessStopResult,
ProcessSupervisorError,
)
from k1link.simulation.provider_contract import (
PROVIDER_PROFILE_SCHEMA,
ProviderRole,
SimulationClockDescriptor,
SimulationProviderContractError,
SimulationProviderDescriptor,
SimulationProviderProfile,
)
from k1link.simulation.run_store import (
QualificationRunConflictError,
QualificationRunIntegrityError,
@@ -83,7 +91,9 @@ __all__ = [
"ProcessSpec",
"ProcessStopResult",
"ProcessSupervisorError",
"PROVIDER_PROFILE_SCHEMA",
"ProviderPin",
"ProviderRole",
"QualificationArtifact",
"QualificationEvent",
"QualificationRun",
@@ -105,8 +115,12 @@ __all__ = [
"StockRoverProfileError",
"StockRoverTargetPaths",
"SimulationContractError",
"SimulationClockDescriptor",
"SimulationApplicationService",
"SimulationOrchestratorError",
"SimulationProviderContractError",
"SimulationProviderDescriptor",
"SimulationProviderProfile",
"SimulationWorkerPort",
"SimulationWorldControl",
"WorkerAdmission",
+3 -5
View File
@@ -11,6 +11,7 @@ from typing import Any, Final
IDENTIFIER_PATTERN: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
SHA256_PATTERN: Final = re.compile(r"^[a-f0-9]{64}$")
GIT_REVISION_PATTERN: Final = re.compile(r"^[a-f0-9]{7,64}$")
CLOCK_DOMAIN_PATTERN: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$")
class SimulationContractError(ValueError):
@@ -243,11 +244,8 @@ class QualificationRun:
_sha256(self.host_profile_sha256, "host profile digest")
if self.seed < 0:
raise SimulationContractError("run seed must not be negative")
if self.clock_domain != "gazebo:/clock" and self.kind in {
RunKind.SIMULATION_CLOSED_LOOP,
RunKind.DIGITAL_TWIN_CLOSED_LOOP,
}:
raise SimulationContractError("closed-loop simulation requires Gazebo /clock")
if not CLOCK_DOMAIN_PATTERN.fullmatch(self.clock_domain):
raise SimulationContractError("clock domain is not a safe identifier")
_utc_timestamp(self.created_at_utc, "created timestamp")
if self.started_at_utc is not None:
_utc_timestamp(self.started_at_utc, "started timestamp")
+264
View File
@@ -0,0 +1,264 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import StrEnum
from typing import Any, Final
from k1link.simulation.contracts import ControlProfile
PROVIDER_PROFILE_SCHEMA: Final = "missioncore.simulation-provider-profile/v1"
IDENTIFIER_PATTERN: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
CAPABILITY_PATTERN: Final = re.compile(r"^[a-z0-9][a-z0-9._/-]{0,127}$")
CLOCK_DOMAIN_PATTERN: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$")
COMMAND_CAPABILITY_BY_PROFILE: Final = {
ControlProfile.ROVER_SPEED_STEERING_V1: "command.rover-speed-steering/v1",
ControlProfile.ROVER_SPEED_YAW_RATE_V1: "command.rover-speed-yaw-rate/v1",
}
class SimulationProviderContractError(ValueError):
"""A simulation provider profile violates the admitted v1 contract."""
class ProviderRole(StrEnum):
WORLD = "world"
PHYSICS = "physics"
STATE = "state"
CONTROLLER = "controller"
TRANSPORT = "transport"
SENSOR = "sensor"
TRAFFIC = "traffic"
@dataclass(frozen=True, slots=True)
class SimulationProviderDescriptor:
provider_id: str
roles: tuple[ProviderRole, ...]
capabilities: tuple[str, ...]
def __post_init__(self) -> None:
_identifier(self.provider_id, "provider id")
if not self.roles:
raise SimulationProviderContractError("provider roles must not be empty")
if any(not isinstance(role, ProviderRole) for role in self.roles):
raise SimulationProviderContractError("provider role is unknown")
if len(self.roles) != len(set(self.roles)):
raise SimulationProviderContractError("provider roles must be unique")
if not self.capabilities:
raise SimulationProviderContractError("provider capabilities must not be empty")
if len(self.capabilities) != len(set(self.capabilities)):
raise SimulationProviderContractError("provider capabilities must be unique")
for capability in self.capabilities:
_capability(capability)
def to_dict(self) -> dict[str, object]:
return {
"provider_id": self.provider_id,
"roles": [role.value for role in self.roles],
"capabilities": list(self.capabilities),
}
@classmethod
def from_dict(cls, value: object) -> SimulationProviderDescriptor:
document = _object(value, "provider descriptor")
_exact_keys(document, {"provider_id", "roles", "capabilities"}, "provider descriptor")
roles = _array(document, "roles")
capabilities = _array(document, "capabilities")
try:
parsed_roles = tuple(
ProviderRole(_string_value(role, "provider role")) for role in roles
)
except ValueError as exc:
raise SimulationProviderContractError("provider role is unknown") from exc
return cls(
provider_id=_string(document, "provider_id"),
roles=parsed_roles,
capabilities=tuple(
_string_value(capability, "provider capability") for capability in capabilities
),
)
@dataclass(frozen=True, slots=True)
class SimulationClockDescriptor:
provider_id: str
domain: str
unit: str = "nanoseconds"
mode: str = "simulation"
def __post_init__(self) -> None:
_identifier(self.provider_id, "clock provider id")
if not CLOCK_DOMAIN_PATTERN.fullmatch(self.domain):
raise SimulationProviderContractError("clock domain is not safe")
if self.unit != "nanoseconds" or self.mode != "simulation":
raise SimulationProviderContractError(
"v1 provider clocks must use simulation nanoseconds"
)
def to_dict(self) -> dict[str, object]:
return {
"provider_id": self.provider_id,
"domain": self.domain,
"unit": self.unit,
"mode": self.mode,
}
@classmethod
def from_dict(cls, value: object) -> SimulationClockDescriptor:
document = _object(value, "simulation clock")
_exact_keys(
document,
{"provider_id", "domain", "unit", "mode"},
"simulation clock",
)
return cls(
provider_id=_string(document, "provider_id"),
domain=_string(document, "domain"),
unit=_string(document, "unit"),
mode=_string(document, "mode"),
)
@dataclass(frozen=True, slots=True)
class SimulationProviderProfile:
profile_id: str
providers: tuple[SimulationProviderDescriptor, ...]
clock: SimulationClockDescriptor
control_profiles: tuple[ControlProfile, ...]
world_frame: str = "map_enu"
body_frame: str = "base_link_flu"
def __post_init__(self) -> None:
_identifier(self.profile_id, "provider profile id")
if not self.providers:
raise SimulationProviderContractError("provider profile must declare providers")
provider_ids = [provider.provider_id for provider in self.providers]
if len(provider_ids) != len(set(provider_ids)):
raise SimulationProviderContractError("provider ids must be unique")
providers_by_id = {provider.provider_id: provider for provider in self.providers}
clock_provider = providers_by_id.get(self.clock.provider_id)
if clock_provider is None or "clock.simulation" not in clock_provider.capabilities:
raise SimulationProviderContractError("clock provider must declare clock.simulation")
if not any(
ProviderRole.STATE in provider.roles and "state.vehicle-pose" in provider.capabilities
for provider in self.providers
):
raise SimulationProviderContractError(
"provider profile must expose canonical vehicle pose"
)
if not self.control_profiles:
raise SimulationProviderContractError("control profiles must not be empty")
if any(not isinstance(profile, ControlProfile) for profile in self.control_profiles):
raise SimulationProviderContractError("control profile is unknown")
if len(self.control_profiles) != len(set(self.control_profiles)):
raise SimulationProviderContractError("control profiles must be unique")
controller_capabilities = {
capability
for provider in self.providers
if ProviderRole.CONTROLLER in provider.roles
for capability in provider.capabilities
}
if any(
COMMAND_CAPABILITY_BY_PROFILE[profile] not in controller_capabilities
for profile in self.control_profiles
):
raise SimulationProviderContractError(
"controller providers do not satisfy the declared control profiles"
)
if self.world_frame != "map_enu" or self.body_frame != "base_link_flu":
raise SimulationProviderContractError(
"v1 provider profiles must expose map_enu and base_link_flu"
)
def to_dict(self) -> dict[str, object]:
return {
"schema_version": PROVIDER_PROFILE_SCHEMA,
"profile_id": self.profile_id,
"providers": [provider.to_dict() for provider in self.providers],
"clock": self.clock.to_dict(),
"control_profiles": [profile.value for profile in self.control_profiles],
"canonical_frames": {
"world": self.world_frame,
"body": self.body_frame,
},
}
@classmethod
def from_dict(cls, value: object) -> SimulationProviderProfile:
document = _object(value, "simulation provider profile")
_exact_keys(
document,
{
"schema_version",
"profile_id",
"providers",
"clock",
"control_profiles",
"canonical_frames",
},
"simulation provider profile",
)
if document.get("schema_version") != PROVIDER_PROFILE_SCHEMA:
raise SimulationProviderContractError("provider profile schema is incompatible")
providers = _array(document, "providers")
control_profiles = _array(document, "control_profiles")
frames = _object(document.get("canonical_frames"), "canonical frames")
_exact_keys(frames, {"world", "body"}, "canonical frames")
try:
parsed_control_profiles = tuple(
ControlProfile(_string_value(profile, "control profile"))
for profile in control_profiles
)
except ValueError as exc:
raise SimulationProviderContractError("control profile is unknown") from exc
return cls(
profile_id=_string(document, "profile_id"),
providers=tuple(
SimulationProviderDescriptor.from_dict(provider) for provider in providers
),
clock=SimulationClockDescriptor.from_dict(document.get("clock")),
control_profiles=parsed_control_profiles,
world_frame=_string(frames, "world"),
body_frame=_string(frames, "body"),
)
def _identifier(value: str, label: str) -> str:
if not IDENTIFIER_PATTERN.fullmatch(value):
raise SimulationProviderContractError(f"{label} is not a safe identifier")
return value
def _capability(value: str) -> str:
if not CAPABILITY_PATTERN.fullmatch(value):
raise SimulationProviderContractError("provider capability is not safe")
return value
def _object(value: object, label: str) -> dict[str, Any]:
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
raise SimulationProviderContractError(f"{label} must be an object")
return value
def _array(document: dict[str, Any], key: str) -> list[object]:
value = document.get(key)
if not isinstance(value, list):
raise SimulationProviderContractError(f"{key} must be an array")
return value
def _string(document: dict[str, Any], key: str) -> str:
return _string_value(document.get(key), key)
def _string_value(value: object, label: str) -> str:
if not isinstance(value, str) or not value:
raise SimulationProviderContractError(f"{label} must be a nonempty string")
return value
def _exact_keys(value: dict[str, Any], expected: set[str], label: str) -> None:
if set(value) != expected:
raise SimulationProviderContractError(f"{label} keys do not match v1")
+44
View File
@@ -7,10 +7,54 @@ from typing import Final
import yaml
from k1link.simulation.contracts import ControlProfile
from k1link.simulation.process_supervisor import ProcessSpec
from k1link.simulation.provider_contract import (
ProviderRole,
SimulationClockDescriptor,
SimulationProviderDescriptor,
SimulationProviderProfile,
)
LIFECYCLE_PROFILE_SCHEMA: Final = "missioncore.stock-rover-lifecycle/v1"
EXPECTED_D_ROOT: Final = Path("/mnt/d/NDC_MISSIONCORE/simulation")
GAZEBO_STATE_PROVIDER_ID: Final = "gazebo"
PX4_COMMAND_PROVIDER_ID: Final = "px4-ros2-offboard"
STOCK_ROVER_PROVIDER_PROFILE: Final = SimulationProviderProfile(
profile_id="stock-rover-gazebo-px4-s1d",
providers=(
SimulationProviderDescriptor(
provider_id=GAZEBO_STATE_PROVIDER_ID,
roles=(
ProviderRole.WORLD,
ProviderRole.PHYSICS,
ProviderRole.STATE,
ProviderRole.SENSOR,
),
capabilities=(
"clock.simulation",
"state.vehicle-pose",
"truth.ground-truth",
"sensor.virtual",
),
),
SimulationProviderDescriptor(
provider_id=PX4_COMMAND_PROVIDER_ID,
roles=(ProviderRole.CONTROLLER,),
capabilities=("command.rover-speed-steering/v1",),
),
SimulationProviderDescriptor(
provider_id="micro-xrce-dds-agent",
roles=(ProviderRole.TRANSPORT,),
capabilities=("transport.ros2",),
),
),
clock=SimulationClockDescriptor(
provider_id=GAZEBO_STATE_PROVIDER_ID,
domain="gazebo:/clock",
),
control_profiles=(ControlProfile.ROVER_SPEED_STEERING_V1,),
)
class StockRoverProfileError(ValueError):
+21 -11
View File
@@ -39,6 +39,9 @@ from k1link.simulation.run_store import (
)
from k1link.simulation.s0 import ComponentPin
from k1link.simulation.stock_rover import (
GAZEBO_STATE_PROVIDER_ID,
PX4_COMMAND_PROVIDER_ID,
STOCK_ROVER_PROVIDER_PROFILE,
StockRoverTargetPaths,
load_stock_rover_lifecycle_profile,
stock_rover_process_environment,
@@ -173,7 +176,7 @@ class GazeboPoseCollector:
},
},
"source": {
"provider": "gazebo",
"provider": GAZEBO_STATE_PROVIDER_ID,
"topic": POSE_TOPIC,
"signal": "ground-truth",
"quality": "diagnostic",
@@ -371,12 +374,14 @@ class SimulationWorkerAgent:
def status(self) -> dict[str, Any]:
active = self._active_run()
provider_ids: list[str] = []
active_provider_ids: list[str] = []
if active is not None and active.state in {RunState.RUNNING, RunState.PAUSED}:
try:
provider_ids = [record.process_id for record in self.supervisor.snapshot()]
active_provider_ids = [
record.process_id for record in self.supervisor.snapshot()
]
except Exception:
provider_ids = []
active_provider_ids = []
return {
"schema_version": STATUS_SCHEMA,
"worker_id": "mission-gpu-s1",
@@ -386,7 +391,8 @@ class SimulationWorkerAgent:
"control_available": True,
"active_run_id": active.run_id if active else None,
"run_state": active.state.value if active else None,
"provider_ids": provider_ids,
"active_provider_ids": active_provider_ids,
"provider_profile": STOCK_ROVER_PROVIDER_PROFILE.to_dict(),
"isolation": {
"network": "loopback-only-netns",
"process_identity": "missioncore",
@@ -552,7 +558,7 @@ class SimulationWorkerAgent:
sim_time_ns=issued_at_sim_ns,
payload={
"command_id": command.command_id,
"provider": "px4-ros2-offboard",
"provider": PX4_COMMAND_PROVIDER_ID,
"detail": type(exc).__name__,
},
expected_revision=current.revision,
@@ -632,7 +638,7 @@ class SimulationWorkerAgent:
command_ttl_max_ns=250_000_000,
heartbeat_timeout_monotonic_ns=500_000_000,
),
clock_domain="gazebo:/clock",
clock_domain=STOCK_ROVER_PROVIDER_PROFILE.clock.domain,
created_at_utc=_utc_now(),
)
@@ -810,11 +816,15 @@ def _command_acceptance(
"steering_normalized": command.steering_normalized,
"authority_scope": command.authority_scope.value,
"delivery": {
"provider": "px4-ros2-offboard",
"mode": "speed-steering",
"armed": snapshot.armed,
"offboard": snapshot.offboard,
"provider_id": PX4_COMMAND_PROVIDER_ID,
"control_profile": command.profile.value,
"accepted": True,
"controller_ready": snapshot.armed and snapshot.offboard,
"ttl_expired_count": snapshot.ttl_expired_count,
"diagnostics": {
"armed": snapshot.armed,
"offboard": snapshot.offboard,
},
},
}
+64 -15
View File
@@ -2,19 +2,27 @@ from __future__ import annotations
import json
import math
import re
import socket
from collections.abc import Mapping
from pathlib import Path
from typing import Any, Final, Protocol
from uuid import uuid4
from k1link.simulation.contracts import ControlProfile
from k1link.simulation.provider_contract import (
SimulationProviderContractError,
SimulationProviderProfile,
)
REQUEST_SCHEMA: Final = "missioncore.simulation-worker-request/v1"
RESPONSE_SCHEMA: Final = "missioncore.simulation-worker-response/v1"
STATUS_SCHEMA: Final = "missioncore.simulation-worker-status/v1"
STATUS_SCHEMA: Final = "missioncore.simulation-worker-status/v2"
VEHICLE_STATE_SCHEMA: Final = "missioncore.vehicle-state/v1"
COMMAND_ACCEPTANCE_SCHEMA: Final = "missioncore.command-acceptance/v1"
COMMAND_ACCEPTANCE_SCHEMA: Final = "missioncore.command-acceptance/v2"
MAX_MESSAGE_BYTES: Final = 64 * 1024
OPERATIONS: Final = frozenset({"status", "live", "start", "command", "stop"})
SAFE_ID_PATTERN: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
class SimulationWorkerGatewayError(RuntimeError):
@@ -237,24 +245,35 @@ def _validate_status(value: Mapping[str, Any]) -> None:
"control_available",
"active_run_id",
"run_state",
"provider_ids",
"active_provider_ids",
"provider_profile",
"isolation",
"authority",
}
if set(value) != expected or value.get("schema_version") != STATUS_SCHEMA:
raise SimulationWorkerGatewayError("worker status does not match v1")
raise SimulationWorkerGatewayError("worker status does not match v2")
active_provider_ids = value.get("active_provider_ids")
if (
not isinstance(value.get("worker_id"), str)
or value.get("transport") != "unix"
or value.get("mode") != "simulation"
or value.get("available") is not True
or not isinstance(value.get("control_available"), bool)
or not isinstance(value.get("provider_ids"), list)
or any(not isinstance(item, str) for item in value["provider_ids"])
or not isinstance(active_provider_ids, list)
or len(active_provider_ids) > 32
or any(
not isinstance(item, str) or not SAFE_ID_PATTERN.fullmatch(item)
for item in active_provider_ids
)
or len(active_provider_ids) != len(set(active_provider_ids))
or not isinstance(value.get("isolation"), dict)
or not isinstance(value.get("authority"), dict)
):
raise SimulationWorkerGatewayError("worker status contains invalid values")
try:
SimulationProviderProfile.from_dict(value.get("provider_profile"))
except SimulationProviderContractError as exc:
raise SimulationWorkerGatewayError("worker provider profile is invalid") from exc
if value.get("active_run_id") is not None and not isinstance(value["active_run_id"], str):
raise SimulationWorkerGatewayError("worker active run id is invalid")
if value.get("run_state") is not None and not isinstance(value["run_state"], str):
@@ -290,6 +309,18 @@ def _validate_vehicle_state(value: Mapping[str, Any]) -> None:
or not isinstance(value.get("safety"), dict)
):
raise SimulationWorkerGatewayError("vehicle state contains invalid values")
source = value["source"]
if (
set(source) != {"provider", "topic", "signal", "quality"}
or not isinstance(source.get("provider"), str)
or not SAFE_ID_PATTERN.fullmatch(source["provider"])
or not isinstance(source.get("topic"), str)
or not 1 <= len(source["topic"]) <= 512
or "\x00" in source["topic"]
or source.get("signal") != "ground-truth"
or source.get("quality") != "diagnostic"
):
raise SimulationWorkerGatewayError("vehicle state source is invalid")
def _validate_command_acceptance(value: Mapping[str, Any]) -> None:
@@ -306,7 +337,7 @@ def _validate_command_acceptance(value: Mapping[str, Any]) -> None:
"delivery",
}
if set(value) != expected or value.get("schema_version") != COMMAND_ACCEPTANCE_SCHEMA:
raise SimulationWorkerGatewayError("command acceptance does not match v1")
raise SimulationWorkerGatewayError("command acceptance does not match v2")
delivery = value.get("delivery")
numeric_fields = (
"sequence",
@@ -328,17 +359,35 @@ def _validate_command_acceptance(value: Mapping[str, Any]) -> None:
or not isinstance(delivery, dict)
or set(delivery)
!= {
"provider",
"mode",
"armed",
"offboard",
"provider_id",
"control_profile",
"accepted",
"controller_ready",
"ttl_expired_count",
"diagnostics",
}
or delivery.get("provider") != "px4-ros2-offboard"
or delivery.get("mode") != "speed-steering"
or not isinstance(delivery.get("armed"), bool)
or not isinstance(delivery.get("offboard"), bool)
or not isinstance(delivery.get("provider_id"), str)
or not SAFE_ID_PATTERN.fullmatch(delivery["provider_id"])
or delivery.get("control_profile")
!= ControlProfile.ROVER_SPEED_STEERING_V1.value
or delivery.get("accepted") is not True
or not isinstance(delivery.get("controller_ready"), bool)
or isinstance(delivery.get("ttl_expired_count"), bool)
or not isinstance(delivery.get("ttl_expired_count"), int)
or delivery["ttl_expired_count"] < 0
or not _valid_boolean_diagnostics(delivery.get("diagnostics"))
):
raise SimulationWorkerGatewayError("command acceptance contains invalid values")
def _valid_boolean_diagnostics(value: object) -> bool:
return (
isinstance(value, dict)
and len(value) <= 16
and all(
isinstance(key, str)
and SAFE_ID_PATTERN.fullmatch(key) is not None
and isinstance(item, bool)
for key, item in value.items()
)
)
+2 -1
View File
@@ -377,7 +377,8 @@ def _unavailable_worker_status() -> dict[str, Any]:
"control_available": False,
"active_run_id": None,
"run_state": None,
"provider_ids": [],
"active_provider_ids": [],
"provider_profile": None,
"isolation": {
"network": "unavailable",
"process_identity": "missioncore",