Compare commits
No commits in common. "feat/simulation-polygon-s1" and "main" have entirely different histories.
feat/simul
...
main
161
README.md
161
README.md
|
|
@ -121,160 +121,11 @@ performance, direct worker transport and bounded live fan-out remain open. See
|
||||||
[ADR 0014](docs/adr/0014-bounded-external-perception-worker.md)
|
[ADR 0014](docs/adr/0014-bounded-external-perception-worker.md)
|
||||||
and the [external worker contract](docs/10_EXTERNAL_PERCEPTION_WORKER.md).
|
and the [external worker contract](docs/10_EXTERNAL_PERCEPTION_WORKER.md).
|
||||||
|
|
||||||
## Simulation Polygon
|
|
||||||
|
|
||||||
Mission Core now has a parallel Polygon product branch for reproducible
|
|
||||||
autonomy qualification. Mission Core owns scenarios, qualification-run
|
|
||||||
lifecycle, authority, canonical contracts, provenance and reports; Gazebo, PX4
|
|
||||||
SITL, ROS 2, Nav2 and viewers remain replaceable providers. Simulation, replay,
|
|
||||||
digital twin, HIL and physical shadow keep distinct causal run kinds.
|
|
||||||
|
|
||||||
The provider boundary is now executable through
|
|
||||||
`missioncore.simulation-provider-profile/v1`: worker status declares provider
|
|
||||||
roles/capabilities, clock, ENU/FLU frames and supported control profiles.
|
|
||||||
Gazebo/PX4 stays the accepted regression profile. Unreal is evaluated as a
|
|
||||||
separate high-fidelity provider family; native Unreal, CARLA and Project AirSim
|
|
||||||
must pass ADR 0017 U0 before a production adapter is selected. Unreal runs on a
|
|
||||||
worker, never inside React; the existing browser scene, lifecycle, evidence and
|
|
||||||
safety contracts are retained.
|
|
||||||
|
|
||||||
SIM S0 is accepted on the reviewed D-only worker. Its strict profile fixes
|
|
||||||
loopback-only process isolation, exact provider pins, a 2 ms physics step,
|
|
||||||
1×/2× RTF/resource budgets and disabled real/direct actuator authority. Two
|
|
||||||
accepted-profile stock-rover runs and nine digest-bound evidence claims passed;
|
|
||||||
the target doctor returned `GO`. The doctor remains read-only and a development
|
|
||||||
host without the private evidence pack still returns `INCOMPLETE`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run missioncore-sim s0 doctor \
|
|
||||||
--evidence /path/to/private/evidence.yaml \
|
|
||||||
--json
|
|
||||||
```
|
|
||||||
|
|
||||||
S1A now adds the first server-owned domain boundary: typed immutable
|
|
||||||
`QualificationRun` identities, legal lifecycle transitions, canonical
|
|
||||||
Ackermann/Differential commands, authority generation and TTL validation, and
|
|
||||||
an append-only crash-safe run/event/command/artifact repository. Active runs
|
|
||||||
recover as failed/interrupted rather than silently resuming, and reset creates
|
|
||||||
a new linked run/episode. This is repository-level lifecycle acceptance only;
|
|
||||||
PX4 command delivery, watchdog/failsafe behavior, navigation behavior and real
|
|
||||||
actuator control remain unaccepted.
|
|
||||||
|
|
||||||
The S1B repository increment adds the transport-neutral
|
|
||||||
`SimulationApplicationService`, an exact-S0/D-only worker admission guard and a
|
|
||||||
POSIX process supervisor. Start is idempotent, a second active owner is
|
|
||||||
rejected, provider processes receive dedicated PGIDs and run-scoped logs, and
|
|
||||||
shutdown follows the S0 dependency order with residue reported as failure.
|
|
||||||
Pause/resume/step/reset/stop are owned by the application service through a
|
|
||||||
worker port.
|
|
||||||
|
|
||||||
Commit `6cb1495` has now passed the real D-only `MissionCore-Sim` S1B lifecycle:
|
|
||||||
the exact immutable source generation started Micro XRCE-DDS Agent and the PX4
|
|
||||||
v1.17.0/Gazebo Harmonic stock Ackermann rover in a fresh loopback-only
|
|
||||||
namespace, admitted the run only after world/startup/DDS-writer health markers,
|
|
||||||
and stopped both owned PGIDs with zero residue. The persisted run reached
|
|
||||||
`completed`, contains eight lifecycle events and zero control commands. This
|
|
||||||
run is now visible through UI-0: a direct internal read-only Control Station
|
|
||||||
surface backed by the same append-only qualification repository. It exposes
|
|
||||||
identity, terminal reason, provider pins, events, command count, artifact
|
|
||||||
metadata and the virtual-only authority boundary. Live pause/resume/step/reset,
|
|
||||||
canonical telemetry, PX4 command mapping, watchdog/failsafe evidence and the
|
|
||||||
accepted command path remain gated.
|
|
||||||
|
|
||||||
S1C commit `b7ccca3` adds the first registered live-worker vertical without
|
|
||||||
collapsing that boundary. An unprivileged worker agent owns the existing
|
|
||||||
application service and providers inside a fresh loopback-only network
|
|
||||||
namespace. Mission Core reaches it through a mode-`0600` Unix socket; React
|
|
||||||
reaches only same-origin Polygon API routes. The live browser panel can
|
|
||||||
start/stop the stock rover and show worker/run/provider status. UI-2 commit
|
|
||||||
`60e7916` adds a worker-gated `Полигон` header root and a browser-native
|
|
||||||
Three.js Ackermann Rover scene with orbit, zoom, pan, follow and reset. The
|
|
||||||
deep-link remains for exact-run review; it is no longer the primary operator
|
|
||||||
entry. Gazebo dynamic-pose ground truth remains explicitly `diagnostic`, not
|
|
||||||
accepted PX4/ROS 2 telemetry.
|
|
||||||
|
|
||||||
The exact D-only UI-2 generation passed 35 target tests and a real UI-driven run
|
|
||||||
`s1c-60e7916-20260724t182006z-2f164e`: both providers reached readiness, the
|
|
||||||
browser received live `VehicleState`, the run reached `completed` after
|
|
||||||
27.74 seconds of Gazebo time, and shutdown left no PX4/Gazebo/XRCE process
|
|
||||||
residue. The run contained zero commands, so this result does not yet prove
|
|
||||||
rover motion, PX4 command delivery, watchdog/failsafe behavior, navigation or
|
|
||||||
safety acceptance.
|
|
||||||
|
|
||||||
S1D exact code generation `49b0f47` closes the first virtual Ackermann command
|
|
||||||
and motion slice. React sends bounded speed/steering intents through the
|
|
||||||
same-origin Mission Core API and private worker socket. The worker journals each
|
|
||||||
accepted command before mapping it to PX4 rover-level Offboard setpoints:
|
|
||||||
`RoverThrottleSetpoint` is calibrated against the stock model's
|
|
||||||
`RO_MAX_THR_SPEED=3.1`, `RoverSteeringSetpoint` carries normalized steering,
|
|
||||||
and PX4 retains arming, Offboard loss handling and control allocation. No
|
|
||||||
`ActuatorMotors` or `ActuatorServos` topic is exposed or published by Mission
|
|
||||||
Core.
|
|
||||||
|
|
||||||
The immutable D-only generation passed 40 focused target tests. Its first cold
|
|
||||||
start completed in one request after 32.08 seconds. Run
|
|
||||||
`s1c-49b0f47-20260724t195259z-62edd2` proved 3.74 m straight motion, a curved
|
|
||||||
turn, reverse motion, 44.6 ms sampled command admission, a 250 ms TTL transition
|
|
||||||
(`ttl_expired_count` 4 -> 5), stable stopped pose and clean provider shutdown
|
|
||||||
with no PX4/Gazebo/XRCE residue. This does not accept calibrated closed-loop
|
|
||||||
speed tracking, Differential Rover, canonical PX4/ROS 2 telemetry,
|
|
||||||
pause/step/reset, heartbeat/offboard/link-loss cases, Nav2, perception or safety
|
|
||||||
behavior. Real actuator authority remains false.
|
|
||||||
|
|
||||||
Start the worker from the exact immutable D source generation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo simulation/s1/worker-agent.sh <exact-40-character-mission-core-commit>
|
|
||||||
```
|
|
||||||
|
|
||||||
Then configure the Mission Core backend. When the worker status is available,
|
|
||||||
open `Полигон` from the Control Station header:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export MISSIONCORE_POLYGON_RUNS_ROOT=/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/runs
|
|
||||||
export MISSIONCORE_POLYGON_WORKER_SOCKET=/run/missioncore-sim/worker.sock
|
|
||||||
export MISSIONCORE_POLYGON_WORKER_CONTROL=internal-virtual-only
|
|
||||||
export MISSIONCORE_COMMIT=<exact-40-character-mission-core-commit>
|
|
||||||
uv run uvicorn k1link.web.app:app --host 127.0.0.1 --port 8765
|
|
||||||
```
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://127.0.0.1:8765/
|
|
||||||
```
|
|
||||||
|
|
||||||
The direct `?workspace=polygon-run&run=<qualification-run-id>` URL remains a
|
|
||||||
compatible evidence deep-link. The archive API remains GET-only. The worker API
|
|
||||||
adds status/live GETs and explicit start, command and stop POSTs; POSTs remain
|
|
||||||
`403` unless the backend has the `internal-virtual-only` gate and an exact
|
|
||||||
commit. Missing configuration, transport drift and corrupt evidence fail
|
|
||||||
closed. The browser receives no Unix socket, D root, artifact bytes, PX4
|
|
||||||
endpoint or direct provider command.
|
|
||||||
|
|
||||||
See the [Polygon product/SRS](docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md),
|
|
||||||
[ADR 0015](docs/adr/0015-simulation-polygon-qualification-boundary.md),
|
|
||||||
[ADR 0016](docs/adr/0016-distributed-product-edge-and-worker-topology.md),
|
|
||||||
[ADR 0017](docs/adr/0017-provider-neutral-simulation-and-unreal-evaluation.md)
|
|
||||||
and the
|
|
||||||
[SIM S0 worker runbook](docs/runbooks/SIM_S0_AI_WORKER.md). The live-worker
|
|
||||||
launch and acceptance procedure is in
|
|
||||||
[SIM S1C live-worker runbook](docs/runbooks/SIM_S1C_LIVE_WORKER.md).
|
|
||||||
|
|
||||||
Mission Core remains a distributed browser product rather than a
|
|
||||||
platform-specific desktop executable. React runs in the operator browser;
|
|
||||||
Gateway/API and Control Plane run as long-lived services; Gazebo/PX4/ROS 2/Nav2
|
|
||||||
run headless on a registered simulation worker; and a future field vehicle runs
|
|
||||||
a headless onboard Linux Edge Agent with local-first evidence and
|
|
||||||
store-and-forward synchronization. Native Gazebo GUI is worker-local
|
|
||||||
diagnostics. The accepted live rover scene uses canonical state in Three.js;
|
|
||||||
Rerun remains the recorded spatial-evidence path. Field execution never depends
|
|
||||||
on streaming a simulator desktop.
|
|
||||||
|
|
||||||
## Mission Core Control Station and visualization adapters
|
## Mission Core Control Station and visualization adapters
|
||||||
|
|
||||||
The browser application is the universal Mission Core Control Station rather than
|
The browser application is the universal Mission Core Control Station rather than
|
||||||
a K1-specific Foxglove launcher. Its fixed shell contains Center, Fleet,
|
a K1-specific Foxglove launcher. Its fixed shell contains six architectural
|
||||||
Observation, Missions, Data and System; a seventh Polygon section is inserted
|
sections — Center, Fleet, Observation, Missions, Data and System — while the K1
|
||||||
only while an admitted Simulation Worker is available. The K1
|
|
||||||
BLE/Wi-Fi/live workflow remains isolated as the first real device adapter. Its
|
BLE/Wi-Fi/live workflow remains isolated as the first real device adapter. Its
|
||||||
React provisioning, acquisition/replay and diagnostic blocks live beside the
|
React provisioning, acquisition/replay and diagnostic blocks live beside the
|
||||||
plugin manifest under `plugins/xgrids-k1/frontend`; the generic application
|
plugin manifest under `plugins/xgrids-k1/frontend`; the generic application
|
||||||
|
|
@ -368,12 +219,6 @@ standalone release install. The frontend consumes sibling `file:` packages from
|
||||||
revision or content hash. Publishing/vendoring those packages or enforcing an
|
revision or content hash. Publishing/vendoring those packages or enforcing an
|
||||||
immutable donor revision remains a packaging and CI prerequisite.
|
immutable donor revision remains a packaging and CI prerequisite.
|
||||||
|
|
||||||
Polygon is implemented as a capability-gated seventh section. When Mission Core
|
|
||||||
confirms an admitted Simulation Worker, its header button opens one direct
|
|
||||||
workspace with live 3D inspection and the server-owned qualification
|
|
||||||
repository. `?workspace=polygon-run[&run=<id>]` remains a compatibility
|
|
||||||
deep-link. Command and safety acceptance remain separate gates.
|
|
||||||
|
|
||||||
The Observation spatial workspace embeds the open-source Rerun Web Viewer
|
The Observation spatial workspace embeds the open-source Rerun Web Viewer
|
||||||
inside the Mission Core shell. It can open a compatible RRD over same-origin
|
inside the Mission Core shell. It can open a compatible RRD over same-origin
|
||||||
HTTP or a Rerun gRPC/proxy source such as
|
HTTP or a Rerun gRPC/proxy source such as
|
||||||
|
|
@ -505,8 +350,6 @@ present.
|
||||||
- [Plugin-owned frontend device workflows](docs/adr/0010-plugin-owned-frontend-device-workflows.md)
|
- [Plugin-owned frontend device workflows](docs/adr/0010-plugin-owned-frontend-device-workflows.md)
|
||||||
- [Laboratory plugin runtime handshake and transport seam](docs/adr/0011-laboratory-plugin-runtime-handshake-and-transport-seam.md)
|
- [Laboratory plugin runtime handshake and transport seam](docs/adr/0011-laboratory-plugin-runtime-handshake-and-transport-seam.md)
|
||||||
- [Device-bound K1 command authority](docs/adr/0012-device-bound-k1-command-authority.md)
|
- [Device-bound K1 command authority](docs/adr/0012-device-bound-k1-command-authority.md)
|
||||||
- [Simulation Polygon qualification boundary](docs/adr/0015-simulation-polygon-qualification-boundary.md)
|
|
||||||
- [Distributed product, edge and worker topology](docs/adr/0016-distributed-product-edge-and-worker-topology.md)
|
|
||||||
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
|
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
|
||||||
- [Canonical control and durable archive milestone](docs/lab/004_K1_CANONICAL_CONTROL_ARCHIVE_20260719.redacted.md)
|
- [Canonical control and durable archive milestone](docs/lab/004_K1_CANONICAL_CONTROL_ARCHIVE_20260719.redacted.md)
|
||||||
- [Session manifest schema](schemas/session-manifest.schema.json)
|
- [Session manifest schema](schemas/session-manifest.schema.json)
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,12 @@
|
||||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||||
"@rerun-io/web-viewer": "0.34.1",
|
"@rerun-io/web-viewer": "0.34.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0"
|
||||||
"three": "0.185.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@types/three": "0.185.1",
|
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^7.0.0",
|
"vite": "^7.0.0",
|
||||||
|
|
@ -346,13 +344,6 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@dimforge/rapier3d-compat": {
|
|
||||||
"version": "0.12.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
|
||||||
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||||
|
|
@ -1232,13 +1223,6 @@
|
||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@tweenjs/tween.js": {
|
|
||||||
"version": "23.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
|
||||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|
@ -1321,35 +1305,6 @@
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/stats.js": {
|
|
||||||
"version": "0.17.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
|
||||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/three": {
|
|
||||||
"version": "0.185.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.1.tgz",
|
|
||||||
"integrity": "sha512-db1xTb+EgYF2didW+eudSvVPtn75zo+fGsY8ShQrJY/B5ZBmC2Fiaykv3aImHAlCNEGuMPkPGXBJGLwzu5mC7A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
|
||||||
"@tweenjs/tween.js": "~23.1.3",
|
|
||||||
"@types/stats.js": "*",
|
|
||||||
"@types/webxr": ">=0.5.17",
|
|
||||||
"fflate": "~0.8.2",
|
|
||||||
"meshoptimizer": "~1.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/webxr": {
|
|
||||||
"version": "0.5.24",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
|
||||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
|
|
@ -1548,13 +1503,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fflate": {
|
|
||||||
"version": "0.8.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
|
||||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
|
@ -1623,13 +1571,6 @@
|
||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/meshoptimizer": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|
@ -1817,12 +1758,6 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/three": {
|
|
||||||
"version": "0.185.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
|
|
||||||
"integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,12 @@
|
||||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||||
"@rerun-io/web-viewer": "0.34.1",
|
"@rerun-io/web-viewer": "0.34.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0"
|
||||||
"three": "0.185.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@types/three": "0.185.1",
|
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^7.0.0",
|
"vite": "^7.0.0",
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,6 @@ import type {
|
||||||
} from "./core/observation/sessionArchive";
|
} from "./core/observation/sessionArchive";
|
||||||
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
|
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
|
||||||
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
|
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
|
||||||
import { resolvePolygonRunRoute } from "./core/polygon/runArchive";
|
|
||||||
import { usePolygonWorkerCapability } from "./core/polygon/usePolygonWorkerCapability";
|
|
||||||
import {
|
import {
|
||||||
OBSERVATION_WORKSPACE_ID,
|
OBSERVATION_WORKSPACE_ID,
|
||||||
OBSERVATION_WORKSPACE_LAYOUT_VERSION,
|
OBSERVATION_WORKSPACE_LAYOUT_VERSION,
|
||||||
|
|
@ -55,7 +53,7 @@ import {
|
||||||
} from "./core/observation/workspaceLayout";
|
} from "./core/observation/workspaceLayout";
|
||||||
import {
|
import {
|
||||||
rootById,
|
rootById,
|
||||||
rootsForCapabilities,
|
roots,
|
||||||
workspaceById,
|
workspaceById,
|
||||||
workspacesForRoot,
|
workspacesForRoot,
|
||||||
type RootId,
|
type RootId,
|
||||||
|
|
@ -151,19 +149,12 @@ function mergeViewerSettings(
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const runtime = useMissionRuntime();
|
const runtime = useMissionRuntime();
|
||||||
const { selection } = useDevicePluginHost();
|
const { selection } = useDevicePluginHost();
|
||||||
const polygonWorker = usePolygonWorkerCapability();
|
|
||||||
const polygonRunRoute = useMemo(
|
|
||||||
() => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const workspace = useApplicationWorkspace<string>({
|
const workspace = useApplicationWorkspace<string>({
|
||||||
navigationOpen: false,
|
navigationOpen: false,
|
||||||
contentExpanded: true,
|
contentExpanded: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [activeRoot, setActiveRoot] = useState<RootId | null>(
|
const [activeRoot, setActiveRoot] = useState<RootId | null>(null);
|
||||||
polygonRunRoute.active ? "polygon" : null,
|
|
||||||
);
|
|
||||||
const [sourceUrl, setSourceUrl] = useState("");
|
const [sourceUrl, setSourceUrl] = useState("");
|
||||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||||
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
||||||
|
|
@ -196,13 +187,8 @@ export default function App() {
|
||||||
const replayActiveRef = useRef(false);
|
const replayActiveRef = useRef(false);
|
||||||
const sceneSettingsCommitterActiveRef = useRef(true);
|
const sceneSettingsCommitterActiveRef = useRef(true);
|
||||||
const sceneSettingsCommitterRef = useRef<LatestAsyncCommitter<SceneSettings> | null>(null);
|
const sceneSettingsCommitterRef = useRef<LatestAsyncCommitter<SceneSettings> | null>(null);
|
||||||
const polygonRunRouteOpenedRef = useRef(false);
|
|
||||||
|
|
||||||
const currentRoot = rootById(activeRoot);
|
const currentRoot = rootById(activeRoot);
|
||||||
const visibleRoots = useMemo(
|
|
||||||
() => rootsForCapabilities({ polygonWorkerAvailable: polygonWorker.available }),
|
|
||||||
[polygonWorker.available],
|
|
||||||
);
|
|
||||||
const activeDefinition = workspaceById(workspace.activeView);
|
const activeDefinition = workspaceById(workspace.activeView);
|
||||||
const spatialWorkspaceActive = Boolean(
|
const spatialWorkspaceActive = Boolean(
|
||||||
workspace.contentOpen && activeDefinition?.kind === "spatial",
|
workspace.contentOpen && activeDefinition?.kind === "spatial",
|
||||||
|
|
@ -218,12 +204,6 @@ export default function App() {
|
||||||
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
|
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
|
||||||
replayActiveRef.current = replayActive;
|
replayActiveRef.current = replayActive;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!polygonRunRoute.active || polygonRunRouteOpenedRef.current) return;
|
|
||||||
polygonRunRouteOpenedRef.current = true;
|
|
||||||
workspace.openView("polygon-run");
|
|
||||||
}, [polygonRunRoute.active, workspace]);
|
|
||||||
|
|
||||||
if (!sceneSettingsCommitterRef.current) {
|
if (!sceneSettingsCommitterRef.current) {
|
||||||
sceneSettingsCommitterRef.current = createLatestAsyncCommitter<SceneSettings>({
|
sceneSettingsCommitterRef.current = createLatestAsyncCommitter<SceneSettings>({
|
||||||
commit: (settings) => replayActiveRef.current
|
commit: (settings) => replayActiveRef.current
|
||||||
|
|
@ -419,10 +399,6 @@ export default function App() {
|
||||||
|
|
||||||
const selectRoot = (rootId: RootId) => {
|
const selectRoot = (rootId: RootId) => {
|
||||||
setActiveRoot(rootId);
|
setActiveRoot(rootId);
|
||||||
if (rootId === "polygon") {
|
|
||||||
workspace.openView("polygon-run");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
workspace.closeView();
|
workspace.closeView();
|
||||||
workspace.openNavigation();
|
workspace.openNavigation();
|
||||||
};
|
};
|
||||||
|
|
@ -628,7 +604,7 @@ export default function App() {
|
||||||
<HeaderNavigation
|
<HeaderNavigation
|
||||||
label="Архитектурные блоки пункта управления"
|
label="Архитектурные блоки пункта управления"
|
||||||
value={activeRoot ?? undefined}
|
value={activeRoot ?? undefined}
|
||||||
items={visibleRoots.map((root) => ({ value: root.id, label: root.label }))}
|
items={roots.map((root) => ({ value: root.id, label: root.label }))}
|
||||||
onChange={selectRoot}
|
onChange={selectRoot}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
@ -651,9 +627,7 @@ export default function App() {
|
||||||
data-nodedc-ui
|
data-nodedc-ui
|
||||||
className="control-station"
|
className="control-station"
|
||||||
data-observation-fullscreen={observationFullscreenActive ? "true" : undefined}
|
data-observation-fullscreen={observationFullscreenActive ? "true" : undefined}
|
||||||
navigationOpen={
|
navigationOpen={workspace.navigationOpen && activeRoot !== null}
|
||||||
workspace.navigationOpen && activeRoot !== null && activeRoot !== "polygon"
|
|
||||||
}
|
|
||||||
contentOpen={workspace.contentOpen && activeDefinition !== null}
|
contentOpen={workspace.contentOpen && activeDefinition !== null}
|
||||||
contentExpanded={workspace.contentExpanded}
|
contentExpanded={workspace.contentExpanded}
|
||||||
header={header}
|
header={header}
|
||||||
|
|
@ -667,7 +641,7 @@ export default function App() {
|
||||||
onOpenDevice={() => openView("local-device")}
|
onOpenDevice={() => openView("local-device")}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
navigation={currentRoot && activeRoot !== "polygon" ? (
|
navigation={currentRoot ? (
|
||||||
<AdminNavigationPanel
|
<AdminNavigationPanel
|
||||||
eyebrow="MISSION CORE"
|
eyebrow="MISSION CORE"
|
||||||
title={currentRoot.title}
|
title={currentRoot.title}
|
||||||
|
|
@ -744,8 +718,6 @@ export default function App() {
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : activeDefinition.kind === "polygon-run" ? (
|
|
||||||
<StatusBadge tone="accent">Virtual only</StatusBadge>
|
|
||||||
) : (
|
) : (
|
||||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||||
)
|
)
|
||||||
|
|
@ -774,7 +746,6 @@ export default function App() {
|
||||||
livePerceptionLayers={livePerceptionLayers}
|
livePerceptionLayers={livePerceptionLayers}
|
||||||
onLivePerceptionLayersChange={changeLivePerceptionLayers}
|
onLivePerceptionLayersChange={changeLivePerceptionLayers}
|
||||||
observationLayout={observationLayout}
|
observationLayout={observationLayout}
|
||||||
polygonRunRoute={polygonRunRoute}
|
|
||||||
spatialControls={selection?.SpatialControlsView
|
spatialControls={selection?.SpatialControlsView
|
||||||
? {
|
? {
|
||||||
View: selection.SpatialControlsView,
|
View: selection.SpatialControlsView,
|
||||||
|
|
|
||||||
|
|
@ -1,734 +0,0 @@
|
||||||
import type { PolygonRunState } from "./runArchive";
|
|
||||||
|
|
||||||
export type PolygonProviderRole =
|
|
||||||
| "world"
|
|
||||||
| "physics"
|
|
||||||
| "state"
|
|
||||||
| "controller"
|
|
||||||
| "transport"
|
|
||||||
| "sensor"
|
|
||||||
| "traffic";
|
|
||||||
|
|
||||||
export interface PolygonProviderDescriptor {
|
|
||||||
providerId: string;
|
|
||||||
roles: PolygonProviderRole[];
|
|
||||||
capabilities: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PolygonProviderProfile {
|
|
||||||
profileId: string;
|
|
||||||
providers: PolygonProviderDescriptor[];
|
|
||||||
clock: {
|
|
||||||
providerId: string;
|
|
||||||
domain: string;
|
|
||||||
unit: "nanoseconds";
|
|
||||||
mode: "simulation";
|
|
||||||
};
|
|
||||||
controlProfiles: string[];
|
|
||||||
canonicalFrames: {
|
|
||||||
world: "map_enu";
|
|
||||||
body: "base_link_flu";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PolygonWorkerStatus {
|
|
||||||
workerId: string;
|
|
||||||
available: boolean;
|
|
||||||
controlAvailable: boolean;
|
|
||||||
activeRunId: string | null;
|
|
||||||
runState: PolygonRunState | null;
|
|
||||||
activeProviderIds: string[];
|
|
||||||
providerProfile: PolygonProviderProfile | null;
|
|
||||||
isolation: {
|
|
||||||
network: string;
|
|
||||||
processIdentity: string;
|
|
||||||
artifactPolicy: "d-only";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PolygonVehicleState {
|
|
||||||
runId: string;
|
|
||||||
sequence: number;
|
|
||||||
observedAtUtc: string;
|
|
||||||
hostMonotonicNs: number;
|
|
||||||
simTimeNs: number;
|
|
||||||
position: { x: number; y: number; z: number };
|
|
||||||
orientation: { x: number; y: number; z: number; w: number };
|
|
||||||
sourceProvider: string;
|
|
||||||
sourceTopic: string;
|
|
||||||
sourceSignal: "ground-truth";
|
|
||||||
sourceQuality: "diagnostic";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PolygonCommandAcceptance {
|
|
||||||
runId: string;
|
|
||||||
commandId: string;
|
|
||||||
sequence: number;
|
|
||||||
issuedAtSimNs: number;
|
|
||||||
validUntilSimNs: number;
|
|
||||||
speedMps: number;
|
|
||||||
steeringNormalized: number;
|
|
||||||
deliveryProvider: string;
|
|
||||||
controlProfile: string;
|
|
||||||
accepted: true;
|
|
||||||
controllerReady: boolean;
|
|
||||||
ttlExpiredCount: number;
|
|
||||||
diagnostics: Record<string, boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PolygonWorkerContractError extends Error {}
|
|
||||||
|
|
||||||
export class PolygonWorkerApiError extends Error {
|
|
||||||
constructor(message: string, readonly status: number | null = null) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type PolygonWorkerFetch = (
|
|
||||||
input: RequestInfo | URL,
|
|
||||||
init?: RequestInit,
|
|
||||||
) => Promise<Response>;
|
|
||||||
|
|
||||||
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
||||||
const SAFE_CAPABILITY = /^[a-z0-9][a-z0-9._/-]{0,127}$/;
|
|
||||||
const SAFE_CLOCK_DOMAIN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
|
||||||
const PROVIDER_ROLES = new Set<PolygonProviderRole>([
|
|
||||||
"world",
|
|
||||||
"physics",
|
|
||||||
"state",
|
|
||||||
"controller",
|
|
||||||
"transport",
|
|
||||||
"sensor",
|
|
||||||
"traffic",
|
|
||||||
]);
|
|
||||||
const RUN_STATES = new Set<PolygonRunState>([
|
|
||||||
"admitted",
|
|
||||||
"starting",
|
|
||||||
"running",
|
|
||||||
"paused",
|
|
||||||
"stopping",
|
|
||||||
"completed",
|
|
||||||
"failed",
|
|
||||||
"aborted",
|
|
||||||
]);
|
|
||||||
const STATUS_KEYS = new Set([
|
|
||||||
"schema_version",
|
|
||||||
"worker_id",
|
|
||||||
"transport",
|
|
||||||
"mode",
|
|
||||||
"available",
|
|
||||||
"control_available",
|
|
||||||
"active_run_id",
|
|
||||||
"run_state",
|
|
||||||
"active_provider_ids",
|
|
||||||
"provider_profile",
|
|
||||||
"isolation",
|
|
||||||
"authority",
|
|
||||||
]);
|
|
||||||
const PROVIDER_PROFILE_KEYS = new Set([
|
|
||||||
"schema_version",
|
|
||||||
"profile_id",
|
|
||||||
"providers",
|
|
||||||
"clock",
|
|
||||||
"control_profiles",
|
|
||||||
"canonical_frames",
|
|
||||||
]);
|
|
||||||
const PROVIDER_DESCRIPTOR_KEYS = new Set(["provider_id", "roles", "capabilities"]);
|
|
||||||
const CLOCK_KEYS = new Set(["provider_id", "domain", "unit", "mode"]);
|
|
||||||
const CANONICAL_FRAME_KEYS = new Set(["world", "body"]);
|
|
||||||
const ISOLATION_KEYS = new Set(["network", "process_identity", "artifact_policy"]);
|
|
||||||
const AUTHORITY_KEYS = new Set([
|
|
||||||
"scope",
|
|
||||||
"actuator_authority",
|
|
||||||
"direct_actuator_setpoints_allowed",
|
|
||||||
]);
|
|
||||||
const VEHICLE_KEYS = new Set([
|
|
||||||
"schema_version",
|
|
||||||
"run_id",
|
|
||||||
"sequence",
|
|
||||||
"observed_at_utc",
|
|
||||||
"host_monotonic_ns",
|
|
||||||
"sim_time_ns",
|
|
||||||
"frame_id",
|
|
||||||
"child_frame_id",
|
|
||||||
"pose",
|
|
||||||
"source",
|
|
||||||
"safety",
|
|
||||||
]);
|
|
||||||
const POSE_KEYS = new Set(["position_m", "orientation_xyzw"]);
|
|
||||||
const POSITION_KEYS = new Set(["x", "y", "z"]);
|
|
||||||
const ORIENTATION_KEYS = new Set(["x", "y", "z", "w"]);
|
|
||||||
const SOURCE_KEYS = new Set(["provider", "topic", "signal", "quality"]);
|
|
||||||
const SAFETY_KEYS = new Set([
|
|
||||||
"scope",
|
|
||||||
"actuator_authority",
|
|
||||||
"navigation_or_safety_accepted",
|
|
||||||
]);
|
|
||||||
const COMMAND_KEYS = new Set([
|
|
||||||
"schema_version",
|
|
||||||
"run_id",
|
|
||||||
"command_id",
|
|
||||||
"sequence",
|
|
||||||
"issued_at_sim_ns",
|
|
||||||
"valid_until_sim_ns",
|
|
||||||
"speed_mps",
|
|
||||||
"steering_normalized",
|
|
||||||
"authority_scope",
|
|
||||||
"delivery",
|
|
||||||
]);
|
|
||||||
const COMMAND_DELIVERY_KEYS = new Set([
|
|
||||||
"provider_id",
|
|
||||||
"control_profile",
|
|
||||||
"accepted",
|
|
||||||
"controller_ready",
|
|
||||||
"ttl_expired_count",
|
|
||||||
"diagnostics",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function record(value: unknown, label: string): Record<string, unknown> {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
throw new PolygonWorkerContractError(`${label} должен быть объектом.`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function exactKeys(
|
|
||||||
value: Record<string, unknown>,
|
|
||||||
expected: ReadonlySet<string>,
|
|
||||||
label: string,
|
|
||||||
) {
|
|
||||||
const keys = Object.keys(value);
|
|
||||||
if (keys.length !== expected.size || keys.some((key) => !expected.has(key))) {
|
|
||||||
throw new PolygonWorkerContractError(`${label} содержит неизвестные или отсутствующие поля.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringValue(value: unknown, label: string, maximum = 512): string {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
throw new PolygonWorkerContractError(`${label} должен быть строкой.`);
|
|
||||||
}
|
|
||||||
const normalized = value.trim();
|
|
||||||
if (!normalized || normalized.length > maximum) {
|
|
||||||
throw new PolygonWorkerContractError(`${label} имеет недопустимую длину.`);
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeId(value: unknown, label: string): string {
|
|
||||||
const identifier = stringValue(value, label, 128);
|
|
||||||
if (!SAFE_ID.test(identifier)) {
|
|
||||||
throw new PolygonWorkerContractError(`${label} содержит небезопасный идентификатор.`);
|
|
||||||
}
|
|
||||||
return identifier;
|
|
||||||
}
|
|
||||||
|
|
||||||
function booleanValue(value: unknown, label: string): boolean {
|
|
||||||
if (typeof value !== "boolean") {
|
|
||||||
throw new PolygonWorkerContractError(`${label} должен быть boolean.`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function integerValue(value: unknown, label: string): number {
|
|
||||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
||||||
throw new PolygonWorkerContractError(`${label} должен быть неотрицательным целым.`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function finiteValue(value: unknown, label: string): number {
|
|
||||||
if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) >= 1e9) {
|
|
||||||
throw new PolygonWorkerContractError(`${label} должен быть конечным числом.`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueStrings(
|
|
||||||
value: unknown,
|
|
||||||
label: string,
|
|
||||||
validator: (item: unknown, itemLabel: string) => string,
|
|
||||||
): string[] {
|
|
||||||
if (!Array.isArray(value) || value.length === 0 || value.length > 32) {
|
|
||||||
throw new PolygonWorkerContractError(`${label} должен быть ограниченным массивом.`);
|
|
||||||
}
|
|
||||||
const result = value.map((item, index) => validator(item, `${label}[${index}]`));
|
|
||||||
if (new Set(result).size !== result.length) {
|
|
||||||
throw new PolygonWorkerContractError(`${label} содержит повторения.`);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeProviderProfile(payload: unknown): PolygonProviderProfile {
|
|
||||||
const value = record(payload, "provider_profile");
|
|
||||||
exactKeys(value, PROVIDER_PROFILE_KEYS, "provider_profile");
|
|
||||||
if (value.schema_version !== "missioncore.simulation-provider-profile/v1") {
|
|
||||||
throw new PolygonWorkerContractError("provider_profile имеет неизвестную схему.");
|
|
||||||
}
|
|
||||||
if (!Array.isArray(value.providers) || value.providers.length === 0 || value.providers.length > 32) {
|
|
||||||
throw new PolygonWorkerContractError("provider_profile.providers имеет недопустимый размер.");
|
|
||||||
}
|
|
||||||
const providers = value.providers.map((item, index): PolygonProviderDescriptor => {
|
|
||||||
const provider = record(item, `providers[${index}]`);
|
|
||||||
exactKeys(provider, PROVIDER_DESCRIPTOR_KEYS, `providers[${index}]`);
|
|
||||||
const roles = uniqueStrings(
|
|
||||||
provider.roles,
|
|
||||||
`providers[${index}].roles`,
|
|
||||||
(role, label) => {
|
|
||||||
const result = stringValue(role, label, 32);
|
|
||||||
if (!PROVIDER_ROLES.has(result as PolygonProviderRole)) {
|
|
||||||
throw new PolygonWorkerContractError(`${label} содержит неизвестную роль.`);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
) as PolygonProviderRole[];
|
|
||||||
const capabilities = uniqueStrings(
|
|
||||||
provider.capabilities,
|
|
||||||
`providers[${index}].capabilities`,
|
|
||||||
(capability, label) => {
|
|
||||||
const result = stringValue(capability, label, 128);
|
|
||||||
if (!SAFE_CAPABILITY.test(result)) {
|
|
||||||
throw new PolygonWorkerContractError(`${label} содержит небезопасную возможность.`);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
providerId: safeId(provider.provider_id, `providers[${index}].provider_id`),
|
|
||||||
roles,
|
|
||||||
capabilities,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
if (new Set(providers.map((provider) => provider.providerId)).size !== providers.length) {
|
|
||||||
throw new PolygonWorkerContractError("provider_profile содержит повторяющиеся provider_id.");
|
|
||||||
}
|
|
||||||
const clockValue = record(value.clock, "provider_profile.clock");
|
|
||||||
exactKeys(clockValue, CLOCK_KEYS, "provider_profile.clock");
|
|
||||||
const clockProviderId = safeId(clockValue.provider_id, "clock.provider_id");
|
|
||||||
const clockDomain = stringValue(clockValue.domain, "clock.domain", 128);
|
|
||||||
if (
|
|
||||||
!SAFE_CLOCK_DOMAIN.test(clockDomain) ||
|
|
||||||
clockValue.unit !== "nanoseconds" ||
|
|
||||||
clockValue.mode !== "simulation"
|
|
||||||
) {
|
|
||||||
throw new PolygonWorkerContractError("provider_profile.clock нарушает v1 контракт.");
|
|
||||||
}
|
|
||||||
const clockProvider = providers.find((provider) => provider.providerId === clockProviderId);
|
|
||||||
if (!clockProvider?.capabilities.includes("clock.simulation")) {
|
|
||||||
throw new PolygonWorkerContractError("Источник simulation clock не объявлен провайдером.");
|
|
||||||
}
|
|
||||||
if (!providers.some((provider) =>
|
|
||||||
provider.roles.includes("state") &&
|
|
||||||
provider.capabilities.includes("state.vehicle-pose"))) {
|
|
||||||
throw new PolygonWorkerContractError("provider_profile не предоставляет VehicleState.");
|
|
||||||
}
|
|
||||||
const controlProfiles = uniqueStrings(
|
|
||||||
value.control_profiles,
|
|
||||||
"provider_profile.control_profiles",
|
|
||||||
(profile, label) => {
|
|
||||||
const result = stringValue(profile, label, 64);
|
|
||||||
if (!["rover-speed-steering/v1", "rover-speed-yaw-rate/v1"].includes(result)) {
|
|
||||||
throw new PolygonWorkerContractError(`${label} содержит неизвестный профиль.`);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const controllerCapabilities = new Set(
|
|
||||||
providers
|
|
||||||
.filter((provider) => provider.roles.includes("controller"))
|
|
||||||
.flatMap((provider) => provider.capabilities),
|
|
||||||
);
|
|
||||||
if (controlProfiles.some((profile) => !controllerCapabilities.has(`command.${profile}`))) {
|
|
||||||
throw new PolygonWorkerContractError("Контроллер не поддерживает заявленный профиль команд.");
|
|
||||||
}
|
|
||||||
const frames = record(value.canonical_frames, "canonical_frames");
|
|
||||||
exactKeys(frames, CANONICAL_FRAME_KEYS, "canonical_frames");
|
|
||||||
if (frames.world !== "map_enu" || frames.body !== "base_link_flu") {
|
|
||||||
throw new PolygonWorkerContractError("provider_profile не нормализован в ENU/FLU.");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
profileId: safeId(value.profile_id, "provider_profile.profile_id"),
|
|
||||||
providers,
|
|
||||||
clock: {
|
|
||||||
providerId: clockProviderId,
|
|
||||||
domain: clockDomain,
|
|
||||||
unit: "nanoseconds",
|
|
||||||
mode: "simulation",
|
|
||||||
},
|
|
||||||
controlProfiles,
|
|
||||||
canonicalFrames: {
|
|
||||||
world: "map_enu",
|
|
||||||
body: "base_link_flu",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodePolygonWorkerStatus(payload: unknown): PolygonWorkerStatus {
|
|
||||||
const value = record(payload, "Статус Simulation Worker");
|
|
||||||
exactKeys(value, STATUS_KEYS, "Статус Simulation Worker");
|
|
||||||
if (
|
|
||||||
value.schema_version !== "missioncore.simulation-worker-status/v2" ||
|
|
||||||
value.transport !== "unix" ||
|
|
||||||
value.mode !== "simulation"
|
|
||||||
) {
|
|
||||||
throw new PolygonWorkerContractError("Статус Simulation Worker имеет неизвестную схему.");
|
|
||||||
}
|
|
||||||
const isolation = record(value.isolation, "isolation");
|
|
||||||
exactKeys(isolation, ISOLATION_KEYS, "isolation");
|
|
||||||
if (isolation.artifact_policy !== "d-only") {
|
|
||||||
throw new PolygonWorkerContractError("Simulation Worker нарушает D-only политику.");
|
|
||||||
}
|
|
||||||
const authority = record(value.authority, "authority");
|
|
||||||
exactKeys(authority, AUTHORITY_KEYS, "authority");
|
|
||||||
if (
|
|
||||||
authority.scope !== "virtual-only" ||
|
|
||||||
authority.actuator_authority !== false ||
|
|
||||||
authority.direct_actuator_setpoints_allowed !== false
|
|
||||||
) {
|
|
||||||
throw new PolygonWorkerContractError("Simulation Worker вышел за virtual-only границу.");
|
|
||||||
}
|
|
||||||
if (!Array.isArray(value.active_provider_ids) || value.active_provider_ids.length > 32) {
|
|
||||||
throw new PolygonWorkerContractError("active_provider_ids должен быть ограниченным массивом.");
|
|
||||||
}
|
|
||||||
const activeProviderIds = value.active_provider_ids.map((item, index) =>
|
|
||||||
safeId(item, `active_provider_ids[${index}]`));
|
|
||||||
if (new Set(activeProviderIds).size !== activeProviderIds.length) {
|
|
||||||
throw new PolygonWorkerContractError("active_provider_ids содержит повторения.");
|
|
||||||
}
|
|
||||||
const available = booleanValue(value.available, "available");
|
|
||||||
const controlAvailable = booleanValue(value.control_available, "control_available");
|
|
||||||
const providerProfile = value.provider_profile === null
|
|
||||||
? null
|
|
||||||
: decodeProviderProfile(value.provider_profile);
|
|
||||||
if ((available && providerProfile === null) || (!available && controlAvailable)) {
|
|
||||||
throw new PolygonWorkerContractError("Доступность worker и provider_profile противоречат.");
|
|
||||||
}
|
|
||||||
const activeRunId = value.active_run_id === null
|
|
||||||
? null
|
|
||||||
: safeId(value.active_run_id, "active_run_id");
|
|
||||||
const runStateValue = value.run_state === null
|
|
||||||
? null
|
|
||||||
: stringValue(value.run_state, "run_state", 32);
|
|
||||||
if (
|
|
||||||
(runStateValue !== null && !RUN_STATES.has(runStateValue as PolygonRunState)) ||
|
|
||||||
(activeRunId === null) !== (runStateValue === null)
|
|
||||||
) {
|
|
||||||
throw new PolygonWorkerContractError("Активный прогон и его состояние противоречат друг другу.");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
workerId: safeId(value.worker_id, "worker_id"),
|
|
||||||
available,
|
|
||||||
controlAvailable,
|
|
||||||
activeRunId,
|
|
||||||
runState: runStateValue as PolygonRunState | null,
|
|
||||||
activeProviderIds,
|
|
||||||
providerProfile,
|
|
||||||
isolation: {
|
|
||||||
network: stringValue(isolation.network, "isolation.network", 64),
|
|
||||||
processIdentity: safeId(isolation.process_identity, "isolation.process_identity"),
|
|
||||||
artifactPolicy: "d-only",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodePolygonVehicleState(payload: unknown): PolygonVehicleState {
|
|
||||||
const value = record(payload, "VehicleState");
|
|
||||||
exactKeys(value, VEHICLE_KEYS, "VehicleState");
|
|
||||||
if (
|
|
||||||
value.schema_version !== "missioncore.vehicle-state/v1" ||
|
|
||||||
value.frame_id !== "map_enu" ||
|
|
||||||
value.child_frame_id !== "base_link_flu"
|
|
||||||
) {
|
|
||||||
throw new PolygonWorkerContractError("VehicleState имеет неизвестную схему координат.");
|
|
||||||
}
|
|
||||||
const pose = record(value.pose, "pose");
|
|
||||||
exactKeys(pose, POSE_KEYS, "pose");
|
|
||||||
const position = record(pose.position_m, "position_m");
|
|
||||||
exactKeys(position, POSITION_KEYS, "position_m");
|
|
||||||
const orientation = record(pose.orientation_xyzw, "orientation_xyzw");
|
|
||||||
exactKeys(orientation, ORIENTATION_KEYS, "orientation_xyzw");
|
|
||||||
const source = record(value.source, "source");
|
|
||||||
exactKeys(source, SOURCE_KEYS, "source");
|
|
||||||
if (
|
|
||||||
source.signal !== "ground-truth" ||
|
|
||||||
source.quality !== "diagnostic"
|
|
||||||
) {
|
|
||||||
throw new PolygonWorkerContractError("Live-сигнал не маркирован как diagnostic ground truth.");
|
|
||||||
}
|
|
||||||
const safety = record(value.safety, "safety");
|
|
||||||
exactKeys(safety, SAFETY_KEYS, "safety");
|
|
||||||
if (
|
|
||||||
safety.scope !== "virtual-only" ||
|
|
||||||
safety.actuator_authority !== false ||
|
|
||||||
safety.navigation_or_safety_accepted !== false
|
|
||||||
) {
|
|
||||||
throw new PolygonWorkerContractError("VehicleState нарушает virtual-only границу.");
|
|
||||||
}
|
|
||||||
const observedAtUtc = stringValue(value.observed_at_utc, "observed_at_utc", 64);
|
|
||||||
if (!Number.isFinite(Date.parse(observedAtUtc))) {
|
|
||||||
throw new PolygonWorkerContractError("observed_at_utc должен быть ISO-датой.");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
runId: safeId(value.run_id, "run_id"),
|
|
||||||
sequence: integerValue(value.sequence, "sequence"),
|
|
||||||
observedAtUtc,
|
|
||||||
hostMonotonicNs: integerValue(value.host_monotonic_ns, "host_monotonic_ns"),
|
|
||||||
simTimeNs: integerValue(value.sim_time_ns, "sim_time_ns"),
|
|
||||||
position: {
|
|
||||||
x: finiteValue(position.x, "position.x"),
|
|
||||||
y: finiteValue(position.y, "position.y"),
|
|
||||||
z: finiteValue(position.z, "position.z"),
|
|
||||||
},
|
|
||||||
orientation: {
|
|
||||||
x: finiteValue(orientation.x, "orientation.x"),
|
|
||||||
y: finiteValue(orientation.y, "orientation.y"),
|
|
||||||
z: finiteValue(orientation.z, "orientation.z"),
|
|
||||||
w: finiteValue(orientation.w, "orientation.w"),
|
|
||||||
},
|
|
||||||
sourceProvider: safeId(source.provider, "source.provider"),
|
|
||||||
sourceTopic: stringValue(source.topic, "source.topic", 512),
|
|
||||||
sourceSignal: "ground-truth",
|
|
||||||
sourceQuality: "diagnostic",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodePolygonCommandAcceptance(payload: unknown): PolygonCommandAcceptance {
|
|
||||||
const value = record(payload, "Подтверждение команды");
|
|
||||||
exactKeys(value, COMMAND_KEYS, "Подтверждение команды");
|
|
||||||
if (
|
|
||||||
value.schema_version !== "missioncore.command-acceptance/v2" ||
|
|
||||||
value.authority_scope !== "virtual-only"
|
|
||||||
) {
|
|
||||||
throw new PolygonWorkerContractError("Команда вышла за virtual-only контракт.");
|
|
||||||
}
|
|
||||||
const delivery = record(value.delivery, "delivery");
|
|
||||||
exactKeys(delivery, COMMAND_DELIVERY_KEYS, "delivery");
|
|
||||||
if (delivery.control_profile !== "rover-speed-steering/v1" || delivery.accepted !== true) {
|
|
||||||
throw new PolygonWorkerContractError("Команда подтверждена несовместимым adapter.");
|
|
||||||
}
|
|
||||||
const diagnosticsValue = record(delivery.diagnostics, "delivery.diagnostics");
|
|
||||||
if (Object.keys(diagnosticsValue).length > 16) {
|
|
||||||
throw new PolygonWorkerContractError("delivery.diagnostics превышает допустимый размер.");
|
|
||||||
}
|
|
||||||
const diagnostics = Object.fromEntries(
|
|
||||||
Object.entries(diagnosticsValue).map(([key, item]) => [
|
|
||||||
safeId(key, "delivery.diagnostics key"),
|
|
||||||
booleanValue(item, `delivery.diagnostics.${key}`),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
const sequence = integerValue(value.sequence, "sequence");
|
|
||||||
const issuedAtSimNs = integerValue(value.issued_at_sim_ns, "issued_at_sim_ns");
|
|
||||||
const validUntilSimNs = integerValue(value.valid_until_sim_ns, "valid_until_sim_ns");
|
|
||||||
const speedMps = finiteValue(value.speed_mps, "speed_mps");
|
|
||||||
const steeringNormalized = finiteValue(
|
|
||||||
value.steering_normalized,
|
|
||||||
"steering_normalized",
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
sequence < 1 ||
|
|
||||||
validUntilSimNs <= issuedAtSimNs ||
|
|
||||||
validUntilSimNs - issuedAtSimNs > 250_000_000 ||
|
|
||||||
speedMps < -1.5 ||
|
|
||||||
speedMps > 1.5 ||
|
|
||||||
steeringNormalized < -1 ||
|
|
||||||
steeringNormalized > 1
|
|
||||||
) {
|
|
||||||
throw new PolygonWorkerContractError("Команда нарушает допустимый S1 envelope.");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
runId: safeId(value.run_id, "run_id"),
|
|
||||||
commandId: safeId(value.command_id, "command_id"),
|
|
||||||
sequence,
|
|
||||||
issuedAtSimNs,
|
|
||||||
validUntilSimNs,
|
|
||||||
speedMps,
|
|
||||||
steeringNormalized,
|
|
||||||
deliveryProvider: safeId(delivery.provider_id, "delivery.provider_id"),
|
|
||||||
controlProfile: "rover-speed-steering/v1",
|
|
||||||
accepted: true,
|
|
||||||
controllerReady: booleanValue(delivery.controller_ready, "delivery.controller_ready"),
|
|
||||||
ttlExpiredCount: integerValue(
|
|
||||||
delivery.ttl_expired_count,
|
|
||||||
"delivery.ttl_expired_count",
|
|
||||||
),
|
|
||||||
diagnostics,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function responseBody(response: Response): Promise<unknown> {
|
|
||||||
const contentType = response.headers.get("content-type") ?? "";
|
|
||||||
if (!contentType.toLowerCase().startsWith("application/json")) {
|
|
||||||
throw new PolygonWorkerContractError("Polygon Worker API вернул не JSON.");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await response.json();
|
|
||||||
} catch {
|
|
||||||
throw new PolygonWorkerContractError("Polygon Worker API вернул повреждённый JSON.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function apiError(body: unknown, fallback: string, status: number): PolygonWorkerApiError {
|
|
||||||
if (isRecord(body) && typeof body.detail === "string") {
|
|
||||||
const detail = body.detail.trim();
|
|
||||||
if (detail && detail.length <= 1_000) return new PolygonWorkerApiError(detail, status);
|
|
||||||
}
|
|
||||||
return new PolygonWorkerApiError(`${fallback} HTTP ${status}.`, status);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestJson(
|
|
||||||
url: string,
|
|
||||||
init: RequestInit,
|
|
||||||
fallback: string,
|
|
||||||
fetcher: PolygonWorkerFetch,
|
|
||||||
): Promise<unknown> {
|
|
||||||
let response: Response;
|
|
||||||
try {
|
|
||||||
response = await fetcher(url, init);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
|
||||||
throw new PolygonWorkerApiError(fallback);
|
|
||||||
}
|
|
||||||
const body = await responseBody(response);
|
|
||||||
if (!response.ok) throw apiError(body, fallback, response.status);
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchPolygonWorkerStatus({
|
|
||||||
signal,
|
|
||||||
fetcher = globalThis.fetch,
|
|
||||||
}: {
|
|
||||||
signal?: AbortSignal;
|
|
||||||
fetcher?: PolygonWorkerFetch;
|
|
||||||
} = {}): Promise<PolygonWorkerStatus> {
|
|
||||||
return decodePolygonWorkerStatus(await requestJson(
|
|
||||||
"/api/v1/polygon/worker",
|
|
||||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
|
||||||
"Не удалось получить статус Simulation Worker.",
|
|
||||||
fetcher,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchPolygonVehicleState({
|
|
||||||
signal,
|
|
||||||
fetcher = globalThis.fetch,
|
|
||||||
}: {
|
|
||||||
signal?: AbortSignal;
|
|
||||||
fetcher?: PolygonWorkerFetch;
|
|
||||||
} = {}): Promise<PolygonVehicleState> {
|
|
||||||
return decodePolygonVehicleState(await requestJson(
|
|
||||||
"/api/v1/polygon/worker/live",
|
|
||||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
|
||||||
"Не удалось получить live-состояние ровера.",
|
|
||||||
fetcher,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startPolygonWorker({
|
|
||||||
idempotencyKey,
|
|
||||||
signal,
|
|
||||||
fetcher = globalThis.fetch,
|
|
||||||
}: {
|
|
||||||
idempotencyKey: string;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
fetcher?: PolygonWorkerFetch;
|
|
||||||
}): Promise<PolygonWorkerStatus> {
|
|
||||||
return decodePolygonWorkerStatus(await requestJson(
|
|
||||||
"/api/v1/polygon/worker/runs",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Idempotency-Key": idempotencyKey,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ scenario_id: "stock-rover-ackermann" }),
|
|
||||||
signal,
|
|
||||||
},
|
|
||||||
"Не удалось запустить Simulation Worker.",
|
|
||||||
fetcher,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function stopPolygonWorker(
|
|
||||||
runId: string,
|
|
||||||
{
|
|
||||||
idempotencyKey,
|
|
||||||
signal,
|
|
||||||
fetcher = globalThis.fetch,
|
|
||||||
}: {
|
|
||||||
idempotencyKey: string;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
fetcher?: PolygonWorkerFetch;
|
|
||||||
},
|
|
||||||
): Promise<PolygonWorkerStatus> {
|
|
||||||
if (!SAFE_ID.test(runId)) {
|
|
||||||
throw new PolygonWorkerContractError("Некорректный идентификатор активного прогона.");
|
|
||||||
}
|
|
||||||
return decodePolygonWorkerStatus(await requestJson(
|
|
||||||
`/api/v1/polygon/worker/runs/${encodeURIComponent(runId)}/stop`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Idempotency-Key": idempotencyKey,
|
|
||||||
},
|
|
||||||
body: "{}",
|
|
||||||
signal,
|
|
||||||
},
|
|
||||||
"Не удалось остановить Simulation Worker.",
|
|
||||||
fetcher,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendPolygonRoverCommand(
|
|
||||||
runId: string,
|
|
||||||
{
|
|
||||||
speedMps,
|
|
||||||
steeringNormalized,
|
|
||||||
idempotencyKey,
|
|
||||||
signal,
|
|
||||||
fetcher = globalThis.fetch,
|
|
||||||
}: {
|
|
||||||
speedMps: number;
|
|
||||||
steeringNormalized: number;
|
|
||||||
idempotencyKey: string;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
fetcher?: PolygonWorkerFetch;
|
|
||||||
},
|
|
||||||
): Promise<PolygonCommandAcceptance> {
|
|
||||||
if (!SAFE_ID.test(runId)) {
|
|
||||||
throw new PolygonWorkerContractError("Некорректный идентификатор активного прогона.");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!Number.isFinite(speedMps) ||
|
|
||||||
!Number.isFinite(steeringNormalized) ||
|
|
||||||
speedMps < -1.5 ||
|
|
||||||
speedMps > 1.5 ||
|
|
||||||
steeringNormalized < -1 ||
|
|
||||||
steeringNormalized > 1
|
|
||||||
) {
|
|
||||||
throw new PolygonWorkerContractError("Команда вышла за допустимый S1 envelope.");
|
|
||||||
}
|
|
||||||
return decodePolygonCommandAcceptance(await requestJson(
|
|
||||||
`/api/v1/polygon/worker/runs/${encodeURIComponent(runId)}/commands`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Idempotency-Key": idempotencyKey,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
speed_mps: speedMps,
|
|
||||||
steering_normalized: steeringNormalized,
|
|
||||||
}),
|
|
||||||
signal,
|
|
||||||
},
|
|
||||||
"Simulation Worker не подтвердил команду ровера.",
|
|
||||||
fetcher,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
@ -1,661 +0,0 @@
|
||||||
export type PolygonRunState =
|
|
||||||
| "admitted"
|
|
||||||
| "starting"
|
|
||||||
| "running"
|
|
||||||
| "paused"
|
|
||||||
| "stopping"
|
|
||||||
| "completed"
|
|
||||||
| "failed"
|
|
||||||
| "aborted";
|
|
||||||
|
|
||||||
export interface PolygonRunSummary {
|
|
||||||
runId: string;
|
|
||||||
episodeId: string;
|
|
||||||
kind: string;
|
|
||||||
state: PolygonRunState;
|
|
||||||
createdAtUtc: string;
|
|
||||||
startedAtUtc: string | null;
|
|
||||||
endedAtUtc: string | null;
|
|
||||||
terminalReason: string | null;
|
|
||||||
scenarioGeneration: string;
|
|
||||||
profileGeneration: string;
|
|
||||||
missionCoreCommit: string;
|
|
||||||
hostProfileId: string;
|
|
||||||
reproducibilityTier: "R0" | "R1" | "R2";
|
|
||||||
clockDomain: string;
|
|
||||||
revision: number;
|
|
||||||
providerIds: string[];
|
|
||||||
artifactCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PolygonProviderPin {
|
|
||||||
identifier: string;
|
|
||||||
version: string;
|
|
||||||
revision: string;
|
|
||||||
digest: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PolygonAuthority {
|
|
||||||
generation: number;
|
|
||||||
commandTtlMaxNs: number;
|
|
||||||
heartbeatTimeoutMonotonicNs: number;
|
|
||||||
simulationOrShadowOnly: boolean;
|
|
||||||
actuatorAuthority: boolean;
|
|
||||||
navigationOrSafetyAccepted: boolean;
|
|
||||||
directActuatorSetpointsAllowed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PolygonRun extends PolygonRunSummary {
|
|
||||||
scenarioSha256: string;
|
|
||||||
profileSha256: string;
|
|
||||||
hostProfileSha256: string;
|
|
||||||
seed: number;
|
|
||||||
parentRunId: string | null;
|
|
||||||
providers: PolygonProviderPin[];
|
|
||||||
authority: PolygonAuthority;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PolygonRunEvent {
|
|
||||||
runId: string;
|
|
||||||
sequence: number;
|
|
||||||
eventType: string;
|
|
||||||
observedAtUtc: string;
|
|
||||||
hostMonotonicNs: number;
|
|
||||||
simTimeNs: number | null;
|
|
||||||
payload: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PolygonRunArtifact {
|
|
||||||
artifactId: string;
|
|
||||||
kind: string;
|
|
||||||
relativePath: string;
|
|
||||||
sha256: string;
|
|
||||||
byteLength: number;
|
|
||||||
sourceOfRecord: boolean;
|
|
||||||
sequence: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PolygonRunCatalog {
|
|
||||||
items: PolygonRunSummary[];
|
|
||||||
total: number;
|
|
||||||
limitations: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PolygonRunDetail {
|
|
||||||
run: PolygonRun;
|
|
||||||
events: PolygonRunEvent[];
|
|
||||||
eventsTotal: number;
|
|
||||||
eventsTruncated: boolean;
|
|
||||||
commandCount: number;
|
|
||||||
artifacts: PolygonRunArtifact[];
|
|
||||||
limitations: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PolygonRunRoute {
|
|
||||||
active: boolean;
|
|
||||||
runId: string | null;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PolygonRunContractError extends Error {}
|
|
||||||
|
|
||||||
export class PolygonRunApiError extends Error {
|
|
||||||
constructor(message: string, readonly status: number | null = null) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type PolygonRunFetch = (
|
|
||||||
input: RequestInfo | URL,
|
|
||||||
init?: RequestInit,
|
|
||||||
) => Promise<Response>;
|
|
||||||
|
|
||||||
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
||||||
const SHA256 = /^[a-f0-9]{64}$/;
|
|
||||||
const GIT_REVISION = /^[a-f0-9]{7,64}$/;
|
|
||||||
const ISO_WITH_TIMEZONE = /^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/;
|
|
||||||
const RUN_STATES = new Set<PolygonRunState>([
|
|
||||||
"admitted",
|
|
||||||
"starting",
|
|
||||||
"running",
|
|
||||||
"paused",
|
|
||||||
"stopping",
|
|
||||||
"completed",
|
|
||||||
"failed",
|
|
||||||
"aborted",
|
|
||||||
]);
|
|
||||||
const REPRODUCIBILITY_TIERS = new Set(["R0", "R1", "R2"]);
|
|
||||||
const SUMMARY_KEYS = new Set([
|
|
||||||
"run_id",
|
|
||||||
"episode_id",
|
|
||||||
"kind",
|
|
||||||
"state",
|
|
||||||
"created_at_utc",
|
|
||||||
"started_at_utc",
|
|
||||||
"ended_at_utc",
|
|
||||||
"terminal_reason",
|
|
||||||
"scenario_generation",
|
|
||||||
"profile_generation",
|
|
||||||
"mission_core_commit",
|
|
||||||
"host_profile_id",
|
|
||||||
"reproducibility_tier",
|
|
||||||
"clock_domain",
|
|
||||||
"revision",
|
|
||||||
"provider_ids",
|
|
||||||
"artifact_count",
|
|
||||||
]);
|
|
||||||
const RUN_KEYS = new Set([
|
|
||||||
...SUMMARY_KEYS,
|
|
||||||
"scenario_sha256",
|
|
||||||
"profile_sha256",
|
|
||||||
"host_profile_sha256",
|
|
||||||
"seed",
|
|
||||||
"parent_run_id",
|
|
||||||
"providers",
|
|
||||||
"authority",
|
|
||||||
]);
|
|
||||||
const PROVIDER_KEYS = new Set(["identifier", "version", "revision", "digest"]);
|
|
||||||
const AUTHORITY_KEYS = new Set([
|
|
||||||
"generation",
|
|
||||||
"command_ttl_max_ns",
|
|
||||||
"heartbeat_timeout_monotonic_ns",
|
|
||||||
"simulation_or_shadow_only",
|
|
||||||
"actuator_authority",
|
|
||||||
"navigation_or_safety_accepted",
|
|
||||||
"direct_actuator_setpoints_allowed",
|
|
||||||
]);
|
|
||||||
const EVENT_KEYS = new Set([
|
|
||||||
"schema_version",
|
|
||||||
"run_id",
|
|
||||||
"sequence",
|
|
||||||
"event_type",
|
|
||||||
"observed_at_utc",
|
|
||||||
"host_monotonic_ns",
|
|
||||||
"sim_time_ns",
|
|
||||||
"payload",
|
|
||||||
]);
|
|
||||||
const ARTIFACT_KEYS = new Set([
|
|
||||||
"artifact_id",
|
|
||||||
"kind",
|
|
||||||
"relative_path",
|
|
||||||
"sha256",
|
|
||||||
"byte_length",
|
|
||||||
"source_of_record",
|
|
||||||
"sequence",
|
|
||||||
]);
|
|
||||||
const CATALOG_KEYS = new Set([
|
|
||||||
"schema_version",
|
|
||||||
"access",
|
|
||||||
"items",
|
|
||||||
"total",
|
|
||||||
"limitations",
|
|
||||||
]);
|
|
||||||
const DETAIL_KEYS = new Set([
|
|
||||||
"schema_version",
|
|
||||||
"access",
|
|
||||||
"run",
|
|
||||||
"events",
|
|
||||||
"events_total",
|
|
||||||
"events_truncated",
|
|
||||||
"commands",
|
|
||||||
"artifacts",
|
|
||||||
"limitations",
|
|
||||||
]);
|
|
||||||
const COMMAND_KEYS = new Set(["count", "content_exposed"]);
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertExactKeys(
|
|
||||||
value: Record<string, unknown>,
|
|
||||||
expected: ReadonlySet<string>,
|
|
||||||
label: string,
|
|
||||||
) {
|
|
||||||
const keys = Object.keys(value);
|
|
||||||
if (keys.length !== expected.size || keys.some((key) => !expected.has(key))) {
|
|
||||||
throw new PolygonRunContractError(`${label} содержит неизвестные или отсутствующие поля.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireString(value: unknown, field: string, maximum = 512): string {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
throw new PolygonRunContractError(`Поле ${field} должно быть строкой.`);
|
|
||||||
}
|
|
||||||
const normalized = value.trim();
|
|
||||||
if (!normalized || normalized.length > maximum) {
|
|
||||||
throw new PolygonRunContractError(`Поле ${field} имеет недопустимую длину.`);
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireId(value: unknown, field: string): string {
|
|
||||||
const identifier = requireString(value, field, 128);
|
|
||||||
if (!SAFE_ID.test(identifier)) {
|
|
||||||
throw new PolygonRunContractError(`Поле ${field} содержит небезопасный идентификатор.`);
|
|
||||||
}
|
|
||||||
return identifier;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireInteger(value: unknown, field: string, minimum = 0): number {
|
|
||||||
if (
|
|
||||||
typeof value !== "number" ||
|
|
||||||
!Number.isSafeInteger(value) ||
|
|
||||||
value < minimum
|
|
||||||
) {
|
|
||||||
throw new PolygonRunContractError(`Поле ${field} должно быть безопасным целым числом.`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireTimestamp(value: unknown, field: string): string {
|
|
||||||
const timestamp = requireString(value, field, 64);
|
|
||||||
if (!ISO_WITH_TIMEZONE.test(timestamp) || !Number.isFinite(Date.parse(timestamp))) {
|
|
||||||
throw new PolygonRunContractError(`Поле ${field} должно быть ISO-датой с часовым поясом.`);
|
|
||||||
}
|
|
||||||
return timestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
function optionalTimestamp(value: unknown, field: string): string | null {
|
|
||||||
return value === null ? null : requireTimestamp(value, field);
|
|
||||||
}
|
|
||||||
|
|
||||||
function optionalString(value: unknown, field: string, maximum = 512): string | null {
|
|
||||||
return value === null ? null : requireString(value, field, maximum);
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireBoolean(value: unknown, field: string): boolean {
|
|
||||||
if (typeof value !== "boolean") {
|
|
||||||
throw new PolygonRunContractError(`Поле ${field} должно быть boolean.`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireSha256(value: unknown, field: string): string {
|
|
||||||
const digest = requireString(value, field, 64);
|
|
||||||
if (!SHA256.test(digest)) {
|
|
||||||
throw new PolygonRunContractError(`Поле ${field} должно содержать SHA-256.`);
|
|
||||||
}
|
|
||||||
return digest;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeLimitations(value: unknown): string[] {
|
|
||||||
if (!Array.isArray(value) || value.length > 16) {
|
|
||||||
throw new PolygonRunContractError("Ограничения UI-0 должны быть ограниченным массивом.");
|
|
||||||
}
|
|
||||||
return value.map((entry, index) => requireString(entry, `limitations[${index}]`, 1_000));
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeSummary(
|
|
||||||
value: unknown,
|
|
||||||
expectedKeys: ReadonlySet<string> = SUMMARY_KEYS,
|
|
||||||
): PolygonRunSummary {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
throw new PolygonRunContractError("Сводка прогона должна быть объектом.");
|
|
||||||
}
|
|
||||||
assertExactKeys(value, expectedKeys, "Сводка прогона");
|
|
||||||
const stateValue = requireString(value.state, "state", 32);
|
|
||||||
if (!RUN_STATES.has(stateValue as PolygonRunState)) {
|
|
||||||
throw new PolygonRunContractError("Прогон содержит неизвестное состояние.");
|
|
||||||
}
|
|
||||||
const tier = requireString(value.reproducibility_tier, "reproducibility_tier", 2);
|
|
||||||
if (!REPRODUCIBILITY_TIERS.has(tier)) {
|
|
||||||
throw new PolygonRunContractError("Прогон содержит неизвестный уровень воспроизводимости.");
|
|
||||||
}
|
|
||||||
const commit = requireString(value.mission_core_commit, "mission_core_commit", 64);
|
|
||||||
if (!GIT_REVISION.test(commit)) {
|
|
||||||
throw new PolygonRunContractError("Прогон не содержит допустимую ревизию Mission Core.");
|
|
||||||
}
|
|
||||||
if (!Array.isArray(value.provider_ids) || value.provider_ids.length > 32) {
|
|
||||||
throw new PolygonRunContractError("Поле provider_ids должно быть ограниченным массивом.");
|
|
||||||
}
|
|
||||||
const providerIds = value.provider_ids.map((entry, index) =>
|
|
||||||
requireId(entry, `provider_ids[${index}]`));
|
|
||||||
if (new Set(providerIds).size !== providerIds.length) {
|
|
||||||
throw new PolygonRunContractError("Поле provider_ids содержит повторения.");
|
|
||||||
}
|
|
||||||
const startedAtUtc = optionalTimestamp(value.started_at_utc, "started_at_utc");
|
|
||||||
const endedAtUtc = optionalTimestamp(value.ended_at_utc, "ended_at_utc");
|
|
||||||
if (startedAtUtc && endedAtUtc && Date.parse(endedAtUtc) < Date.parse(startedAtUtc)) {
|
|
||||||
throw new PolygonRunContractError("Прогон завершился раньше времени запуска.");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
runId: requireId(value.run_id, "run_id"),
|
|
||||||
episodeId: requireId(value.episode_id, "episode_id"),
|
|
||||||
kind: requireId(value.kind, "kind"),
|
|
||||||
state: stateValue as PolygonRunState,
|
|
||||||
createdAtUtc: requireTimestamp(value.created_at_utc, "created_at_utc"),
|
|
||||||
startedAtUtc,
|
|
||||||
endedAtUtc,
|
|
||||||
terminalReason: optionalString(value.terminal_reason, "terminal_reason"),
|
|
||||||
scenarioGeneration: requireId(value.scenario_generation, "scenario_generation"),
|
|
||||||
profileGeneration: requireId(value.profile_generation, "profile_generation"),
|
|
||||||
missionCoreCommit: commit,
|
|
||||||
hostProfileId: requireId(value.host_profile_id, "host_profile_id"),
|
|
||||||
reproducibilityTier: tier as "R0" | "R1" | "R2",
|
|
||||||
clockDomain: requireString(value.clock_domain, "clock_domain", 128),
|
|
||||||
revision: requireInteger(value.revision, "revision"),
|
|
||||||
providerIds,
|
|
||||||
artifactCount: requireInteger(value.artifact_count, "artifact_count"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeProvider(value: unknown, index: number): PolygonProviderPin {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
throw new PolygonRunContractError(`providers[${index}] должен быть объектом.`);
|
|
||||||
}
|
|
||||||
assertExactKeys(value, PROVIDER_KEYS, `providers[${index}]`);
|
|
||||||
const digest = value.digest === null ? null : requireSha256(value.digest, `providers[${index}].digest`);
|
|
||||||
return {
|
|
||||||
identifier: requireId(value.identifier, `providers[${index}].identifier`),
|
|
||||||
version: requireString(value.version, `providers[${index}].version`, 128),
|
|
||||||
revision: requireString(value.revision, `providers[${index}].revision`, 128),
|
|
||||||
digest,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeAuthority(value: unknown): PolygonAuthority {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
throw new PolygonRunContractError("authority должен быть объектом.");
|
|
||||||
}
|
|
||||||
assertExactKeys(value, AUTHORITY_KEYS, "authority");
|
|
||||||
return {
|
|
||||||
generation: requireInteger(value.generation, "authority.generation", 1),
|
|
||||||
commandTtlMaxNs: requireInteger(value.command_ttl_max_ns, "authority.command_ttl_max_ns", 1),
|
|
||||||
heartbeatTimeoutMonotonicNs: requireInteger(
|
|
||||||
value.heartbeat_timeout_monotonic_ns,
|
|
||||||
"authority.heartbeat_timeout_monotonic_ns",
|
|
||||||
1,
|
|
||||||
),
|
|
||||||
simulationOrShadowOnly: requireBoolean(
|
|
||||||
value.simulation_or_shadow_only,
|
|
||||||
"authority.simulation_or_shadow_only",
|
|
||||||
),
|
|
||||||
actuatorAuthority: requireBoolean(value.actuator_authority, "authority.actuator_authority"),
|
|
||||||
navigationOrSafetyAccepted: requireBoolean(
|
|
||||||
value.navigation_or_safety_accepted,
|
|
||||||
"authority.navigation_or_safety_accepted",
|
|
||||||
),
|
|
||||||
directActuatorSetpointsAllowed: requireBoolean(
|
|
||||||
value.direct_actuator_setpoints_allowed,
|
|
||||||
"authority.direct_actuator_setpoints_allowed",
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeRun(value: unknown): PolygonRun {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
throw new PolygonRunContractError("Прогон должен быть объектом.");
|
|
||||||
}
|
|
||||||
const summary = decodeSummary(value, RUN_KEYS);
|
|
||||||
if (!Array.isArray(value.providers) || value.providers.length > 32) {
|
|
||||||
throw new PolygonRunContractError("providers должен быть ограниченным массивом.");
|
|
||||||
}
|
|
||||||
const providers = value.providers.map(decodeProvider);
|
|
||||||
if (
|
|
||||||
providers.length !== summary.providerIds.length ||
|
|
||||||
providers.some((provider, index) => provider.identifier !== summary.providerIds[index])
|
|
||||||
) {
|
|
||||||
throw new PolygonRunContractError("Провайдеры прогона не совпадают со сводкой.");
|
|
||||||
}
|
|
||||||
const parentRunId = value.parent_run_id === null
|
|
||||||
? null
|
|
||||||
: requireId(value.parent_run_id, "parent_run_id");
|
|
||||||
return {
|
|
||||||
...summary,
|
|
||||||
scenarioSha256: requireSha256(value.scenario_sha256, "scenario_sha256"),
|
|
||||||
profileSha256: requireSha256(value.profile_sha256, "profile_sha256"),
|
|
||||||
hostProfileSha256: requireSha256(value.host_profile_sha256, "host_profile_sha256"),
|
|
||||||
seed: requireInteger(value.seed, "seed"),
|
|
||||||
parentRunId,
|
|
||||||
providers,
|
|
||||||
authority: decodeAuthority(value.authority),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeEvent(value: unknown, index: number, runId: string): PolygonRunEvent {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
throw new PolygonRunContractError(`events[${index}] должен быть объектом.`);
|
|
||||||
}
|
|
||||||
assertExactKeys(value, EVENT_KEYS, `events[${index}]`);
|
|
||||||
if (value.schema_version !== "missioncore.qualification-event/v1") {
|
|
||||||
throw new PolygonRunContractError(`events[${index}] содержит неизвестную схему.`);
|
|
||||||
}
|
|
||||||
if (!isRecord(value.payload)) {
|
|
||||||
throw new PolygonRunContractError(`events[${index}].payload должен быть объектом.`);
|
|
||||||
}
|
|
||||||
const eventRunId = requireId(value.run_id, `events[${index}].run_id`);
|
|
||||||
if (eventRunId !== runId) {
|
|
||||||
throw new PolygonRunContractError(`events[${index}] относится к другому прогону.`);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
runId: eventRunId,
|
|
||||||
sequence: requireInteger(value.sequence, `events[${index}].sequence`, 1),
|
|
||||||
eventType: requireId(value.event_type, `events[${index}].event_type`),
|
|
||||||
observedAtUtc: requireTimestamp(value.observed_at_utc, `events[${index}].observed_at_utc`),
|
|
||||||
hostMonotonicNs: requireInteger(
|
|
||||||
value.host_monotonic_ns,
|
|
||||||
`events[${index}].host_monotonic_ns`,
|
|
||||||
),
|
|
||||||
simTimeNs: value.sim_time_ns === null
|
|
||||||
? null
|
|
||||||
: requireInteger(value.sim_time_ns, `events[${index}].sim_time_ns`),
|
|
||||||
payload: value.payload,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeArtifact(value: unknown, index: number): PolygonRunArtifact {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
throw new PolygonRunContractError(`artifacts[${index}] должен быть объектом.`);
|
|
||||||
}
|
|
||||||
assertExactKeys(value, ARTIFACT_KEYS, `artifacts[${index}]`);
|
|
||||||
const relativePath = requireString(value.relative_path, `artifacts[${index}].relative_path`, 512);
|
|
||||||
const segments = relativePath.split("/");
|
|
||||||
if (
|
|
||||||
relativePath.startsWith("/") ||
|
|
||||||
relativePath.includes("\\") ||
|
|
||||||
segments.some((segment) => !segment || segment === "." || segment === "..")
|
|
||||||
) {
|
|
||||||
throw new PolygonRunContractError(`artifacts[${index}] содержит небезопасный путь.`);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
artifactId: requireId(value.artifact_id, `artifacts[${index}].artifact_id`),
|
|
||||||
kind: requireId(value.kind, `artifacts[${index}].kind`),
|
|
||||||
relativePath,
|
|
||||||
sha256: requireSha256(value.sha256, `artifacts[${index}].sha256`),
|
|
||||||
byteLength: requireInteger(value.byte_length, `artifacts[${index}].byte_length`),
|
|
||||||
sourceOfRecord: requireBoolean(
|
|
||||||
value.source_of_record,
|
|
||||||
`artifacts[${index}].source_of_record`,
|
|
||||||
),
|
|
||||||
sequence: requireInteger(value.sequence, `artifacts[${index}].sequence`, 1),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodePolygonRunCatalog(payload: unknown): PolygonRunCatalog {
|
|
||||||
if (!isRecord(payload)) {
|
|
||||||
throw new PolygonRunContractError("Каталог прогонов должен быть объектом.");
|
|
||||||
}
|
|
||||||
assertExactKeys(payload, CATALOG_KEYS, "Каталог прогонов");
|
|
||||||
if (
|
|
||||||
payload.schema_version !== "missioncore.polygon-run-catalog/v1" ||
|
|
||||||
payload.access !== "read-only"
|
|
||||||
) {
|
|
||||||
throw new PolygonRunContractError("Каталог не является поддерживаемым read-only контрактом.");
|
|
||||||
}
|
|
||||||
if (!Array.isArray(payload.items) || payload.items.length > 100) {
|
|
||||||
throw new PolygonRunContractError("Каталог должен содержать ограниченный массив items.");
|
|
||||||
}
|
|
||||||
const items = payload.items.map((item) => decodeSummary(item));
|
|
||||||
if (new Set(items.map(({ runId }) => runId)).size !== items.length) {
|
|
||||||
throw new PolygonRunContractError("Каталог содержит повторяющиеся прогоны.");
|
|
||||||
}
|
|
||||||
const total = requireInteger(payload.total, "total");
|
|
||||||
if (total < items.length) {
|
|
||||||
throw new PolygonRunContractError("Размер каталога меньше числа возвращённых прогонов.");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
total,
|
|
||||||
limitations: decodeLimitations(payload.limitations),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodePolygonRunDetail(payload: unknown): PolygonRunDetail {
|
|
||||||
if (!isRecord(payload)) {
|
|
||||||
throw new PolygonRunContractError("Детали прогона должны быть объектом.");
|
|
||||||
}
|
|
||||||
assertExactKeys(payload, DETAIL_KEYS, "Детали прогона");
|
|
||||||
if (
|
|
||||||
payload.schema_version !== "missioncore.polygon-run-detail/v1" ||
|
|
||||||
payload.access !== "read-only"
|
|
||||||
) {
|
|
||||||
throw new PolygonRunContractError("Детали не являются поддерживаемым read-only контрактом.");
|
|
||||||
}
|
|
||||||
const run = decodeRun(payload.run);
|
|
||||||
if (!Array.isArray(payload.events) || payload.events.length > 500) {
|
|
||||||
throw new PolygonRunContractError("Журнал событий должен быть ограниченным массивом.");
|
|
||||||
}
|
|
||||||
const events = payload.events.map((event, index) => decodeEvent(event, index, run.runId));
|
|
||||||
for (let index = 1; index < events.length; index += 1) {
|
|
||||||
if (events[index].sequence !== events[index - 1].sequence + 1) {
|
|
||||||
throw new PolygonRunContractError("Возвращённый фрагмент событий не является непрерывным.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const eventsTotal = requireInteger(payload.events_total, "events_total");
|
|
||||||
const eventsTruncated = requireBoolean(payload.events_truncated, "events_truncated");
|
|
||||||
if (eventsTotal < events.length || eventsTruncated !== (eventsTotal !== events.length)) {
|
|
||||||
throw new PolygonRunContractError("Метаданные усечения событий противоречат журналу.");
|
|
||||||
}
|
|
||||||
if (!isRecord(payload.commands)) {
|
|
||||||
throw new PolygonRunContractError("commands должен быть объектом.");
|
|
||||||
}
|
|
||||||
assertExactKeys(payload.commands, COMMAND_KEYS, "commands");
|
|
||||||
if (payload.commands.content_exposed !== false) {
|
|
||||||
throw new PolygonRunContractError("UI-0 не принимает содержимое команд.");
|
|
||||||
}
|
|
||||||
if (!Array.isArray(payload.artifacts) || payload.artifacts.length > 500) {
|
|
||||||
throw new PolygonRunContractError("Артефакты должны быть ограниченным массивом.");
|
|
||||||
}
|
|
||||||
const artifacts = payload.artifacts.map(decodeArtifact);
|
|
||||||
return {
|
|
||||||
run,
|
|
||||||
events,
|
|
||||||
eventsTotal,
|
|
||||||
eventsTruncated,
|
|
||||||
commandCount: requireInteger(payload.commands.count, "commands.count"),
|
|
||||||
artifacts,
|
|
||||||
limitations: decodeLimitations(payload.limitations),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolvePolygonRunRoute(search: string): PolygonRunRoute {
|
|
||||||
const parameters = new URLSearchParams(search);
|
|
||||||
const workspaceValues = parameters.getAll("workspace");
|
|
||||||
if (workspaceValues.length !== 1 || workspaceValues[0] !== "polygon-run") {
|
|
||||||
return { active: false, runId: null, error: null };
|
|
||||||
}
|
|
||||||
const runValues = parameters.getAll("run");
|
|
||||||
if (runValues.length === 0) {
|
|
||||||
return { active: true, runId: null, error: null };
|
|
||||||
}
|
|
||||||
if (runValues.length !== 1 || !SAFE_ID.test(runValues[0])) {
|
|
||||||
return {
|
|
||||||
active: true,
|
|
||||||
runId: null,
|
|
||||||
error: "Прямая ссылка содержит некорректный идентификатор прогона.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { active: true, runId: runValues[0], error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function responseBody(response: Response): Promise<unknown> {
|
|
||||||
const contentType = response.headers.get("content-type") ?? "";
|
|
||||||
if (!contentType.toLowerCase().startsWith("application/json")) {
|
|
||||||
throw new PolygonRunContractError("Polygon API вернул ответ не в формате JSON.");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await response.json();
|
|
||||||
} catch {
|
|
||||||
throw new PolygonRunContractError("Polygon API вернул повреждённый JSON.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function apiErrorMessage(body: unknown, fallback: string): string {
|
|
||||||
if (!isRecord(body) || typeof body.detail !== "string") return fallback;
|
|
||||||
const detail = body.detail.trim();
|
|
||||||
return detail && detail.length <= 1_000 ? detail : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getJson(
|
|
||||||
url: string,
|
|
||||||
fallback: string,
|
|
||||||
signal: AbortSignal | undefined,
|
|
||||||
fetcher: PolygonRunFetch,
|
|
||||||
): Promise<unknown> {
|
|
||||||
let response: Response;
|
|
||||||
try {
|
|
||||||
response = await fetcher(url, {
|
|
||||||
method: "GET",
|
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
|
||||||
throw new PolygonRunApiError(fallback);
|
|
||||||
}
|
|
||||||
const body = await responseBody(response);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new PolygonRunApiError(
|
|
||||||
apiErrorMessage(body, `${fallback} HTTP ${response.status}.`),
|
|
||||||
response.status,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchPolygonRunCatalog({
|
|
||||||
signal,
|
|
||||||
limit = 20,
|
|
||||||
fetcher = globalThis.fetch,
|
|
||||||
}: {
|
|
||||||
signal?: AbortSignal;
|
|
||||||
limit?: number;
|
|
||||||
fetcher?: PolygonRunFetch;
|
|
||||||
} = {}): Promise<PolygonRunCatalog> {
|
|
||||||
const admittedLimit = Number.isInteger(limit) && limit >= 1 && limit <= 100 ? limit : 20;
|
|
||||||
const body = await getJson(
|
|
||||||
`/api/v1/polygon/runs?limit=${admittedLimit}`,
|
|
||||||
"Не удалось загрузить каталог прогонов Полигона.",
|
|
||||||
signal,
|
|
||||||
fetcher,
|
|
||||||
);
|
|
||||||
return decodePolygonRunCatalog(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchPolygonRunDetail(
|
|
||||||
runId: string,
|
|
||||||
{
|
|
||||||
signal,
|
|
||||||
eventLimit = 200,
|
|
||||||
fetcher = globalThis.fetch,
|
|
||||||
}: {
|
|
||||||
signal?: AbortSignal;
|
|
||||||
eventLimit?: number;
|
|
||||||
fetcher?: PolygonRunFetch;
|
|
||||||
} = {},
|
|
||||||
): Promise<PolygonRunDetail> {
|
|
||||||
if (!SAFE_ID.test(runId)) {
|
|
||||||
throw new PolygonRunContractError("Идентификатор прогона имеет недопустимый формат.");
|
|
||||||
}
|
|
||||||
const admittedLimit = Number.isInteger(eventLimit) && eventLimit >= 1 && eventLimit <= 500
|
|
||||||
? eventLimit
|
|
||||||
: 200;
|
|
||||||
const body = await getJson(
|
|
||||||
`/api/v1/polygon/runs/${encodeURIComponent(runId)}?event_limit=${admittedLimit}`,
|
|
||||||
"Не удалось загрузить доказательства прогона.",
|
|
||||||
signal,
|
|
||||||
fetcher,
|
|
||||||
);
|
|
||||||
return decodePolygonRunDetail(body);
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
fetchPolygonWorkerStatus,
|
|
||||||
type PolygonWorkerStatus,
|
|
||||||
} from "./liveWorker";
|
|
||||||
|
|
||||||
export interface PolygonWorkerCapability {
|
|
||||||
checked: boolean;
|
|
||||||
available: boolean;
|
|
||||||
status: PolygonWorkerStatus | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialCapability: PolygonWorkerCapability = {
|
|
||||||
checked: false,
|
|
||||||
available: false,
|
|
||||||
status: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function usePolygonWorkerCapability(): PolygonWorkerCapability {
|
|
||||||
const [capability, setCapability] = useState(initialCapability);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
let timer: number | null = null;
|
|
||||||
|
|
||||||
const poll = async () => {
|
|
||||||
try {
|
|
||||||
const status = await fetchPolygonWorkerStatus({ signal: controller.signal });
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
setCapability({
|
|
||||||
checked: true,
|
|
||||||
available: status.available,
|
|
||||||
status,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
setCapability({
|
|
||||||
checked: true,
|
|
||||||
available: false,
|
|
||||||
status: null,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
timer = window.setTimeout(() => void poll(), 3_000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void poll();
|
|
||||||
return () => {
|
|
||||||
controller.abort();
|
|
||||||
if (timer !== null) window.clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return capability;
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,6 @@
|
||||||
import type { IconName } from "@nodedc/ui-react";
|
import type { IconName } from "@nodedc/ui-react";
|
||||||
|
|
||||||
export type RootId =
|
export type RootId = "center" | "fleet" | "observation" | "missions" | "data" | "system";
|
||||||
| "center"
|
|
||||||
| "fleet"
|
|
||||||
| "observation"
|
|
||||||
| "missions"
|
|
||||||
| "data"
|
|
||||||
| "polygon"
|
|
||||||
| "system";
|
|
||||||
|
|
||||||
export type WorkspaceKind =
|
export type WorkspaceKind =
|
||||||
| "overview"
|
| "overview"
|
||||||
|
|
@ -17,8 +10,7 @@ export type WorkspaceKind =
|
||||||
| "map"
|
| "map"
|
||||||
| "timeline"
|
| "timeline"
|
||||||
| "missions"
|
| "missions"
|
||||||
| "catalog"
|
| "catalog";
|
||||||
| "polygon-run";
|
|
||||||
|
|
||||||
export type CapabilityStatus = "active" | "ready" | "contract" | "later";
|
export type CapabilityStatus = "active" | "ready" | "contract" | "later";
|
||||||
|
|
||||||
|
|
@ -43,7 +35,6 @@ export interface WorkspaceDefinition {
|
||||||
description: string;
|
description: string;
|
||||||
icon: IconName;
|
icon: IconName;
|
||||||
kind: WorkspaceKind;
|
kind: WorkspaceKind;
|
||||||
internalOnly?: boolean;
|
|
||||||
groups: CapabilityGroup[];
|
groups: CapabilityGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,15 +118,6 @@ export const roots: RootDefinition[] = [
|
||||||
statement: "Хранить живой контур и воспроизводимый эксперимент как одну модель данных.",
|
statement: "Хранить живой контур и воспроизводимый эксперимент как одну модель данных.",
|
||||||
accent: "ПОТОКИ И ДОКАЗАТЕЛЬСТВА",
|
accent: "ПОТОКИ И ДОКАЗАТЕЛЬСТВА",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "polygon",
|
|
||||||
label: "Полигон",
|
|
||||||
title: "Полигон",
|
|
||||||
eyebrow: "СИМУЛЯЦИЯ И КВАЛИФИКАЦИЯ",
|
|
||||||
description: "Живые PX4/Gazebo прогоны, сценарии, траектории и доказательства.",
|
|
||||||
statement: "Проверять поведение машины в воспроизводимой виртуальной среде.",
|
|
||||||
accent: "СИМУЛЯЦИЯ И ДОКАЗАТЕЛЬСТВА",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "system",
|
id: "system",
|
||||||
label: "Система",
|
label: "Система",
|
||||||
|
|
@ -148,17 +130,6 @@ export const roots: RootDefinition[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export const workspaces: WorkspaceDefinition[] = [
|
export const workspaces: WorkspaceDefinition[] = [
|
||||||
{
|
|
||||||
id: "polygon-run",
|
|
||||||
root: "polygon",
|
|
||||||
label: "Полигон",
|
|
||||||
title: "Полигон",
|
|
||||||
eyebrow: "ПОЛИГОН / LIVE",
|
|
||||||
description: "Live Simulation Worker и доказательства прогонов PX4/Gazebo.",
|
|
||||||
icon: "activity",
|
|
||||||
kind: "polygon-run",
|
|
||||||
groups: [],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "command-overview",
|
id: "command-overview",
|
||||||
root: "center",
|
root: "center",
|
||||||
|
|
@ -771,22 +742,12 @@ export function rootById(id: RootId | null): RootDefinition | null {
|
||||||
return id ? roots.find((root) => root.id === id) ?? null : null;
|
return id ? roots.find((root) => root.id === id) ?? null : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rootsForCapabilities({
|
|
||||||
polygonWorkerAvailable,
|
|
||||||
}: {
|
|
||||||
polygonWorkerAvailable: boolean;
|
|
||||||
}): RootDefinition[] {
|
|
||||||
return roots.filter((root) => root.id !== "polygon" || polygonWorkerAvailable);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function workspaceById(id: string | null): WorkspaceDefinition | null {
|
export function workspaceById(id: string | null): WorkspaceDefinition | null {
|
||||||
return id ? workspaces.find((workspace) => workspace.id === id) ?? null : null;
|
return id ? workspaces.find((workspace) => workspace.id === id) ?? null : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function workspacesForRoot(root: RootId | null): WorkspaceDefinition[] {
|
export function workspacesForRoot(root: RootId | null): WorkspaceDefinition[] {
|
||||||
return root
|
return root ? workspaces.filter((workspace) => workspace.root === root) : [];
|
||||||
? workspaces.filter((workspace) => workspace.root === root && !workspace.internalOnly)
|
|
||||||
: [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const capabilityStatusLabel: Record<CapabilityStatus, string> = {
|
export const capabilityStatusLabel: Record<CapabilityStatus, string> = {
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,10 @@
|
||||||
|
|
||||||
@media (max-width: 1280px) {
|
@media (max-width: 1280px) {
|
||||||
.overview-grid,
|
.overview-grid,
|
||||||
.mission-layout,
|
.mission-layout {
|
||||||
.polygon-live-layout,
|
|
||||||
.polygon-run-layout,
|
|
||||||
.polygon-run-evidence-grid {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.polygon-run-providers {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.pipeline-strip {
|
.pipeline-strip {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
@ -74,25 +67,12 @@
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.polygon-run-metrics {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-lead {
|
.workspace-lead {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.polygon-live-heading {
|
|
||||||
align-items: stretch;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-live-heading > div:last-child {
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-lead__note,
|
.workspace-lead__note,
|
||||||
.workspace-lead__status {
|
.workspace-lead__status {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
|
|
@ -151,38 +131,10 @@
|
||||||
|
|
||||||
.metrics-grid,
|
.metrics-grid,
|
||||||
.capability-summary,
|
.capability-summary,
|
||||||
.camera-grid,
|
.camera-grid {
|
||||||
.polygon-run-metrics,
|
|
||||||
.polygon-run-providers {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.polygon-run-identity dl {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-live-heading > div:last-child {
|
|
||||||
align-items: stretch;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene {
|
|
||||||
min-height: 19rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene__frames small,
|
|
||||||
.polygon-rover-scene__help {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-event-list article {
|
|
||||||
grid-template-columns: 2.3rem minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-event-list article > span:last-child {
|
|
||||||
grid-column: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-grid {
|
.camera-grid {
|
||||||
grid-template-rows: repeat(4, minmax(14rem, 1fr));
|
grid-template-rows: repeat(4, minmax(14rem, 1fr));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,691 +90,6 @@
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.polygon-run-workspace {
|
|
||||||
gap: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-live-panel {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-live-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-live-heading h2,
|
|
||||||
.polygon-live-heading p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-live-heading h2 {
|
|
||||||
margin-top: 0.32rem;
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
font-size: 1.25rem;
|
|
||||||
letter-spacing: -0.035em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-live-heading p {
|
|
||||||
max-width: 42rem;
|
|
||||||
margin-top: 0.42rem;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.66rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-live-heading > div:last-child {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-live-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(22rem, 1.45fr) minmax(17rem, 0.55fr);
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 25rem;
|
|
||||||
border: 1px solid var(--station-hairline);
|
|
||||||
border-radius: 1rem;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 62% 28%, rgb(var(--nodedc-accent-rgb) / 0.13), transparent 42%),
|
|
||||||
linear-gradient(155deg, #0d1722, #070c12 70%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene--loading {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.62rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene__viewport,
|
|
||||||
.polygon-rover-scene__viewport canvas {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene__viewport canvas {
|
|
||||||
cursor: grab;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene__viewport canvas:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene__toolbar,
|
|
||||||
.polygon-rover-scene__help,
|
|
||||||
.polygon-rover-scene__frames,
|
|
||||||
.polygon-rover-scene__idle {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene__toolbar {
|
|
||||||
top: 0.75rem;
|
|
||||||
left: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene__toolbar button {
|
|
||||||
border: 1px solid rgb(255 255 255 / 0.14);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgb(7 12 18 / 0.76);
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
padding: 0.38rem 0.62rem;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.54rem;
|
|
||||||
line-height: 1;
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene__toolbar button:hover {
|
|
||||||
border-color: rgb(var(--nodedc-accent-rgb) / 0.5);
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene__help {
|
|
||||||
bottom: 0.75rem;
|
|
||||||
left: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene__help span,
|
|
||||||
.polygon-rover-scene__frames,
|
|
||||||
.polygon-rover-scene__frames small {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
|
||||||
font-size: 0.58rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene__frames {
|
|
||||||
right: 0.75rem;
|
|
||||||
bottom: 0.7rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene__frames span {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene__frames i {
|
|
||||||
width: 0.36rem;
|
|
||||||
height: 0.36rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene__frames i[data-axis="east"] {
|
|
||||||
background: #ff5f6d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene__frames i[data-axis="north"] {
|
|
||||||
background: #4ee19b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene__frames i[data-axis="up"] {
|
|
||||||
background: #5d9cff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene__frames small {
|
|
||||||
margin-left: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene__idle {
|
|
||||||
top: 4.1rem;
|
|
||||||
right: 0.8rem;
|
|
||||||
display: grid;
|
|
||||||
max-width: min(18rem, 52%);
|
|
||||||
gap: 0.2rem;
|
|
||||||
border: 1px solid rgb(255 255 255 / 0.09);
|
|
||||||
border-radius: 0.72rem;
|
|
||||||
background: rgb(7 12 18 / 0.58);
|
|
||||||
padding: 0.55rem 0.7rem;
|
|
||||||
text-align: right;
|
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene__idle strong {
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
font-size: 0.68rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene__idle span,
|
|
||||||
.polygon-rover-scene__error {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.56rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-scene__error {
|
|
||||||
position: absolute;
|
|
||||||
inset: 50% auto auto 50%;
|
|
||||||
margin: 0;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-live-telemetry {
|
|
||||||
display: grid;
|
|
||||||
align-content: start;
|
|
||||||
gap: 0.42rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-live-telemetry > .polygon-rover-controls {
|
|
||||||
gap: 0.55rem;
|
|
||||||
border: 1px solid rgb(var(--nodedc-accent-rgb) / 0.24);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 0 0, rgb(var(--nodedc-accent-rgb) / 0.1), transparent 58%),
|
|
||||||
rgb(255 255 255 / 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-controls__heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-controls__heading > span {
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
font-size: 0.62rem;
|
|
||||||
font-weight: 680;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-controls__grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 0.38rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-controls__grid > button:last-child {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-rover-controls small {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
|
||||||
font-size: 0.53rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-live-telemetry > div:not(.polygon-live-boundary) {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
gap: 0.22rem;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
background: rgb(255 255 255 / 0.028);
|
|
||||||
padding: 0.65rem 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-live-telemetry span {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.55rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-live-telemetry strong {
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
|
||||||
font-size: 0.62rem;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-live-boundary {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.4rem;
|
|
||||||
margin-top: 0.2rem;
|
|
||||||
border: 1px solid rgb(var(--nodedc-success-rgb) / 0.18);
|
|
||||||
border-radius: 0.8rem;
|
|
||||||
background: rgb(var(--nodedc-success-rgb) / 0.035);
|
|
||||||
padding: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-live-boundary p,
|
|
||||||
.polygon-live-error {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.56rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-live-error {
|
|
||||||
border-radius: 0.65rem;
|
|
||||||
background: rgb(var(--nodedc-danger-rgb) / 0.08);
|
|
||||||
padding: 0.6rem;
|
|
||||||
color: rgb(var(--nodedc-danger-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-message {
|
|
||||||
display: grid;
|
|
||||||
min-height: 18rem;
|
|
||||||
place-items: start;
|
|
||||||
align-content: center;
|
|
||||||
gap: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-message h2,
|
|
||||||
.polygon-run-message p,
|
|
||||||
.polygon-run-panel-heading h3,
|
|
||||||
.polygon-run-artifacts h3,
|
|
||||||
.polygon-run-limitations h3 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-message h2 {
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
font-size: 1.35rem;
|
|
||||||
letter-spacing: -0.035em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-message p {
|
|
||||||
max-width: 38rem;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-lead-status {
|
|
||||||
display: grid;
|
|
||||||
justify-items: end;
|
|
||||||
gap: 0.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-lead-status > span {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
|
||||||
font-size: 0.58rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-metrics {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 0.65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-metrics > div {
|
|
||||||
display: grid;
|
|
||||||
min-height: 5.5rem;
|
|
||||||
align-content: space-between;
|
|
||||||
gap: 0.32rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
background: var(--station-panel);
|
|
||||||
padding: 0.9rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-metrics span,
|
|
||||||
.polygon-run-metrics small,
|
|
||||||
.polygon-run-panel-heading > span {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-metrics strong {
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 680;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-metrics small {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-layout,
|
|
||||||
.polygon-run-evidence-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(17rem, 0.72fr) minmax(0, 1.28fr);
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-panel-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-panel-heading h3,
|
|
||||||
.polygon-run-artifacts h3,
|
|
||||||
.polygon-run-limitations h3 {
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
font-size: 0.94rem;
|
|
||||||
letter-spacing: -0.025em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-list button {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.65rem;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0.8rem;
|
|
||||||
background: rgb(255 255 255 / 0.025);
|
|
||||||
padding: 0.65rem 0.7rem;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-list button:hover,
|
|
||||||
.polygon-run-list button:focus-visible,
|
|
||||||
.polygon-run-list button[data-active="true"] {
|
|
||||||
outline: 0;
|
|
||||||
background: rgb(255 255 255 / 0.07);
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-list button > i,
|
|
||||||
.polygon-run-providers i {
|
|
||||||
width: 0.48rem;
|
|
||||||
height: 0.48rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--nodedc-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-list button > i[data-state="completed"],
|
|
||||||
.polygon-run-providers i {
|
|
||||||
background: rgb(var(--nodedc-success-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-list button > i[data-state="failed"],
|
|
||||||
.polygon-run-list button > i[data-state="aborted"] {
|
|
||||||
background: rgb(var(--nodedc-danger-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-list button > i[data-state="running"] {
|
|
||||||
background: var(--nodedc-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-list button > span {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
gap: 0.18rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-list strong,
|
|
||||||
.polygon-run-list small,
|
|
||||||
.polygon-run-list em {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-list strong {
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
font-size: 0.66rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-list small,
|
|
||||||
.polygon-run-list em {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.56rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-list em {
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-identity dl {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 0.3rem 1rem;
|
|
||||||
margin: 0.9rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-identity dl > div {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
grid-template-columns: 7rem minmax(0, 1fr);
|
|
||||||
gap: 0.7rem;
|
|
||||||
border-bottom: 1px solid var(--station-hairline);
|
|
||||||
padding: 0.48rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-identity dt,
|
|
||||||
.polygon-run-identity dd {
|
|
||||||
overflow: hidden;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.61rem;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-identity dt {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-identity dd {
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-safety-boundary {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.45rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
border-radius: 0.8rem;
|
|
||||||
background: rgb(255 255 255 / 0.035);
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-safety-boundary p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.59rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-providers {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 0.55rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-providers > div {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
|
||||||
gap: 0.22rem 0.55rem;
|
|
||||||
border-radius: 0.85rem;
|
|
||||||
background: var(--station-panel);
|
|
||||||
padding: 0.72rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-providers i {
|
|
||||||
grid-row: 1 / 3;
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-providers span {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
gap: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-providers strong,
|
|
||||||
.polygon-run-providers small,
|
|
||||||
.polygon-run-providers code {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-providers strong {
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
font-size: 0.64rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-providers small,
|
|
||||||
.polygon-run-providers code {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.54rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-providers code {
|
|
||||||
grid-column: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-event-list {
|
|
||||||
display: grid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-event-list article {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
grid-template-columns: 2.3rem minmax(0, 1fr) auto;
|
|
||||||
align-items: start;
|
|
||||||
gap: 0.8rem;
|
|
||||||
border-top: 1px solid var(--station-hairline);
|
|
||||||
padding: 0.75rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-event-list article:first-child {
|
|
||||||
border-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-event-list article > div {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
gap: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-event-list strong {
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
font-size: 0.66rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-event-list small,
|
|
||||||
.polygon-run-event-list article > span:last-child,
|
|
||||||
.polygon-run-event-sequence {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.55rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-event-list code {
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
font-size: 0.56rem;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-event-sequence {
|
|
||||||
border-radius: 0.4rem;
|
|
||||||
background: rgb(255 255 255 / 0.04);
|
|
||||||
padding: 0.2rem 0.3rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-artifacts > div {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin-top: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-artifacts article {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.8rem;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
background: rgb(255 255 255 / 0.025);
|
|
||||||
padding: 0.65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-artifacts article > span {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
gap: 0.18rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-artifacts strong,
|
|
||||||
.polygon-run-artifacts small,
|
|
||||||
.polygon-run-artifacts code {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-artifacts strong {
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
font-size: 0.62rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-artifacts small,
|
|
||||||
.polygon-run-artifacts code,
|
|
||||||
.polygon-run-artifacts p,
|
|
||||||
.polygon-run-limitations li {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.56rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-artifacts p {
|
|
||||||
margin: 0.9rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-limitations ul {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.55rem;
|
|
||||||
margin: 0.85rem 0 0;
|
|
||||||
padding-left: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.polygon-run-limitations li {
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.capability-summary {
|
.capability-summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
|
|
||||||
|
|
@ -1,316 +0,0 @@
|
||||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
GlassSurface,
|
|
||||||
StatusBadge,
|
|
||||||
} from "@nodedc/ui-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
fetchPolygonVehicleState,
|
|
||||||
fetchPolygonWorkerStatus,
|
|
||||||
sendPolygonRoverCommand,
|
|
||||||
startPolygonWorker,
|
|
||||||
stopPolygonWorker,
|
|
||||||
type PolygonCommandAcceptance,
|
|
||||||
type PolygonVehicleState,
|
|
||||||
type PolygonWorkerStatus,
|
|
||||||
} from "../core/polygon/liveWorker";
|
|
||||||
|
|
||||||
interface ControlIntent {
|
|
||||||
id: "forward" | "reverse" | "left" | "right";
|
|
||||||
label: string;
|
|
||||||
speedMps: number;
|
|
||||||
steeringNormalized: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONTROL_INTENTS: ControlIntent[] = [
|
|
||||||
{ id: "forward", label: "Вперёд", speedMps: 1, steeringNormalized: 0 },
|
|
||||||
{ id: "left", label: "Влево", speedMps: 0.8, steeringNormalized: -0.55 },
|
|
||||||
{ id: "right", label: "Вправо", speedMps: 0.8, steeringNormalized: 0.55 },
|
|
||||||
{ id: "reverse", label: "Назад", speedMps: -0.65, steeringNormalized: 0 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const PolygonRoverScene = lazy(async () => {
|
|
||||||
const module = await import("./PolygonRoverScene");
|
|
||||||
return { default: module.PolygonRoverScene };
|
|
||||||
});
|
|
||||||
|
|
||||||
function message(error: unknown): string {
|
|
||||||
if (error instanceof Error && error.message.trim()) return error.message;
|
|
||||||
return "Simulation Worker не подтвердил операцию.";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PolygonLivePanel() {
|
|
||||||
const [worker, setWorker] = useState<PolygonWorkerStatus | null>(null);
|
|
||||||
const [live, setLive] = useState<PolygonVehicleState | null>(null);
|
|
||||||
const [trajectory, setTrajectory] = useState<PolygonVehicleState[]>([]);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [actionBusy, setActionBusy] = useState(false);
|
|
||||||
const [controlIntent, setControlIntent] = useState<ControlIntent | null>(null);
|
|
||||||
const [commandAcceptance, setCommandAcceptance] =
|
|
||||||
useState<PolygonCommandAcceptance | null>(null);
|
|
||||||
const [generation, setGeneration] = useState(0);
|
|
||||||
const commandInFlight = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (actionBusy) return;
|
|
||||||
const controller = new AbortController();
|
|
||||||
let timer: number | null = null;
|
|
||||||
const poll = async () => {
|
|
||||||
try {
|
|
||||||
const status = await fetchPolygonWorkerStatus({ signal: controller.signal });
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
setWorker(status);
|
|
||||||
setError(null);
|
|
||||||
if (status.available && status.activeRunId && status.runState === "running") {
|
|
||||||
const state = await fetchPolygonVehicleState({ signal: controller.signal });
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
setLive(state);
|
|
||||||
setTrajectory((current) => {
|
|
||||||
const sameRun = current[0]?.runId === state.runId;
|
|
||||||
const next = sameRun ? [...current, state] : [state];
|
|
||||||
return next.slice(-120);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setLive(null);
|
|
||||||
if (!status.activeRunId) setTrajectory([]);
|
|
||||||
}
|
|
||||||
} catch (pollError) {
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
setError(message(pollError));
|
|
||||||
} finally {
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
timer = window.setTimeout(() => void poll(), 1_000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void poll();
|
|
||||||
return () => {
|
|
||||||
controller.abort();
|
|
||||||
if (timer !== null) window.clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [actionBusy, generation]);
|
|
||||||
|
|
||||||
const runActive = Boolean(worker?.activeRunId);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const runId = worker?.activeRunId;
|
|
||||||
if (!runId || worker.runState !== "running" || !controlIntent) return;
|
|
||||||
const controller = new AbortController();
|
|
||||||
let timer: number | null = null;
|
|
||||||
const publish = async () => {
|
|
||||||
if (commandInFlight.current || controller.signal.aborted) return;
|
|
||||||
commandInFlight.current = true;
|
|
||||||
try {
|
|
||||||
const accepted = await sendPolygonRoverCommand(runId, {
|
|
||||||
speedMps: controlIntent.speedMps,
|
|
||||||
steeringNormalized: controlIntent.steeringNormalized,
|
|
||||||
idempotencyKey: crypto.randomUUID(),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
setCommandAcceptance(accepted);
|
|
||||||
setError(null);
|
|
||||||
} catch (commandError) {
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
setControlIntent(null);
|
|
||||||
setError(message(commandError));
|
|
||||||
} finally {
|
|
||||||
commandInFlight.current = false;
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
timer = window.setTimeout(() => void publish(), 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void publish();
|
|
||||||
return () => {
|
|
||||||
controller.abort();
|
|
||||||
if (timer !== null) window.clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [controlIntent, worker?.activeRunId, worker?.runState]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!worker?.activeRunId) {
|
|
||||||
setControlIntent(null);
|
|
||||||
setCommandAcceptance(null);
|
|
||||||
}
|
|
||||||
}, [worker?.activeRunId]);
|
|
||||||
|
|
||||||
const controllerProvider = worker?.providerProfile?.providers.find((provider) =>
|
|
||||||
provider.roles.includes("controller"));
|
|
||||||
const providerStack = worker?.providerProfile?.providers
|
|
||||||
.map((provider) => provider.providerId)
|
|
||||||
.join(" · ");
|
|
||||||
|
|
||||||
const stopMotion = async () => {
|
|
||||||
const runId = worker?.activeRunId;
|
|
||||||
setControlIntent(null);
|
|
||||||
if (!runId || worker?.runState !== "running") return;
|
|
||||||
try {
|
|
||||||
const accepted = await sendPolygonRoverCommand(runId, {
|
|
||||||
speedMps: 0,
|
|
||||||
steeringNormalized: 0,
|
|
||||||
idempotencyKey: crypto.randomUUID(),
|
|
||||||
});
|
|
||||||
setCommandAcceptance(accepted);
|
|
||||||
setError(null);
|
|
||||||
} catch (commandError) {
|
|
||||||
setError(message(commandError));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const runAction = async () => {
|
|
||||||
if (!worker?.controlAvailable || actionBusy) return;
|
|
||||||
setActionBusy(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const idempotencyKey = crypto.randomUUID();
|
|
||||||
if (worker.activeRunId) {
|
|
||||||
await stopMotion();
|
|
||||||
}
|
|
||||||
const next = worker.activeRunId
|
|
||||||
? await stopPolygonWorker(worker.activeRunId, { idempotencyKey })
|
|
||||||
: await startPolygonWorker({ idempotencyKey });
|
|
||||||
setWorker(next);
|
|
||||||
if (!next.activeRunId) {
|
|
||||||
setLive(null);
|
|
||||||
setTrajectory([]);
|
|
||||||
}
|
|
||||||
setGeneration((value) => value + 1);
|
|
||||||
} catch (actionError) {
|
|
||||||
setError(message(actionError));
|
|
||||||
} finally {
|
|
||||||
setActionBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GlassSurface className="polygon-live-panel" padding="lg">
|
|
||||||
<header className="polygon-live-heading">
|
|
||||||
<div>
|
|
||||||
<span className="section-eyebrow">ПОЛИГОН / LIVE</span>
|
|
||||||
<h2>Stock Ackermann Rover</h2>
|
|
||||||
<p>
|
|
||||||
Провайдеры симуляции исполняются на отдельном worker; браузер показывает
|
|
||||||
канонический срез ENU/FLU.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<StatusBadge
|
|
||||||
tone={worker?.available ? (runActive ? "accent" : "success") : "neutral"}
|
|
||||||
>
|
|
||||||
{worker?.available ? (runActive ? "Симуляция идёт" : "Worker готов") : "Worker offline"}
|
|
||||||
</StatusBadge>
|
|
||||||
<Button
|
|
||||||
size="compact"
|
|
||||||
variant={runActive ? "secondary" : "primary"}
|
|
||||||
disabled={!worker?.controlAvailable || actionBusy}
|
|
||||||
onClick={() => void runAction()}
|
|
||||||
>
|
|
||||||
{actionBusy ? "Ожидаем провайдеры…" : runActive ? "Остановить" : "Запустить"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="polygon-live-layout">
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="polygon-rover-scene polygon-rover-scene--loading">
|
|
||||||
Загружаем 3D-сцену…
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PolygonRoverScene
|
|
||||||
live={live}
|
|
||||||
trajectory={trajectory}
|
|
||||||
workerAvailable={Boolean(worker?.available)}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
<div className="polygon-live-telemetry">
|
|
||||||
<div className="polygon-rover-controls">
|
|
||||||
<div className="polygon-rover-controls__heading">
|
|
||||||
<span>
|
|
||||||
Управление {controllerProvider?.providerId ?? "машиной"}
|
|
||||||
</span>
|
|
||||||
<StatusBadge tone={commandAcceptance?.controllerReady ? "success" : "neutral"}>
|
|
||||||
{commandAcceptance?.controllerReady ? "Controller ready" : "Ожидает прогона"}
|
|
||||||
</StatusBadge>
|
|
||||||
</div>
|
|
||||||
<div className="polygon-rover-controls__grid">
|
|
||||||
{CONTROL_INTENTS.map((intent) => (
|
|
||||||
<Button
|
|
||||||
key={intent.id}
|
|
||||||
size="compact"
|
|
||||||
variant={controlIntent?.id === intent.id ? "primary" : "secondary"}
|
|
||||||
disabled={!runActive || worker?.runState !== "running" || actionBusy}
|
|
||||||
onClick={() => setControlIntent(intent)}
|
|
||||||
>
|
|
||||||
{intent.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
size="compact"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={!runActive || worker?.runState !== "running" || actionBusy}
|
|
||||||
onClick={() => void stopMotion()}
|
|
||||||
>
|
|
||||||
Стоп
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<small>
|
|
||||||
{controlIntent
|
|
||||||
? `${controlIntent.label}: ${controlIntent.speedMps.toFixed(2)} m/s · steering ${controlIntent.steeringNormalized.toFixed(2)}`
|
|
||||||
: "Команда 250 мс; потеря браузера автоматически обнуляет скорость."}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Прогон</span>
|
|
||||||
<strong>{worker?.activeRunId ?? "—"}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Sim time</span>
|
|
||||||
<strong>{live ? `${(live.simTimeNs / 1e9).toFixed(2)} s` : "—"}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Позиция ENU</span>
|
|
||||||
<strong>
|
|
||||||
{live
|
|
||||||
? `${live.position.x.toFixed(2)} · ${live.position.y.toFixed(2)} · ${live.position.z.toFixed(2)} m`
|
|
||||||
: "—"}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Провайдерный стек</span>
|
|
||||||
<strong>{providerStack || "—"}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Активные процессы</span>
|
|
||||||
<strong>{worker?.activeProviderIds.join(" · ") || "—"}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Источник live-состояния</span>
|
|
||||||
<strong>{live ? `${live.sourceProvider} · ${live.sourceSignal}` : "—"}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Последняя команда</span>
|
|
||||||
<strong>
|
|
||||||
{commandAcceptance
|
|
||||||
? `#${commandAcceptance.sequence} · ${commandAcceptance.speedMps.toFixed(2)} m/s · ${commandAcceptance.steeringNormalized.toFixed(2)}`
|
|
||||||
: "—"}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
<div className="polygon-live-boundary">
|
|
||||||
<StatusBadge tone="success">Virtual only</StatusBadge>
|
|
||||||
<p>
|
|
||||||
Команды проходят только через заявленный controller provider
|
|
||||||
{controllerProvider ? ` ${controllerProvider.providerId}` : ""}. Физического
|
|
||||||
actuator authority нет; live-поза остаётся диагностической.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{error && <p className="polygon-live-error">{error}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</GlassSurface>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,387 +0,0 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import * as THREE from "three";
|
|
||||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
|
||||||
|
|
||||||
import type { PolygonVehicleState } from "../core/polygon/liveWorker";
|
|
||||||
|
|
||||||
interface PolygonRoverSceneProps {
|
|
||||||
live: PolygonVehicleState | null;
|
|
||||||
trajectory: PolygonVehicleState[];
|
|
||||||
workerAvailable: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CAMERA_POSITION = new THREE.Vector3(5.3, 3.9, 5.3);
|
|
||||||
const CAMERA_TARGET = new THREE.Vector3(0, 0.55, 0);
|
|
||||||
|
|
||||||
function threePosition(state: PolygonVehicleState): THREE.Vector3 {
|
|
||||||
return new THREE.Vector3(
|
|
||||||
state.position.x,
|
|
||||||
Math.max(0, state.position.z),
|
|
||||||
-state.position.y,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function yawRadians(state: PolygonVehicleState): number {
|
|
||||||
const { x, y, z, w } = state.orientation;
|
|
||||||
return Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRover(): THREE.Group {
|
|
||||||
const rover = new THREE.Group();
|
|
||||||
rover.name = "ackermann-rover";
|
|
||||||
|
|
||||||
const bodyMaterial = new THREE.MeshStandardMaterial({
|
|
||||||
color: 0x2879ff,
|
|
||||||
metalness: 0.42,
|
|
||||||
roughness: 0.3,
|
|
||||||
});
|
|
||||||
const darkMaterial = new THREE.MeshStandardMaterial({
|
|
||||||
color: 0x111820,
|
|
||||||
metalness: 0.25,
|
|
||||||
roughness: 0.58,
|
|
||||||
});
|
|
||||||
const trimMaterial = new THREE.MeshStandardMaterial({
|
|
||||||
color: 0xb8c9dc,
|
|
||||||
metalness: 0.72,
|
|
||||||
roughness: 0.22,
|
|
||||||
});
|
|
||||||
const glassMaterial = new THREE.MeshPhysicalMaterial({
|
|
||||||
color: 0x72b7d8,
|
|
||||||
metalness: 0.05,
|
|
||||||
roughness: 0.08,
|
|
||||||
transmission: 0.3,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.72,
|
|
||||||
});
|
|
||||||
const tireMaterial = new THREE.MeshStandardMaterial({
|
|
||||||
color: 0x080a0d,
|
|
||||||
metalness: 0.05,
|
|
||||||
roughness: 0.9,
|
|
||||||
});
|
|
||||||
const headlightMaterial = new THREE.MeshStandardMaterial({
|
|
||||||
color: 0xf2fbff,
|
|
||||||
emissive: 0x8ecbff,
|
|
||||||
emissiveIntensity: 2.4,
|
|
||||||
});
|
|
||||||
const tailMaterial = new THREE.MeshStandardMaterial({
|
|
||||||
color: 0xff3355,
|
|
||||||
emissive: 0xff1838,
|
|
||||||
emissiveIntensity: 1.7,
|
|
||||||
});
|
|
||||||
|
|
||||||
const addBox = (
|
|
||||||
name: string,
|
|
||||||
size: [number, number, number],
|
|
||||||
position: [number, number, number],
|
|
||||||
material: THREE.Material,
|
|
||||||
) => {
|
|
||||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(...size), material);
|
|
||||||
mesh.name = name;
|
|
||||||
mesh.position.set(...position);
|
|
||||||
mesh.castShadow = true;
|
|
||||||
mesh.receiveShadow = true;
|
|
||||||
rover.add(mesh);
|
|
||||||
return mesh;
|
|
||||||
};
|
|
||||||
|
|
||||||
addBox("lower-chassis", [2.55, 0.34, 1.32], [0, 0.58, 0], darkMaterial);
|
|
||||||
addBox("body-shell", [2.34, 0.42, 1.18], [0.02, 0.86, 0], bodyMaterial);
|
|
||||||
addBox("front-hood", [0.92, 0.24, 1.06], [0.69, 1.16, 0], bodyMaterial);
|
|
||||||
addBox("equipment-cabin", [0.82, 0.58, 1.0], [-0.35, 1.22, 0], darkMaterial);
|
|
||||||
addBox("windshield", [0.04, 0.42, 0.88], [0.08, 1.31, 0], glassMaterial)
|
|
||||||
.rotation.z = -0.18;
|
|
||||||
addBox("front-bumper", [0.16, 0.22, 1.48], [1.34, 0.56, 0], trimMaterial);
|
|
||||||
addBox("rear-bumper", [0.15, 0.2, 1.42], [-1.33, 0.55, 0], trimMaterial);
|
|
||||||
|
|
||||||
for (const x of [-0.82, 0.82]) {
|
|
||||||
for (const z of [-0.76, 0.76]) {
|
|
||||||
const wheel = new THREE.Mesh(
|
|
||||||
new THREE.CylinderGeometry(0.42, 0.42, 0.28, 24),
|
|
||||||
tireMaterial,
|
|
||||||
);
|
|
||||||
wheel.name = x > 0 ? "front-wheel" : "rear-wheel";
|
|
||||||
wheel.position.set(x, 0.43, z);
|
|
||||||
wheel.rotation.x = Math.PI / 2;
|
|
||||||
wheel.castShadow = true;
|
|
||||||
rover.add(wheel);
|
|
||||||
|
|
||||||
const hub = new THREE.Mesh(
|
|
||||||
new THREE.CylinderGeometry(0.16, 0.16, 0.3, 20),
|
|
||||||
trimMaterial,
|
|
||||||
);
|
|
||||||
hub.position.copy(wheel.position);
|
|
||||||
hub.rotation.x = Math.PI / 2;
|
|
||||||
hub.castShadow = true;
|
|
||||||
rover.add(hub);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const z of [-0.4, 0.4]) {
|
|
||||||
addBox("headlight", [0.08, 0.16, 0.22], [1.22, 0.9, z], headlightMaterial);
|
|
||||||
addBox("tail-light", [0.06, 0.15, 0.2], [-1.19, 0.88, z], tailMaterial);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mast = new THREE.Mesh(
|
|
||||||
new THREE.CylinderGeometry(0.045, 0.055, 0.7, 16),
|
|
||||||
trimMaterial,
|
|
||||||
);
|
|
||||||
mast.position.set(-0.45, 1.85, 0);
|
|
||||||
mast.castShadow = true;
|
|
||||||
rover.add(mast);
|
|
||||||
|
|
||||||
const lidar = new THREE.Mesh(
|
|
||||||
new THREE.CylinderGeometry(0.22, 0.22, 0.14, 32),
|
|
||||||
darkMaterial,
|
|
||||||
);
|
|
||||||
lidar.name = "lidar";
|
|
||||||
lidar.position.set(-0.45, 2.22, 0);
|
|
||||||
lidar.castShadow = true;
|
|
||||||
rover.add(lidar);
|
|
||||||
|
|
||||||
const lidarBand = new THREE.Mesh(
|
|
||||||
new THREE.CylinderGeometry(0.225, 0.225, 0.045, 32),
|
|
||||||
glassMaterial,
|
|
||||||
);
|
|
||||||
lidarBand.position.set(-0.45, 2.22, 0);
|
|
||||||
rover.add(lidarBand);
|
|
||||||
|
|
||||||
return rover;
|
|
||||||
}
|
|
||||||
|
|
||||||
function disposeScene(scene: THREE.Scene) {
|
|
||||||
scene.traverse((object) => {
|
|
||||||
if (!(object instanceof THREE.Mesh || object instanceof THREE.Line)) return;
|
|
||||||
object.geometry.dispose();
|
|
||||||
const materials = Array.isArray(object.material) ? object.material : [object.material];
|
|
||||||
materials.forEach((material) => material.dispose());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PolygonRoverScene({
|
|
||||||
live,
|
|
||||||
trajectory,
|
|
||||||
workerAvailable,
|
|
||||||
}: PolygonRoverSceneProps) {
|
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
|
||||||
const controlsRef = useRef<OrbitControls | null>(null);
|
|
||||||
const roverRef = useRef<THREE.Group | null>(null);
|
|
||||||
const trajectoryRef = useRef<THREE.Line<THREE.BufferGeometry, THREE.LineBasicMaterial> | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const desiredPositionRef = useRef(new THREE.Vector3());
|
|
||||||
const desiredYawRef = useRef(0);
|
|
||||||
const followRef = useRef(true);
|
|
||||||
const [followRover, setFollowRover] = useState(true);
|
|
||||||
const [renderError, setRenderError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
desiredPositionRef.current.copy(live ? threePosition(live) : new THREE.Vector3());
|
|
||||||
desiredYawRef.current = live ? -yawRadians(live) : 0;
|
|
||||||
}, [live]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
followRef.current = followRover;
|
|
||||||
}, [followRover]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const line = trajectoryRef.current;
|
|
||||||
if (!line) return;
|
|
||||||
const points = trajectory.map((state) => {
|
|
||||||
const point = threePosition(state);
|
|
||||||
point.y += 0.06;
|
|
||||||
return point;
|
|
||||||
});
|
|
||||||
line.geometry.setFromPoints(points);
|
|
||||||
line.geometry.computeBoundingSphere();
|
|
||||||
}, [trajectory]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const host = hostRef.current;
|
|
||||||
if (!host) return;
|
|
||||||
|
|
||||||
let renderer: THREE.WebGLRenderer;
|
|
||||||
try {
|
|
||||||
renderer = new THREE.WebGLRenderer({
|
|
||||||
antialias: true,
|
|
||||||
alpha: true,
|
|
||||||
powerPreference: "high-performance",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
setRenderError("Браузер не смог создать WebGL-сцену.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
||||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
||||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
||||||
renderer.toneMappingExposure = 1.06;
|
|
||||||
renderer.shadowMap.enabled = true;
|
|
||||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
||||||
renderer.domElement.setAttribute("aria-label", "Интерактивная 3D-сцена Ackermann Rover");
|
|
||||||
host.prepend(renderer.domElement);
|
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
|
||||||
scene.fog = new THREE.FogExp2(0x081019, 0.025);
|
|
||||||
|
|
||||||
const camera = new THREE.PerspectiveCamera(46, 1, 0.08, 220);
|
|
||||||
camera.position.copy(CAMERA_POSITION);
|
|
||||||
cameraRef.current = camera;
|
|
||||||
|
|
||||||
const controls = new OrbitControls(camera, renderer.domElement);
|
|
||||||
controls.enableDamping = true;
|
|
||||||
controls.dampingFactor = 0.07;
|
|
||||||
controls.enablePan = true;
|
|
||||||
controls.enableZoom = true;
|
|
||||||
controls.minDistance = 2.2;
|
|
||||||
controls.maxDistance = 70;
|
|
||||||
controls.minPolarAngle = 0.04;
|
|
||||||
controls.maxPolarAngle = Math.PI / 2 - 0.02;
|
|
||||||
controls.target.copy(CAMERA_TARGET);
|
|
||||||
controls.update();
|
|
||||||
controlsRef.current = controls;
|
|
||||||
|
|
||||||
const hemisphere = new THREE.HemisphereLight(0xaed7ff, 0x10151b, 2.2);
|
|
||||||
scene.add(hemisphere);
|
|
||||||
const key = new THREE.DirectionalLight(0xffffff, 3.7);
|
|
||||||
key.position.set(5, 9, 4);
|
|
||||||
key.castShadow = true;
|
|
||||||
key.shadow.mapSize.set(1024, 1024);
|
|
||||||
key.shadow.camera.near = 1;
|
|
||||||
key.shadow.camera.far = 32;
|
|
||||||
key.shadow.camera.left = -10;
|
|
||||||
key.shadow.camera.right = 10;
|
|
||||||
key.shadow.camera.top = 10;
|
|
||||||
key.shadow.camera.bottom = -10;
|
|
||||||
scene.add(key);
|
|
||||||
const rim = new THREE.DirectionalLight(0x397dff, 2.1);
|
|
||||||
rim.position.set(-5, 3, -5);
|
|
||||||
scene.add(rim);
|
|
||||||
|
|
||||||
const ground = new THREE.Mesh(
|
|
||||||
new THREE.PlaneGeometry(160, 160),
|
|
||||||
new THREE.MeshStandardMaterial({
|
|
||||||
color: 0x0b1118,
|
|
||||||
metalness: 0.12,
|
|
||||||
roughness: 0.9,
|
|
||||||
side: THREE.DoubleSide,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
ground.name = "ground";
|
|
||||||
ground.rotation.x = -Math.PI / 2;
|
|
||||||
ground.receiveShadow = true;
|
|
||||||
scene.add(ground);
|
|
||||||
|
|
||||||
const grid = new THREE.GridHelper(160, 160, 0x2c7cff, 0x263746);
|
|
||||||
grid.position.y = 0.012;
|
|
||||||
const gridMaterials = Array.isArray(grid.material) ? grid.material : [grid.material];
|
|
||||||
gridMaterials.forEach((material) => {
|
|
||||||
material.transparent = true;
|
|
||||||
material.opacity = 0.34;
|
|
||||||
});
|
|
||||||
scene.add(grid);
|
|
||||||
|
|
||||||
const rover = createRover();
|
|
||||||
roverRef.current = rover;
|
|
||||||
scene.add(rover);
|
|
||||||
|
|
||||||
const line = new THREE.Line(
|
|
||||||
new THREE.BufferGeometry(),
|
|
||||||
new THREE.LineBasicMaterial({
|
|
||||||
color: 0x44a6ff,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.92,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
line.name = "ground-truth-trajectory";
|
|
||||||
trajectoryRef.current = line;
|
|
||||||
scene.add(line);
|
|
||||||
|
|
||||||
const resize = () => {
|
|
||||||
const width = Math.max(host.clientWidth, 1);
|
|
||||||
const height = Math.max(host.clientHeight, 1);
|
|
||||||
camera.aspect = width / height;
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
renderer.setSize(width, height, false);
|
|
||||||
};
|
|
||||||
const observer = new ResizeObserver(resize);
|
|
||||||
observer.observe(host);
|
|
||||||
resize();
|
|
||||||
|
|
||||||
const clock = new THREE.Clock();
|
|
||||||
let animationFrame = 0;
|
|
||||||
const render = () => {
|
|
||||||
animationFrame = window.requestAnimationFrame(render);
|
|
||||||
const delta = Math.min(clock.getDelta(), 0.1);
|
|
||||||
const smoothing = 1 - Math.exp(-8 * delta);
|
|
||||||
rover.position.lerp(desiredPositionRef.current, smoothing);
|
|
||||||
rover.rotation.y = THREE.MathUtils.lerp(rover.rotation.y, desiredYawRef.current, smoothing);
|
|
||||||
if (followRef.current) {
|
|
||||||
const nextTarget = rover.position.clone();
|
|
||||||
nextTarget.y += 0.72;
|
|
||||||
controls.target.lerp(nextTarget, smoothing);
|
|
||||||
}
|
|
||||||
controls.update();
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
};
|
|
||||||
render();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.cancelAnimationFrame(animationFrame);
|
|
||||||
observer.disconnect();
|
|
||||||
controls.dispose();
|
|
||||||
renderer.dispose();
|
|
||||||
renderer.domElement.remove();
|
|
||||||
disposeScene(scene);
|
|
||||||
cameraRef.current = null;
|
|
||||||
controlsRef.current = null;
|
|
||||||
roverRef.current = null;
|
|
||||||
trajectoryRef.current = null;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetCamera = () => {
|
|
||||||
const camera = cameraRef.current;
|
|
||||||
const controls = controlsRef.current;
|
|
||||||
if (!camera || !controls) return;
|
|
||||||
camera.position.copy(CAMERA_POSITION);
|
|
||||||
const rover = roverRef.current;
|
|
||||||
controls.target.copy(rover ? rover.position.clone().add(new THREE.Vector3(0, 0.55, 0)) : CAMERA_TARGET);
|
|
||||||
controls.update();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="polygon-rover-scene" aria-label="Live 3D-полигон">
|
|
||||||
<div ref={hostRef} className="polygon-rover-scene__viewport">
|
|
||||||
{renderError ? <p className="polygon-rover-scene__error">{renderError}</p> : null}
|
|
||||||
</div>
|
|
||||||
<div className="polygon-rover-scene__toolbar">
|
|
||||||
<button type="button" onClick={() => setFollowRover((value) => !value)}>
|
|
||||||
{followRover ? "Следовать за ровером" : "Свободная камера"}
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={resetCamera}>Сбросить ракурс</button>
|
|
||||||
</div>
|
|
||||||
<div className="polygon-rover-scene__help">
|
|
||||||
<span>ЛКМ · вращение</span>
|
|
||||||
<span>Колесо · масштаб</span>
|
|
||||||
<span>ПКМ · панорама</span>
|
|
||||||
</div>
|
|
||||||
<div className="polygon-rover-scene__frames">
|
|
||||||
<span><i data-axis="east" />E</span>
|
|
||||||
<span><i data-axis="north" />N</span>
|
|
||||||
<span><i data-axis="up" />U</span>
|
|
||||||
<small>map_enu · base_link_flu · ground truth</small>
|
|
||||||
</div>
|
|
||||||
{!live ? (
|
|
||||||
<div className="polygon-rover-scene__idle">
|
|
||||||
<strong>{workerAvailable ? "Ackermann Rover готов" : "Нет связи с worker"}</strong>
|
|
||||||
<span>
|
|
||||||
{workerAvailable
|
|
||||||
? "Запустите PX4/Gazebo — модель останется доступна для осмотра."
|
|
||||||
: "Полигон появится в шапке после подтверждения Simulation Worker."}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,327 +0,0 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
GlassSurface,
|
|
||||||
StatusBadge,
|
|
||||||
} from "@nodedc/ui-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
fetchPolygonRunCatalog,
|
|
||||||
fetchPolygonRunDetail,
|
|
||||||
type PolygonRunCatalog,
|
|
||||||
type PolygonRunDetail,
|
|
||||||
type PolygonRunRoute,
|
|
||||||
type PolygonRunState,
|
|
||||||
} from "../core/polygon/runArchive";
|
|
||||||
import { PolygonLivePanel } from "./PolygonLivePanel";
|
|
||||||
|
|
||||||
interface PolygonRunWorkspaceProps {
|
|
||||||
route: PolygonRunRoute;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateLabels: Record<PolygonRunState, string> = {
|
|
||||||
admitted: "Допущен",
|
|
||||||
starting: "Запускается",
|
|
||||||
running: "Выполняется",
|
|
||||||
paused: "На паузе",
|
|
||||||
stopping: "Останавливается",
|
|
||||||
completed: "Завершён",
|
|
||||||
failed: "Ошибка",
|
|
||||||
aborted: "Прерван",
|
|
||||||
};
|
|
||||||
|
|
||||||
function stateTone(
|
|
||||||
state: PolygonRunState,
|
|
||||||
): "success" | "accent" | "warning" | "danger" | "neutral" {
|
|
||||||
if (state === "completed") return "success";
|
|
||||||
if (state === "running") return "accent";
|
|
||||||
if (state === "failed" || state === "aborted") return "danger";
|
|
||||||
if (state === "starting" || state === "stopping" || state === "paused") return "warning";
|
|
||||||
return "neutral";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(value: string | null): string {
|
|
||||||
if (!value) return "—";
|
|
||||||
return new Intl.DateTimeFormat("ru-RU", {
|
|
||||||
dateStyle: "medium",
|
|
||||||
timeStyle: "medium",
|
|
||||||
}).format(new Date(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(value: number): string {
|
|
||||||
if (value === 0) return "0 Б";
|
|
||||||
const units = ["Б", "КБ", "МБ", "ГБ"];
|
|
||||||
const exponent = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
|
||||||
return `${(value / 1024 ** exponent).toLocaleString("ru-RU", {
|
|
||||||
maximumFractionDigits: 1,
|
|
||||||
})} ${units[exponent]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorMessage(error: unknown): string {
|
|
||||||
if (error instanceof Error && error.message.trim()) return error.message;
|
|
||||||
return "Не удалось прочитать доказательства прогона.";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
|
||||||
const [catalog, setCatalog] = useState<PolygonRunCatalog | null>(null);
|
|
||||||
const [detail, setDetail] = useState<PolygonRunDetail | null>(null);
|
|
||||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(route.runId);
|
|
||||||
const [loading, setLoading] = useState(route.error === null);
|
|
||||||
const [error, setError] = useState<string | null>(route.error);
|
|
||||||
const [reloadGeneration, setReloadGeneration] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (route.error) {
|
|
||||||
setLoading(false);
|
|
||||||
setError(route.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const controller = new AbortController();
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const nextCatalog = await fetchPolygonRunCatalog({ signal: controller.signal });
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
setCatalog(nextCatalog);
|
|
||||||
const targetRunId = selectedRunId ?? route.runId ?? nextCatalog.items[0]?.runId ?? null;
|
|
||||||
if (!targetRunId) {
|
|
||||||
setDetail(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextDetail = await fetchPolygonRunDetail(targetRunId, {
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
setSelectedRunId(targetRunId);
|
|
||||||
setDetail(nextDetail);
|
|
||||||
} catch (loadError) {
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
setDetail(null);
|
|
||||||
setError(errorMessage(loadError));
|
|
||||||
} finally {
|
|
||||||
if (!controller.signal.aborted) setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [reloadGeneration, route.error, route.runId, selectedRunId]);
|
|
||||||
|
|
||||||
const visibleEvents = useMemo(
|
|
||||||
() => detail ? [...detail.events].reverse() : [],
|
|
||||||
[detail],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (loading && !detail) {
|
|
||||||
return (
|
|
||||||
<div className="standard-workspace polygon-run-workspace">
|
|
||||||
<PolygonLivePanel />
|
|
||||||
<GlassSurface className="polygon-run-message" padding="lg">
|
|
||||||
<StatusBadge tone="accent">Только чтение</StatusBadge>
|
|
||||||
<h2>Проверяем журнал прогона</h2>
|
|
||||||
<p>Mission Core читает манифест, события и индекс артефактов без запуска провайдеров.</p>
|
|
||||||
</GlassSurface>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="standard-workspace polygon-run-workspace">
|
|
||||||
<PolygonLivePanel />
|
|
||||||
<GlassSurface className="polygon-run-message" padding="lg">
|
|
||||||
<StatusBadge tone="danger">Данные недоступны</StatusBadge>
|
|
||||||
<h2>UI-0 не может открыть прогон</h2>
|
|
||||||
<p>{error}</p>
|
|
||||||
<Button
|
|
||||||
size="compact"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setReloadGeneration((value) => value + 1)}
|
|
||||||
>
|
|
||||||
Повторить чтение
|
|
||||||
</Button>
|
|
||||||
</GlassSurface>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!detail) {
|
|
||||||
return (
|
|
||||||
<div className="standard-workspace polygon-run-workspace">
|
|
||||||
<PolygonLivePanel />
|
|
||||||
<GlassSurface className="polygon-run-message" padding="lg">
|
|
||||||
<StatusBadge tone="neutral">Журнал пуст</StatusBadge>
|
|
||||||
<h2>Квалификационных прогонов пока нет</h2>
|
|
||||||
<p>Экран появится автоматически после публикации первого журнала в read-only источник.</p>
|
|
||||||
</GlassSurface>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { run } = detail;
|
|
||||||
return (
|
|
||||||
<div className="standard-workspace polygon-run-workspace">
|
|
||||||
<PolygonLivePanel />
|
|
||||||
<section className="workspace-lead workspace-lead--compact">
|
|
||||||
<div>
|
|
||||||
<span className="section-eyebrow">ПОЛИГОН / КВАЛИФИКАЦИОННЫЙ ПРОГОН</span>
|
|
||||||
<h2>{run.runId}</h2>
|
|
||||||
<p>
|
|
||||||
Канонический архив Mission Core. Lifecycle live-контура отделён от истории;
|
|
||||||
команд физическим актуаторам и реального управления здесь нет.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="polygon-run-lead-status">
|
|
||||||
<StatusBadge tone={stateTone(run.state)}>{stateLabels[run.state]}</StatusBadge>
|
|
||||||
<span>read-only · {run.reproducibilityTier}</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="polygon-run-metrics" aria-label="Сводка прогона">
|
|
||||||
<div>
|
|
||||||
<span>Состояние</span>
|
|
||||||
<strong>{stateLabels[run.state]}</strong>
|
|
||||||
<small>{run.terminalReason ?? "терминальная причина отсутствует"}</small>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Провайдеры</span>
|
|
||||||
<strong>{run.providers.length}</strong>
|
|
||||||
<small>{run.providerIds.join(" · ")}</small>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>События</span>
|
|
||||||
<strong>{detail.eventsTotal}</strong>
|
|
||||||
<small>{detail.eventsTruncated ? "показан последний фрагмент" : "журнал целиком"}</small>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Команды</span>
|
|
||||||
<strong>{detail.commandCount}</strong>
|
|
||||||
<small>содержимое не публикуется UI-0</small>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="polygon-run-layout">
|
|
||||||
<GlassSurface className="polygon-run-catalog" padding="lg">
|
|
||||||
<header className="polygon-run-panel-heading">
|
|
||||||
<div>
|
|
||||||
<span className="section-eyebrow">ПОСЛЕДНИЕ ПРОГОНЫ</span>
|
|
||||||
<h3>{catalog?.total ?? 0} в источнике</h3>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="compact"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setReloadGeneration((value) => value + 1)}
|
|
||||||
>
|
|
||||||
Обновить
|
|
||||||
</Button>
|
|
||||||
</header>
|
|
||||||
<div className="polygon-run-list">
|
|
||||||
{catalog?.items.map((item) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={item.runId}
|
|
||||||
data-active={item.runId === run.runId ? "true" : undefined}
|
|
||||||
onClick={() => setSelectedRunId(item.runId)}
|
|
||||||
>
|
|
||||||
<i data-state={item.state} aria-hidden="true" />
|
|
||||||
<span>
|
|
||||||
<strong>{item.runId}</strong>
|
|
||||||
<small>{formatTimestamp(item.createdAtUtc)}</small>
|
|
||||||
</span>
|
|
||||||
<em>{stateLabels[item.state]}</em>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</GlassSurface>
|
|
||||||
|
|
||||||
<GlassSurface className="polygon-run-identity" padding="lg">
|
|
||||||
<span className="section-eyebrow">ИДЕНТИЧНОСТЬ И ГРАНИЦА</span>
|
|
||||||
<dl>
|
|
||||||
<div><dt>Сценарий</dt><dd>{run.scenarioGeneration}</dd></div>
|
|
||||||
<div><dt>Профиль</dt><dd>{run.profileGeneration}</dd></div>
|
|
||||||
<div><dt>Host profile</dt><dd>{run.hostProfileId}</dd></div>
|
|
||||||
<div><dt>Mission Core</dt><dd><code>{run.missionCoreCommit.slice(0, 12)}</code></dd></div>
|
|
||||||
<div><dt>Clock</dt><dd><code>{run.clockDomain}</code></dd></div>
|
|
||||||
<div><dt>Seed</dt><dd>{run.seed}</dd></div>
|
|
||||||
<div><dt>Начало</dt><dd>{formatTimestamp(run.startedAtUtc)}</dd></div>
|
|
||||||
<div><dt>Завершение</dt><dd>{formatTimestamp(run.endedAtUtc)}</dd></div>
|
|
||||||
</dl>
|
|
||||||
<div className="polygon-run-safety-boundary">
|
|
||||||
<StatusBadge tone="success">Virtual only</StatusBadge>
|
|
||||||
<p>
|
|
||||||
Actuator authority: {run.authority.actuatorAuthority ? "да" : "нет"} ·
|
|
||||||
direct setpoints: {run.authority.directActuatorSetpointsAllowed ? "да" : "нет"} ·
|
|
||||||
navigation/safety accepted: {run.authority.navigationOrSafetyAccepted ? "да" : "нет"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</GlassSurface>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="polygon-run-providers" aria-label="Провайдеры прогона">
|
|
||||||
{run.providers.map((provider) => (
|
|
||||||
<div key={provider.identifier}>
|
|
||||||
<i aria-hidden="true" />
|
|
||||||
<span>
|
|
||||||
<strong>{provider.identifier}</strong>
|
|
||||||
<small>{provider.version}</small>
|
|
||||||
</span>
|
|
||||||
<code>{provider.revision.slice(0, 16)}</code>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<GlassSurface className="polygon-run-events" padding="lg">
|
|
||||||
<header className="polygon-run-panel-heading">
|
|
||||||
<div>
|
|
||||||
<span className="section-eyebrow">ЖУРНАЛ СОБЫТИЙ</span>
|
|
||||||
<h3>Последние переходы и факты</h3>
|
|
||||||
</div>
|
|
||||||
<span>revision {run.revision}</span>
|
|
||||||
</header>
|
|
||||||
<div className="polygon-run-event-list">
|
|
||||||
{visibleEvents.map((event) => (
|
|
||||||
<article key={event.sequence}>
|
|
||||||
<span className="polygon-run-event-sequence">
|
|
||||||
{String(event.sequence).padStart(3, "0")}
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<strong>{event.eventType}</strong>
|
|
||||||
<small>{formatTimestamp(event.observedAtUtc)}</small>
|
|
||||||
<code>{JSON.stringify(event.payload)}</code>
|
|
||||||
</div>
|
|
||||||
<span>{event.simTimeNs === null ? "host" : `${event.simTimeNs} ns`}</span>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</GlassSurface>
|
|
||||||
|
|
||||||
<div className="polygon-run-evidence-grid">
|
|
||||||
<GlassSurface className="polygon-run-artifacts" padding="lg">
|
|
||||||
<span className="section-eyebrow">АРТЕФАКТЫ</span>
|
|
||||||
<h3>{detail.artifacts.length || run.artifactCount} ссылок в индексе</h3>
|
|
||||||
{detail.artifacts.length ? (
|
|
||||||
<div>
|
|
||||||
{detail.artifacts.map((artifact) => (
|
|
||||||
<article key={artifact.artifactId}>
|
|
||||||
<span>
|
|
||||||
<strong>{artifact.kind}</strong>
|
|
||||||
<small>{artifact.relativePath}</small>
|
|
||||||
</span>
|
|
||||||
<code>{artifact.sha256.slice(0, 12)} · {formatBytes(artifact.byteLength)}</code>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p>В журнале прогона нет зарегистрированных artifact-index записей.</p>
|
|
||||||
)}
|
|
||||||
</GlassSurface>
|
|
||||||
<GlassSurface className="polygon-run-limitations" padding="lg">
|
|
||||||
<span className="section-eyebrow">ЧЕСТНАЯ ГРАНИЦА UI-0</span>
|
|
||||||
<h3>Что этот результат ещё не доказывает</h3>
|
|
||||||
<ul>
|
|
||||||
{detail.limitations.map((limitation) => <li key={limitation}>{limitation}</li>)}
|
|
||||||
</ul>
|
|
||||||
</GlassSurface>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -22,7 +22,6 @@ import {
|
||||||
import { ObservationTimeline } from "../components/ObservationTimeline";
|
import { ObservationTimeline } from "../components/ObservationTimeline";
|
||||||
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
|
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
|
||||||
import type { ObservationSessionReplayLaunch } from "../core/observation/sessionArchive";
|
import type { ObservationSessionReplayLaunch } from "../core/observation/sessionArchive";
|
||||||
import type { PolygonRunRoute } from "../core/polygon/runArchive";
|
|
||||||
import type { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission";
|
import type { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission";
|
||||||
import type {
|
import type {
|
||||||
RecordedAdmissionPhase,
|
RecordedAdmissionPhase,
|
||||||
|
|
@ -56,7 +55,6 @@ import {
|
||||||
} from "../productModel";
|
} from "../productModel";
|
||||||
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
|
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
|
||||||
import type { SceneSettings } from "../sceneSettings";
|
import type { SceneSettings } from "../sceneSettings";
|
||||||
import { PolygonRunWorkspace } from "./PolygonRunWorkspace";
|
|
||||||
|
|
||||||
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
|
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
|
||||||
if (status === "active") return "success";
|
if (status === "active") return "success";
|
||||||
|
|
@ -138,7 +136,6 @@ export interface WorkspaceRendererProps {
|
||||||
cuboids3d: boolean;
|
cuboids3d: boolean;
|
||||||
}) => void;
|
}) => void;
|
||||||
observationLayout: ObservationLayoutController;
|
observationLayout: ObservationLayoutController;
|
||||||
polygonRunRoute: PolygonRunRoute;
|
|
||||||
navigation: WorkspaceNavigation;
|
navigation: WorkspaceNavigation;
|
||||||
spatialControls: {
|
spatialControls: {
|
||||||
View: ComponentType<DevicePluginConnectionProps>;
|
View: ComponentType<DevicePluginConnectionProps>;
|
||||||
|
|
@ -1306,8 +1303,6 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||||
return <MissionWorkspace {...props} />;
|
return <MissionWorkspace {...props} />;
|
||||||
case "catalog":
|
case "catalog":
|
||||||
return <CatalogWorkspace {...props} />;
|
return <CatalogWorkspace {...props} />;
|
||||||
case "polygon-run":
|
|
||||||
return <PolygonRunWorkspace route={props.polygonRunRoute} />;
|
|
||||||
case "device":
|
case "device":
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,525 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { after, before, test } from "node:test";
|
|
||||||
|
|
||||||
import { createServer } from "vite";
|
|
||||||
|
|
||||||
let server;
|
|
||||||
let decodePolygonRunCatalog;
|
|
||||||
let decodePolygonRunDetail;
|
|
||||||
let fetchPolygonRunCatalog;
|
|
||||||
let fetchPolygonRunDetail;
|
|
||||||
let resolvePolygonRunRoute;
|
|
||||||
let PolygonRunContractError;
|
|
||||||
let decodePolygonWorkerStatus;
|
|
||||||
let decodePolygonVehicleState;
|
|
||||||
let decodePolygonCommandAcceptance;
|
|
||||||
let fetchPolygonWorkerStatus;
|
|
||||||
let fetchPolygonVehicleState;
|
|
||||||
let sendPolygonRoverCommand;
|
|
||||||
let startPolygonWorker;
|
|
||||||
let stopPolygonWorker;
|
|
||||||
let PolygonWorkerContractError;
|
|
||||||
let workspaceById;
|
|
||||||
let workspacesForRoot;
|
|
||||||
let rootsForCapabilities;
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
server = await createServer({
|
|
||||||
appType: "custom",
|
|
||||||
logLevel: "silent",
|
|
||||||
server: { middlewareMode: true },
|
|
||||||
});
|
|
||||||
({
|
|
||||||
decodePolygonRunCatalog,
|
|
||||||
decodePolygonRunDetail,
|
|
||||||
fetchPolygonRunCatalog,
|
|
||||||
fetchPolygonRunDetail,
|
|
||||||
resolvePolygonRunRoute,
|
|
||||||
PolygonRunContractError,
|
|
||||||
} = await server.ssrLoadModule("/src/core/polygon/runArchive.ts"));
|
|
||||||
({
|
|
||||||
decodePolygonWorkerStatus,
|
|
||||||
decodePolygonVehicleState,
|
|
||||||
decodePolygonCommandAcceptance,
|
|
||||||
fetchPolygonWorkerStatus,
|
|
||||||
fetchPolygonVehicleState,
|
|
||||||
sendPolygonRoverCommand,
|
|
||||||
startPolygonWorker,
|
|
||||||
stopPolygonWorker,
|
|
||||||
PolygonWorkerContractError,
|
|
||||||
} = await server.ssrLoadModule("/src/core/polygon/liveWorker.ts"));
|
|
||||||
({
|
|
||||||
workspaceById,
|
|
||||||
workspacesForRoot,
|
|
||||||
rootsForCapabilities,
|
|
||||||
} = await server.ssrLoadModule("/src/productModel.ts"));
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
await server?.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
function summary(overrides = {}) {
|
|
||||||
return {
|
|
||||||
run_id: "s1b-6cb1495-20260724t1535z",
|
|
||||||
episode_id: "episode-s1b-6cb1495-20260724t1535z",
|
|
||||||
kind: "simulation_closed_loop",
|
|
||||||
state: "completed",
|
|
||||||
created_at_utc: "2026-07-24T15:35:00Z",
|
|
||||||
started_at_utc: "2026-07-24T15:35:02Z",
|
|
||||||
ended_at_utc: "2026-07-24T15:35:05Z",
|
|
||||||
terminal_reason: "operator-stop-clean",
|
|
||||||
scenario_generation: "px4-v1.17.0-stock-rover-ackermann",
|
|
||||||
profile_generation: "stock-rover-lifecycle-v1",
|
|
||||||
mission_core_commit: "6cb1495a1234567890abcdef1234567890abcdef",
|
|
||||||
host_profile_id: "mission-gpu-s0",
|
|
||||||
reproducibility_tier: "R1",
|
|
||||||
clock_domain: "gazebo:/clock",
|
|
||||||
revision: 5,
|
|
||||||
provider_ids: ["px4-autopilot", "gazebo"],
|
|
||||||
artifact_count: 1,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function catalog(overrides = {}) {
|
|
||||||
return {
|
|
||||||
schema_version: "missioncore.polygon-run-catalog/v1",
|
|
||||||
access: "read-only",
|
|
||||||
items: [summary()],
|
|
||||||
total: 1,
|
|
||||||
limitations: ["Qualification evidence only."],
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function detail(overrides = {}) {
|
|
||||||
return {
|
|
||||||
schema_version: "missioncore.polygon-run-detail/v1",
|
|
||||||
access: "read-only",
|
|
||||||
run: {
|
|
||||||
...summary(),
|
|
||||||
scenario_sha256: "a".repeat(64),
|
|
||||||
profile_sha256: "b".repeat(64),
|
|
||||||
host_profile_sha256: "c".repeat(64),
|
|
||||||
seed: 42,
|
|
||||||
parent_run_id: null,
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
identifier: "px4-autopilot",
|
|
||||||
version: "v1.17.0",
|
|
||||||
revision: "v1.17.0",
|
|
||||||
digest: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
identifier: "gazebo",
|
|
||||||
version: "harmonic",
|
|
||||||
revision: "8.9.0",
|
|
||||||
digest: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
authority: {
|
|
||||||
generation: 1,
|
|
||||||
command_ttl_max_ns: 250_000_000,
|
|
||||||
heartbeat_timeout_monotonic_ns: 500_000_000,
|
|
||||||
simulation_or_shadow_only: true,
|
|
||||||
actuator_authority: false,
|
|
||||||
navigation_or_safety_accepted: false,
|
|
||||||
direct_actuator_setpoints_allowed: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
schema_version: "missioncore.qualification-event/v1",
|
|
||||||
run_id: "s1b-6cb1495-20260724t1535z",
|
|
||||||
sequence: 5,
|
|
||||||
event_type: "lifecycle.state-changed",
|
|
||||||
observed_at_utc: "2026-07-24T15:35:05Z",
|
|
||||||
host_monotonic_ns: 5,
|
|
||||||
sim_time_ns: 2_000_000,
|
|
||||||
payload: {
|
|
||||||
from: "stopping",
|
|
||||||
to: "completed",
|
|
||||||
reason: "operator-stop-clean",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
events_total: 1,
|
|
||||||
events_truncated: false,
|
|
||||||
commands: {
|
|
||||||
count: 0,
|
|
||||||
content_exposed: false,
|
|
||||||
},
|
|
||||||
artifacts: [
|
|
||||||
{
|
|
||||||
artifact_id: "provider-log-index",
|
|
||||||
kind: "log-index",
|
|
||||||
relative_path: "provider-runtime/processes.json",
|
|
||||||
sha256: "a".repeat(64),
|
|
||||||
byte_length: 512,
|
|
||||||
source_of_record: true,
|
|
||||||
sequence: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
limitations: ["Qualification evidence only."],
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function jsonResponse(payload, status = 200) {
|
|
||||||
return new Response(JSON.stringify(payload), {
|
|
||||||
status,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function workerStatus(overrides = {}) {
|
|
||||||
return {
|
|
||||||
schema_version: "missioncore.simulation-worker-status/v2",
|
|
||||||
worker_id: "mission-gpu-s1",
|
|
||||||
transport: "unix",
|
|
||||||
mode: "simulation",
|
|
||||||
available: true,
|
|
||||||
control_available: true,
|
|
||||||
active_run_id: "s1c-6cb1495-20260724t180000z-aabbcc",
|
|
||||||
run_state: "running",
|
|
||||||
active_provider_ids: ["micro-xrce-dds-agent", "px4-gazebo-stock-rover"],
|
|
||||||
provider_profile: {
|
|
||||||
schema_version: "missioncore.simulation-provider-profile/v1",
|
|
||||||
profile_id: "stock-rover-gazebo-px4-s1d",
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provider_id: "gazebo",
|
|
||||||
roles: ["world", "physics", "state", "sensor"],
|
|
||||||
capabilities: [
|
|
||||||
"clock.simulation",
|
|
||||||
"state.vehicle-pose",
|
|
||||||
"truth.ground-truth",
|
|
||||||
"sensor.virtual",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider_id: "px4-ros2-offboard",
|
|
||||||
roles: ["controller"],
|
|
||||||
capabilities: ["command.rover-speed-steering/v1"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider_id: "micro-xrce-dds-agent",
|
|
||||||
roles: ["transport"],
|
|
||||||
capabilities: ["transport.ros2"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
clock: {
|
|
||||||
provider_id: "gazebo",
|
|
||||||
domain: "gazebo:/clock",
|
|
||||||
unit: "nanoseconds",
|
|
||||||
mode: "simulation",
|
|
||||||
},
|
|
||||||
control_profiles: ["rover-speed-steering/v1"],
|
|
||||||
canonical_frames: {
|
|
||||||
world: "map_enu",
|
|
||||||
body: "base_link_flu",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
isolation: {
|
|
||||||
network: "loopback-only-netns",
|
|
||||||
process_identity: "missioncore",
|
|
||||||
artifact_policy: "d-only",
|
|
||||||
},
|
|
||||||
authority: {
|
|
||||||
scope: "virtual-only",
|
|
||||||
actuator_authority: false,
|
|
||||||
direct_actuator_setpoints_allowed: false,
|
|
||||||
},
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function vehicleState(overrides = {}) {
|
|
||||||
return {
|
|
||||||
schema_version: "missioncore.vehicle-state/v1",
|
|
||||||
run_id: "s1c-6cb1495-20260724t180000z-aabbcc",
|
|
||||||
sequence: 7,
|
|
||||||
observed_at_utc: "2026-07-24T18:00:07Z",
|
|
||||||
host_monotonic_ns: 123_456_789,
|
|
||||||
sim_time_ns: 7_000_000_000,
|
|
||||||
frame_id: "map_enu",
|
|
||||||
child_frame_id: "base_link_flu",
|
|
||||||
pose: {
|
|
||||||
position_m: { x: 1.25, y: -0.5, z: 0.18 },
|
|
||||||
orientation_xyzw: { x: 0, y: 0, z: 0.1, w: 0.995 },
|
|
||||||
},
|
|
||||||
source: {
|
|
||||||
provider: "gazebo",
|
|
||||||
topic: "/world/rover/dynamic_pose/info",
|
|
||||||
signal: "ground-truth",
|
|
||||||
quality: "diagnostic",
|
|
||||||
},
|
|
||||||
safety: {
|
|
||||||
scope: "virtual-only",
|
|
||||||
actuator_authority: false,
|
|
||||||
navigation_or_safety_accepted: false,
|
|
||||||
},
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function commandAcceptance(overrides = {}) {
|
|
||||||
return {
|
|
||||||
schema_version: "missioncore.command-acceptance/v2",
|
|
||||||
run_id: "s1c-6cb1495-20260724t180000z-aabbcc",
|
|
||||||
command_id: "cmd-aabbcc",
|
|
||||||
sequence: 1,
|
|
||||||
issued_at_sim_ns: 7_000_000_000,
|
|
||||||
valid_until_sim_ns: 7_250_000_000,
|
|
||||||
speed_mps: 1,
|
|
||||||
steering_normalized: -0.55,
|
|
||||||
authority_scope: "virtual-only",
|
|
||||||
delivery: {
|
|
||||||
provider_id: "px4-ros2-offboard",
|
|
||||||
control_profile: "rover-speed-steering/v1",
|
|
||||||
accepted: true,
|
|
||||||
controller_ready: true,
|
|
||||||
ttl_expired_count: 0,
|
|
||||||
diagnostics: {
|
|
||||||
armed: true,
|
|
||||||
offboard: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Polygon is a worker-gated product root and keeps a compatible direct route", () => {
|
|
||||||
assert.deepEqual(resolvePolygonRunRoute("?workspace=polygon-run"), {
|
|
||||||
active: true,
|
|
||||||
runId: null,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
assert.deepEqual(
|
|
||||||
resolvePolygonRunRoute("?workspace=polygon-run&run=s1b-6cb1495-20260724t1535z"),
|
|
||||||
{
|
|
||||||
active: true,
|
|
||||||
runId: "s1b-6cb1495-20260724t1535z",
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert.equal(resolvePolygonRunRoute("?workspace=missions").active, false);
|
|
||||||
assert.match(
|
|
||||||
resolvePolygonRunRoute("?workspace=polygon-run&run=../../etc").error,
|
|
||||||
/некорректный/,
|
|
||||||
);
|
|
||||||
assert.equal(workspaceById("polygon-run").root, "polygon");
|
|
||||||
assert.equal(
|
|
||||||
workspacesForRoot("polygon").some(({ id }) => id === "polygon-run"),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
rootsForCapabilities({ polygonWorkerAvailable: false })
|
|
||||||
.some(({ id }) => id === "polygon"),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
rootsForCapabilities({ polygonWorkerAvailable: true })
|
|
||||||
.some(({ id }) => id === "polygon"),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Polygon contracts decode path-free evidence and preserve the safety boundary", () => {
|
|
||||||
const decodedCatalog = decodePolygonRunCatalog(catalog());
|
|
||||||
const decodedDetail = decodePolygonRunDetail(detail());
|
|
||||||
|
|
||||||
assert.equal(decodedCatalog.items[0].state, "completed");
|
|
||||||
assert.equal(decodedDetail.run.authority.actuatorAuthority, false);
|
|
||||||
assert.equal(decodedDetail.run.authority.navigationOrSafetyAccepted, false);
|
|
||||||
assert.equal(decodedDetail.commandCount, 0);
|
|
||||||
assert.equal(decodedDetail.artifacts[0].relativePath, "provider-runtime/processes.json");
|
|
||||||
assert.equal("content" in decodedDetail, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Polygon contracts fail closed on command content, paths and unknown fields", () => {
|
|
||||||
assert.throws(
|
|
||||||
() => decodePolygonRunDetail(detail({
|
|
||||||
commands: { count: 1, content_exposed: true },
|
|
||||||
})),
|
|
||||||
PolygonRunContractError,
|
|
||||||
);
|
|
||||||
assert.throws(
|
|
||||||
() => decodePolygonRunDetail(detail({
|
|
||||||
artifacts: [{
|
|
||||||
...detail().artifacts[0],
|
|
||||||
relative_path: "/mnt/d/private/processes.json",
|
|
||||||
}],
|
|
||||||
})),
|
|
||||||
PolygonRunContractError,
|
|
||||||
);
|
|
||||||
assert.throws(
|
|
||||||
() => decodePolygonRunCatalog(catalog({ unexpected: true })),
|
|
||||||
PolygonRunContractError,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Polygon fetchers use bounded same-origin GET endpoints", async () => {
|
|
||||||
const calls = [];
|
|
||||||
const fetcher = async (url, init) => {
|
|
||||||
calls.push({ url, init });
|
|
||||||
return String(url).includes("event_limit") ? jsonResponse(detail()) : jsonResponse(catalog());
|
|
||||||
};
|
|
||||||
|
|
||||||
await fetchPolygonRunCatalog({ limit: 20, fetcher });
|
|
||||||
await fetchPolygonRunDetail("s1b-6cb1495-20260724t1535z", {
|
|
||||||
eventLimit: 200,
|
|
||||||
fetcher,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(calls.map(({ url }) => url), [
|
|
||||||
"/api/v1/polygon/runs?limit=20",
|
|
||||||
"/api/v1/polygon/runs/s1b-6cb1495-20260724t1535z?event_limit=200",
|
|
||||||
]);
|
|
||||||
assert.ok(calls.every(({ init }) => init.method === "GET"));
|
|
||||||
assert.ok(calls.every(({ init }) => init.headers.Accept === "application/json"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Polygon Live decodes only D-only virtual diagnostic state", () => {
|
|
||||||
const status = decodePolygonWorkerStatus(workerStatus());
|
|
||||||
const live = decodePolygonVehicleState(vehicleState());
|
|
||||||
const command = decodePolygonCommandAcceptance(commandAcceptance());
|
|
||||||
|
|
||||||
assert.equal(status.available, true);
|
|
||||||
assert.equal(status.activeRunId, live.runId);
|
|
||||||
assert.equal(status.isolation.artifactPolicy, "d-only");
|
|
||||||
assert.deepEqual(live.position, { x: 1.25, y: -0.5, z: 0.18 });
|
|
||||||
assert.equal(command.controllerReady, true);
|
|
||||||
assert.equal(command.deliveryProvider, "px4-ros2-offboard");
|
|
||||||
assert.equal(command.steeringNormalized, -0.55);
|
|
||||||
assert.throws(
|
|
||||||
() => decodePolygonWorkerStatus(workerStatus({
|
|
||||||
authority: {
|
|
||||||
...workerStatus().authority,
|
|
||||||
actuator_authority: true,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
PolygonWorkerContractError,
|
|
||||||
);
|
|
||||||
assert.throws(
|
|
||||||
() => decodePolygonVehicleState(vehicleState({
|
|
||||||
source: {
|
|
||||||
...vehicleState().source,
|
|
||||||
quality: "accepted",
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
PolygonWorkerContractError,
|
|
||||||
);
|
|
||||||
assert.throws(
|
|
||||||
() => decodePolygonCommandAcceptance(commandAcceptance({
|
|
||||||
authority_scope: "physical",
|
|
||||||
})),
|
|
||||||
PolygonWorkerContractError,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Polygon Live accepts an Unreal provider behind the canonical contract", () => {
|
|
||||||
const unrealStatus = workerStatus({
|
|
||||||
provider_profile: {
|
|
||||||
...workerStatus().provider_profile,
|
|
||||||
profile_id: "unreal-native-rover-v1",
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provider_id: "unreal-native",
|
|
||||||
roles: ["world", "physics", "state", "sensor"],
|
|
||||||
capabilities: [
|
|
||||||
"clock.simulation",
|
|
||||||
"state.vehicle-pose",
|
|
||||||
"truth.ground-truth",
|
|
||||||
"sensor.virtual",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider_id: "unreal-direct-control",
|
|
||||||
roles: ["controller"],
|
|
||||||
capabilities: ["command.rover-speed-steering/v1"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
clock: {
|
|
||||||
provider_id: "unreal-native",
|
|
||||||
domain: "unreal:fixed-step",
|
|
||||||
unit: "nanoseconds",
|
|
||||||
mode: "simulation",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const status = decodePolygonWorkerStatus(unrealStatus);
|
|
||||||
const live = decodePolygonVehicleState(vehicleState({
|
|
||||||
source: {
|
|
||||||
provider: "unreal-native",
|
|
||||||
topic: "missioncore/vehicle-state",
|
|
||||||
signal: "ground-truth",
|
|
||||||
quality: "diagnostic",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
const command = decodePolygonCommandAcceptance(commandAcceptance({
|
|
||||||
delivery: {
|
|
||||||
provider_id: "unreal-direct-control",
|
|
||||||
control_profile: "rover-speed-steering/v1",
|
|
||||||
accepted: true,
|
|
||||||
controller_ready: true,
|
|
||||||
ttl_expired_count: 0,
|
|
||||||
diagnostics: { fixed_step: true },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
assert.equal(status.providerProfile.clock.domain, "unreal:fixed-step");
|
|
||||||
assert.equal(live.sourceProvider, "unreal-native");
|
|
||||||
assert.equal(command.deliveryProvider, "unreal-direct-control");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Polygon Live uses same-origin gateway and idempotent lifecycle requests", async () => {
|
|
||||||
const calls = [];
|
|
||||||
const stopped = workerStatus({
|
|
||||||
active_run_id: null,
|
|
||||||
run_state: null,
|
|
||||||
active_provider_ids: [],
|
|
||||||
});
|
|
||||||
const fetcher = async (url, init) => {
|
|
||||||
calls.push({ url: String(url), init });
|
|
||||||
if (String(url).endsWith("/live")) return jsonResponse(vehicleState());
|
|
||||||
if (String(url).endsWith("/commands")) return jsonResponse(commandAcceptance());
|
|
||||||
if (String(url).endsWith("/stop")) return jsonResponse(stopped);
|
|
||||||
return jsonResponse(workerStatus());
|
|
||||||
};
|
|
||||||
|
|
||||||
await fetchPolygonWorkerStatus({ fetcher });
|
|
||||||
await fetchPolygonVehicleState({ fetcher });
|
|
||||||
await startPolygonWorker({ idempotencyKey: "start-001", fetcher });
|
|
||||||
await sendPolygonRoverCommand("s1c-6cb1495-20260724t180000z-aabbcc", {
|
|
||||||
speedMps: 1,
|
|
||||||
steeringNormalized: -0.55,
|
|
||||||
idempotencyKey: "command-001",
|
|
||||||
fetcher,
|
|
||||||
});
|
|
||||||
await stopPolygonWorker("s1c-6cb1495-20260724t180000z-aabbcc", {
|
|
||||||
idempotencyKey: "stop-001",
|
|
||||||
fetcher,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(calls.map(({ url }) => url), [
|
|
||||||
"/api/v1/polygon/worker",
|
|
||||||
"/api/v1/polygon/worker/live",
|
|
||||||
"/api/v1/polygon/worker/runs",
|
|
||||||
"/api/v1/polygon/worker/runs/s1c-6cb1495-20260724t180000z-aabbcc/commands",
|
|
||||||
"/api/v1/polygon/worker/runs/s1c-6cb1495-20260724t180000z-aabbcc/stop",
|
|
||||||
]);
|
|
||||||
assert.equal(calls[2].init.headers["Idempotency-Key"], "start-001");
|
|
||||||
assert.equal(calls[3].init.headers["Idempotency-Key"], "command-001");
|
|
||||||
assert.equal(calls[4].init.headers["Idempotency-Key"], "stop-001");
|
|
||||||
assert.equal(calls[2].init.body, JSON.stringify({ scenario_id: "stock-rover-ackermann" }));
|
|
||||||
assert.equal(calls[3].init.body, JSON.stringify({
|
|
||||||
speed_mps: 1,
|
|
||||||
steering_normalized: -0.55,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
This plan supersedes the app-dependent experiment order in the reference Bible.
|
This plan supersedes the app-dependent experiment order in the reference Bible.
|
||||||
Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
|
Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
|
||||||
|
|
||||||
## Current checkpoint — 2026-07-24
|
## Current checkpoint — 2026-07-20
|
||||||
|
|
||||||
| Stage | Result |
|
| Stage | Result |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|
@ -24,7 +24,6 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
|
||||||
| Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process |
|
| Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process |
|
||||||
| K1 application control | GO (physical staged cycle) — after fixing the PCAP-proven `sint64` time field, one explicit UI launch completed all 14 canonical operations on one control session, reached live `SCANNING + project + init_ready`, displayed real points, then one explicit STOP returned K1 to unbound `READY`. No retry or fallback command was sent. Native-project reuse through LixelGO/USB remains an independent verification |
|
| K1 application control | GO (physical staged cycle) — after fixing the PCAP-proven `sint64` time field, one explicit UI launch completed all 14 canonical operations on one control session, reached live `SCANNING + project + init_ready`, displayed real points, then one explicit STOP returned K1 to unbound `READY`. No retry or fallback command was sent. Native-project reuse through LixelGO/USB remains an independent verification |
|
||||||
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
|
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
|
||||||
| Simulation Polygon | SIM S0 GO; S1A–S1D lifecycle/browser/Ackermann command slices PASS on the accepted Gazebo/PX4 regression profile. S1E now makes provider interchangeability executable through a strict provider profile, generic live-state source and generic command acceptance. Gazebo remains the frozen low-cost regression tier; ADR 0017 gates a measured native Unreal/CARLA/Project AirSim U0 selection before any production bridge. Differential Rover, calibrated closed-loop speed, canonical PX4/ROS 2 telemetry, pause/step/reset, heartbeat/offboard/link-loss cases, navigation/safety acceptance, shared-deployment auth/RBAC and real actuator authority remain absent |
|
|
||||||
|
|
||||||
USB project copying remains optional ground truth rather than a blocker for the
|
USB project copying remains optional ground truth rather than a blocker for the
|
||||||
now-verified network path. Owner-operated LixelGO traffic verifies the MQTT
|
now-verified network path. Owner-operated LixelGO traffic verifies the MQTT
|
||||||
|
|
@ -122,94 +121,6 @@ control is plugin-owned. A plugin-commanded acquisition sends one canonical STOP
|
||||||
and seals locally after protocol-reported standby; an operator-manual acquisition
|
and seals locally after protocol-reported standby; an operator-manual acquisition
|
||||||
still stops and seals only local reception and reports scanner state as unknown.
|
still stops and seals only local reception and reports scanner state as unknown.
|
||||||
|
|
||||||
## Parallel branch — Simulation Polygon
|
|
||||||
|
|
||||||
The Polygon branch follows
|
|
||||||
[`docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md`](12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md)
|
|
||||||
and
|
|
||||||
[`ADR 0015`](adr/0015-simulation-polygon-qualification-boundary.md). Component
|
|
||||||
placement follows [`ADR 0016`](adr/0016-distributed-product-edge-and-worker-topology.md);
|
|
||||||
provider neutrality and the Unreal evaluation follow
|
|
||||||
[`ADR 0017`](adr/0017-provider-neutral-simulation-and-unreal-evaluation.md).
|
|
||||||
It does not reorder or weaken the K1 physical-evidence gates in this document.
|
|
||||||
Its first gate, SIM S0, is accepted:
|
|
||||||
|
|
||||||
- exact Windows 11 / dedicated `MissionCore-Sim` WSL2 Ubuntu 24.04 worker
|
|
||||||
profile;
|
|
||||||
- all mutable runtime bytes physically on D;
|
|
||||||
- ROS 2 Jazzy, Gazebo Harmonic, PX4 v1.17.0, matching `px4_msgs`,
|
|
||||||
Micro XRCE-DDS and Nav2 compatibility;
|
|
||||||
- authoritative Gazebo `/clock`;
|
|
||||||
- loopback port and deterministic process ownership;
|
|
||||||
- target-worker rover/telemetry/pause/2 ms step/1×/2× resource evidence;
|
|
||||||
- target doctor `GO` for profile SHA-256
|
|
||||||
`aeecf5005301db230e1340b85215cbbd862b72974a22b8c43767c28be8453e2f`.
|
|
||||||
|
|
||||||
The repository doctor is side-effect free:
|
|
||||||
|
|
||||||
```text
|
|
||||||
uv run missioncore-sim s0 doctor --json
|
|
||||||
```
|
|
||||||
|
|
||||||
Without the exact admitted target-worker evidence its correct result remains
|
|
||||||
`INCOMPLETE`. S1 starts from the accepted S0 version/storage/time/process
|
|
||||||
boundary. Its S1A increment implements server-owned immutable run identity,
|
|
||||||
legal lifecycle transitions, canonical rover command envelopes, authority/TTL
|
|
||||||
admission, append-only artifacts and restart reconciliation. The S1B increment
|
|
||||||
adds idempotent application lifecycle, single active ownership, an
|
|
||||||
exact-profile/D-only worker guard, health-gated real provider specs and
|
|
||||||
deterministic POSIX PGID supervision. Exact generation `6cb1495` passed on
|
|
||||||
`MissionCore-Sim`: Micro XRCE-DDS Agent and PX4/Gazebo stock rover ran inside a
|
|
||||||
fresh loopback-only namespace, the run reached `completed`, and both provider
|
|
||||||
groups stopped without residue. S1C commit `b7ccca3` adds a persistent
|
|
||||||
unprivileged worker agent in the same isolation boundary, a bounded Unix-socket
|
|
||||||
gateway, an explicit `internal-virtual-only` backend gate and a browser-native
|
|
||||||
live panel. UI-2 commit `60e7916` promotes that panel into a worker-gated
|
|
||||||
top-level product root and replaces the diagnostic 2D marker with a
|
|
||||||
browser-native Three.js Ackermann Rover scene. The exact D-only generation
|
|
||||||
passed 35 target tests and one UI-driven provider run with diagnostic Gazebo
|
|
||||||
`VehicleState`, eight persisted lifecycle events, terminal `completed` and zero
|
|
||||||
process residue.
|
|
||||||
|
|
||||||
S1D exact code generation `49b0f47` adds the bounded
|
|
||||||
`missioncore.ackermann-command/v1` browser-to-worker path and the first physical
|
|
||||||
motion evidence. Mission Core keeps the external command in metres per second
|
|
||||||
plus normalized steering, journals it append-only, applies a 250 ms TTL and
|
|
||||||
maps it on the worker to stock-model PX4 rover Offboard throttle/steering
|
|
||||||
setpoints. PX4 still owns arming, Offboard state and control allocation; Mission
|
|
||||||
Core does not publish direct actuator topics. The exact D-only target passed 40
|
|
||||||
focused tests. Its first cold start took 32.08 seconds, and run
|
|
||||||
`s1c-49b0f47-20260724t195259z-62edd2` proved straight, turn, reverse, TTL stop
|
|
||||||
and zero-residue lifecycle shutdown. This closes only the primary Ackermann
|
|
||||||
command/motion slice. Differential Rover, calibrated speed tracking, canonical
|
|
||||||
PX4/ROS 2 telemetry, pause/step/reset, heartbeat/offboard/link-loss and
|
|
||||||
failsafe cases remain open before S1 can pass.
|
|
||||||
|
|
||||||
The archival surface consumes the same append-only run repository through
|
|
||||||
`GET /api/v1/polygon/runs` and
|
|
||||||
`GET /api/v1/polygon/runs/{run-id}`. `QualificationRunStore(read_only=True)`
|
|
||||||
does not create, chmod or mutate the configured repository and rejects every
|
|
||||||
write transition. When `/api/v1/polygon/worker` returns an admitted available
|
|
||||||
worker, the Control Station adds `Полигон` as a seventh header root. One header
|
|
||||||
click opens the direct `polygon-run` workspace without a System launcher panel.
|
|
||||||
The old `?workspace=polygon-run[&run=<id>]` URL remains a compatible deep-link
|
|
||||||
for review and exact-run selection, not the primary operator navigation.
|
|
||||||
Browser QA loaded the accepted archive and the S1C live worker, rotated and
|
|
||||||
zoomed the 3D rover, observed live run/provider status, Gazebo sim time and
|
|
||||||
ENU/FLU pose, then completed a clean stop. Start/stop stays behind the backend
|
|
||||||
gate and reaches the worker through a local Unix socket; SSH remains
|
|
||||||
qualification bootstrap, never the product data plane. A shared
|
|
||||||
operator-facing deployment still requires authentication/RBAC, service
|
|
||||||
supervision and an accepted routed worker transport.
|
|
||||||
|
|
||||||
Mission Core remains a distributed web product. React is served by a long-lived
|
|
||||||
Gateway/API and renders canonical state through browser-native components.
|
|
||||||
Gazebo/PX4/ROS 2/Nav2 run headless on a registered Linux simulation worker; the
|
|
||||||
native Gazebo GUI is optional worker-local diagnostics. A future field vehicle
|
|
||||||
runs a headless Linux Edge Agent with local-first evidence and store-and-forward
|
|
||||||
synchronization. The browser never talks directly to PX4, and field
|
|
||||||
execution/failsafe cannot depend on a browser or WAN connection.
|
|
||||||
|
|
||||||
## Stage 0 — repository and host baseline
|
## Stage 0 — repository and host baseline
|
||||||
|
|
||||||
Deliverables:
|
Deliverables:
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
# External perception worker contract
|
# External perception worker contract
|
||||||
|
|
||||||
The LiDAR-native extension is governed by
|
|
||||||
`missioncore.lidar-evidence-profile/v1` and
|
|
||||||
`docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md`. A worker process starting
|
|
||||||
successfully is not evidence that its LiDAR assumptions are satisfied. In
|
|
||||||
particular, the accepted E10 replay pack v1 has no intensity, and the current K1
|
|
||||||
stream is a vendor map increment rather than an unregistered sensor sweep.
|
|
||||||
|
|
||||||
## Boundary
|
## Boundary
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,6 @@
|
||||||
|
|
||||||
Status date: 2026-07-20.
|
Status date: 2026-07-20.
|
||||||
|
|
||||||
LiDAR-native work after E26 follows
|
|
||||||
`docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md`: lossless replay and scanner
|
|
||||||
quality first, then ground segmentation and a PointPillars baseline. Alternative
|
|
||||||
odometry/SLAM is not admitted for the current vendor-mapped K1 stream.
|
|
||||||
|
|
||||||
## Outcome
|
## Outcome
|
||||||
|
|
||||||
Mission Core will use the K1 factory camera/LiDAR calibration instead of
|
Mission Core will use the K1 factory camera/LiDAR calibration instead of
|
||||||
|
|
|
||||||
|
|
@ -1,905 +0,0 @@
|
||||||
# Mission Core Polygon: product definition and system requirements
|
|
||||||
|
|
||||||
Status: canonical working SRS, 2026-07-24.
|
|
||||||
|
|
||||||
Ops source of truth:
|
|
||||||
|
|
||||||
- [MISSIONCOR-38](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-38) is the
|
|
||||||
product epic and original simulation-branch scope.
|
|
||||||
- [MISSIONCOR-39](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-39) is the
|
|
||||||
canonical, changeable architecture plan.
|
|
||||||
- [MISSIONCOR-40](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-40) is the
|
|
||||||
completed SIM S0 implementation and qualification gate.
|
|
||||||
- [MISSIONCOR-41](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-41) is the
|
|
||||||
active S1 lifecycle/authority implementation and qualification gate.
|
|
||||||
|
|
||||||
This document, ADR 0015, ADR 0016 and ADR 0017 are the repository truth. A material change
|
|
||||||
to run kinds, authority, clocks, frames, source-of-record, provider boundaries,
|
|
||||||
component placement or phase gates must update the ADR/SRS and MISSIONCOR-39
|
|
||||||
together.
|
|
||||||
|
|
||||||
## 1. Product thesis
|
|
||||||
|
|
||||||
Polygon is Mission Core's autonomy qualification and assurance layer. It is not
|
|
||||||
a simulator embedded for visual effect and it is not a second observation
|
|
||||||
viewer.
|
|
||||||
|
|
||||||
Polygon makes one product promise:
|
|
||||||
|
|
||||||
> Given a versioned scenario and qualification profile, Mission Core can run or
|
|
||||||
> replay the autonomy stack, preserve exact provenance and evidence, explain
|
|
||||||
> each control/safety decision and compare outcomes without granting undeclared
|
|
||||||
> authority.
|
|
||||||
|
|
||||||
The product value appears in increments:
|
|
||||||
|
|
||||||
| Gate | Value |
|
|
||||||
| --- | --- |
|
|
||||||
| P0/S0 | Makes the stack installable, inspectable and reproducible rather than an ad hoc lab |
|
|
||||||
| S1 | Proves Mission Core can own a complete virtual rover run lifecycle |
|
|
||||||
| S2 | Produces the first useful navigation baseline and comparable safety/latency evidence |
|
|
||||||
| S3 | Connects Mission Core perception to a closed-loop consumer and exposes product differentiation |
|
|
||||||
| S4 | Makes recorded real data useful for regression and shadow-policy analysis |
|
|
||||||
| S5/S6 | Reduces risk before a real vehicle, without claiming real-world safety certification |
|
|
||||||
|
|
||||||
The first product-useful baseline is P0 through S2. The current estimate is
|
|
||||||
27–46 engineer-days, excluding waits for hardware, external accounts or a
|
|
||||||
resolved worker-access blocker.
|
|
||||||
|
|
||||||
## 2. Current truth
|
|
||||||
|
|
||||||
The repository's proven vertical remains K1 observation, durable archive,
|
|
||||||
recorded perception and diagnostic motion evidence through LAB E26. It has not
|
|
||||||
accepted navigation behavior, safety behavior or real actuator control.
|
|
||||||
|
|
||||||
Polygon is now a parallel product branch. As of this document:
|
|
||||||
|
|
||||||
- P0 architecture and SRS are committed on the Polygon branch;
|
|
||||||
- SIM S0 has a strict accepted qualification profile and read-only doctor;
|
|
||||||
- the dedicated D-only worker distribution has been created and the accepted
|
|
||||||
PX4/Gazebo/ROS 2/Nav2 stack has been installed without using Docker;
|
|
||||||
- PX4 v1.17.0 and the stock Ackermann rover have completed repeated factual
|
|
||||||
smoke runs with ROS 2 vehicle status, Gazebo clock and clean shutdown;
|
|
||||||
- the exact accepted-profile generation has passed pause freeze, one 2 ms
|
|
||||||
single-step, 1×/2× speed and structured resource cases;
|
|
||||||
- all nine digest-bound S0 evidence claims validate and the target doctor
|
|
||||||
returns `GO` for profile SHA-256
|
|
||||||
`aeecf5005301db230e1340b85215cbbd862b72974a22b8c43767c28be8453e2f`;
|
|
||||||
- S1B exact generation `6cb1495` has passed a product-owned real-provider
|
|
||||||
lifecycle in a fresh loopback-only namespace: two provider PGIDs, explicit
|
|
||||||
world/startup/DDS-writer readiness, eight persisted events, zero commands,
|
|
||||||
terminal `completed` and zero process residue;
|
|
||||||
- the UI-0 archive contract remains available over GET-only Polygon API routes
|
|
||||||
and as a compatibility deep-link. Browser QA loaded the real accepted S1B
|
|
||||||
journal and displayed its four provider pins, eight events, zero commands and
|
|
||||||
unchanged virtual-only authority boundary;
|
|
||||||
- S1C exact code generation `b7ccca3` adds a persistent, unprivileged
|
|
||||||
Simulation Worker agent in a fresh loopback-only namespace, a bounded
|
|
||||||
Unix-socket Mission Core gateway and an explicit `internal-virtual-only`
|
|
||||||
lifecycle gate;
|
|
||||||
- UI-2 exact generation `60e7916` exposes `Полигон` as a dedicated header root
|
|
||||||
only while Mission Core confirms an available Simulation Worker. The header
|
|
||||||
opens one direct workspace without a secondary launcher panel;
|
|
||||||
- the browser-native Three.js scene renders a recognisable procedural Ackermann
|
|
||||||
Rover, ground grid and ENU trajectory. Orbit supports 360° azimuth, bounded
|
|
||||||
vertical inspection from overhead to horizon, zoom, pan, follow and camera
|
|
||||||
reset. The Gazebo dynamic-pose signal remains marked `diagnostic`;
|
|
||||||
- the UI-driven target run
|
|
||||||
`s1c-60e7916-20260724t182006z-2f164e` reached `completed` with eight
|
|
||||||
persisted events, 27.74 seconds of Gazebo time and zero provider residue;
|
|
||||||
it contained zero commands and therefore does not prove rover motion or PX4
|
|
||||||
command delivery;
|
|
||||||
- S1D exact code generation `49b0f47` adds the admitted Ackermann
|
|
||||||
speed/steering command route from React through Mission Core and the private
|
|
||||||
worker gateway. The worker journals before delivery and translates the
|
|
||||||
bounded intent to PX4 rover-level Offboard `RoverThrottleSetpoint` plus
|
|
||||||
`RoverSteeringSetpoint`; the stock-model conversion uses
|
|
||||||
`RO_MAX_THR_SPEED=3.1`, while direct actuator topics remain prohibited;
|
|
||||||
- the exact D-only generation passed 40 focused target tests. Its first cold
|
|
||||||
start completed in one request after 32.08 seconds. Run
|
|
||||||
`s1c-49b0f47-20260724t195259z-62edd2` proved 3.74 m straight motion, a curved
|
|
||||||
turn, reverse motion, 44.6 ms sampled command admission, 250 ms TTL expiry,
|
|
||||||
stable stopped pose and clean shutdown with no provider residue;
|
|
||||||
- this is not a calibrated closed-loop speed controller acceptance and does
|
|
||||||
not close Differential Rover, canonical PX4/ROS 2 telemetry,
|
|
||||||
heartbeat/offboard/link-loss, failsafe, navigation or safety checkers;
|
|
||||||
- the next repository slice introduces
|
|
||||||
`missioncore.simulation-provider-profile/v1`, worker status v2 and command
|
|
||||||
acceptance v2. Runtime roles/capabilities, authoritative simulation clock,
|
|
||||||
canonical frames and control profiles are no longer inferred from hardcoded
|
|
||||||
Gazebo/PX4 names;
|
|
||||||
- the stock Gazebo/PX4 profile remains the accepted regression baseline.
|
|
||||||
Unreal is a candidate high-fidelity provider family; native Unreal, CARLA and
|
|
||||||
Project AirSim require the bounded U0 comparison from ADR 0017 before one is
|
|
||||||
selected;
|
|
||||||
- `actuator_authority=false`;
|
|
||||||
- `navigation_or_safety_accepted=false`.
|
|
||||||
|
|
||||||
The doctor reports `GO` only on the matching target with the exact confined
|
|
||||||
worker evidence. A repository manifest alone remains insufficient.
|
|
||||||
|
|
||||||
## 3. Goals
|
|
||||||
|
|
||||||
### 3.1 Product goals
|
|
||||||
|
|
||||||
- Turn perception and planning work into measurable sense-plan-act experiments.
|
|
||||||
- Compare standard and Mission Core planners against the same versioned inputs.
|
|
||||||
- Preserve failures, limitations and uncertainty as first-class evidence.
|
|
||||||
- Reuse the same scenario/run/report concepts across simulation, replay, digital
|
|
||||||
twin, controller-in-loop, HIL and physical shadow.
|
|
||||||
- Keep physics, autopilot, middleware, planner and viewer replaceable.
|
|
||||||
- Create a controlled path toward a future trike without silently widening
|
|
||||||
authority.
|
|
||||||
|
|
||||||
### 3.2 Engineering goals
|
|
||||||
|
|
||||||
- Deterministic lifecycle ownership with no orphan simulator processes.
|
|
||||||
- Exact version, environment, input and artifact provenance.
|
|
||||||
- Explicit clock-domain and coordinate-frame conversions.
|
|
||||||
- Fail-closed setpoint validity, TTL, heartbeat, watchdog and loss behavior.
|
|
||||||
- Append-only run history and digest-bound source/derived artifacts.
|
|
||||||
- Side-effect-free infrastructure diagnosis before installation.
|
|
||||||
|
|
||||||
## 4. Non-goals
|
|
||||||
|
|
||||||
The first delivery does not:
|
|
||||||
|
|
||||||
- grant real actuator authority;
|
|
||||||
- expose PX4 directly to the browser;
|
|
||||||
- use direct actuator setpoints;
|
|
||||||
- certify Nav2, PX4 or Mission Core as a real-time safety system;
|
|
||||||
- treat replay as closed-loop evidence;
|
|
||||||
- import all 300 BARN worlds before a stock-world baseline;
|
|
||||||
- download complete CODa, JRDB or NCLT collections;
|
|
||||||
- build a custom physics engine;
|
|
||||||
- make `px4-ros2-interface-lib` mandatory for S1;
|
|
||||||
- overload the observation-session model for qualification runs;
|
|
||||||
- treat the capability-gated top-level UI as proof of the backend S1 command or
|
|
||||||
safety gate.
|
|
||||||
|
|
||||||
## 5. Product and system boundaries
|
|
||||||
|
|
||||||
### 5.1 Mission Core ownership
|
|
||||||
|
|
||||||
Mission Core owns:
|
|
||||||
|
|
||||||
- scenario definitions and profile selection;
|
|
||||||
- qualification-run identity and lifecycle;
|
|
||||||
- command authority and safety policy;
|
|
||||||
- canonical state, setpoint, decision and event contracts;
|
|
||||||
- orchestration of replaceable providers;
|
|
||||||
- evidence admission, provenance and immutable history;
|
|
||||||
- evaluation, comparison and qualification reports.
|
|
||||||
|
|
||||||
### 5.2 Provider ownership
|
|
||||||
|
|
||||||
Providers remain replaceable:
|
|
||||||
|
|
||||||
- a world/physics/state provider owns simulated world state, sensors, contacts,
|
|
||||||
truth and the declared simulation clock; the accepted regression provider is
|
|
||||||
Gazebo, while Unreal candidates remain unaccepted;
|
|
||||||
- a controller provider owns rover control loops, constraints, command
|
|
||||||
lifecycle and controller failsafe; the accepted stock controller is PX4 SITL;
|
|
||||||
- a transport provider carries typed telemetry and commands; the stock profile
|
|
||||||
uses ROS 2 and Micro XRCE-DDS;
|
|
||||||
- Nav2 supplies the first mature planning/costmap/collision-checking baseline.
|
|
||||||
- Rerun or another viewer presents derived evidence and never becomes a queue,
|
|
||||||
orchestrator or source-of-record.
|
|
||||||
- Dataset adapters expose recorded streams but never pretend alternative
|
|
||||||
commands changed future recorded observations.
|
|
||||||
|
|
||||||
### 5.3 Neighboring Mission Core contexts
|
|
||||||
|
|
||||||
- Observation owns real and recorded sensor streams plus perception layers.
|
|
||||||
- Polygon owns scenarios, qualification runs, decisions, simulated state and
|
|
||||||
comparisons.
|
|
||||||
- Missions owns goals and routes that may later target a virtual or real
|
|
||||||
vehicle.
|
|
||||||
- Data owns source-of-record, derived artifacts, retention and report access.
|
|
||||||
|
|
||||||
Observation `SessionStatus` and its modality model are not extended to describe
|
|
||||||
simulation. Shared UI components require an explicit data-source contract.
|
|
||||||
|
|
||||||
## 6. Target architecture
|
|
||||||
|
|
||||||
```text
|
|
||||||
Mission Core UI
|
|
||||||
|
|
|
||||||
Simulation API
|
|
||||||
|
|
|
||||||
Simulation Orchestrator
|
|
||||||
+---- scenario/profile registry
|
|
||||||
+---- run state machine and authority gate
|
|
||||||
+---- clock, namespace, port and process ownership
|
|
||||||
+---- artifact/provenance store and evaluator
|
|
||||||
|
|
|
||||||
+---- provider profile -- roles/capabilities/clock/frames
|
|
||||||
+---- world adapter ----- Gazebo baseline or admitted Unreal profile
|
|
||||||
+---- control adapter --- PX4 SITL or another admitted controller
|
|
||||||
+---- transport adapter - ROS 2/XRCE or another admitted transport
|
|
||||||
+---- planner adapter --- Nav2 or Mission Core planner
|
|
||||||
+---- viewer adapter ---- canonical state/derived report
|
|
||||||
```
|
|
||||||
|
|
||||||
Native simulation and controller providers never run inside the web process. The browser never owns process
|
|
||||||
lifecycle or command authority. Losing the browser cannot terminate the only
|
|
||||||
copy of run state and cannot bypass the server-side authority gate.
|
|
||||||
|
|
||||||
### 6.1 Product form and component placement
|
|
||||||
|
|
||||||
Mission Core is a distributed web product, not a monolithic desktop
|
|
||||||
application:
|
|
||||||
|
|
||||||
| Component | Product placement | Packaging and responsibility |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Control Station | Any operator browser on macOS, Windows, Linux or tablet | React static assets; interactive planning, live state, history, comparison and reports |
|
|
||||||
| Gateway/API and Control Plane | Control-centre server, laboratory host or offline field kit | Long-lived service; identity, policy, mission/scenario/run state, orchestration and catalogues |
|
|
||||||
| Simulation Worker | Registered Linux GPU worker; currently dedicated WSL2 on the reviewed Windows host | Headless worker service plus Gazebo, PX4 SITL, ROS 2 and Nav2 |
|
|
||||||
| Native Gazebo GUI | Worker desktop only | Optional diagnostic attachment; never product state, authority or source-of-record |
|
|
||||||
| Vehicle Edge Agent | Future onboard or vehicle-adjacent Linux computer | Headless device/runtime adapters, local authority gate, bounded mission state, durable evidence and store-and-forward sync |
|
|
||||||
| PX4 controller | SITL process in simulation or separate controller hardware in the field | Vehicle control loops, constraints and failsafe behind an allowlisted adapter |
|
|
||||||
| Observation/perception worker | Onboard, nearby edge or control-centre worker according to latency/bandwidth profile | Canonical perception outputs; placement does not change contracts |
|
|
||||||
| Archive/evidence storage | Worker/vehicle local-first, then optional central synchronization | Immutable source evidence, derived artifacts and reports |
|
|
||||||
|
|
||||||
The primary deliverable remains the browser application. A PWA, kiosk package
|
|
||||||
or thin Tauri/Electron wrapper may distribute the same assets for an offline
|
|
||||||
field kit, but it is not a second product and never owns provider processes or
|
|
||||||
command authority.
|
|
||||||
|
|
||||||
The Polygon browser view consumes bounded canonical state and derived evidence:
|
|
||||||
rover pose, route, planned/factual trajectory, footprint, contacts, safety
|
|
||||||
state, metrics and optional media. The accepted live rover scene uses
|
|
||||||
browser-native Three.js; Rerun remains available for recorded spatial evidence.
|
|
||||||
Both presentation paths consume server-owned contracts rather than worker
|
|
||||||
processes. Full Gazebo pixels are optional diagnostics through the worker
|
|
||||||
desktop or a separately gated remote-display capability; they are not required
|
|
||||||
for the product view.
|
|
||||||
|
|
||||||
### 6.2 Deployment topologies
|
|
||||||
|
|
||||||
Laboratory simulation:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Operator browser
|
|
||||||
-> Mission Core Gateway/API
|
|
||||||
-> Simulation Orchestrator
|
|
||||||
-> registered Linux/WSL2 worker
|
|
||||||
-> Gazebo + PX4 SITL + ROS 2 + Nav2
|
|
||||||
-> canonical live stream + local source/derived artifacts
|
|
||||||
```
|
|
||||||
|
|
||||||
Connected field vehicle:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Operator browser/control centre
|
|
||||||
-> Mission Core Gateway/API
|
|
||||||
<-> Vehicle Edge Agent on onboard Linux
|
|
||||||
-> PX4 hardware boundary
|
|
||||||
-> sensors and actuators
|
|
||||||
```
|
|
||||||
|
|
||||||
Disconnected field vehicle and later review:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Locally admitted mission + Edge Agent + PX4
|
|
||||||
-> accepted local watchdog/failsafe behavior
|
|
||||||
-> append-only onboard evidence
|
|
||||||
-> later idempotent digest-bound synchronization
|
|
||||||
-> browser history/replay/report
|
|
||||||
```
|
|
||||||
|
|
||||||
Field execution and safety never depend on a browser tab or continuous WAN.
|
|
||||||
The Edge Agent writes source evidence locally before upload, retains
|
|
||||||
high-volume data according to storage policy and sends bounded telemetry,
|
|
||||||
events, decisions and previews when connected. Reconnection synchronizes the
|
|
||||||
same immutable run identity; a partial upload is never presented as complete.
|
|
||||||
|
|
||||||
The authority path remains:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Browser intent
|
|
||||||
-> Mission Core API/policy
|
|
||||||
-> Simulation Orchestrator or Vehicle Edge Agent
|
|
||||||
-> allowlisted provider/device adapter
|
|
||||||
-> PX4 boundary
|
|
||||||
```
|
|
||||||
|
|
||||||
The placement decision is specified by
|
|
||||||
[ADR 0016](adr/0016-distributed-product-edge-and-worker-topology.md).
|
|
||||||
|
|
||||||
## 7. Canonical domain model
|
|
||||||
|
|
||||||
### 7.1 Versioned inputs
|
|
||||||
|
|
||||||
`ScenarioDefinition` contains identifiers and references for:
|
|
||||||
|
|
||||||
- world and deterministic actor placement;
|
|
||||||
- vehicle profile;
|
|
||||||
- sensor profile;
|
|
||||||
- route/goal;
|
|
||||||
- planner profile;
|
|
||||||
- fault profile;
|
|
||||||
- metric profile;
|
|
||||||
- seed and reproducibility tier.
|
|
||||||
|
|
||||||
`VehicleProfile`, `SensorProfile`, `PlannerProfile`, `FaultProfile` and
|
|
||||||
`MetricProfile` are independently versioned. A run stores the exact resolved
|
|
||||||
generation and hashes, not only mutable names.
|
|
||||||
|
|
||||||
### 7.2 QualificationRun
|
|
||||||
|
|
||||||
`QualificationRun` is the umbrella aggregate. `SimulationRun` is not used as
|
|
||||||
the common type because replay, HIL and physical shadow are not simulations.
|
|
||||||
|
|
||||||
Required identity:
|
|
||||||
|
|
||||||
- immutable run ID and episode ID;
|
|
||||||
- run kind;
|
|
||||||
- scenario/profile generations and hashes;
|
|
||||||
- Mission Core commit;
|
|
||||||
- provider versions, commits or image digests;
|
|
||||||
- host and resource profile;
|
|
||||||
- seed and reproducibility tier;
|
|
||||||
- authority profile;
|
|
||||||
- start/end UTC provenance;
|
|
||||||
- authoritative clock domain.
|
|
||||||
|
|
||||||
Reset creates a new episode/run identity. It never reuses a completed identity
|
|
||||||
or overwrites artifacts.
|
|
||||||
|
|
||||||
### 7.3 Run kinds
|
|
||||||
|
|
||||||
| Kind | Commands affect future inputs | Actuator authority |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `simulation_closed_loop` | Yes, through virtual physics | Virtual only |
|
|
||||||
| `replay_shadow` | No | None |
|
|
||||||
| `digital_twin_closed_loop` | Yes, through an attested twin | Virtual only |
|
|
||||||
| `controller_in_loop` | Yes, through a bounded controller interface | Lab-specific |
|
|
||||||
| `hil` | Yes, with hardware in the loop | Separate gate |
|
|
||||||
| `physical_shadow` | No | None |
|
|
||||||
|
|
||||||
No result may be promoted from one kind to another by changing report wording.
|
|
||||||
|
|
||||||
### 7.4 Canonical streams
|
|
||||||
|
|
||||||
- `VehicleState`: pose, twist, acceleration, steering/wheel state, estimator
|
|
||||||
state and source frames.
|
|
||||||
- `PerceptionWorldState`: occupancy, objects, motion, confidence, unknown and
|
|
||||||
uncertainty.
|
|
||||||
- `ControlSetpoint`: profile, sequence, issued time, validity deadline,
|
|
||||||
speed/steering or speed/yaw-rate and authority scope.
|
|
||||||
- `SafetyDecision`: allow/slow/stop, reason, active constraint, TTC/clearance
|
|
||||||
evidence and input generation.
|
|
||||||
- `QualificationEvent`: lifecycle, collision/contact, reset, deadline miss,
|
|
||||||
data loss, watchdog, failsafe and evaluator events.
|
|
||||||
- `QualificationReport`: verdict, metrics, violations, limitations and evidence
|
|
||||||
links.
|
|
||||||
|
|
||||||
## 8. Lifecycle requirements
|
|
||||||
|
|
||||||
The minimum lifecycle is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
admitted -> starting -> running <-> paused -> stopping -> completed
|
|
||||||
| |
|
|
||||||
+--------------------+-> failed
|
|
||||||
```
|
|
||||||
|
|
||||||
Cancellation is a requested transition, not evidence that processes stopped.
|
|
||||||
A run reaches a terminal state only after:
|
|
||||||
|
|
||||||
- child-process ownership is reconciled;
|
|
||||||
- stop/failsafe outcome is captured;
|
|
||||||
- source artifacts are sealed;
|
|
||||||
- derived indexes are generated or explicitly marked absent;
|
|
||||||
- the report records the final limitations.
|
|
||||||
|
|
||||||
Process shutdown order is explicit and deterministic. Orchestrator shutdown is
|
|
||||||
last. An orphan process is an S0/S1 failure.
|
|
||||||
|
|
||||||
## 9. Clock contract
|
|
||||||
|
|
||||||
For `simulation_closed_loop`:
|
|
||||||
|
|
||||||
- the admitted provider profile declares exactly one authoritative simulation
|
|
||||||
clock and a provider with `clock.simulation`;
|
|
||||||
- the stock regression profile uses Gazebo `/clock`; an Unreal adapter must
|
|
||||||
publish a distinct fixed-step clock domain;
|
|
||||||
- ROS 2 consumers use `use_sim_time=true`.
|
|
||||||
- PX4 uXRCE-DDS time synchronization is disabled when Gazebo time is used.
|
|
||||||
- Canonical timestamps are integer nanoseconds and include their clock domain.
|
|
||||||
- UTC is provenance only.
|
|
||||||
- TTL, deadline and watchdog calculations declare whether they use simulation
|
|
||||||
or monotonic host time.
|
|
||||||
- Pause, resume, single-step and real-time-factor changes are qualification
|
|
||||||
cases, not UI-only behavior.
|
|
||||||
|
|
||||||
Wall clock must not appear implicitly in planner or safety calculations.
|
|
||||||
|
|
||||||
## 10. Coordinate-frame contract
|
|
||||||
|
|
||||||
Mission Core navigation uses:
|
|
||||||
|
|
||||||
- `map` and `odom`: ENU;
|
|
||||||
- `base_link`: FLU.
|
|
||||||
|
|
||||||
PX4 uses:
|
|
||||||
|
|
||||||
- world/local: NED;
|
|
||||||
- body: FRD.
|
|
||||||
|
|
||||||
All PX4 conversion happens at one adapter boundary. Pose, vector, yaw, yaw-rate
|
|
||||||
and covariance transformations require golden tests. Every canonical message
|
|
||||||
identifies source and target frame. A frame mismatch fails closed and cannot be
|
|
||||||
reclassified as planner noise.
|
|
||||||
|
|
||||||
## 11. Authority and safety requirements
|
|
||||||
|
|
||||||
Until a new physical-control card and formal safety gate:
|
|
||||||
|
|
||||||
- `simulation_or_shadow_only=true`;
|
|
||||||
- `actuator_authority=false`;
|
|
||||||
- `navigation_or_safety_accepted=false`;
|
|
||||||
- direct actuator setpoints are rejected;
|
|
||||||
- supported S1 command profiles are `rover-speed-steering/v1` and
|
|
||||||
`rover-speed-yaw-rate/v1`;
|
|
||||||
- each command carries a monotonic sequence and explicit expiry;
|
|
||||||
- heartbeat loss, TTL expiry, invalid state, planner timeout or link loss causes
|
|
||||||
a stop/failsafe transition;
|
|
||||||
- unknown occupancy never becomes free space by omission;
|
|
||||||
- ground truth is unavailable to the planner and perception runtime;
|
|
||||||
- ground truth is separately available to the evaluator.
|
|
||||||
|
|
||||||
Nav2 Collision Monitor is an additional software safety layer. It is not a
|
|
||||||
hard-real-time certified safety controller.
|
|
||||||
|
|
||||||
## 12. Evidence and data requirements
|
|
||||||
|
|
||||||
### 12.1 Source-of-record
|
|
||||||
|
|
||||||
The minimum source set is:
|
|
||||||
|
|
||||||
- exact scenario and qualification profiles;
|
|
||||||
- stack/version lock;
|
|
||||||
- run manifest and authority profile;
|
|
||||||
- PX4 ULog;
|
|
||||||
- Gazebo log or equivalent world-state/contact evidence;
|
|
||||||
- rosbag2/MCAP for admitted canonical streams;
|
|
||||||
- factual host/resource and process-lifecycle evidence.
|
|
||||||
|
|
||||||
### 12.2 Derived artifacts
|
|
||||||
|
|
||||||
Derived data includes:
|
|
||||||
|
|
||||||
- normalized metrics;
|
|
||||||
- evaluator outputs;
|
|
||||||
- comparison tables;
|
|
||||||
- Rerun recording;
|
|
||||||
- qualification report.
|
|
||||||
|
|
||||||
Presentation state is not source evidence. Derived artifacts are rebuildable and
|
|
||||||
must retain input hashes. Source and derived generations are append-only.
|
|
||||||
|
|
||||||
### 12.3 Required metrics
|
|
||||||
|
|
||||||
Each applicable run reports:
|
|
||||||
|
|
||||||
- completion and terminal reason;
|
|
||||||
- collision/contact count and minimum clearance;
|
|
||||||
- path length, elapsed simulation time and efficiency;
|
|
||||||
- route/pose error;
|
|
||||||
- speed/steering tracking;
|
|
||||||
- stop time and distance;
|
|
||||||
- TTC and constraint activation;
|
|
||||||
- replans and oscillation;
|
|
||||||
- dropped/late messages and deadline misses;
|
|
||||||
- stage latency p50/p95/max and queue depth;
|
|
||||||
- real-time factor;
|
|
||||||
- CPU, GPU, RAM, VRAM and disk usage.
|
|
||||||
|
|
||||||
Inapplicable metrics are explicit, not silently zero.
|
|
||||||
|
|
||||||
## 13. Reproducibility
|
|
||||||
|
|
||||||
- R0: exact inputs and versions are retained; outcome may vary.
|
|
||||||
- R1: the same inputs/seed/version lock reproduce the same verdict within
|
|
||||||
declared tolerances.
|
|
||||||
- R2: defined metrics remain within accepted tolerance bands.
|
|
||||||
|
|
||||||
Bitwise-identical physics is not promised. Comparison is rejected when scenario
|
|
||||||
schema, metric profile, provider generation or reproducibility tier are
|
|
||||||
incompatible.
|
|
||||||
|
|
||||||
## 14. Upstream baseline
|
|
||||||
|
|
||||||
The S0 accepted regression line is:
|
|
||||||
|
|
||||||
- Windows 11 AI worker;
|
|
||||||
- dedicated `MissionCore-Sim` WSL2 Ubuntu 24.04 distribution physically on D;
|
|
||||||
- no Docker dependency in S0, so active shared Docker co-tenants remain untouched;
|
|
||||||
- ROS 2 Jazzy;
|
|
||||||
- Gazebo Harmonic;
|
|
||||||
- PX4 Autopilot v1.17.0;
|
|
||||||
- matching `px4_msgs` v1.17.0 generation;
|
|
||||||
- Micro XRCE-DDS Agent v2.4.3;
|
|
||||||
- Nav2 Jazzy;
|
|
||||||
- PX4 Gazebo stock rover models.
|
|
||||||
|
|
||||||
The exact source commits and package versions are resolved in the profile.
|
|
||||||
Target installation, PX4 build, DDS telemetry, Nav2/ROS package discovery and
|
|
||||||
repeated 1×/2× stock-rover evidence have moved every component pin to
|
|
||||||
`accepted`. A future pin change creates a new profile generation and invalidates
|
|
||||||
evidence tied to the prior SHA-256.
|
|
||||||
|
|
||||||
S1 uses direct exact `px4_msgs` integration. The experimental
|
|
||||||
`px4-ros2-interface-lib` is evaluated later and is not on the S1 critical path.
|
|
||||||
|
|
||||||
## 15. Storage and runtime placement
|
|
||||||
|
|
||||||
All mutable worker bytes must be physically placed under
|
|
||||||
`D:\NDC_MISSIONCORE`, including:
|
|
||||||
|
|
||||||
- the dedicated `MissionCore-Sim` WSL distribution/VHD;
|
|
||||||
- Docker data and images if a later profile elects to use containers; the S0
|
|
||||||
profile explicitly does not;
|
|
||||||
- sources and build products;
|
|
||||||
- package/model caches;
|
|
||||||
- runtime and logs;
|
|
||||||
- bags, ULog and Gazebo evidence;
|
|
||||||
- datasets and reports.
|
|
||||||
|
|
||||||
The WSL view is `/mnt/d/NDC_MISSIONCORE`. C-drive placement is a blocking
|
|
||||||
failure. S0 requires at least 300 GiB free before installation and a stop-below
|
|
||||||
threshold of 200 GiB. Full external datasets are out of S0 scope.
|
|
||||||
|
|
||||||
S0 runtime processes execute in a new loopback-only Linux network namespace.
|
|
||||||
Micro XRCE-DDS Agent v2.4.3 has no address-selection flag and therefore binds
|
|
||||||
UDP wildcard inside that namespace; the namespace contains only `lo`, so the
|
|
||||||
effective exposure remains loopback-only. ROS domain 0 is isolated by the same
|
|
||||||
boundary. This distinction is part of the port evidence and must not be
|
|
||||||
misreported as a host-wide `127.0.0.1` bind.
|
|
||||||
|
|
||||||
The stock rover world declares a 2 ms physics step. S0 pause acceptance permits
|
|
||||||
zero simulation-time advance; single-step requires exactly +2,000,000 ns and
|
|
||||||
+1 iteration. Speed factors 1× and 2× are measured over four seconds with a
|
|
||||||
±20% RTF tolerance. Resource sampling runs every 500 ms with an owned-process
|
|
||||||
budget of 2 GiB RSS and 800% CPU, at least 8 GiB available WSL memory and the
|
|
||||||
existing 200 GiB D stop floor. GPU values are recorded as global co-tenant
|
|
||||||
context and are not misattributed to headless S0.
|
|
||||||
|
|
||||||
## 16. Delivery plan
|
|
||||||
|
|
||||||
| Phase | Estimate | Exit condition |
|
|
||||||
| --- | ---: | --- |
|
|
||||||
| P0 | 2–4 days | Product thesis, SRS, ADR and canonical contracts accepted |
|
|
||||||
| S0 | 3–5 days | Target compatibility, D-only runtime, exact pins, clock/process/resource evidence |
|
|
||||||
| S1 | 7–12 days | Stock Ackermann and Differential lifecycle/control/failsafe evidence |
|
|
||||||
| U0 | 2–4 days | Native Unreal/CARLA/Project AirSim selection matrix with measured worker fit |
|
|
||||||
| U1 | 5–10 days | Selected Unreal candidate proves canonical clock/state/command lifecycle |
|
|
||||||
| S2 | 15–25 days | LiDAR/odometry/Nav2 baseline, evaluator and obstacle scenarios |
|
|
||||||
| S2B | 5–8 days | Representative 30-world BARN pilot |
|
|
||||||
| S3 | 20–35 days | K1-like virtual sensors and Mission Core perception closed loop |
|
|
||||||
| S4 | 10–20 days | Canonical replay/shadow adapters and reports |
|
|
||||||
| S5 | 20–40 days | Attested RAVNOVES00 digital twin |
|
|
||||||
| S6 | 20–40+ days | Trike model, controller-in-loop/HIL and physical-shadow gate |
|
|
||||||
|
|
||||||
Implementation cards follow this order:
|
|
||||||
|
|
||||||
1. Product SRS and ADR.
|
|
||||||
2. D-only inventory and stack lock.
|
|
||||||
3. Clock, frame and authority contracts.
|
|
||||||
4. Orchestrator lifecycle and artifact store.
|
|
||||||
5. PX4 stock rover control and failsafe.
|
|
||||||
6. Provider-neutral profile/status/command boundary.
|
|
||||||
7. Complete the U0 Unreal candidate comparison; U1 may run in parallel.
|
|
||||||
8. Nav2 adapter and truth baseline on the frozen Gazebo profile.
|
|
||||||
9. Evaluator, report and BARN pilot.
|
|
||||||
10. Virtual K1 and perception; promote an Unreal provider only after U1.
|
|
||||||
11. Replay/shadow datasets.
|
|
||||||
12. RAVNOVES00 twin and Unreal physical/sensor world if U2 is accepted.
|
|
||||||
13. Trike, HIL and safety gate.
|
|
||||||
|
|
||||||
## 17. SIM S0 acceptance
|
|
||||||
|
|
||||||
SIM S0 requires all of the following factual evidence:
|
|
||||||
|
|
||||||
- worker inventory;
|
|
||||||
- physical D-only placement of the dedicated WSL VHD, sources, builds, caches
|
|
||||||
and artifacts;
|
|
||||||
- confirmation that S0 uses the dedicated D-only WSL and does not use the
|
|
||||||
existing C-backed Docker Desktop runtime;
|
|
||||||
- exact component/package pins and licenses;
|
|
||||||
- one stock PX4 rover launch;
|
|
||||||
- ROS 2 telemetry;
|
|
||||||
- authoritative simulation clock;
|
|
||||||
- pause, resume, single-step and speed-factor behavior;
|
|
||||||
- clean stop with no orphan processes;
|
|
||||||
- 1× resource baseline;
|
|
||||||
- 2× resource baseline;
|
|
||||||
- explicit `GO`, `INCOMPLETE` or `BLOCKED` report.
|
|
||||||
|
|
||||||
The repository profile and doctor implement preflight and evidence admission.
|
|
||||||
They do not install packages, start processes, open ports or write a target
|
|
||||||
report.
|
|
||||||
|
|
||||||
Run the local read-only preflight:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run missioncore-sim s0 doctor --json
|
|
||||||
```
|
|
||||||
|
|
||||||
An optional evidence manifest uses
|
|
||||||
`missioncore.simulation-s0-evidence/v1`. Each required claim points to a
|
|
||||||
confined regular artifact and supplies its SHA-256. The profile SHA-256 must
|
|
||||||
match the exact candidate generation.
|
|
||||||
|
|
||||||
## 18. S1 and S2 acceptance outline
|
|
||||||
|
|
||||||
### S1
|
|
||||||
|
|
||||||
- Ackermann primary and Differential reference launch from the orchestrator.
|
|
||||||
- Start, pause, resume, step, reset and stop are server-owned.
|
|
||||||
- Straight, turn, reverse, deceleration and emergency-stop cases pass.
|
|
||||||
- TTL, heartbeat, offboard loss and link loss produce a captured safe outcome.
|
|
||||||
- Pose, speed, steering/controller state and failsafe telemetry are retained.
|
|
||||||
- Reset creates a new run/episode.
|
|
||||||
- A repeated seed produces the declared R1 verdict tolerance.
|
|
||||||
|
|
||||||
K1, Nav2 and top-level UI do not block S1 backend acceptance.
|
|
||||||
|
|
||||||
#### S1A implementation status
|
|
||||||
|
|
||||||
The first S1 increment is implemented in `k1link.simulation.contracts` and
|
|
||||||
`k1link.simulation.run_store`:
|
|
||||||
|
|
||||||
- the immutable manifest owns run/episode identity, exact scenario/profile
|
|
||||||
digests, Mission Core revision, provider pins, host profile, seed,
|
|
||||||
reproducibility tier, clock domain and fail-closed authority;
|
|
||||||
- lifecycle and domain events are stored as exclusive, individually atomic,
|
|
||||||
contiguous records; state is reconstructed by replay and the immutable
|
|
||||||
manifest is never rewritten;
|
|
||||||
- only legal lifecycle edges are admitted; terminal history and its artifact
|
|
||||||
index are sealed;
|
|
||||||
- Ackermann speed/steering and Differential speed/yaw-rate commands require a
|
|
||||||
positive monotonic sequence, simulation-time expiry and the current authority
|
|
||||||
generation; non-finite values, over-limit TTL, stale generations, direct
|
|
||||||
actuator authority and commands outside `running` fail closed;
|
|
||||||
- replay/shadow cannot causally accept commands, while HIL and
|
|
||||||
controller-in-loop admission remain blocked pending a separate safety gate;
|
|
||||||
- restart reconciliation marks an active run `failed` with
|
|
||||||
`orchestrator-recovery-interrupted`; it never hides a resume;
|
|
||||||
- reset is accepted only after the prior run is terminal and creates a new
|
|
||||||
linked run and episode identity;
|
|
||||||
- registered artifacts use confined relative POSIX paths, SHA-256 and
|
|
||||||
append-only sequence records.
|
|
||||||
|
|
||||||
The repository root is caller-supplied so unit tests and development remain
|
|
||||||
portable. The target Simulation Orchestrator must bind that root under the
|
|
||||||
accepted `/mnt/d/NDC_MISSIONCORE` storage boundary; S1 provider orchestration
|
|
||||||
and target evidence are not implied by this increment.
|
|
||||||
|
|
||||||
#### S1B ownership foundation status
|
|
||||||
|
|
||||||
The next local increment implements the provider-neutral ownership boundary:
|
|
||||||
|
|
||||||
- `SimulationApplicationService` is the only caller allowed to drive persisted
|
|
||||||
start, pause, resume, single-step, stop, reset and restart reconciliation;
|
|
||||||
- start and stop requests are journal-bound to idempotency keys, and a
|
|
||||||
different request cannot rebind the same run operation;
|
|
||||||
- a second `starting|running|paused|stopping` run cannot acquire the same worker
|
|
||||||
authority;
|
|
||||||
- the worker port exposes lifecycle operations without exposing a browser-to-PX4
|
|
||||||
channel or choosing a remote transport;
|
|
||||||
- `S0WorkerGuard` requires the run's exact accepted S0 profile ID/SHA-256, the
|
|
||||||
canonical `/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/runs/<run-id>`
|
|
||||||
artifact layout and the actual
|
|
||||||
`/mnt/d/NDC_MISSIONCORE/simulation/runtime/s1/runs/<run-id>` supervisor
|
|
||||||
working layout before provider start;
|
|
||||||
- `PosixProcessSupervisor` launches argv without a shell, creates one dedicated
|
|
||||||
session/PGID per provider, writes run-scoped stdout/stderr and a process
|
|
||||||
registry, rolls back partial starts, and stops in ascending S0 shutdown order;
|
|
||||||
- failure, profile drift or process residue becomes a persisted terminal
|
|
||||||
failure rather than a successful stop.
|
|
||||||
|
|
||||||
The first target adapter now supplies concrete Micro XRCE-DDS Agent and combined
|
|
||||||
PX4/Gazebo stock-rover specs. Readiness is an executable contract: the Agent
|
|
||||||
must be listening, while PX4 must report the exact rover world/model, completed
|
|
||||||
startup and a `vehicle_status_v1` DDS writer. PX4 stdin remains open without
|
|
||||||
sending a command, so its shell cannot enter an EOF prompt loop. Provider logs
|
|
||||||
are read incrementally, all argv are shell-free, and every provider owns a
|
|
||||||
dedicated PGID.
|
|
||||||
|
|
||||||
The exact commit `6cb1495` passed `25` target tests and the real
|
|
||||||
`s1b-6cb1495-20260724t1535z` lifecycle inside `MissionCore-Sim`. Its immutable
|
|
||||||
Git archive SHA-256 is
|
|
||||||
`37da47744e855bb1031734893a6f2dd82bb4eb92b7e15edd70794c29b2dfb451`.
|
|
||||||
Mission Core persisted the admitted/starting/running/stopping/completed history,
|
|
||||||
started `micro-xrce-dds-agent` plus `px4-gazebo-stock-rover`, observed an XRCE
|
|
||||||
session and PX4 DDS writer, sent zero control commands, and stopped with no
|
|
||||||
registry or `/proc` residue. Evidence is D-only and digest indexed under
|
|
||||||
`/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/lifecycle/`.
|
|
||||||
|
|
||||||
This accepts the S1B target start/health/stop boundary. UI-0 now consumes the
|
|
||||||
accepted persisted history without widening authority. It does not accept
|
|
||||||
restart-safe PID/start-token reconciliation, live
|
|
||||||
pause/resume/step/reset, canonical VehicleState, frame conversion, rover command
|
|
||||||
mapping, watchdog/failsafe cases, repeatability or S1 as a whole.
|
|
||||||
|
|
||||||
S1C commit `b7ccca3` accepts the next placement/presentation slice only. A
|
|
||||||
persistent worker agent runs unprivileged in a new loopback-only namespace and
|
|
||||||
is registered to Mission Core through a private Unix socket. The browser uses
|
|
||||||
same-origin APIs for worker status, explicit virtual-only start/stop and a
|
|
||||||
bounded `missioncore.vehicle-state/v1` view. Exact target source archive
|
|
||||||
SHA-256 is
|
|
||||||
`fc3195dbffef270f9669c194cf90e3fe4b62e113a5a7b74af89caa123b601ccd`;
|
|
||||||
the xattr-free Control Station build archive SHA-256 is
|
|
||||||
`76b687d12a7675275dfae8ebe3d806d3725240182136108c9bd01f0e8e364df6`.
|
|
||||||
The first UI-driven run completed cleanly with 52.836 seconds of Gazebo time
|
|
||||||
and no provider residue. Its `VehicleState` is Gazebo ground truth marked
|
|
||||||
`diagnostic`; PX4/ROS 2 canonical telemetry and command-induced rover motion
|
|
||||||
remain open.
|
|
||||||
|
|
||||||
UI-2 commit `60e7916` accepts the next browser-placement slice. Its exact
|
|
||||||
source archive SHA-256 is
|
|
||||||
`d0f343154981c6dc78deb8ac57de4d980388a0d3f80ff7405ff53e52166d2bd3`;
|
|
||||||
the xattr-free Control Station build archive SHA-256 is
|
|
||||||
`250898e82ae3c97c1328ea90b5a1fc0fed9aeedcd2c06002b9b5d1e5bfa50f36`.
|
|
||||||
The exact D-only generation passed 35 focused target tests. Browser QA entered
|
|
||||||
through the capability-gated header, found no secondary launcher, exercised
|
|
||||||
orbit and zoom, observed advancing Gazebo time and stopped
|
|
||||||
`s1c-60e7916-20260724t182006z-2f164e` cleanly at 27.74 seconds of simulation
|
|
||||||
time with eight events, zero commands and zero provider residue.
|
|
||||||
|
|
||||||
S1D code generation `49b0f47fea0ae29c977bbd60808696b85a9380cd`
|
|
||||||
accepts the first Ackermann command/motion slice. Its exact source archive
|
|
||||||
SHA-256 is
|
|
||||||
`30aad33664a536f2a6cb87bc172a5dae111ae479d5899723832a1abc17c4517b`;
|
|
||||||
the xattr-free Control Station build archive SHA-256 is
|
|
||||||
`014724896c8c2169a89b0ed6971b285b6e80da918d892509e9e93568efb99f25`.
|
|
||||||
The target imported product code from the immutable source generation before
|
|
||||||
the D-only dependency site and passed 40 focused tests.
|
|
||||||
|
|
||||||
The browser contract remains speed in metres per second plus normalized
|
|
||||||
steering. On this stock PX4 v1.17.0 model, the worker's S1 adapter converts speed
|
|
||||||
to `RoverThrottleSetpoint` using `RO_MAX_THR_SPEED=3.1` and publishes
|
|
||||||
`RoverSteeringSetpoint` under Offboard `thrust_and_torque`, which retains PX4
|
|
||||||
arming and control allocation. It never publishes `ActuatorMotors` or
|
|
||||||
`ActuatorServos`. This adapter is sufficient for causal motion qualification
|
|
||||||
but is not evidence of calibrated closed-loop speed tracking.
|
|
||||||
|
|
||||||
The first cold start completed in one request after 32.08 seconds. Run
|
|
||||||
`s1c-49b0f47-20260724t195259z-62edd2` moved from `(0.00, 0.00)` to
|
|
||||||
`(3.74, -0.00)` in the straight case, then to `(5.11, 2.13)` through a turn and
|
|
||||||
to `(5.58, 0.67)` in reverse. One sampled command completed in 44.6 ms. A
|
|
||||||
single non-zero command expired at the 250 ms boundary and incremented
|
|
||||||
`ttl_expired_count` from 4 to 5; two following one-second-separated live
|
|
||||||
samples had identical X/Y. Explicit stop disarmed the virtual rover, completed
|
|
||||||
the run and left no PX4, Gazebo or Micro XRCE-DDS process residue.
|
|
||||||
|
|
||||||
This evidence checks the primary Ackermann command item only. S1 stays open for
|
|
||||||
Differential Rover, deceleration/emergency-stop profiles, accepted PX4/ROS 2
|
|
||||||
telemetry, reset/repeatability, heartbeat/offboard/link-loss, full failsafe and
|
|
||||||
navigation/safety cases.
|
|
||||||
|
|
||||||
#### S1E provider-neutral boundary status
|
|
||||||
|
|
||||||
The next repository increment makes provider interchangeability executable
|
|
||||||
rather than aspirational:
|
|
||||||
|
|
||||||
- `SimulationProviderProfile` strictly declares logical provider roles,
|
|
||||||
capabilities, authoritative simulation clock, canonical ENU/FLU frames and
|
|
||||||
supported rover control profiles;
|
|
||||||
- worker status v2 separates the configured provider stack from currently
|
|
||||||
active process IDs;
|
|
||||||
- `VehicleState` retains the v1 canonical frame/safety contract but accepts any
|
|
||||||
safe named diagnostic ground-truth provider;
|
|
||||||
- command acceptance v2 reports a generic controller provider, canonical
|
|
||||||
control profile, readiness and bounded boolean diagnostics;
|
|
||||||
- the stock Gazebo/PX4/XRCE profile satisfies the new contract;
|
|
||||||
- backend and browser tests admit an Unreal-shaped profile without adding
|
|
||||||
Unreal-specific branches.
|
|
||||||
|
|
||||||
This is contract acceptance only. It does not claim that Unreal is installed,
|
|
||||||
selected, running or qualified. ADR 0017 U0 is the next decision gate.
|
|
||||||
|
|
||||||
### S2
|
|
||||||
|
|
||||||
- Gazebo LiDAR/odometry feed the ROS 2/Nav2 baseline.
|
|
||||||
- Ackermann uses MPPI plus Smac Hybrid/Lattice or an explicitly justified
|
|
||||||
equivalent.
|
|
||||||
- Static wall, corridor, bypass, narrow passage, no-data, planner timeout and
|
|
||||||
control-loss scenarios produce comparable reports.
|
|
||||||
- PX4 waypoint following without obstacle avoidance and Nav2 are measured on
|
|
||||||
the same profiles.
|
|
||||||
- A 30-world BARN pilot follows stock-world acceptance; only geometry, maps,
|
|
||||||
paths and difficulty are imported from the legacy runtime.
|
|
||||||
|
|
||||||
## 19. Replay and dataset policy
|
|
||||||
|
|
||||||
Recorded datasets support detector, segmentation, tracking, occupancy and
|
|
||||||
shadow-policy regression. They do not react to alternative commands and cannot
|
|
||||||
prove a closed-loop avoidance maneuver.
|
|
||||||
|
|
||||||
- CODa and JRDB non-commercial/share-alike restrictions prohibit treating them
|
|
||||||
as unrestricted commercial runtime dependencies.
|
|
||||||
- NCLT is large and must be admitted by a size/license manifest.
|
|
||||||
- SCAND supplies social-navigation demonstrations but remains replay evidence.
|
|
||||||
- RAVNOVES00 first enters as canonical replay/shadow; only S5 may turn an
|
|
||||||
attested reconstruction into a digital-twin world.
|
|
||||||
|
|
||||||
Unknown or unobserved space remains unknown/occupied according to the selected
|
|
||||||
safety policy.
|
|
||||||
|
|
||||||
## 20. UI gate
|
|
||||||
|
|
||||||
Polygon is implemented as a capability-gated seventh top-level bounded context:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Center | Fleet | Observation | Missions | Data | Polygon* | System
|
|
||||||
|
|
||||||
* visible only while Mission Core confirms an admitted available Simulation Worker
|
|
||||||
```
|
|
||||||
|
|
||||||
Two UI gates are intentionally distinct.
|
|
||||||
|
|
||||||
### UI-0 — early read-only run view
|
|
||||||
|
|
||||||
Target S1B proves persisted start/health/stop history. UI-0 is implemented as a
|
|
||||||
direct internal run view and exposes:
|
|
||||||
|
|
||||||
- run identity, lifecycle and terminal reason;
|
|
||||||
- authoritative clock and current pause/running state;
|
|
||||||
- provider health/process ownership;
|
|
||||||
- qualification events and admitted artifact links;
|
|
||||||
- explicit limitations and gate status.
|
|
||||||
|
|
||||||
The backend reads the configured repository from
|
|
||||||
`MISSIONCORE_POLYGON_RUNS_ROOT` using
|
|
||||||
`QualificationRunStore(read_only=True)`. It exposes only:
|
|
||||||
|
|
||||||
```text
|
|
||||||
GET /api/v1/polygon/runs
|
|
||||||
GET /api/v1/polygon/runs/{run-id}
|
|
||||||
```
|
|
||||||
|
|
||||||
Missing/malformed configuration returns `503`; invalid or corrupt evidence
|
|
||||||
fails closed. Responses never contain the configured root, artifact bytes or
|
|
||||||
command payloads. `?workspace=polygon-run[&run=<run-id>]` remains a compatible
|
|
||||||
deep-link into the same product workspace.
|
|
||||||
|
|
||||||
UI-0 itself has no PX4 transport or lifecycle/command operations. It remains a
|
|
||||||
read-only evidence contract backed by the server-owned run repository and is
|
|
||||||
composed into UI-2. A shared review deployment requires a reviewed read-only D
|
|
||||||
mount or a co-located backend; SSH is not a product data plane.
|
|
||||||
|
|
||||||
### UI-2 — worker-gated top-level Polygon
|
|
||||||
|
|
||||||
The full UI-2 control/scenario gate begins only after:
|
|
||||||
|
|
||||||
1. this SRS and the clock/frame/authority contracts are accepted;
|
|
||||||
2. S1 target backend lifecycle and command authority are proven;
|
|
||||||
3. run history is persisted independently of a browser session.
|
|
||||||
|
|
||||||
Commit `60e7916` accepts the presentation/lifecycle slice before all three
|
|
||||||
conditions are complete: conditional header root, direct workspace,
|
|
||||||
virtual-only start/stop, 3D rover inspection, diagnostic ENU pose and read-only
|
|
||||||
history. It does not claim command authority. Scenarios, pause/step/reset,
|
|
||||||
canonical rover commands, Compare and Report remain planned. UI-2 cannot own
|
|
||||||
provider processes, bypass authority checks or write directly to PX4.
|
|
||||||
|
|
||||||
The browser scene remains a canonical-state product view, not the physics
|
|
||||||
engine. A future Unreal worker supplies the same state/evidence contracts; it
|
|
||||||
is not embedded into React and its native editor/window remains diagnostic.
|
|
||||||
|
|
||||||
## 21. Change control
|
|
||||||
|
|
||||||
A material change requires:
|
|
||||||
|
|
||||||
1. an ADR/SRS diff;
|
|
||||||
2. an update to MISSIONCOR-39;
|
|
||||||
3. migration and compatibility notes;
|
|
||||||
4. new or updated tests;
|
|
||||||
5. fresh evidence before a previously accepted gate remains accepted.
|
|
||||||
|
|
||||||
No checkbox is closed from intent alone. A commit plus factual validation or
|
|
||||||
admitted evidence is required.
|
|
||||||
|
|
@ -1,255 +0,0 @@
|
||||||
# LiDAR worker: product value, evidence boundary and implementation roadmap
|
|
||||||
|
|
||||||
Date: 2026-07-25
|
|
||||||
Status: accepted architecture plan; L0 contract implemented
|
|
||||||
Scope: real scanner records, replay and future live shadow processing
|
|
||||||
Explicitly out of scope: Unreal U0/U1, Gaussian assets and simulator rendering
|
|
||||||
|
|
||||||
## 1. Decision
|
|
||||||
|
|
||||||
Mission Core does not treat “LiDAR processing” as one model. It owns four
|
|
||||||
separate products:
|
|
||||||
|
|
||||||
1. scanner evidence and quality;
|
|
||||||
2. geometric preprocessing and map quality;
|
|
||||||
3. object/occupancy perception;
|
|
||||||
4. planner-facing local world state.
|
|
||||||
|
|
||||||
Simulation can exercise their contracts and generate synthetic regressions, but
|
|
||||||
it cannot prove real scanner timing, returns, reflectance, calibration, motion
|
|
||||||
distortion or environmental failure modes.
|
|
||||||
|
|
||||||
The AI worker remains external, Linux/NVIDIA-capable and replaceable. React is
|
|
||||||
the control and review surface. Mission Core owns immutable inputs, exact
|
|
||||||
profiles, result identities, acceptance gates and diagnostic-only authority.
|
|
||||||
Models do not gain scanner, navigation, command or safety authority.
|
|
||||||
|
|
||||||
## 2. What the current K1 source actually contains
|
|
||||||
|
|
||||||
The firmware-3 `lio_pcl` stream currently exposes:
|
|
||||||
|
|
||||||
- finite XYZ points already expressed in the canonical `map` frame;
|
|
||||||
- one verified uint8 intensity value in the low byte of `rgbi`;
|
|
||||||
- a frame header with sequence, stamp and scaler;
|
|
||||||
- a separate `T_map_from_lidar` pose stream.
|
|
||||||
|
|
||||||
It does not currently expose an admitted:
|
|
||||||
|
|
||||||
- raw sensor-frame sweep;
|
|
||||||
- per-point firing time;
|
|
||||||
- ring/channel number;
|
|
||||||
- IMU sample stream;
|
|
||||||
- LiDAR/IMU extrinsic;
|
|
||||||
- proven common hardware clock for LiDAR and cameras;
|
|
||||||
- LiDAR scan model required by projective integrations.
|
|
||||||
|
|
||||||
The accepted `missioncore.e10-lidar-replay-pack/v1` narrows this further: it
|
|
||||||
keeps XYZ, pose and best-effort camera binding, but drops intensity. It must not
|
|
||||||
be silently mutated because E10–E26 results are content-bound to that schema.
|
|
||||||
|
|
||||||
These facts have architectural consequences:
|
|
||||||
|
|
||||||
- current K1 points can support display, calibrated projection, persistent
|
|
||||||
support and bounded geometric analysis;
|
|
||||||
- the live stream can be converted back to a pose-relative sensor XYZI tensor;
|
|
||||||
- the v1 replay pack cannot feed the admitted NVIDIA PointPillars baseline;
|
|
||||||
- KISS-ICP, KISS-SLAM, FAST-LIO2, LIO-SAM or GLIM cannot honestly rebuild K1
|
|
||||||
odometry from points that are already vendor-mapped;
|
|
||||||
- deskew and LiDAR-inertial SLAM are blocked until the scanner or a future
|
|
||||||
vehicle LiDAR driver supplies raw scans, timing and IMU evidence.
|
|
||||||
|
|
||||||
The executable truth is
|
|
||||||
`missioncore.lidar-evidence-profile/v1` in
|
|
||||||
`src/k1link/compute/lidar_contract.py`. It assesses each stage as `ready`,
|
|
||||||
`degraded` or `blocked` and refuses to infer absent sensor fields.
|
|
||||||
|
|
||||||
## 3. Current system result
|
|
||||||
|
|
||||||
The existing camera-heavy E10–E26 line is valuable and remains in place:
|
|
||||||
|
|
||||||
- raw recording and content-addressed worker handoff;
|
|
||||||
- camera detection and semantic segmentation;
|
|
||||||
- factory KB4 camera/LiDAR projection;
|
|
||||||
- map-frame LiDAR support and ground-aware cuboids;
|
|
||||||
- bounded temporal stabilization;
|
|
||||||
- persistent support motion evidence;
|
|
||||||
- camera ego-motion evidence and conservative fusion.
|
|
||||||
|
|
||||||
E25 proved the LiDAR limit: missing current returns cannot be recovered by
|
|
||||||
threshold tuning. E26 passed its reviewed 11/11 diagnostic windows by adding
|
|
||||||
camera parallax, but it is not planner-ready: most camera observations remain
|
|
||||||
unknown, camera-only velocity is not metric, 374 conflicts remain, and the
|
|
||||||
benchmark is not independent ground truth.
|
|
||||||
|
|
||||||
The next work must therefore improve the LiDAR-native input and evaluation
|
|
||||||
surface, not add another visual smoothing pass.
|
|
||||||
|
|
||||||
## 4. Market and stack assessment
|
|
||||||
|
|
||||||
### 4.1 NVIDIA components worth retaining
|
|
||||||
|
|
||||||
| Component | Correct use in Mission Core | Decision |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| [TAO PointPillars](https://docs.nvidia.com/tao/tao-toolkit/latest/text/cv_finetuning/pytorch/point_cloud/pointpillars.html) | Train/evaluate a LiDAR-native 3D detector over sensor-frame XYZI | First neural 3D baseline after replay v2 and labels |
|
|
||||||
| [DeepStream LiDAR 3D inference](https://docs.nvidia.com/metropolis/deepstream/7.1/text/DS_3D_Lidar_Inference.html) | Reference production pipeline for XYZI → Triton/TensorRT → 3D boxes | Reuse the inference pattern, not its file loader or UI |
|
|
||||||
| [TAO Deploy PointPillars](https://docs.nvidia.com/tao/tao-toolkit/latest/text/tao_deploy/pointpillars.html) | Build a pinned FP16/FP32 TensorRT engine and evaluate it | Worker optimization only after an accuracy baseline |
|
|
||||||
| [Isaac ROS nvblox](https://nvidia-isaac-ros.github.io/repositories_and_packages/isaac_ros_nvblox/isaac_ros_nvblox/index.html) | GPU local TSDF/occupancy/ESDF and Nav2 cost-map producer | Later; blocked on admitted scan geometry and better timing |
|
|
||||||
| DeepStream multimodal 3D fusion | Later camera/LiDAR BEV baseline | Deferred until LiDAR-only, sync and calibration gates pass |
|
|
||||||
|
|
||||||
NVIDIA does not provide a magic “clean the K1 map” stage. PointPillars produces
|
|
||||||
classified 3D objects. Nvblox produces reconstruction and distance fields. The
|
|
||||||
quality of both remains bounded by source timing, calibration, scan geometry
|
|
||||||
and training-domain fit.
|
|
||||||
|
|
||||||
### 4.2 Independent components worth benchmarking
|
|
||||||
|
|
||||||
| Component | Correct use | Decision |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| [Patchwork++](https://github.com/url-kaist/patchwork-plusplus) | Fast adaptive ground segmentation, including reflection-noise handling | First non-neural geometry baseline |
|
|
||||||
| [Autoware CenterPoint](https://github.com/autowarefoundation/autoware_universe/tree/main/perception/autoware_lidar_centerpoint) | Mature ROS 2/TensorRT 3D detection and multi-frame reference | Second detector baseline after PointPillars |
|
|
||||||
| [MMDetection3D](https://github.com/open-mmlab/mmdetection3d) | Training/evaluation harness and dataset adapters | Laboratory only |
|
|
||||||
| [OpenPCDet](https://github.com/open-mmlab/OpenPCDet) | Alternative LiDAR detector benchmark/model zoo | Laboratory only; not the production runtime |
|
|
||||||
| [KISS-ICP](https://github.com/PRBonn/kiss-icp) | Simple LiDAR-only odometry baseline | Future raw sensor scans only |
|
|
||||||
| [KISS-SLAM](https://github.com/PRBonn/kiss-slam) | Global LiDAR SLAM/loop-closure baseline | Future raw sensor scans only |
|
|
||||||
| [FAST-LIO2](https://github.com/hku-mars/FAST_LIO) | Raw LiDAR + IMU odometry/mapping | Future vehicle sensor profile only |
|
|
||||||
| [LIO-SAM](https://github.com/TixiaoShan/LIO-SAM) | Deskewed LiDAR-inertial factor-graph reference | Future profile with ring/time/IMU only |
|
|
||||||
| [GLIM](https://github.com/koide3/glim) | GPU-accelerated range-inertial mapping, loop correction and map cleanup | Strong future mapping candidate; blocked for current K1 evidence |
|
|
||||||
|
|
||||||
Autoware is a useful architecture and component reference; importing the whole
|
|
||||||
autonomous-driving distribution into the worker would add a large operational
|
|
||||||
surface that Mission Core does not currently need.
|
|
||||||
|
|
||||||
## 5. Target worker shape
|
|
||||||
|
|
||||||
The worker exposes provider-neutral jobs rather than one growing camera script:
|
|
||||||
|
|
||||||
| Job profile | Input | Output | Authority |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `lidar-quality/v1` | immutable LiDAR evidence | field/timing/density/intensity report | diagnostic |
|
|
||||||
| `lidar-ground/v1` | sensor-frame XYZI + pose | ground/non-ground points and metrics | diagnostic |
|
|
||||||
| `lidar-3d-detection/v1` | sensor-frame XYZI | classified 3D observations + uncertainty | shadow |
|
|
||||||
| `lidar-local-map/v1` | scans + synchronized pose | occupancy/TSDF/ESDF artifacts | shadow |
|
|
||||||
| `lidar-mapping-benchmark/v1` | raw scans + optional IMU | trajectory/map comparison | offline diagnostic |
|
|
||||||
|
|
||||||
Every result is content-addressed and binds:
|
|
||||||
|
|
||||||
- source evidence identity;
|
|
||||||
- exact evidence profile/readiness document;
|
|
||||||
- model, weights, runtime and preprocessing identities;
|
|
||||||
- coordinate transforms and timestamp basis;
|
|
||||||
- resource/queue/drop telemetry;
|
|
||||||
- acceptance policy and explicit authority.
|
|
||||||
|
|
||||||
Offline replay may batch work for throughput. Live shadow uses bounded
|
|
||||||
latest-wins queues. Accuracy is frozen before FP16/INT8, CUDA graph, pinned
|
|
||||||
memory, zero-copy or batching optimizations are accepted.
|
|
||||||
|
|
||||||
## 6. Implementation sequence
|
|
||||||
|
|
||||||
### L0 — evidence truth and detector adapter — complete
|
|
||||||
|
|
||||||
- [x] Add `missioncore.lidar-evidence-profile/v1`.
|
|
||||||
- [x] Encode current live K1 and replay-pack-v1 facts.
|
|
||||||
- [x] Produce deterministic stage readiness and blockers.
|
|
||||||
- [x] Add tested `map + T_map_from_lidar + intensity → sensor XYZI`.
|
|
||||||
- [x] Add bounded live point-count, cadence and intensity telemetry to the
|
|
||||||
existing external worker report.
|
|
||||||
- [x] Keep authority diagnostic-only.
|
|
||||||
|
|
||||||
### L1 — lossless replay v2 and scanner quality
|
|
||||||
|
|
||||||
- [ ] Create a new replay schema; do not rewrite pack v1.
|
|
||||||
- [ ] Preserve XYZ, intensity, source sequence/header stamp/scaler and exact
|
|
||||||
host capture/receive times.
|
|
||||||
- [ ] Record explicitly absent ring, per-point time and IMU fields.
|
|
||||||
- [ ] Add bounded reports for frame gaps, arrival jitter, points/frame, range
|
|
||||||
distribution, intensity distribution and pose coverage.
|
|
||||||
- [ ] Compare live-vs-replay decoding byte-for-byte on a frozen slice.
|
|
||||||
- [ ] Publish the report to the React observation view without bundling a
|
|
||||||
desktop renderer.
|
|
||||||
|
|
||||||
Exit: a recording cannot be called detector-ready when required fields were
|
|
||||||
dropped, and an operator can distinguish scanner dropout from model failure.
|
|
||||||
|
|
||||||
### L2 — geometric baseline
|
|
||||||
|
|
||||||
- [ ] Run Patchwork++ over the frozen XYZI slice.
|
|
||||||
- [ ] Retain ground and non-ground outputs separately; never delete raw points.
|
|
||||||
- [ ] Label a small independent ground/obstacle evaluation set in CVAT or an
|
|
||||||
equivalent accepted annotation workspace.
|
|
||||||
- [ ] Measure ground IoU, curb/low-obstacle recall, reflection-noise rejection
|
|
||||||
and CPU/GPU latency.
|
|
||||||
- [ ] Compare against the current heuristic ground proposal branch.
|
|
||||||
|
|
||||||
Exit: an evidence-backed decision to retain or reject Patchwork++.
|
|
||||||
|
|
||||||
### L3 — LiDAR-native 3D detection
|
|
||||||
|
|
||||||
- [ ] Freeze independent 3D annotations covering people, vehicles, cyclists,
|
|
||||||
stroller groups, vegetation and confusing static structures.
|
|
||||||
- [ ] Run NVIDIA PointPillars through the existing external worker/Triton seam.
|
|
||||||
- [ ] Treat pretrained output as a baseline, not an accepted product model.
|
|
||||||
- [ ] Measure class precision/recall, center/range/yaw error, distance-bucket
|
|
||||||
recall, false occupied objects and end-to-end latency.
|
|
||||||
- [ ] Compare Autoware CenterPoint only after the PointPillars harness is stable.
|
|
||||||
- [ ] Fine-tune only if the baseline demonstrates useful transfer and the
|
|
||||||
annotation budget is justified.
|
|
||||||
|
|
||||||
Exit: the selected detector beats the camera-derived cuboid baseline on the
|
|
||||||
independent gate without increasing unsafe false-free or false-dynamic output.
|
|
||||||
|
|
||||||
### L4 — live shadow integration
|
|
||||||
|
|
||||||
- [ ] Add a bounded LiDAR queue independent of camera cadence.
|
|
||||||
- [ ] Run the accepted detector profile on the NVIDIA worker.
|
|
||||||
- [ ] Fuse LiDAR-native objects with E26 camera evidence as independent sources.
|
|
||||||
- [ ] Publish `agree`, `single-source`, `conflict` and `unknown`; unknown remains
|
|
||||||
occupied.
|
|
||||||
- [ ] Measure sensor-to-result latency, deadline misses, drops, memory and GPU
|
|
||||||
headroom on a physical run.
|
|
||||||
|
|
||||||
Exit: repeatable shadow telemetry only. Navigation and safety acceptance remain
|
|
||||||
false.
|
|
||||||
|
|
||||||
### L5 — local occupancy and Nav2
|
|
||||||
|
|
||||||
- [ ] Obtain and validate the K1/future vehicle LiDAR scan model, or use a
|
|
||||||
different admitted source that supplies it.
|
|
||||||
- [ ] Prove pose and time behavior required by nvblox.
|
|
||||||
- [ ] Benchmark static occupancy/TSDF and ESDF output on real replay.
|
|
||||||
- [ ] Keep dynamic observations in a separate decaying layer.
|
|
||||||
- [ ] Connect the accepted 2D slice to Nav2 through the existing world-state
|
|
||||||
boundary.
|
|
||||||
|
|
||||||
Exit: local collision-space quality and deadline gates pass in replay and
|
|
||||||
shadow. This still does not authorize control.
|
|
||||||
|
|
||||||
### L6 — alternative odometry/mapping
|
|
||||||
|
|
||||||
This stage starts only when a source supplies unregistered sensor scans. Add
|
|
||||||
per-point time and IMU/extrinsic requirements before FAST-LIO2, LIO-SAM or GLIM.
|
|
||||||
Benchmark KISS-ICP first, then a global SLAM candidate using ATE/RPE, loop
|
|
||||||
closure residual, wall/surface thickness, map entropy, repeat-pass alignment
|
|
||||||
and compute cost.
|
|
||||||
|
|
||||||
The current K1 `lio_pcl` stream cannot satisfy this gate.
|
|
||||||
|
|
||||||
## 7. Product value
|
|
||||||
|
|
||||||
The near-term value is not a prettier point cloud:
|
|
||||||
|
|
||||||
- a trustworthy observation tells the operator whether the scanner, transport,
|
|
||||||
pose, calibration or model failed;
|
|
||||||
- lossless replay makes model and worker upgrades repeatable;
|
|
||||||
- LiDAR-native objects reduce dependence on camera visibility and provide
|
|
||||||
metric geometry;
|
|
||||||
- ground/non-ground and local occupancy become the bridge from archive review
|
|
||||||
to route validation and later collision checking;
|
|
||||||
- the same job/result contracts accept real, replayed or simulated sources
|
|
||||||
without moving heavy compute into React;
|
|
||||||
- hardware selection becomes evidence-driven: a future vehicle LiDAR is
|
|
||||||
accepted by its timing/fields/profile, not by vendor marketing.
|
|
||||||
|
|
||||||
The highest-value immediate work is L1, followed by L2 and L3. Nvblox and
|
|
||||||
alternative SLAM are useful, but starting them before their input gates would
|
|
||||||
produce attractive demos with unqualified geometry.
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
# ADR 0015: Simulation Polygon as a qualification boundary
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted for architecture; SIM S0 qualification returned `GO`, the S1B
|
|
||||||
real-provider start/health/stop boundary passed on 2026-07-24, and the direct
|
|
||||||
read-only UI-0 view is implemented and visually verified against that accepted
|
|
||||||
journal. Navigation behavior, safety behavior and real actuator authority are
|
|
||||||
not accepted.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Mission Core has proven a K1 observation/archive/perception vertical, but the
|
|
||||||
perception outputs do not yet have a closed-loop consumer. Adding Gazebo, PX4,
|
|
||||||
ROS 2 and Nav2 directly to the web process or modeling every experiment as an
|
|
||||||
observation session would blur product ownership, lifecycle, evidence and
|
|
||||||
authority.
|
|
||||||
|
|
||||||
Replay, physics simulation, a digital twin, HIL and real shadow also have
|
|
||||||
different causal meaning. A recorded stream cannot react to a hypothetical
|
|
||||||
command, while a closed-loop world can. Calling both `SimulationRun` would make
|
|
||||||
reports easy to misuse.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
1. Polygon is a first-class Mission Core bounded context for autonomy
|
|
||||||
qualification and assurance.
|
|
||||||
2. Mission Core owns scenarios, qualification-run lifecycle, authority policy,
|
|
||||||
canonical contracts, provenance, evidence admission, evaluation and reports.
|
|
||||||
3. Gazebo, PX4 SITL, ROS 2, Nav2, Rerun and datasets are replaceable providers.
|
|
||||||
4. `QualificationRun` is the umbrella aggregate. Its kinds explicitly separate
|
|
||||||
`simulation_closed_loop`, `replay_shadow`,
|
|
||||||
`digital_twin_closed_loop`, `controller_in_loop`, `hil` and
|
|
||||||
`physical_shadow`.
|
|
||||||
5. Simulation Orchestrator is the sole process-lifecycle owner. Gazebo and PX4
|
|
||||||
do not run inside the Mission Core web process, and the browser has no direct
|
|
||||||
PX4 channel.
|
|
||||||
6. Each admitted closed-loop provider profile declares exactly one
|
|
||||||
authoritative simulation clock. The accepted stock profile uses Gazebo
|
|
||||||
`/clock`; ROS 2 uses simulated time and PX4 uXRCE-DDS time synchronization
|
|
||||||
is disabled for that profile.
|
|
||||||
7. Mission Core uses ENU/FLU. PX4 uses NED/FRD. Exactly one adapter boundary
|
|
||||||
converts frames and requires golden tests.
|
|
||||||
8. The first command boundary permits only rover speed+steering and
|
|
||||||
speed+yaw-rate profiles with sequence, TTL, heartbeat, watchdog and
|
|
||||||
stop-on-loss behavior. Direct actuator setpoints are rejected.
|
|
||||||
9. Source-of-record, derived artifacts and presentation state remain separate.
|
|
||||||
Reset creates a new immutable run/episode.
|
|
||||||
10. Ground truth is evaluator-only and is not visible to perception or planning.
|
|
||||||
11. SIM S0 targets the reviewed Windows 11 worker through a dedicated
|
|
||||||
`MissionCore-Sim` WSL2 Ubuntu 24.04 distribution physically under
|
|
||||||
`D:\NDC_MISSIONCORE`.
|
|
||||||
12. SIM S0 does not depend on the existing Docker Desktop runtime. Its WSL
|
|
||||||
backend is C-backed and currently serves unrelated co-tenants.
|
|
||||||
13. S1 uses exact `px4_msgs` directly. The experimental
|
|
||||||
`px4-ros2-interface-lib` is not a mandatory S1 dependency.
|
|
||||||
14. The read-only direct UI-0 run view consumes proven target S1B persisted
|
|
||||||
history through GET-only server routes. It is hidden from navigation and
|
|
||||||
cannot mutate a run. Top-level Polygon UI and control remain gated on
|
|
||||||
accepted S1 target command/lifecycle behavior.
|
|
||||||
15. Real actuator authority requires a new decision and physical safety gate.
|
|
||||||
16. S0 processes run inside an ephemeral loopback-only Linux network namespace.
|
|
||||||
Micro XRCE-DDS Agent's UDP wildcard bind is acceptable only inside that
|
|
||||||
boundary; no S0 listener may be reachable from the worker LAN or Windows
|
|
||||||
host network.
|
|
||||||
17. Product packaging, simulation-worker placement, the future onboard Edge
|
|
||||||
Agent, local-first field evidence and offline synchronization follow
|
|
||||||
[ADR 0016](0016-distributed-product-edge-and-worker-topology.md). Native
|
|
||||||
Gazebo GUI is worker-local diagnostics; the operator product remains the
|
|
||||||
browser Control Station over canonical state.
|
|
||||||
18. Provider roles, capabilities, clock and canonical frames follow
|
|
||||||
[ADR 0017](0017-provider-neutral-simulation-and-unreal-evaluation.md).
|
|
||||||
Gazebo remains the regression baseline; Unreal is an unevaluated
|
|
||||||
high-fidelity provider family, not a replacement product architecture.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- Perception can be measured by downstream decisions without granting it hidden
|
|
||||||
behavior authority.
|
|
||||||
- Standard planners and future Mission Core planners can be compared on the
|
|
||||||
same scenario and metric profiles.
|
|
||||||
- Replay cannot be presented as closed-loop safety evidence.
|
|
||||||
- Provider upgrades require explicit compatibility and evidence refresh.
|
|
||||||
- More up-front domain and artifact work is required before a visual demo.
|
|
||||||
- The AI worker must satisfy D-only, resource and coexistence constraints before
|
|
||||||
installation is accepted.
|
|
||||||
- Simulation and HIL reduce risk but do not certify the real trike.
|
|
||||||
|
|
||||||
## First implementation
|
|
||||||
|
|
||||||
The qualification doctor is deliberately read-only:
|
|
||||||
|
|
||||||
- `simulation/s0/qualification-profile.yaml` is the accepted S0 stack,
|
|
||||||
storage, clock, authority, port and process contract.
|
|
||||||
- `k1link.simulation.s0` strictly validates that contract and optional
|
|
||||||
digest-bound worker evidence.
|
|
||||||
- `missioncore-sim s0 doctor` inspects the current host without installing,
|
|
||||||
starting or modifying anything.
|
|
||||||
- MISSIONCOR-40 tracks factual worker qualification.
|
|
||||||
|
|
||||||
The first target bootstrap also includes `simulation/s0/worker-smoke.sh`. It
|
|
||||||
creates the reviewed loopback-only namespace, starts only the pinned stock-rover
|
|
||||||
providers, observes ROS 2 vehicle status and Gazebo clock, requests PX4 shutdown,
|
|
||||||
checks for owned-process residue and writes D-only factual artifacts. It never
|
|
||||||
arms the vehicle or sends an actuator command.
|
|
||||||
|
|
||||||
`simulation/s0/gazebo_time_control.py` owns the S0 pause/single-step/1×/2× RTF
|
|
||||||
and resource acceptance probe. `simulation/s0/build_evidence_manifest.py`
|
|
||||||
admits only exact-profile, D-confined, passing 1×/2× runs into the digest-bound
|
|
||||||
evidence generation. These scripts are qualification tooling; the S1
|
|
||||||
Simulation Orchestrator remains the future product lifecycle owner.
|
|
||||||
|
|
||||||
The doctor reports `INCOMPLETE` until it is running on the reviewed target with
|
|
||||||
resolved/accepted pins and all required evidence. On the accepted profile
|
|
||||||
generation and nine-claim confined evidence set it returned `GO`; it cannot
|
|
||||||
infer that result from repository state alone.
|
|
||||||
|
|
||||||
## S1B target lifecycle
|
|
||||||
|
|
||||||
Exact generation `6cb1495` is the first product-owned real-provider acceptance.
|
|
||||||
`SimulationApplicationService` persisted the lifecycle while the worker adapter
|
|
||||||
created a fresh loopback-only namespace and `PosixProcessSupervisor` owned two
|
|
||||||
dedicated PGIDs: Micro XRCE-DDS Agent and the combined PX4/Gazebo stock
|
|
||||||
Ackermann launch. The run became `running` only after the exact world/model,
|
|
||||||
PX4 startup and DDS-writer markers existed. No arm, setpoint or other PX4 shell
|
|
||||||
command was sent. Stop produced `completed`, eight events, zero commands and no
|
|
||||||
process residue.
|
|
||||||
|
|
||||||
UI-0 now implements that decision. `QualificationRunStore(read_only=True)`
|
|
||||||
opens an existing repository without creating or chmodding it and rejects every
|
|
||||||
mutating method. The web process exposes only run catalog/detail GET routes,
|
|
||||||
and the hidden Control Station workspace opens only from
|
|
||||||
`?workspace=polygon-run[&run=<id>]`. Browser QA loaded the accepted
|
|
||||||
`s1b-6cb1495-20260724t1535z` journal. This does not widen command authority or
|
|
||||||
accept pause/step/reset, canonical telemetry, frame conversion,
|
|
||||||
watchdog/failsafe behavior, navigation or physical control.
|
|
||||||
|
|
||||||
## S1A implementation
|
|
||||||
|
|
||||||
`k1link.simulation.contracts` now defines the first product-owned
|
|
||||||
`QualificationRun`, provider/authority/artifact provenance, canonical
|
|
||||||
Ackermann and Differential rover setpoints, and qualification events.
|
|
||||||
`k1link.simulation.run_store` persists them as an immutable manifest plus
|
|
||||||
one-file-per-sequence append-only journals. Exclusive creation and file/directory
|
|
||||||
fsync prevent partially acknowledged records; loading rejects gaps, identity
|
|
||||||
drift, non-regular records and illegal lifecycle history.
|
|
||||||
|
|
||||||
The aggregate derives its current state by replay. Terminal runs cannot accept
|
|
||||||
new events, commands or artifacts. A restart never silently resumes an active
|
|
||||||
run: reconciliation terminates it as `failed` with
|
|
||||||
`orchestrator-recovery-interrupted`. Reset requires a terminal parent and
|
|
||||||
creates a new linked run/episode. Commands are admitted only for a running
|
|
||||||
closed-loop virtual run with the current authority generation and bounded
|
|
||||||
simulation-time TTL. Replay/shadow commands, direct actuator authority and
|
|
||||||
ungated HIL/controller-in-loop admission fail closed.
|
|
||||||
|
|
||||||
This increment proves the local lifecycle/repository contract. It does not yet
|
|
||||||
own provider processes, transmit a PX4 setpoint, execute heartbeat/watchdog
|
|
||||||
failsafe behavior, or qualify navigation/safety behavior. The target
|
|
||||||
orchestrator must place the caller-supplied repository root under the accepted
|
|
||||||
D-only worker boundary.
|
|
||||||
|
|
||||||
## S1B ownership foundation
|
|
||||||
|
|
||||||
`k1link.simulation.orchestrator` now owns the application lifecycle through a
|
|
||||||
transport-neutral worker port. Start and stop requests are persisted with
|
|
||||||
idempotency identity, one active run owns a worker at a time, and
|
|
||||||
pause/resume/step/reset cannot bypass run-state validation. Provider start
|
|
||||||
failure, S0 profile drift, stop residue and restart interruption all become
|
|
||||||
explicit run events and terminal failures.
|
|
||||||
|
|
||||||
`k1link.simulation.process_supervisor` provides the worker-local POSIX ownership
|
|
||||||
primitive: shell-free argv, dedicated provider PGIDs, run-scoped logs, atomic
|
|
||||||
process registry, partial-start rollback and ascending shutdown order matching
|
|
||||||
the accepted S0 dependency graph. `k1link.simulation.worker` binds that
|
|
||||||
primitive to the exact accepted S0 profile digest and canonical D-only S1 run
|
|
||||||
artifact and process-runtime layouts.
|
|
||||||
|
|
||||||
The later exact generation `6cb1495` supplied the target provider
|
|
||||||
specifications, loopback namespace and live readiness probes and passed the
|
|
||||||
real-provider start/health/stop boundary described above. Restart-safe
|
|
||||||
PID/start-token reconciliation after orchestrator loss and Gazebo
|
|
||||||
world-control/PX4 command adapters remain open. The worker port deliberately
|
|
||||||
fixes ownership semantics without making SSH or the browser a provider
|
|
||||||
transport.
|
|
||||||
|
|
||||||
Commit `630d1ae` subsequently passed a D-only `MissionCore-Sim` bootstrap:
|
|
||||||
the exact Git archive SHA was verified, both S1 test modules returned
|
|
||||||
`20 passed`, and the test process-residue record was empty. This accepts target
|
|
||||||
executability of the foundation only; the later `6cb1495` evidence, not that
|
|
||||||
bootstrap, closes the real-provider lifecycle boundary.
|
|
||||||
|
|
@ -1,181 +0,0 @@
|
||||||
# ADR 0016: Distributed web product, edge runtime and worker topology
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted for architecture on 2026-07-24. It defines placement and packaging,
|
|
||||||
not field actuator acceptance. The current Windows/WSL2 simulation worker is a
|
|
||||||
laboratory deployment of this topology; the future onboard Edge Agent and its
|
|
||||||
physical command authority remain separate gates.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Mission Core has a React Control Station, a Python control plane, native
|
|
||||||
simulation providers and future onboard components. Treating all of them as one
|
|
||||||
application creates two misleading alternatives:
|
|
||||||
|
|
||||||
1. embed Gazebo, PX4, ROS 2 and Nav2 inside the browser; or
|
|
||||||
2. make Mission Core a Windows/macOS/Linux desktop executable.
|
|
||||||
|
|
||||||
Neither matches the system.
|
|
||||||
|
|
||||||
Gazebo Harmonic has a native Qt GUI, PX4 SITL and ROS 2 are Linux processes, and
|
|
||||||
Nav2 is a runtime graph. A browser cannot own those processes or preserve their
|
|
||||||
state when its tab closes. Conversely, a field vehicle must keep bounded
|
|
||||||
autonomy, safety behavior and durable local evidence when the operator browser,
|
|
||||||
WAN or control centre is unavailable.
|
|
||||||
|
|
||||||
The operator still needs one coherent product: plan a mission, inspect a live
|
|
||||||
or historical vehicle, run a simulation, compare outcomes and review evidence
|
|
||||||
from a browser on macOS, Windows, Linux or a tablet.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
### Product form
|
|
||||||
|
|
||||||
1. The primary Mission Core operator product remains the React Control Station
|
|
||||||
served as static web assets by a Mission Core Gateway/API. It is not a
|
|
||||||
platform-specific desktop executable.
|
|
||||||
2. An optional PWA, kiosk shell or thin Tauri/Electron wrapper may package the
|
|
||||||
same web assets for offline distribution. Such a wrapper is not a second
|
|
||||||
product, does not own authority and is not required for the core topology.
|
|
||||||
3. Native provider GUIs are diagnostic tools. Gazebo GUI, QGroundControl and
|
|
||||||
similar tools do not become product navigation or a source-of-record.
|
|
||||||
|
|
||||||
### Runtime roles
|
|
||||||
|
|
||||||
4. Mission Core Control Plane owns users, missions, scenarios, policy,
|
|
||||||
qualification-run identity, authority admission, orchestration, catalogues,
|
|
||||||
comparison and reports.
|
|
||||||
5. Simulation Worker is a separately registered Linux execution target. It runs
|
|
||||||
Simulation Orchestrator worker components plus Gazebo, PX4 SITL, ROS 2 and
|
|
||||||
Nav2. Normal product operation is headless. A native Gazebo GUI may attach
|
|
||||||
locally for laboratory diagnosis without becoming the control plane.
|
|
||||||
6. Vehicle Edge Agent is a future headless Linux service on the onboard
|
|
||||||
computer or a vehicle-adjacent edge computer. It owns device adapters,
|
|
||||||
bounded local mission state, health, canonical telemetry, local evidence,
|
|
||||||
store-and-forward synchronization and the server-side physical authority
|
|
||||||
boundary accepted for that vehicle.
|
|
||||||
7. The flight/rover controller remains a separate PX4 hardware boundary. A
|
|
||||||
browser never talks to PX4 directly.
|
|
||||||
8. Observation/perception workers may run onboard, on a nearby edge computer or
|
|
||||||
in a control-centre worker according to latency and bandwidth budgets. Their
|
|
||||||
placement does not change canonical contracts or authority ownership.
|
|
||||||
|
|
||||||
### Browser presentation
|
|
||||||
|
|
||||||
9. The browser receives bounded canonical state, events, metrics, maps,
|
|
||||||
trajectories, perception layers and optional media previews. It renders
|
|
||||||
interactive product views through browser-native components such as Rerun
|
|
||||||
Web Viewer, WebGL/canvas and ordinary React controls.
|
|
||||||
10. A Polygon run view shows the canonical rover pose, route, planned/factual
|
|
||||||
trajectory, footprint, safety state and derived scene. It does not require
|
|
||||||
remote pixels from Gazebo GUI.
|
|
||||||
11. Full native Gazebo rendering may be exposed only as an explicitly
|
|
||||||
diagnostic remote-desktop/WebRTC capability. It is optional, isolated from
|
|
||||||
command authority and never the only representation of run state.
|
|
||||||
|
|
||||||
### Connectivity and evidence
|
|
||||||
|
|
||||||
12. Field safety and bounded mission execution cannot depend on a browser tab,
|
|
||||||
a WAN connection or continuous connectivity to a central Mission Core
|
|
||||||
instance.
|
|
||||||
13. The Edge Agent persists raw/source evidence and the canonical event/state
|
|
||||||
journal locally before optional upload. High-volume sensor data remains on
|
|
||||||
vehicle storage until retention and connectivity policy permits transfer.
|
|
||||||
14. Connected mode sends bounded live telemetry, health, decisions and previews
|
|
||||||
outward. Disconnected mode continues the locally admitted policy, applies
|
|
||||||
the accepted loss/failsafe behavior and queues evidence for later sync.
|
|
||||||
15. Reconnection performs idempotent, digest-bound store-and-forward
|
|
||||||
synchronization. The central archive never rewrites the vehicle's original
|
|
||||||
run identity or presents a partial upload as complete.
|
|
||||||
16. A field kit may host the Gateway/API and React assets locally on an operator
|
|
||||||
laptop or edge hub. The same deployment can later synchronize with a
|
|
||||||
control-centre instance; no Internet connection is required to render the
|
|
||||||
UI.
|
|
||||||
|
|
||||||
### Authority path
|
|
||||||
|
|
||||||
17. The authority path is always:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Browser intent
|
|
||||||
-> Mission Core API/policy
|
|
||||||
-> Orchestrator or Vehicle Edge Agent
|
|
||||||
-> allowlisted provider/device adapter
|
|
||||||
-> PX4 boundary
|
|
||||||
```
|
|
||||||
|
|
||||||
18. Losing the browser affects presentation only. Losing upstream connectivity
|
|
||||||
invokes the locally accepted watchdog/failsafe policy; it does not grant a
|
|
||||||
new command source.
|
|
||||||
|
|
||||||
## Supported deployment topologies
|
|
||||||
|
|
||||||
### Laboratory simulation
|
|
||||||
|
|
||||||
```text
|
|
||||||
Operator browser
|
|
||||||
-> Mission Core Gateway/API
|
|
||||||
-> Simulation Orchestrator
|
|
||||||
-> registered Linux/WSL2 GPU worker
|
|
||||||
-> Gazebo + PX4 SITL + ROS 2 + Nav2
|
|
||||||
-> local source evidence + canonical/derived streams
|
|
||||||
```
|
|
||||||
|
|
||||||
The current `MissionCore-Sim` WSL2 distribution on the D drive is this
|
|
||||||
topology's first qualified worker. Its native Gazebo window is local diagnostics;
|
|
||||||
the product view remains browser-native.
|
|
||||||
|
|
||||||
The first implemented registration slice is S1C commit `b7ccca3`. Mission Core
|
|
||||||
and the worker share only a private mode-`0600` Unix socket in the WSL mount
|
|
||||||
namespace. PX4, Gazebo and Micro XRCE-DDS stay inside the worker's
|
|
||||||
loopback-only network namespace and run as the unprivileged `missioncore`
|
|
||||||
identity. Run artifacts and provider logs remain under the admitted D-only
|
|
||||||
roots. The React client calls same-origin Mission Core endpoints and never
|
|
||||||
receives the socket path or a provider endpoint. Remote/routed worker
|
|
||||||
registration is intentionally not inferred from this local laboratory
|
|
||||||
transport; it requires an authenticated contract of its own.
|
|
||||||
|
|
||||||
### Connected field vehicle
|
|
||||||
|
|
||||||
```text
|
|
||||||
Operator browser/control centre
|
|
||||||
-> Mission Core Gateway/API
|
|
||||||
<-> Vehicle Edge Agent on onboard Linux
|
|
||||||
-> PX4 hardware boundary
|
|
||||||
-> sensors and actuators
|
|
||||||
```
|
|
||||||
|
|
||||||
Bounded telemetry and previews can be live. Raw/high-volume evidence is written
|
|
||||||
locally first and uploaded according to policy.
|
|
||||||
|
|
||||||
### Disconnected field vehicle and later review
|
|
||||||
|
|
||||||
```text
|
|
||||||
Approved local mission + Edge Agent + PX4
|
|
||||||
-> local execution/failsafe
|
|
||||||
-> local append-only evidence
|
|
||||||
-> later digest-bound synchronization
|
|
||||||
-> browser history/replay/report
|
|
||||||
```
|
|
||||||
|
|
||||||
The historical/replay product is therefore not a fallback or a separate
|
|
||||||
application. It is the review side of the same distributed system.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- Operators use one browser product across simulation, field monitoring,
|
|
||||||
planning, archive and comparison.
|
|
||||||
- Native Linux dependencies do not force a Windows-only or macOS-only Mission
|
|
||||||
Core executable.
|
|
||||||
- Gazebo is not installed on field vehicles unless a specific lab/HIL profile
|
|
||||||
requires it.
|
|
||||||
- The product needs explicit Gateway, worker-registration, Edge Agent,
|
|
||||||
store-and-forward and capability-discovery contracts.
|
|
||||||
- Live 3D is a canonical-state presentation problem, not a remote-Gazebo-window
|
|
||||||
dependency.
|
|
||||||
- A thin desktop wrapper remains possible for distribution or kiosk use, but it
|
|
||||||
cannot widen authority or hide missing offline server components.
|
|
||||||
- Field hardware, storage budgets, network-loss policy, physical e-stop,
|
|
||||||
operator takeover and real actuator authority still require separate
|
|
||||||
acceptance.
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
# ADR 0017: Provider-neutral simulation and Unreal evaluation
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted for architecture on 2026-07-25. The provider contract is implemented
|
|
||||||
on the Polygon branch. Gazebo remains the accepted regression baseline. No
|
|
||||||
Unreal integration, Unreal runtime profile or choice between native Unreal,
|
|
||||||
CARLA and Project AirSim is accepted by this decision.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The first Polygon vertical proved that Mission Core can own a virtual Ackermann
|
|
||||||
run, display canonical ENU/FLU state in React, persist evidence and deliver
|
|
||||||
bounded virtual-only rover commands through PX4. That proof used one concrete
|
|
||||||
stack: Gazebo Harmonic, PX4 SITL, ROS 2 and Micro XRCE-DDS.
|
|
||||||
|
|
||||||
Treating that stack as the product architecture would make later work expensive:
|
|
||||||
|
|
||||||
- a realistic vehicle, road surface, terrain and sensor model may be easier to
|
|
||||||
build and inspect in Unreal;
|
|
||||||
- reconstructed Gaussian scenes are primarily visual evidence and do not by
|
|
||||||
themselves supply collision, friction, road or navigation geometry;
|
|
||||||
- native Unreal, CARLA and Project AirSim have different vehicle, sensor,
|
|
||||||
controller, platform and lifecycle assumptions;
|
|
||||||
- a browser cannot and should not execute the full physics/autopilot stack;
|
|
||||||
- replacing Gazebo must not replace Mission Core run identity, authority,
|
|
||||||
evidence, UI or evaluator contracts.
|
|
||||||
|
|
||||||
The choice is therefore not “Gazebo or Unreal for the whole product.” It is
|
|
||||||
which worker-side provider profile is appropriate for each qualification case.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
1. Mission Core remains simulation-provider neutral. A worker publishes an
|
|
||||||
exact `missioncore.simulation-provider-profile/v1` before it may be used by
|
|
||||||
the browser or gateway.
|
|
||||||
2. A provider profile declares logical providers, roles, capabilities,
|
|
||||||
authoritative simulation clock, supported canonical control profiles and
|
|
||||||
the fixed `map_enu`/`base_link_flu` boundary.
|
|
||||||
3. Provider roles are `world`, `physics`, `state`, `controller`, `transport`,
|
|
||||||
`sensor` and `traffic`. One process may implement several roles; several
|
|
||||||
processes may compose one profile.
|
|
||||||
4. Component pins and provider roles remain separate:
|
|
||||||
component pins prove exact software provenance, while provider descriptors
|
|
||||||
explain runtime responsibility and interoperability.
|
|
||||||
5. Worker status v2 exposes the configured provider profile separately from
|
|
||||||
currently active process IDs. `VehicleState` names its source provider.
|
|
||||||
Command acceptance v2 names the delivering controller provider and canonical
|
|
||||||
control profile.
|
|
||||||
6. The browser consumes canonical state and evidence. Unreal, Gazebo, PX4,
|
|
||||||
ROS 2 and their native windows remain outside React and run on registered
|
|
||||||
workers.
|
|
||||||
7. Gazebo/PX4 remains the frozen, comparatively cheap deterministic regression
|
|
||||||
profile until another profile passes the same lifecycle, clock, command,
|
|
||||||
safety and evidence gates.
|
|
||||||
8. Unreal is evaluated as a high-fidelity provider family. Native Unreal,
|
|
||||||
CARLA and Project AirSim are separate candidates and are not interchangeable
|
|
||||||
labels:
|
|
||||||
- native Unreal maximizes project control but requires Mission Core-owned
|
|
||||||
vehicle, sensor, clock and bridge work;
|
|
||||||
- CARLA is evaluated when road/traffic simulation and its supported vehicle
|
|
||||||
model fit the rover case;
|
|
||||||
- Project AirSim is evaluated only against its current supported vehicle,
|
|
||||||
PX4, ROS 2, platform and maintenance constraints.
|
|
||||||
9. A Gaussian scene may provide appearance and sensor-rendering context. Every
|
|
||||||
qualification world still needs separately versioned physical collision
|
|
||||||
meshes, road/drivable surfaces, materials/friction, semantic regions,
|
|
||||||
spawn/goal definitions and evaluator truth.
|
|
||||||
10. Unreal does not block S1 completion or the S2 Gazebo/Nav2 baseline. It
|
|
||||||
becomes most valuable in S3 virtual-sensor/perception work and S5
|
|
||||||
high-fidelity/digital-twin work. An earlier bounded adapter spike is allowed
|
|
||||||
because it reduces architectural risk.
|
|
||||||
11. PX4 remains a replaceable controller provider. An Unreal profile may use
|
|
||||||
PX4 through an admitted external-simulator boundary or may use a different
|
|
||||||
virtual controller, but each choice creates a distinct profile and cannot
|
|
||||||
inherit the other profile's evidence.
|
|
||||||
12. No real actuator authority is introduced. All current profiles remain
|
|
||||||
`virtual-only`.
|
|
||||||
|
|
||||||
## Fidelity ladder
|
|
||||||
|
|
||||||
| Level | Purpose | Initial provider |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| L0 contract tests | Fast lifecycle, schema and safety regression | In-process fakes |
|
|
||||||
| L1 dynamics regression | Cheap repeated control/navigation qualification | Gazebo + PX4 SITL |
|
|
||||||
| L2 sensor/scene fidelity | Camera/LiDAR/perception and difficult visual worlds | Unreal candidate |
|
|
||||||
| L3 hardware/control integration | Controller-in-loop and HIL | Separate gated profile |
|
|
||||||
| L4 physical evidence | Shadow and later controlled field qualification | Vehicle Edge Agent |
|
|
||||||
|
|
||||||
A higher level does not replace lower-level regression. Evidence is comparable
|
|
||||||
only when scenario, provider profile, metric profile and reproducibility tier
|
|
||||||
allow it.
|
|
||||||
|
|
||||||
## Unreal evaluation gates
|
|
||||||
|
|
||||||
### U0 — selection spike
|
|
||||||
|
|
||||||
For native Unreal, CARLA and Project AirSim, record:
|
|
||||||
|
|
||||||
- supported rover/Ackermann dynamics and controllable wheel/suspension model;
|
|
||||||
- fixed-step clock, pause/step/reset and headless execution;
|
|
||||||
- external command/state bridge and PX4 compatibility;
|
|
||||||
- camera, depth, LiDAR, IMU, GNSS and ground-truth availability;
|
|
||||||
- collision/contact and semantic truth;
|
|
||||||
- Linux/Windows worker placement, GPU/VRAM/RAM and startup time;
|
|
||||||
- automation, packaging, licensing, upstream activity and version pinning;
|
|
||||||
- import path for existing Unreal assets and Gaussian reconstructions.
|
|
||||||
|
|
||||||
Exit: one candidate or native implementation is selected for a disposable U1
|
|
||||||
adapter. “Looks better” is not an exit criterion.
|
|
||||||
|
|
||||||
### U1 — canonical adapter proof
|
|
||||||
|
|
||||||
- publish one provider profile and authoritative fixed-step clock;
|
|
||||||
- launch/reset/stop through the Simulation Orchestrator;
|
|
||||||
- map a stock Ackermann pose to `VehicleState`;
|
|
||||||
- accept `rover-speed-steering/v1` under the existing TTL/watchdog boundary;
|
|
||||||
- prove straight, turn, reverse, command expiry and clean zero-residue stop;
|
|
||||||
- retain exact world, provider, host and resource evidence on D.
|
|
||||||
|
|
||||||
Exit: the existing React panel and run archive work without Unreal-specific
|
|
||||||
branches in Mission Core.
|
|
||||||
|
|
||||||
### U2 — physical and sensor world
|
|
||||||
|
|
||||||
- add reviewed collision/drivable geometry alongside visual assets/Gaussians;
|
|
||||||
- pin vehicle mass, inertia, wheelbase, steering limits, suspension, friction
|
|
||||||
and sensor extrinsics;
|
|
||||||
- publish evaluator-only truth separately from planner/perception inputs;
|
|
||||||
- validate camera/LiDAR timing, noise and frame conversion.
|
|
||||||
|
|
||||||
Exit: sensor/perception experiments are repeatable and do not consume hidden
|
|
||||||
ground truth.
|
|
||||||
|
|
||||||
### U3 — comparative qualification
|
|
||||||
|
|
||||||
- run equivalent scenarios through the Gazebo and Unreal profiles;
|
|
||||||
- report control, collision, latency, resource and reproducibility differences;
|
|
||||||
- declare which conclusions are portable across providers and which are not.
|
|
||||||
|
|
||||||
Exit: Unreal supplies measurable product value beyond rendering quality.
|
|
||||||
|
|
||||||
## Immediate work policy
|
|
||||||
|
|
||||||
Continue work that remains valuable across providers:
|
|
||||||
|
|
||||||
- provider-neutral orchestration and capability admission;
|
|
||||||
- run, event, command, artifact and report contracts;
|
|
||||||
- TTL, heartbeat, watchdog, failsafe and authority gates;
|
|
||||||
- canonical frames, clocks, telemetry and evaluator metrics;
|
|
||||||
- scenario/profile registry and browser presentation of canonical state;
|
|
||||||
- Gazebo regression cases needed to validate those boundaries.
|
|
||||||
|
|
||||||
Defer until U0 selects a direction:
|
|
||||||
|
|
||||||
- a polished custom Gazebo vehicle;
|
|
||||||
- large Gazebo world/model libraries;
|
|
||||||
- simulator-specific UI controls;
|
|
||||||
- a production Unreal bridge;
|
|
||||||
- Gaussian collision or road generation assumptions;
|
|
||||||
- provider-specific perception contracts.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- Existing Polygon work is retained as the control plane and regression tier.
|
|
||||||
- Unreal experimentation can proceed independently without forking the product
|
|
||||||
model or exposing a native engine to the browser.
|
|
||||||
- The first Unreal work is a measured adapter spike, not a platform migration.
|
|
||||||
- More explicit provider metadata and compatibility testing is required.
|
|
||||||
- Two physics providers increase qualification cost, but allow fast regression
|
|
||||||
and high-fidelity experiments to coexist.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [PX4 simulator MAVLink API](https://docs.px4.io/main/en/simulation/)
|
|
||||||
- [CARLA documentation](https://carla.readthedocs.io/)
|
|
||||||
- [Project AirSim documentation](https://iamaisim.github.io/ProjectAirSim/)
|
|
||||||
- [ASAM OpenDRIVE](https://www.asam.net/standards/detail/opendrive/)
|
|
||||||
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
# ADR 0018: qualify LiDAR evidence before selecting accelerated models
|
|
||||||
|
|
||||||
Date: 2026-07-25
|
|
||||||
Status: accepted
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Mission Core preserves K1 point clouds, poses, cameras and factory calibration
|
|
||||||
and already runs an external NVIDIA worker for camera perception. The next
|
|
||||||
candidate components include PointPillars, CenterPoint, Patchwork++, nvblox and
|
|
||||||
several LiDAR SLAM systems.
|
|
||||||
|
|
||||||
Those components do not share the same input assumptions. The current K1
|
|
||||||
`lio_pcl` source is a vendor-mapped increment in the canonical `map` frame with intensity and a
|
|
||||||
separate best-effort pose. It is not an unregistered sweep and exposes no
|
|
||||||
admitted ring, per-point time or IMU stream. The accepted E10 replay pack also
|
|
||||||
drops intensity.
|
|
||||||
|
|
||||||
Installing a component without expressing those facts would let runtime success
|
|
||||||
be mistaken for geometric validity.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
1. Mission Core owns a versioned, provider-neutral
|
|
||||||
`missioncore.lidar-evidence-profile/v1`.
|
|
||||||
2. Each processing stage receives a deterministic readiness result with
|
|
||||||
explicit blockers.
|
|
||||||
3. Existing content-addressed replay schemas are immutable. A lossless replay
|
|
||||||
improvement is a new version.
|
|
||||||
4. The first detector seam consumes sensor-frame XYZI. Map-frame K1 points are
|
|
||||||
converted only with the bound `T_map_from_lidar` pose, and verified uint8
|
|
||||||
intensity is normalized to `[0, 1]`.
|
|
||||||
5. Missing ring, point time, scan geometry or IMU evidence remains missing. It
|
|
||||||
is never synthesized to satisfy a model.
|
|
||||||
6. Accelerated models remain external worker providers. React consumes reports,
|
|
||||||
overlays and world-state products; it does not host CUDA/TensorRT/ROS 2
|
|
||||||
compute.
|
|
||||||
7. All LiDAR results remain diagnostic/shadow until separate navigation and
|
|
||||||
safety gates pass.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- PointPillars integration is blocked for replay pack v1 and degraded for the
|
|
||||||
current live vendor-mapped source.
|
|
||||||
- Nvblox is blocked until the LiDAR scan geometry and timing/pose contract are
|
|
||||||
admitted.
|
|
||||||
- LiDAR odometry and LiDAR-inertial SLAM are blocked for current K1 evidence.
|
|
||||||
- L1 lossless replay and scanner-quality telemetry precede new model installs.
|
|
||||||
- Future scanners, simulation providers and datasets can use the same
|
|
||||||
readiness contract without being forced into K1-specific code.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
The detailed product rationale, market review, gates and sequence are in
|
|
||||||
`docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md`.
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
# Lab 006: SIM S0 worker bootstrap and stock-rover smoke
|
|
||||||
|
|
||||||
Date: 2026-07-24 (Europe/Moscow).
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
The reviewed Windows 11 AI worker received a dedicated Docker-free
|
|
||||||
`MissionCore-Sim` WSL2 Ubuntu 24.04 distribution physically stored at
|
|
||||||
`D:\NDC_MISSIONCORE\simulation\wsl\MissionCore-Sim`. Source, build, cache,
|
|
||||||
runtime, artifacts and datasets are all under the same D-only root. Existing
|
|
||||||
Docker Desktop co-tenants were inventoried but not stopped, reconfigured or
|
|
||||||
used.
|
|
||||||
|
|
||||||
This lab grants no real or simulated actuator authority. Every smoke run kept
|
|
||||||
`actuator_authority=false`; it observed the stock rover while disarmed.
|
|
||||||
|
|
||||||
## Redacted worker inventory
|
|
||||||
|
|
||||||
- Windows 11 Pro, build 26200;
|
|
||||||
- WSL2 kernel `6.18.33.1-microsoft-standard-WSL2`;
|
|
||||||
- Ubuntu 24.04.4 LTS, x86_64;
|
|
||||||
- 13th Gen Intel Core i9-13900KF, 32 WSL-visible logical CPUs;
|
|
||||||
- 50,427,760,640 WSL-visible RAM bytes and 12,884,901,888 swap bytes;
|
|
||||||
- NVIDIA GeForce RTX 4090, driver 610.47, 24,564 MiB VRAM;
|
|
||||||
- D capacity 2,000,381,014,016 bytes;
|
|
||||||
- D free after bootstrap and builds: 392,758,685,696 bytes.
|
|
||||||
|
|
||||||
The remaining D capacity is above the 300 GiB S0 start threshold and 200 GiB
|
|
||||||
stop threshold. This does not waive continuous disk monitoring.
|
|
||||||
|
|
||||||
## Installed candidate generation
|
|
||||||
|
|
||||||
- ROS 2 Jazzy `ros-base=0.11.0-1noble.20260616.084325`;
|
|
||||||
- `ros-gz=1.0.22-1noble.20260616.074726`;
|
|
||||||
- Gazebo Harmonic `1.0.0-1~noble`, Gazebo Sim `8.14.0-1~noble`,
|
|
||||||
SDFormat `14.9.0-1~noble`;
|
|
||||||
- PX4 Autopilot v1.17.0 commit
|
|
||||||
`d6f12ad1c4f70ad3230afd7d86e971421e02fef4`;
|
|
||||||
- `px4_msgs` v1.17.0 commit
|
|
||||||
`86d8239e962f6939e05c3737784f60c02fa884db`;
|
|
||||||
- PX4 Gazebo models commit
|
|
||||||
`b6127f4ec20de867e215fb5f78ae88b80f371909`;
|
|
||||||
- Micro XRCE-DDS Agent v2.4.3 commit
|
|
||||||
`73622810d984349b80bbac0ef55fc0b694d62222`;
|
|
||||||
- Nav2 `navigation2=1.3.12-1noble.20260615.181551` and
|
|
||||||
`nav2-bringup=1.3.12-1noble.20260616.082701`.
|
|
||||||
|
|
||||||
PX4 SITL and `px4_msgs` were built successfully from the pinned D-only source.
|
|
||||||
The Micro XRCE-DDS Agent was built from source and installed under the D-only
|
|
||||||
runtime root. Nav2 is installed but has not yet passed an S2 navigation case.
|
|
||||||
|
|
||||||
## Observed smoke result
|
|
||||||
|
|
||||||
Two consecutive canonical runs and one preliminary resource run passed:
|
|
||||||
|
|
||||||
- `20260724T134400Z-stock-rover`;
|
|
||||||
- `20260724T134600Z-stock-rover-repeat`;
|
|
||||||
- `20260724T135000Z-stock-rover-baseline`.
|
|
||||||
|
|
||||||
The final smoke-script generation also passed as
|
|
||||||
`20260724T135800Z-stock-rover-final` and added a runtime listener inventory plus
|
|
||||||
the point resource snapshot.
|
|
||||||
|
|
||||||
Each run started PX4 v1.17.0 with Gazebo world `rover` and model
|
|
||||||
`rover_ackermann_0`, established a Micro XRCE-DDS session, observed
|
|
||||||
`/fmu/out/vehicle_status_v1`, observed Gazebo `/clock`, requested PX4 shutdown
|
|
||||||
and found no owned runtime residue. The captured vehicle state was disarmed,
|
|
||||||
`failsafe=false`, with pre-flight checks passing.
|
|
||||||
|
|
||||||
Each provider set ran inside a new Linux network namespace containing only
|
|
||||||
`127.0.0.1` and `::1`. The Agent's UDP `0.0.0.0:8888` bind was therefore
|
|
||||||
effective only inside that loopback boundary. No PX4, Gazebo or Agent service
|
|
||||||
was exposed to the LAN.
|
|
||||||
|
|
||||||
The two canonical artifact directories are 68 KiB each. The preliminary
|
|
||||||
resource run completed in 38.75 seconds. One point sample observed approximately
|
|
||||||
202 MiB RSS for headless Gazebo, 19 MiB for PX4 and 17 MiB for the Agent. The
|
|
||||||
worker GPU was already occupied by unrelated co-tenants; no S0 GPU allocation
|
|
||||||
was attributable in this headless run. These point observations are not the
|
|
||||||
accepted 1× or 2× resource baselines.
|
|
||||||
|
|
||||||
## Diagnostics retained
|
|
||||||
|
|
||||||
Earlier bootstrap attempts remain outside Git under the D-only artifact root:
|
|
||||||
|
|
||||||
- one check raced the asynchronous DDS connection even though the session
|
|
||||||
subsequently established;
|
|
||||||
- one used an unsupported ROS 2 Jazzy long option;
|
|
||||||
- one used ROS domain 42 while PX4 v1.17.0 published on domain 0.
|
|
||||||
|
|
||||||
The smoke contract now waits for the Agent session plus the PX4
|
|
||||||
`vehicle_status_v1` writer, uses Jazzy `topic list -t`, and relies on the
|
|
||||||
loopback-only namespace to isolate domain 0.
|
|
||||||
|
|
||||||
An initial malformed interactive log repeatedly captured PX4 terminal repaint
|
|
||||||
output and reached approximately 1.1 GiB. That generated diagnostic artifact
|
|
||||||
was removed, then the case was rerun non-interactively; the valid standalone
|
|
||||||
PX4 log was approximately 8 KiB.
|
|
||||||
|
|
||||||
## Target doctor
|
|
||||||
|
|
||||||
The exact candidate profile was executed through the read-only doctor inside
|
|
||||||
`MissionCore-Sim`. Profile SHA-256:
|
|
||||||
`70bf0df44c85dd57eb3cedb98a474a46ba498c6ffc60ce0d8a06829e28587df1`.
|
|
||||||
|
|
||||||
Target identity, authority, clock, port/process registries, all seven D-only
|
|
||||||
mutable roots, the disk threshold and all eight required executables passed.
|
|
||||||
Version acceptance and the nine-item digest-bound evidence set remain
|
|
||||||
incomplete, so the doctor correctly returned `INCOMPLETE`.
|
|
||||||
|
|
||||||
## Verdict
|
|
||||||
|
|
||||||
`INCOMPLETE`.
|
|
||||||
|
|
||||||
Worker compatibility, D-only placement, exact candidate pins, PX4 build,
|
|
||||||
stock-rover launch, DDS telemetry, authoritative clock observation and repeated
|
|
||||||
clean lifecycle are factually proven. S0 still requires:
|
|
||||||
|
|
||||||
- pause/resume and deterministic single-step evidence;
|
|
||||||
- 1× speed/real-time-factor acceptance;
|
|
||||||
- isolated 2× resource/coexistence baseline;
|
|
||||||
- a complete digest-bound evidence manifest;
|
|
||||||
- final `GO`, `INCOMPLETE` or `BLOCKED` decision.
|
|
||||||
|
|
||||||
This lab does not accept Nav2 behavior, navigation safety, real-time guarantees,
|
|
||||||
or real actuator control.
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
# Lab 007: SIM S0 acceptance
|
|
||||||
|
|
||||||
Date: 2026-07-24 (Europe/Moscow).
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
This lab closes the infrastructure qualification gate for Mission Core
|
|
||||||
Polygon. It validates the exact D-only worker profile, stock PX4 Ackermann
|
|
||||||
runtime, ROS 2 telemetry, Gazebo time control, 1×/2× resource behavior,
|
|
||||||
deterministic shutdown and digest-bound evidence admission.
|
|
||||||
|
|
||||||
It does not accept S1 orchestration, rover command behavior, Nav2 navigation,
|
|
||||||
safety behavior, HIL, physical shadow or real actuator control.
|
|
||||||
`actuator_authority=false` throughout.
|
|
||||||
|
|
||||||
## Accepted profile
|
|
||||||
|
|
||||||
- profile ID: `ai-worker-px4-gazebo-v1`;
|
|
||||||
- profile SHA-256:
|
|
||||||
`aeecf5005301db230e1340b85215cbbd862b72974a22b8c43767c28be8453e2f`;
|
|
||||||
- evidence tooling commit:
|
|
||||||
`2548bb57403411ce9c8374151e3bfa34ac0866ee`;
|
|
||||||
- target: dedicated Docker-free `MissionCore-Sim` WSL2 Ubuntu 24.04 on D;
|
|
||||||
- all seven component pins: resolved and `accepted`;
|
|
||||||
- all seven mutable roots: present under `/mnt/d/NDC_MISSIONCORE`;
|
|
||||||
- disk at final doctor: 365.55 GiB free, above the 300 GiB start and
|
|
||||||
200 GiB stop thresholds.
|
|
||||||
|
|
||||||
Exact ROS 2, Gazebo, PX4, `px4_msgs`, PX4 Gazebo model, Micro XRCE-DDS Agent and
|
|
||||||
Nav2 versions remain recorded in the qualification profile and Lab 006.
|
|
||||||
|
|
||||||
## Accepted runs
|
|
||||||
|
|
||||||
The exact accepted-profile generation produced two immutable runs:
|
|
||||||
|
|
||||||
- `20260724T155000Z-accepted-1x`;
|
|
||||||
- `20260724T160000Z-accepted-2x`.
|
|
||||||
|
|
||||||
Both runs passed:
|
|
||||||
|
|
||||||
- stock world `rover`, model `rover_ackermann_0`;
|
|
||||||
- PX4 v1.17.0 and Micro XRCE-DDS session;
|
|
||||||
- ROS 2 `/fmu/out/vehicle_status_v1`;
|
|
||||||
- authoritative Gazebo `/clock`;
|
|
||||||
- loopback-only network namespace;
|
|
||||||
- pause, single-step and declared speed behavior;
|
|
||||||
- structured resource baseline;
|
|
||||||
- clean shutdown with no owned process residue.
|
|
||||||
|
|
||||||
The vehicle stayed disarmed. No actuator command was issued.
|
|
||||||
|
|
||||||
## Time acceptance
|
|
||||||
|
|
||||||
| Case | Pause advance | Single step | Measured RTF | Accepted range |
|
|
||||||
| --- | ---: | ---: | ---: | ---: |
|
|
||||||
| 1× | 0 ns | +2,000,000 ns / +1 iteration | 0.981913 | 0.8–1.2 |
|
|
||||||
| 2× | 0 ns | +2,000,000 ns / +1 iteration | 1.967258 | 1.6–2.4 |
|
|
||||||
|
|
||||||
Gazebo `/clock` is the closed-loop simulation authority. The zero pause
|
|
||||||
advance and exact 2 ms single-step are observed deltas, not inferred settings.
|
|
||||||
|
|
||||||
## Resource acceptance
|
|
||||||
|
|
||||||
| Metric | 1× | 2× | Contract |
|
|
||||||
| --- | ---: | ---: | ---: |
|
|
||||||
| Samples at 500 ms | 15 | 15 | at least 6 |
|
|
||||||
| Peak owned RSS | 407,629,824 B | 408,756,224 B | at most 2 GiB |
|
|
||||||
| Peak owned CPU | 101.8% | 169.2% | at most 800% |
|
|
||||||
| Minimum available WSL RAM | 28,437,897,216 B | 28,662,071,296 B | at least 8 GiB |
|
|
||||||
| Minimum D free | 392,613,560,320 B | 392,568,233,984 B | at least 200 GiB |
|
|
||||||
| Maximum swap used | 0 B | 0 B | observed |
|
|
||||||
|
|
||||||
Global GPU context peaked at 13,579 MiB used and 52% utilization in both cases.
|
|
||||||
Those values include unrelated co-tenants and are intentionally not attributed
|
|
||||||
to headless S0.
|
|
||||||
|
|
||||||
## Evidence admission
|
|
||||||
|
|
||||||
The private D-only evidence generation contains nine redacted JSON artifacts
|
|
||||||
and one manifest, approximately 100 KiB total:
|
|
||||||
|
|
||||||
1. worker inventory;
|
|
||||||
2. D-only physical placement;
|
|
||||||
3. PX4 stock-rover launch;
|
|
||||||
4. ROS 2 telemetry;
|
|
||||||
5. authoritative simulation clock;
|
|
||||||
6. pause/step/speed;
|
|
||||||
7. clean stop with no orphans;
|
|
||||||
8. 1× resource baseline;
|
|
||||||
9. 2× resource baseline.
|
|
||||||
|
|
||||||
Every manifest entry is `pass`, names a confined regular file and binds its
|
|
||||||
SHA-256. The manifest binds the accepted profile SHA-256. Failed diagnostic
|
|
||||||
generations were not reused.
|
|
||||||
|
|
||||||
## Target doctor
|
|
||||||
|
|
||||||
The read-only doctor returned:
|
|
||||||
|
|
||||||
```text
|
|
||||||
verdict=go
|
|
||||||
target_host_match=true
|
|
||||||
profile-contract=pass
|
|
||||||
authority-contract=pass
|
|
||||||
clock-contract=pass
|
|
||||||
port-registry=pass
|
|
||||||
process-registry=pass
|
|
||||||
target-host=pass
|
|
||||||
d-only-storage=pass
|
|
||||||
disk-budget=pass
|
|
||||||
version-lock=pass
|
|
||||||
required-tools=pass
|
|
||||||
factual-evidence=pass
|
|
||||||
```
|
|
||||||
|
|
||||||
The persisted redacted `doctor.json` SHA-256 is
|
|
||||||
`6a2571bd7af0e85b1345201068ccd4bffa215f84a8a9f62a4357d5dbfa236c6c`.
|
|
||||||
|
|
||||||
## Verdict
|
|
||||||
|
|
||||||
`GO` for SIM S0 infrastructure qualification.
|
|
||||||
|
|
||||||
The next permitted product phase is SIM S1: server-owned start/pause/resume/
|
|
||||||
step/reset/stop, immutable `QualificationRun`, canonical Ackermann and
|
|
||||||
Differential rover commands, TTL/heartbeat/watchdog, stop-on-loss and captured
|
|
||||||
PX4 failsafe behavior. No real-control permission is created by this verdict.
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
# SIM S1 target bootstrap — 2026-07-24
|
|
||||||
|
|
||||||
## Result
|
|
||||||
|
|
||||||
`PASS` for the exact S1 source-generation/bootstrap boundary. This proves that
|
|
||||||
the committed S1A/S1B contracts, repository, application service, worker guard
|
|
||||||
and process-supervisor tests execute inside the accepted `MissionCore-Sim`
|
|
||||||
target. It does not accept the stock-rover provider lifecycle, PX4 commands,
|
|
||||||
telemetry, frames, watchdog behavior or S1 as a whole.
|
|
||||||
|
|
||||||
## Exact source generation
|
|
||||||
|
|
||||||
- Mission Core commit: `630d1ae`.
|
|
||||||
- Branch: `feat/simulation-polygon-s1`.
|
|
||||||
- Archive: `NODEDC_MISSION_CORE-s1-630d1ae.tgz`.
|
|
||||||
- Archive SHA-256:
|
|
||||||
`0ca5d3ffb008add6e793428e96b88920fdc65fa8d9982e5018ce7a4e83958dae`.
|
|
||||||
- Source:
|
|
||||||
`/mnt/d/NDC_MISSIONCORE/simulation/source/NODEDC_MISSION_CORE-630d1ae`.
|
|
||||||
- Cache:
|
|
||||||
`/mnt/d/NDC_MISSIONCORE/simulation/cache/mission-core-s1/`.
|
|
||||||
|
|
||||||
The archive was produced with `git archive` from the exact commit and an
|
|
||||||
explicit path set. The pre-existing S0 source and evidence generations were not
|
|
||||||
modified.
|
|
||||||
|
|
||||||
## Target and storage
|
|
||||||
|
|
||||||
- WSL distribution: `MissionCore-Sim`.
|
|
||||||
- Python: `3.12.3`.
|
|
||||||
- Kernel: WSL2 Linux `6.18.33.1-microsoft-standard-WSL2`, x86-64.
|
|
||||||
- Mutable source, cache and evidence: D-only.
|
|
||||||
- D free bytes at bootstrap: `392483127296`.
|
|
||||||
- Evidence:
|
|
||||||
`/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/bootstrap/20260724T150000Z-630d1ae`.
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
```text
|
|
||||||
PYTHONPATH=src python3 -m pytest -q \
|
|
||||||
tests/test_simulation_s1_run_store.py \
|
|
||||||
tests/test_simulation_s1_orchestrator.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Result:
|
|
||||||
|
|
||||||
```text
|
|
||||||
20 passed in 14.73s
|
|
||||||
```
|
|
||||||
|
|
||||||
The evidence generation retains environment and source identity, target pytest
|
|
||||||
output, source-file SHA-256 values, an empty process-residue record, an evidence
|
|
||||||
digest index and `verdict=pass`.
|
|
||||||
|
|
||||||
## Accepted and open boundaries
|
|
||||||
|
|
||||||
Accepted:
|
|
||||||
|
|
||||||
- exact committed S1 source executes on the target worker;
|
|
||||||
- the D-only source/cache/artifact split is viable;
|
|
||||||
- local process-group tests stop without retained test processes;
|
|
||||||
- S1 requires neither a global Python package install nor Docker.
|
|
||||||
|
|
||||||
Still open:
|
|
||||||
|
|
||||||
- concrete Gazebo/PX4/XRCE/ROS provider specifications;
|
|
||||||
- product-owned loopback target namespace;
|
|
||||||
- real-provider health and deterministic shutdown;
|
|
||||||
- restart-safe PID/start-token reconciliation;
|
|
||||||
- live pause/resume/step/reset;
|
|
||||||
- Ackermann/Differential command mapping;
|
|
||||||
- canonical `VehicleState`, frame conversion and failsafe evidence.
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
# SIM S1B real-provider acceptance — 2026-07-24
|
|
||||||
|
|
||||||
## Result
|
|
||||||
|
|
||||||
`PASS` for the product-owned stock-rover start/health/stop boundary on the
|
|
||||||
accepted `MissionCore-Sim` worker.
|
|
||||||
|
|
||||||
This is not S1 acceptance. It does not prove live world control, rover command
|
|
||||||
mapping, canonical telemetry/frames, watchdog/failsafe behavior, repeatability,
|
|
||||||
navigation behavior or real actuator safety.
|
|
||||||
|
|
||||||
## Accepted source generation
|
|
||||||
|
|
||||||
- Branch: `feat/simulation-polygon-s1`.
|
|
||||||
- Mission Core commit:
|
|
||||||
`6cb14954d4696270e12d4adb800bc337251dc631`.
|
|
||||||
- Archive: `NODEDC_MISSION_CORE-s1-6cb1495.tgz`.
|
|
||||||
- Archive SHA-256:
|
|
||||||
`37da47744e855bb1031734893a6f2dd82bb4eb92b7e15edd70794c29b2dfb451`.
|
|
||||||
- Source:
|
|
||||||
`/mnt/d/NDC_MISSIONCORE/simulation/source/NODEDC_MISSION_CORE-6cb1495`.
|
|
||||||
- Target test evidence:
|
|
||||||
`/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/bootstrap/20260724T153453Z-6cb1495`.
|
|
||||||
- Target test result: `25 passed`.
|
|
||||||
|
|
||||||
The source was produced with `git archive` from the exact commit and an
|
|
||||||
explicit path set. Existing S0 and earlier S1 source/evidence generations were
|
|
||||||
not modified.
|
|
||||||
|
|
||||||
## Accepted run
|
|
||||||
|
|
||||||
- Run: `s1b-6cb1495-20260724t1535z`.
|
|
||||||
- Evidence:
|
|
||||||
`/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/lifecycle/s1b-6cb1495-20260724t1535z`.
|
|
||||||
- Host profile:
|
|
||||||
`ai-worker-px4-gazebo-v1`.
|
|
||||||
- Host profile SHA-256:
|
|
||||||
`aeecf5005301db230e1340b85215cbbd862b72974a22b8c43767c28be8453e2f`.
|
|
||||||
- Lifecycle profile SHA-256:
|
|
||||||
`06fadcd1b55b70c9a53556dadc65d15a35d8d73f7664ebf766b9986cbe19a883`.
|
|
||||||
- Scenario SHA-256:
|
|
||||||
`49e8f4009bc96c40964a5657fef5c2e035f22fa567a2247b0e40571cb5e4ee5e`.
|
|
||||||
- Network interfaces inside the run: `lo` only.
|
|
||||||
- Provider owners:
|
|
||||||
`micro-xrce-dds-agent`, `px4-gazebo-stock-rover`.
|
|
||||||
- Terminal state: `completed`.
|
|
||||||
- Terminal reason: `operator-stop-clean`.
|
|
||||||
- Persisted events: `8`.
|
|
||||||
- Control commands: `0`.
|
|
||||||
- Registry residue: none.
|
|
||||||
- Independent exact-process check after the run: no `px4`,
|
|
||||||
`MicroXRCEAgent` or `gz`.
|
|
||||||
- D free bytes after the run: `392023871488`, above the accepted 200 GiB stop
|
|
||||||
floor.
|
|
||||||
|
|
||||||
Provider readiness was not inferred from PIDs. The admitted log evidence
|
|
||||||
contains:
|
|
||||||
|
|
||||||
```text
|
|
||||||
world: rover, model: rover_ackermann_0
|
|
||||||
Startup script returned successfully
|
|
||||||
successfully created rt/fmu/out/vehicle_status_v1 data writer
|
|
||||||
session established
|
|
||||||
```
|
|
||||||
|
|
||||||
PX4 stdin remained open to prevent an EOF prompt loop, but Mission Core wrote no
|
|
||||||
PX4 shell command. The worker received no arm or actuator/setpoint request.
|
|
||||||
|
|
||||||
## Failed generations retained as negative evidence
|
|
||||||
|
|
||||||
`01ec122` stopped before starting providers because the first namespace check
|
|
||||||
read WSL sysfs rather than the current namespace through netlink. The transient
|
|
||||||
namespace itself contained only loopback. Source was corrected in a new
|
|
||||||
generation; the failed generation was not retried.
|
|
||||||
|
|
||||||
`b4e3b3b` reached the real stock rover and XRCE session but ended
|
|
||||||
`orchestrator-start-failed`. Its declared profile incorrectly required
|
|
||||||
`Running, connected`, a line emitted by the S0 diagnostic CLI command rather
|
|
||||||
than spontaneous provider health, while PX4 stdin was closed. The full failed
|
|
||||||
run, provider logs, terminal process registry and digest index remain under:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/lifecycle/s1b-b4e3b3b-20260724t1531z
|
|
||||||
```
|
|
||||||
|
|
||||||
That generation was not retried. `6cb1495` replaced repeated full-file polling
|
|
||||||
with incremental log readers, kept PX4 stdin open without writing commands and
|
|
||||||
used the factual DDS-writer marker.
|
|
||||||
|
|
||||||
## Accepted and open boundaries
|
|
||||||
|
|
||||||
Accepted:
|
|
||||||
|
|
||||||
- exact S0 generation and D-only admission;
|
|
||||||
- product-owned loopback namespace;
|
|
||||||
- shell-free real provider specifications;
|
|
||||||
- readiness-gated Mission Core start;
|
|
||||||
- persisted run lifecycle and provider registry;
|
|
||||||
- deterministic real-provider stop with no residue;
|
|
||||||
- zero command/actuator authority.
|
|
||||||
|
|
||||||
Open:
|
|
||||||
|
|
||||||
- restart-safe PID/start-token reconciliation after orchestrator process loss;
|
|
||||||
- live pause/resume/single-step/reset;
|
|
||||||
- PX4/ROS canonical VehicleState and ENU/NED/FLU/FRD golden conversion;
|
|
||||||
- Ackermann and Differential command mapping;
|
|
||||||
- TTL/heartbeat/watchdog and provider-loss cases;
|
|
||||||
- repeatability verdict and complete S1 QualificationReport;
|
|
||||||
- S1C/S1D and S2.
|
|
||||||
|
|
||||||
## UI-0 follow-on
|
|
||||||
|
|
||||||
The direct internal read-only UI-0 surface is implemented after this acceptance
|
|
||||||
without changing the accepted S1B evidence. It reads the same
|
|
||||||
`artifacts/s1/runs` journal through GET-only backend routes and a
|
|
||||||
non-navigable Control Station workspace. Visual QA loaded the copied accepted
|
|
||||||
run and showed terminal `completed`, `operator-stop-clean`, four provider pins,
|
|
||||||
eight events, zero commands and the virtual-only authority boundary. The
|
|
||||||
browser received neither the configured D root nor artifact bytes or command
|
|
||||||
payloads.
|
|
||||||
|
|
||||||
This follow-on is interface/read-model acceptance only. It does not promote the
|
|
||||||
S1B run to S1 acceptance and does not close any remaining control, telemetry,
|
|
||||||
frame, failsafe, repeatability, navigation or physical-safety gate.
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
# SIM S0 AI worker runbook
|
|
||||||
|
|
||||||
This runbook is for MISSIONCOR-40. It does not authorize system installation,
|
|
||||||
real vehicle control or a `GO` verdict by itself.
|
|
||||||
|
|
||||||
## Safety and placement
|
|
||||||
|
|
||||||
- Target: reviewed Windows 11 worker with the dedicated `MissionCore-Sim` WSL2
|
|
||||||
Ubuntu 24.04 distribution physically installed on D.
|
|
||||||
- All mutable bytes must be under `D:\NDC_MISSIONCORE`.
|
|
||||||
- Do not place a WSL distribution, source, build, cache, log, bag, ULog,
|
|
||||||
Gazebo artifact or dataset on C.
|
|
||||||
- Do not use or reconfigure the existing Docker Desktop runtime in S0. Its
|
|
||||||
backend is C-backed and serves unrelated co-tenants.
|
|
||||||
- Do not expose S0 services beyond loopback.
|
|
||||||
- Run providers inside the ephemeral loopback-only network namespace created
|
|
||||||
by `simulation/s0/worker-smoke.sh`. Micro XRCE-DDS Agent binds UDP wildcard
|
|
||||||
inside that namespace because v2.4.3 has no address-selection option.
|
|
||||||
- Do not download full external datasets.
|
|
||||||
- Keep `actuator_authority=false`.
|
|
||||||
|
|
||||||
## Repository preflight
|
|
||||||
|
|
||||||
From the repository-local Python 3.12 environment:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv sync --frozen --group dev
|
|
||||||
uv run missioncore-sim s0 doctor --json
|
|
||||||
```
|
|
||||||
|
|
||||||
Before target evidence exists, the expected verdict is `INCOMPLETE`.
|
|
||||||
|
|
||||||
## Stock-rover smoke
|
|
||||||
|
|
||||||
Copy the reviewed script to the D-only worker source root, then invoke it as
|
|
||||||
root inside `MissionCore-Sim` with a unique immutable run ID:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
simulation/s0/worker-smoke.sh <UTC-run-id>-stock-rover <1|2>
|
|
||||||
```
|
|
||||||
|
|
||||||
The script:
|
|
||||||
|
|
||||||
- creates a new network namespace containing only loopback;
|
|
||||||
- starts Micro XRCE-DDS Agent, PX4 SITL and the stock Gazebo Ackermann world;
|
|
||||||
- verifies the DDS session, `/fmu/out/vehicle_status_v1` and Gazebo `/clock`;
|
|
||||||
- proves pause freeze and exactly one 2 ms physics step;
|
|
||||||
- measures the declared 1× or 2× real-time factor over four seconds;
|
|
||||||
- samples owned CPU/RSS, available RAM, swap, D free space and global co-tenant
|
|
||||||
GPU load at 500 ms intervals;
|
|
||||||
- records Gazebo service/topic inventory and runtime listeners;
|
|
||||||
- sends the PX4 `shutdown` command and rejects owned-process residue.
|
|
||||||
|
|
||||||
Run both factors as separate immutable cases. Do not reuse a run ID. A failed
|
|
||||||
run remains diagnostic evidence; rerun with a new ID after correction.
|
|
||||||
|
|
||||||
## Required target inventory
|
|
||||||
|
|
||||||
Record a redacted artifact for each of:
|
|
||||||
|
|
||||||
1. Windows release, WSL version, Ubuntu version and architecture.
|
|
||||||
2. GPU/driver, CPU, RAM and VRAM.
|
|
||||||
3. Physical location of the dedicated WSL distribution and confirmation that
|
|
||||||
the Docker-free S0 profile is active.
|
|
||||||
4. D total/free bytes before installation.
|
|
||||||
5. Existing Docker images, WSL distributions and simulation-related processes.
|
|
||||||
6. Existing listeners and the candidate port registry.
|
|
||||||
7. Existing Frigate/Ollama/Triton resource use and whether S0 is coexistence or
|
|
||||||
exclusive-profile testing.
|
|
||||||
|
|
||||||
Do not record credentials, tokens or private network secrets.
|
|
||||||
|
|
||||||
## Candidate stack
|
|
||||||
|
|
||||||
Use `simulation/s0/qualification-profile.yaml` as the input. Resolve and record:
|
|
||||||
|
|
||||||
- exact ROS 2 Jazzy package versions;
|
|
||||||
- exact Gazebo Harmonic package versions;
|
|
||||||
- PX4 v1.17.0 commit;
|
|
||||||
- matching `px4_msgs` commit;
|
|
||||||
- Micro XRCE-DDS Agent commit/version;
|
|
||||||
- Nav2 Jazzy commit/package versions;
|
|
||||||
- PX4 Gazebo model commit;
|
|
||||||
- licenses and image digests where containers are used.
|
|
||||||
|
|
||||||
Change every component to `accepted` only after target build/runtime evidence
|
|
||||||
exists. Updating a pin changes the profile SHA-256 and invalidates evidence for
|
|
||||||
the prior generation.
|
|
||||||
|
|
||||||
## Runtime cases
|
|
||||||
|
|
||||||
The S0 factual cases are:
|
|
||||||
|
|
||||||
1. Launch one stock rover headless.
|
|
||||||
2. Observe PX4 health and ROS 2 telemetry.
|
|
||||||
3. Prove Gazebo `/clock` is authoritative.
|
|
||||||
4. Prove pause freezes simulation time.
|
|
||||||
5. Prove one single-step advances the declared amount.
|
|
||||||
6. Prove 1× and 2× speed behavior and measure real-time factor.
|
|
||||||
7. Stop through the intended lifecycle.
|
|
||||||
8. Prove no owned process remains.
|
|
||||||
9. Repeat from a clean start.
|
|
||||||
|
|
||||||
QGroundControl is diagnostic only and cannot become the Mission Core command
|
|
||||||
owner.
|
|
||||||
|
|
||||||
## Evidence manifest
|
|
||||||
|
|
||||||
Create a private/redacted evidence directory outside normal Git. Its manifest:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
schema_version: missioncore.simulation-s0-evidence/v1
|
|
||||||
profile_sha256: <sha256 of qualification-profile.yaml>
|
|
||||||
checks:
|
|
||||||
- id: worker-inventory
|
|
||||||
status: pass
|
|
||||||
artifact: worker-inventory.redacted.json
|
|
||||||
sha256: <artifact sha256>
|
|
||||||
note: Redacted host and resource inventory.
|
|
||||||
```
|
|
||||||
|
|
||||||
The manifest must contain exactly all IDs from `required_evidence`. Artifact
|
|
||||||
paths are relative, confined regular files. The doctor rejects missing,
|
|
||||||
additional, changed or cross-generation evidence.
|
|
||||||
|
|
||||||
Build the redacted manifest only from two passing runs made with the exact
|
|
||||||
accepted profile generation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 simulation/s0/build_evidence_manifest.py \
|
|
||||||
--profile /mnt/d/NDC_MISSIONCORE/simulation/source/qualification-profile.yaml \
|
|
||||||
--run-1x /mnt/d/NDC_MISSIONCORE/simulation/artifacts/s0/<accepted-1x-run> \
|
|
||||||
--run-2x /mnt/d/NDC_MISSIONCORE/simulation/artifacts/s0/<accepted-2x-run> \
|
|
||||||
--output-dir /mnt/d/NDC_MISSIONCORE/simulation/artifacts/s0/evidence/<generation> \
|
|
||||||
--d-root /mnt/d/NDC_MISSIONCORE \
|
|
||||||
--source-root /mnt/d/NDC_MISSIONCORE/simulation/source \
|
|
||||||
--repository-commit <full-commit-sha> \
|
|
||||||
--wsl-windows-path 'D:\NDC_MISSIONCORE\simulation\wsl\MissionCore-Sim'
|
|
||||||
```
|
|
||||||
|
|
||||||
The generator refuses an existing output directory, non-passing runs,
|
|
||||||
candidate pins, commit drift, incomplete Nav2/ROS discovery or any input/output
|
|
||||||
outside the reviewed D root.
|
|
||||||
|
|
||||||
Validate without writing:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run missioncore-sim s0 doctor \
|
|
||||||
--profile simulation/s0/qualification-profile.yaml \
|
|
||||||
--evidence /path/to/redacted/evidence.yaml \
|
|
||||||
--json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verdict rules
|
|
||||||
|
|
||||||
- `GO`: target host matches, every pin is accepted, D-only and disk thresholds
|
|
||||||
pass, required tools exist and all digest-bound evidence validates.
|
|
||||||
- `INCOMPLETE`: work remains or the doctor is running on a development host.
|
|
||||||
- `BLOCKED`: contract, storage, tools or evidence fail validation.
|
|
||||||
|
|
||||||
Publish the factual result to MISSIONCOR-40 with exact repository commit,
|
|
||||||
profile SHA-256, files touched and validation performed.
|
|
||||||
|
|
@ -1,236 +0,0 @@
|
||||||
# SIM S1C live-worker runbook
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
This runbook starts and commands the first browser-visible stock Ackermann rover
|
|
||||||
through the Mission Core control plane. It accepts only a virtual PX4/Gazebo
|
|
||||||
run. It does not admit a physical vehicle, direct actuator setpoints, navigation
|
|
||||||
or safety.
|
|
||||||
|
|
||||||
Runtime placement:
|
|
||||||
|
|
||||||
```text
|
|
||||||
React Control Station
|
|
||||||
-> same-origin Mission Core Polygon API
|
|
||||||
-> mode-0600 Unix socket
|
|
||||||
-> unprivileged Simulation Worker agent
|
|
||||||
-> loopback-only network namespace
|
|
||||||
-> Micro XRCE-DDS Agent + PX4 SITL + Gazebo Harmonic
|
|
||||||
```
|
|
||||||
|
|
||||||
The Unix socket is ephemeral runtime coordination. Source, run journals,
|
|
||||||
provider logs and qualification artifacts remain under
|
|
||||||
`/mnt/d/NDC_MISSIONCORE/simulation`.
|
|
||||||
|
|
||||||
## Preconditions
|
|
||||||
|
|
||||||
- use the dedicated `MissionCore-Sim` WSL distribution;
|
|
||||||
- stage an immutable Git archive below the reviewed D root;
|
|
||||||
- verify its SHA-256 against the source artifact;
|
|
||||||
- use an exact 40-character Mission Core commit;
|
|
||||||
- confirm no PX4, Gazebo or Micro XRCE-DDS provider residue exists;
|
|
||||||
- keep `actuator_authority=false`;
|
|
||||||
- do not add a routed NIC to the worker namespace.
|
|
||||||
|
|
||||||
## Start worker agent
|
|
||||||
|
|
||||||
From the exact staged source generation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo simulation/s1/worker-agent.sh <exact-40-character-commit>
|
|
||||||
```
|
|
||||||
|
|
||||||
The wrapper creates `/run/missioncore-sim` as a private runtime directory,
|
|
||||||
enters a fresh network namespace, raises only loopback and drops to the
|
|
||||||
`missioncore` user before importing product code.
|
|
||||||
|
|
||||||
Expected status through the backend gateway:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"schema_version": "missioncore.simulation-worker-status/v2",
|
|
||||||
"worker_id": "mission-gpu-s1",
|
|
||||||
"transport": "unix",
|
|
||||||
"mode": "simulation",
|
|
||||||
"available": true,
|
|
||||||
"control_available": true,
|
|
||||||
"active_run_id": null,
|
|
||||||
"run_state": null,
|
|
||||||
"active_provider_ids": [],
|
|
||||||
"provider_profile": {
|
|
||||||
"schema_version": "missioncore.simulation-provider-profile/v1",
|
|
||||||
"profile_id": "stock-rover-gazebo-px4-s1d",
|
|
||||||
"providers": [
|
|
||||||
{
|
|
||||||
"provider_id": "gazebo",
|
|
||||||
"roles": ["world", "physics", "state", "sensor"],
|
|
||||||
"capabilities": [
|
|
||||||
"clock.simulation",
|
|
||||||
"state.vehicle-pose",
|
|
||||||
"truth.ground-truth",
|
|
||||||
"sensor.virtual"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"provider_id": "px4-ros2-offboard",
|
|
||||||
"roles": ["controller"],
|
|
||||||
"capabilities": ["command.rover-speed-steering/v1"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"provider_id": "micro-xrce-dds-agent",
|
|
||||||
"roles": ["transport"],
|
|
||||||
"capabilities": ["transport.ros2"]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"clock": {
|
|
||||||
"provider_id": "gazebo",
|
|
||||||
"domain": "gazebo:/clock",
|
|
||||||
"unit": "nanoseconds",
|
|
||||||
"mode": "simulation"
|
|
||||||
},
|
|
||||||
"control_profiles": ["rover-speed-steering/v1"],
|
|
||||||
"canonical_frames": {
|
|
||||||
"world": "map_enu",
|
|
||||||
"body": "base_link_flu"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"isolation": {
|
|
||||||
"network": "loopback-only-netns",
|
|
||||||
"process_identity": "missioncore",
|
|
||||||
"artifact_policy": "d-only"
|
|
||||||
},
|
|
||||||
"authority": {
|
|
||||||
"scope": "virtual-only",
|
|
||||||
"actuator_authority": false,
|
|
||||||
"direct_actuator_setpoints_allowed": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Start Mission Core
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export MISSIONCORE_POLYGON_RUNS_ROOT=/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/runs
|
|
||||||
export MISSIONCORE_POLYGON_WORKER_SOCKET=/run/missioncore-sim/worker.sock
|
|
||||||
export MISSIONCORE_POLYGON_WORKER_CONTROL=internal-virtual-only
|
|
||||||
export MISSIONCORE_COMMIT=<exact-40-character-commit>
|
|
||||||
uv run uvicorn k1link.web.app:app --host 127.0.0.1 --port 8765
|
|
||||||
```
|
|
||||||
|
|
||||||
Open the Control Station:
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://127.0.0.1:8765/
|
|
||||||
```
|
|
||||||
|
|
||||||
After Mission Core confirms the worker, `Полигон` must appear in the header and
|
|
||||||
open the live workspace directly. The
|
|
||||||
`?workspace=polygon-run[&run=<qualification-run-id>]` URL remains a compatible
|
|
||||||
deep-link. The live panel must show `Worker готов`. Start/stop calls require an
|
|
||||||
`Idempotency-Key`; the React client generates one per operator intent. Removing
|
|
||||||
the `internal-virtual-only` environment gate must leave status visible while
|
|
||||||
disabling lifecycle actions.
|
|
||||||
|
|
||||||
Cold PX4/Gazebo/ROS 2 startup is expected to take roughly 30–60 seconds on the
|
|
||||||
reviewed worker. The lifecycle request remains bounded at the gateway and the
|
|
||||||
controller fails closed if ROS publication or armed Offboard admission is not
|
|
||||||
confirmed.
|
|
||||||
|
|
||||||
## Live-state contract
|
|
||||||
|
|
||||||
The first S1C live sample comes from:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/world/rover/dynamic_pose/info
|
|
||||||
```
|
|
||||||
|
|
||||||
It is published to the browser as `missioncore.vehicle-state/v1` with:
|
|
||||||
|
|
||||||
- `frame_id=map_enu`;
|
|
||||||
- `child_frame_id=base_link_flu`;
|
|
||||||
- integer Gazebo simulation time;
|
|
||||||
- bounded position and quaternion;
|
|
||||||
- `signal=ground-truth`;
|
|
||||||
- `quality=diagnostic`;
|
|
||||||
- `scope=virtual-only`;
|
|
||||||
- no navigation or safety acceptance.
|
|
||||||
|
|
||||||
This signal proves live presentation and frame naming only. It is not the
|
|
||||||
accepted PX4/ROS 2 telemetry path.
|
|
||||||
|
|
||||||
## S1D command contract
|
|
||||||
|
|
||||||
While the run is `running`, the React controls submit:
|
|
||||||
|
|
||||||
```text
|
|
||||||
POST /api/v1/polygon/worker/runs/<run-id>/commands
|
|
||||||
Idempotency-Key: <unique operator intent>
|
|
||||||
{"speed_mps": 1.0, "steering_normalized": -0.6}
|
|
||||||
```
|
|
||||||
|
|
||||||
Mission Core validates the S1 envelope, assigns a monotonic sequence and
|
|
||||||
250 ms simulation-time TTL, and durably journals the command before worker
|
|
||||||
delivery. The worker keeps the external speed/steering contract but maps the
|
|
||||||
stock model to PX4 rover-level Offboard control:
|
|
||||||
|
|
||||||
- `RoverThrottleSetpoint.throttle_body_x = speed_mps / 3.1`;
|
|
||||||
- `RoverSteeringSetpoint.normalized_steering_setpoint` carries steering;
|
|
||||||
- `OffboardControlMode.thrust_and_torque=true`;
|
|
||||||
- `OffboardControlMode.direct_actuator=false`;
|
|
||||||
- no `ActuatorMotors` or `ActuatorServos` message is published by Mission Core.
|
|
||||||
|
|
||||||
The browser repeats a held directional intent fast enough to renew TTL. Release,
|
|
||||||
the explicit `Стоп` control, stale command TTL or run stop all drive the
|
|
||||||
setpoint to zero. The `3.1 m/s` factor is the stock PX4 model's
|
|
||||||
`RO_MAX_THR_SPEED`; S1D motion acceptance must not be described as calibrated
|
|
||||||
closed-loop speed tracking.
|
|
||||||
|
|
||||||
## Acceptance checklist
|
|
||||||
|
|
||||||
- [ ] exact source commit and source/build SHA-256 recorded;
|
|
||||||
- [ ] focused target tests pass from the same staged generation;
|
|
||||||
- [ ] worker status reports loopback-only, unprivileged and D-only boundaries;
|
|
||||||
- [ ] browser start creates one new immutable qualification run;
|
|
||||||
- [ ] status traverses `starting -> running`;
|
|
||||||
- [ ] both provider IDs appear only after readiness;
|
|
||||||
- [ ] live samples advance in Gazebo time;
|
|
||||||
- [ ] React shows run ID, sim time, ENU pose and provider list;
|
|
||||||
- [ ] archive remains path-free and command content is not exposed;
|
|
||||||
- [ ] browser stop reaches terminal `completed`;
|
|
||||||
- [ ] stop records `operator-stop-clean`;
|
|
||||||
- [ ] PX4, Gazebo and Micro XRCE-DDS leave no process residue;
|
|
||||||
- [ ] straight, turn and reverse commands produce causal pose changes;
|
|
||||||
- [ ] one isolated non-zero command expires at 250 ms and produces a stable
|
|
||||||
stopped pose;
|
|
||||||
- [ ] command acceptance reports armed Offboard and remains below TTL;
|
|
||||||
- [ ] command count and command journal sequences match submitted intents.
|
|
||||||
|
|
||||||
## Accepted S1D evidence
|
|
||||||
|
|
||||||
- code commit:
|
|
||||||
`49b0f47fea0ae29c977bbd60808696b85a9380cd`;
|
|
||||||
- source archive SHA-256:
|
|
||||||
`30aad33664a536f2a6cb87bc172a5dae111ae479d5899723832a1abc17c4517b`;
|
|
||||||
- Control Station archive SHA-256:
|
|
||||||
`014724896c8c2169a89b0ed6971b285b6e80da918d892509e9e93568efb99f25`;
|
|
||||||
- 40 focused tests passed from the exact source-first D-only generation;
|
|
||||||
- cold start: one request, 32.08 seconds;
|
|
||||||
- run: `s1c-49b0f47-20260724t195259z-62edd2`;
|
|
||||||
- straight endpoint: `(3.74, -0.00)` m from origin;
|
|
||||||
- turn endpoint: `(5.11, 2.13)` m;
|
|
||||||
- reverse endpoint: `(5.58, 0.67)` m;
|
|
||||||
- sampled command admission: 44.6 ms;
|
|
||||||
- TTL evidence: counter 4 -> 5 and identical following X/Y samples;
|
|
||||||
- explicit stop: no PX4, Gazebo or Micro XRCE-DDS residue.
|
|
||||||
|
|
||||||
## Failure handling
|
|
||||||
|
|
||||||
- A missing socket returns unavailable status; it does not fall back to PX4.
|
|
||||||
- Start/stop is `403` without the explicit backend control gate.
|
|
||||||
- Commit drift is rejected before provider start.
|
|
||||||
- A malformed or oversized worker message fails the gateway contract.
|
|
||||||
- Provider readiness failure seals the run as failed and triggers supervised
|
|
||||||
cleanup.
|
|
||||||
- Agent termination aborts an active run and stops owned provider groups.
|
|
||||||
- Agent restart reconciles an interrupted journal as failed; restart-safe
|
|
||||||
cross-process PID ownership remains a separate open checker.
|
|
||||||
|
|
@ -92,11 +92,6 @@ from k1link.compute.inline_temporal import (
|
||||||
read_inline_profile,
|
read_inline_profile,
|
||||||
stabilize_world_state,
|
stabilize_world_state,
|
||||||
)
|
)
|
||||||
from k1link.compute.lidar_contract import (
|
|
||||||
K1_LIVE_LIDAR_PROFILE,
|
|
||||||
LidarQualityMonitor,
|
|
||||||
lidar_readiness_document,
|
|
||||||
)
|
|
||||||
from k1link.compute.live_perception import (
|
from k1link.compute.live_perception import (
|
||||||
LIVE_RESULT_MAX_PAYLOAD_BYTES,
|
LIVE_RESULT_MAX_PAYLOAD_BYTES,
|
||||||
LiveSensorSynchronizer,
|
LiveSensorSynchronizer,
|
||||||
|
|
@ -568,7 +563,6 @@ def _receiver(
|
||||||
max_duration_seconds: float,
|
max_duration_seconds: float,
|
||||||
decoder: PersistentFmp4Decoder,
|
decoder: PersistentFmp4Decoder,
|
||||||
synchronizer: LiveSensorSynchronizer,
|
synchronizer: LiveSensorSynchronizer,
|
||||||
lidar_quality: LidarQualityMonitor,
|
|
||||||
state: _TransportState,
|
state: _TransportState,
|
||||||
sensor_decode_ms: dict[str, list[float]],
|
sensor_decode_ms: dict[str, list[float]],
|
||||||
result_queue: queue.Queue[bytes],
|
result_queue: queue.Queue[bytes],
|
||||||
|
|
@ -696,7 +690,6 @@ def _receiver(
|
||||||
)
|
)
|
||||||
sensor_decode_ms[modality].append((time.perf_counter() - decode_started) * 1000)
|
sensor_decode_ms[modality].append((time.perf_counter() - decode_started) * 1000)
|
||||||
if modality == "lidar" and isinstance(normalized, DecodedPointCloudView):
|
if modality == "lidar" and isinstance(normalized, DecodedPointCloudView):
|
||||||
lidar_quality.observe(normalized)
|
|
||||||
synchronizer.publish_point_cloud(normalized)
|
synchronizer.publish_point_cloud(normalized)
|
||||||
elif modality == "pose" and isinstance(normalized, DecodedPoseView):
|
elif modality == "pose" and isinstance(normalized, DecodedPoseView):
|
||||||
synchronizer.publish_pose(normalized)
|
synchronizer.publish_pose(normalized)
|
||||||
|
|
@ -926,7 +919,6 @@ def run(args: argparse.Namespace, loaded: _LoadedModels | None = None) -> int:
|
||||||
capacity_per_modality=int(temporal["buffer_capacity_per_modality"]),
|
capacity_per_modality=int(temporal["buffer_capacity_per_modality"]),
|
||||||
retention_seconds=float(temporal["retention_seconds"]),
|
retention_seconds=float(temporal["retention_seconds"]),
|
||||||
)
|
)
|
||||||
lidar_quality = LidarQualityMonitor(K1_LIVE_LIDAR_PROFILE)
|
|
||||||
first_camera_epoch_ns: list[int] = []
|
first_camera_epoch_ns: list[int] = []
|
||||||
last_camera_epoch_ns: list[int] = []
|
last_camera_epoch_ns: list[int] = []
|
||||||
decoded_frame_count = 0
|
decoded_frame_count = 0
|
||||||
|
|
@ -1042,7 +1034,6 @@ def run(args: argparse.Namespace, loaded: _LoadedModels | None = None) -> int:
|
||||||
"semantic": semantic_queue.snapshot,
|
"semantic": semantic_queue.snapshot,
|
||||||
"decoder": decoder.snapshot,
|
"decoder": decoder.snapshot,
|
||||||
"synchronizer": synchronizer.snapshot,
|
"synchronizer": synchronizer.snapshot,
|
||||||
"lidar_quality": lidar_quality.snapshot,
|
|
||||||
"result": lambda: {
|
"result": lambda: {
|
||||||
"capacity": result_queue.maxsize,
|
"capacity": result_queue.maxsize,
|
||||||
"depth": result_queue.qsize(),
|
"depth": result_queue.qsize(),
|
||||||
|
|
@ -1111,7 +1102,6 @@ def run(args: argparse.Namespace, loaded: _LoadedModels | None = None) -> int:
|
||||||
"max_duration_seconds": args.max_duration_seconds,
|
"max_duration_seconds": args.max_duration_seconds,
|
||||||
"decoder": decoder,
|
"decoder": decoder,
|
||||||
"synchronizer": synchronizer,
|
"synchronizer": synchronizer,
|
||||||
"lidar_quality": lidar_quality,
|
|
||||||
"state": transport,
|
"state": transport,
|
||||||
"sensor_decode_ms": sensor_decode_ms,
|
"sensor_decode_ms": sensor_decode_ms,
|
||||||
"result_queue": result_queue,
|
"result_queue": result_queue,
|
||||||
|
|
@ -1501,7 +1491,6 @@ def run(args: argparse.Namespace, loaded: _LoadedModels | None = None) -> int:
|
||||||
"id": common["projection_manifest"]["pack_id"],
|
"id": common["projection_manifest"]["pack_id"],
|
||||||
"identity_sha256": common["projection_manifest"]["identity_sha256"],
|
"identity_sha256": common["projection_manifest"]["identity_sha256"],
|
||||||
},
|
},
|
||||||
"lidar_evidence": lidar_readiness_document(K1_LIVE_LIDAR_PROFILE),
|
|
||||||
"worker_package": {
|
"worker_package": {
|
||||||
"id": common["worker_package"]["package_id"],
|
"id": common["worker_package"]["package_id"],
|
||||||
"identity_sha256": common["worker_package"]["identity_sha256"],
|
"identity_sha256": common["worker_package"]["identity_sha256"],
|
||||||
|
|
@ -1565,7 +1554,6 @@ def run(args: argparse.Namespace, loaded: _LoadedModels | None = None) -> int:
|
||||||
"synchronizer": synchronizer.snapshot(),
|
"synchronizer": synchronizer.snapshot(),
|
||||||
"sensor_decode_ms": sensor_decode_summary,
|
"sensor_decode_ms": sensor_decode_summary,
|
||||||
},
|
},
|
||||||
"lidar_quality": lidar_quality.snapshot(),
|
|
||||||
"latency_ms": latency_summary,
|
"latency_ms": latency_summary,
|
||||||
"temporal_stability": {
|
"temporal_stability": {
|
||||||
"enabled": stability is not None,
|
"enabled": stability is not None,
|
||||||
|
|
@ -1614,8 +1602,6 @@ def run(args: argparse.Namespace, loaded: _LoadedModels | None = None) -> int:
|
||||||
"limitations": [
|
"limitations": [
|
||||||
"Shadow diagnostic authority only; no commands or navigation output.",
|
"Shadow diagnostic authority only; no commands or navigation output.",
|
||||||
"Camera/LiDAR matching uses recorded host arrival time, not a hardware clock.",
|
"Camera/LiDAR matching uses recorded host arrival time, not a hardware clock.",
|
||||||
"K1 LiDAR is a vendor map increment, not an admitted raw sensor sweep.",
|
|
||||||
"K1 LiDAR has no admitted per-point time, ring, scan geometry or IMU stream.",
|
|
||||||
"Cross-host source epoch age is diagnostic and excluded from acceptance.",
|
"Cross-host source epoch age is diagnostic and excluded from acceptance.",
|
||||||
"COCO and Cityscapes models are not forest-domain or safety validated.",
|
"COCO and Cityscapes models are not forest-domain or safety validated.",
|
||||||
"Amodal cuboids infer unobserved volume from class priors.",
|
"Amodal cuboids infer unobserved volume from class priors.",
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ missioncore-plugin-sdk = { path = "packages/plugin-sdk", editable = true }
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
k1link = "k1link.device_plugins.xgrids_k1.cli:app"
|
k1link = "k1link.device_plugins.xgrids_k1.cli:app"
|
||||||
missioncore-sim = "k1link.simulation.cli:app"
|
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|
|
||||||
|
|
@ -1,493 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Build a confined, digest-bound SIM S0 evidence pack from accepted worker runs."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
EVIDENCE_SCHEMA = "missioncore.simulation-s0-evidence/v1"
|
|
||||||
|
|
||||||
|
|
||||||
class EvidenceError(RuntimeError):
|
|
||||||
"""Evidence inputs are incomplete, inconsistent, or outside the reviewed boundary."""
|
|
||||||
|
|
||||||
|
|
||||||
def _run(command: list[str]) -> str:
|
|
||||||
result = subprocess.run(
|
|
||||||
command,
|
|
||||||
check=False,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise EvidenceError(
|
|
||||||
f"command failed ({result.returncode}): {' '.join(command)}; "
|
|
||||||
f"stderr={result.stderr.strip()!r}"
|
|
||||||
)
|
|
||||||
return result.stdout.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _load_json(path: Path) -> dict[str, Any]:
|
|
||||||
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
if not isinstance(loaded, dict):
|
|
||||||
raise EvidenceError(f"expected a JSON object: {path}")
|
|
||||||
return loaded
|
|
||||||
|
|
||||||
|
|
||||||
def _load_yaml(path: Path) -> dict[str, Any]:
|
|
||||||
documents = [
|
|
||||||
document
|
|
||||||
for document in yaml.safe_load_all(path.read_text(encoding="utf-8"))
|
|
||||||
if document is not None
|
|
||||||
]
|
|
||||||
if len(documents) != 1 or not isinstance(documents[0], dict):
|
|
||||||
raise EvidenceError(f"expected a YAML mapping: {path}")
|
|
||||||
return documents[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _result_env(path: Path) -> dict[str, str]:
|
|
||||||
result: dict[str, str] = {}
|
|
||||||
for line in path.read_text(encoding="utf-8").splitlines():
|
|
||||||
key, separator, value = line.partition("=")
|
|
||||||
if not separator or not key:
|
|
||||||
raise EvidenceError(f"invalid result line in {path}: {line!r}")
|
|
||||||
result[key] = value
|
|
||||||
required_passes = {
|
|
||||||
"px4_stock_rover",
|
|
||||||
"uxrce_dds",
|
|
||||||
"ros2_vehicle_status",
|
|
||||||
"gazebo_clock",
|
|
||||||
"pause_step_speed",
|
|
||||||
"resource_baseline",
|
|
||||||
"clean_lifecycle",
|
|
||||||
}
|
|
||||||
failed = sorted(key for key in required_passes if result.get(key) != "pass")
|
|
||||||
if failed:
|
|
||||||
raise EvidenceError(f"run did not pass required checks: {', '.join(failed)}")
|
|
||||||
if result.get("actuator_authority") != "false":
|
|
||||||
raise EvidenceError("run widened actuator authority")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _sha256(path: Path) -> str:
|
|
||||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
|
||||||
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _package_version(package: str) -> str:
|
|
||||||
return _run(["dpkg-query", "-W", "-f=${Version}", package])
|
|
||||||
|
|
||||||
|
|
||||||
def _git_commit(path: Path) -> str:
|
|
||||||
commit = _run(
|
|
||||||
[
|
|
||||||
"git",
|
|
||||||
"-c",
|
|
||||||
f"safe.directory={path}",
|
|
||||||
"-C",
|
|
||||||
str(path),
|
|
||||||
"rev-parse",
|
|
||||||
"HEAD",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if re.fullmatch(r"[a-f0-9]{40}", commit) is None:
|
|
||||||
raise EvidenceError(f"invalid Git commit at {path}: {commit!r}")
|
|
||||||
return commit
|
|
||||||
|
|
||||||
|
|
||||||
def _memory_inventory() -> dict[str, int]:
|
|
||||||
values: dict[str, int] = {}
|
|
||||||
for line in Path("/proc/meminfo").read_text(encoding="utf-8").splitlines():
|
|
||||||
key, raw = line.split(":", maxsplit=1)
|
|
||||||
values[key] = int(raw.strip().split()[0]) * 1024
|
|
||||||
return {
|
|
||||||
"total_bytes": values["MemTotal"],
|
|
||||||
"available_bytes": values["MemAvailable"],
|
|
||||||
"swap_total_bytes": values["SwapTotal"],
|
|
||||||
"swap_free_bytes": values["SwapFree"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _gpu_inventory() -> dict[str, str | int] | None:
|
|
||||||
if shutil.which("nvidia-smi") is None:
|
|
||||||
return None
|
|
||||||
output = _run(
|
|
||||||
[
|
|
||||||
"nvidia-smi",
|
|
||||||
"--query-gpu=name,driver_version,memory.total",
|
|
||||||
"--format=csv,noheader,nounits",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
name, driver, memory_mib = (part.strip() for part in output.splitlines()[0].split(","))
|
|
||||||
return {
|
|
||||||
"name": name,
|
|
||||||
"driver": driver,
|
|
||||||
"memory_total_mib": int(memory_mib),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _cpu_model() -> str:
|
|
||||||
for line in Path("/proc/cpuinfo").read_text(encoding="utf-8").splitlines():
|
|
||||||
if line.startswith("model name"):
|
|
||||||
return line.split(":", maxsplit=1)[1].strip()
|
|
||||||
raise EvidenceError("CPU model was not available")
|
|
||||||
|
|
||||||
|
|
||||||
def _component_inventory(profile: dict[str, Any], source_root: Path) -> dict[str, Any]:
|
|
||||||
components = profile.get("components")
|
|
||||||
if not isinstance(components, list) or not components:
|
|
||||||
raise EvidenceError("profile components are missing")
|
|
||||||
if any(
|
|
||||||
not isinstance(component, dict) or component.get("qualification_state") != "accepted"
|
|
||||||
for component in components
|
|
||||||
):
|
|
||||||
raise EvidenceError("all profile components must be accepted before evidence admission")
|
|
||||||
|
|
||||||
packages = {
|
|
||||||
"ros-jazzy-ros-base": _package_version("ros-jazzy-ros-base"),
|
|
||||||
"ros-jazzy-ros-gz": _package_version("ros-jazzy-ros-gz"),
|
|
||||||
"gz-harmonic": _package_version("gz-harmonic"),
|
|
||||||
"gz-sim8-cli": _package_version("gz-sim8-cli"),
|
|
||||||
"libsdformat14": _package_version("libsdformat14"),
|
|
||||||
"ros-jazzy-navigation2": _package_version("ros-jazzy-navigation2"),
|
|
||||||
"ros-jazzy-nav2-bringup": _package_version("ros-jazzy-nav2-bringup"),
|
|
||||||
}
|
|
||||||
commits = {
|
|
||||||
"px4-autopilot": _git_commit(source_root / "PX4-Autopilot"),
|
|
||||||
"px4-msgs": _git_commit(source_root / "px4_msgs"),
|
|
||||||
"px4-gazebo-models": _git_commit(
|
|
||||||
source_root / "PX4-Autopilot" / "Tools" / "simulation" / "gz"
|
|
||||||
),
|
|
||||||
"micro-xrce-dds-agent": _git_commit(source_root / "Micro-XRCE-DDS-Agent"),
|
|
||||||
}
|
|
||||||
declared = {
|
|
||||||
str(component["id"]): {
|
|
||||||
"resolved_version": component.get("resolved_version"),
|
|
||||||
"resolved_commit": component.get("resolved_commit"),
|
|
||||||
"license": component.get("license"),
|
|
||||||
"qualification_state": component.get("qualification_state"),
|
|
||||||
}
|
|
||||||
for component in components
|
|
||||||
}
|
|
||||||
for identifier, commit in commits.items():
|
|
||||||
if declared[identifier]["resolved_commit"] != commit:
|
|
||||||
raise EvidenceError(f"worker commit drift for {identifier}")
|
|
||||||
|
|
||||||
nav2_prefix = _run(["ros2", "pkg", "prefix", "nav2_bringup"])
|
|
||||||
px4_msgs_prefix = _run(["ros2", "pkg", "prefix", "px4_msgs"])
|
|
||||||
nav2_executables = _run(["ros2", "pkg", "executables", "nav2_controller"]).splitlines()
|
|
||||||
if not nav2_executables:
|
|
||||||
raise EvidenceError("Nav2 controller executable is unavailable")
|
|
||||||
return {
|
|
||||||
"declared": declared,
|
|
||||||
"installed_packages": packages,
|
|
||||||
"observed_commits": commits,
|
|
||||||
"runtime_discovery": {
|
|
||||||
"nav2_bringup_prefix": nav2_prefix,
|
|
||||||
"nav2_controller_executables": nav2_executables,
|
|
||||||
"px4_msgs_prefix": px4_msgs_prefix,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _world_model_from_log(path: Path) -> dict[str, str]:
|
|
||||||
text = path.read_text(encoding="utf-8", errors="replace")
|
|
||||||
world_model = re.search(r"world:\s*([^,\s]+),\s*model:\s*([^\s]+)", text)
|
|
||||||
commit = re.search(r"PX4 git-hash:\s*([a-f0-9]{40})", text)
|
|
||||||
version = re.search(r"PX4 version:\s*(.+)", text)
|
|
||||||
if world_model is None or commit is None or version is None:
|
|
||||||
raise EvidenceError(f"PX4 identity is incomplete in {path}")
|
|
||||||
return {
|
|
||||||
"world": world_model.group(1),
|
|
||||||
"model": world_model.group(2),
|
|
||||||
"px4_commit": commit.group(1),
|
|
||||||
"px4_version": version.group(1).strip(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_confined(path: Path, root: Path, *, label: str) -> Path:
|
|
||||||
resolved = path.expanduser().resolve(strict=True)
|
|
||||||
if not resolved.is_relative_to(root):
|
|
||||||
raise EvidenceError(f"{label} escaped reviewed root {root}: {resolved}")
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
|
|
||||||
def _arguments() -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--profile", type=Path, required=True)
|
|
||||||
parser.add_argument("--run-1x", type=Path, required=True)
|
|
||||||
parser.add_argument("--run-2x", type=Path, required=True)
|
|
||||||
parser.add_argument("--output-dir", type=Path, required=True)
|
|
||||||
parser.add_argument("--d-root", type=Path, required=True)
|
|
||||||
parser.add_argument("--source-root", type=Path, required=True)
|
|
||||||
parser.add_argument("--repository-commit", required=True)
|
|
||||||
parser.add_argument("--wsl-windows-path", required=True)
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
args = _arguments()
|
|
||||||
d_root = args.d_root.expanduser().resolve(strict=True)
|
|
||||||
profile_path = _ensure_confined(args.profile, d_root, label="profile")
|
|
||||||
run_1x = _ensure_confined(args.run_1x, d_root, label="1x run")
|
|
||||||
run_2x = _ensure_confined(args.run_2x, d_root, label="2x run")
|
|
||||||
source_root = _ensure_confined(args.source_root, d_root, label="source root")
|
|
||||||
output_dir = args.output_dir.expanduser().resolve(strict=False)
|
|
||||||
if not output_dir.is_relative_to(d_root):
|
|
||||||
raise EvidenceError("evidence output escaped the reviewed D-only root")
|
|
||||||
if output_dir.exists():
|
|
||||||
raise EvidenceError(f"evidence output already exists: {output_dir}")
|
|
||||||
if re.fullmatch(r"[a-f0-9]{40}", args.repository_commit) is None:
|
|
||||||
raise EvidenceError("repository commit must be a full Git SHA")
|
|
||||||
|
|
||||||
profile = _load_yaml(profile_path)
|
|
||||||
profile_sha256 = _sha256(profile_path)
|
|
||||||
result_1x = _result_env(run_1x / "result.env")
|
|
||||||
result_2x = _result_env(run_2x / "result.env")
|
|
||||||
if result_1x.get("speed_factor") != "1" or result_2x.get("speed_factor") != "2":
|
|
||||||
raise EvidenceError("run speed factors are not the declared 1x/2x pair")
|
|
||||||
time_1x = _load_json(run_1x / "time-control.json")
|
|
||||||
time_2x = _load_json(run_2x / "time-control.json")
|
|
||||||
resource_1x = _load_json(run_1x / "resource-baseline.json")
|
|
||||||
resource_2x = _load_json(run_2x / "resource-baseline.json")
|
|
||||||
if any(
|
|
||||||
report.get("verdict") != "pass" for report in (time_1x, time_2x, resource_1x, resource_2x)
|
|
||||||
):
|
|
||||||
raise EvidenceError("time or resource report is not a pass")
|
|
||||||
|
|
||||||
output_dir.mkdir(parents=True)
|
|
||||||
disk = shutil.disk_usage(d_root)
|
|
||||||
target_host = profile["target_host"]
|
|
||||||
worker_inventory = {
|
|
||||||
"redacted": True,
|
|
||||||
"repository_commit": args.repository_commit,
|
|
||||||
"profile_sha256": profile_sha256,
|
|
||||||
"target": {
|
|
||||||
"system": platform.system(),
|
|
||||||
"kernel_release": platform.release(),
|
|
||||||
"architecture": platform.machine(),
|
|
||||||
"wsl_distribution": os.environ.get("WSL_DISTRO_NAME"),
|
|
||||||
"linux_pretty_name": _run(
|
|
||||||
["bash", "-lc", ". /etc/os-release && printf '%s' \"$PRETTY_NAME\""]
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"cpu": {
|
|
||||||
"model": _cpu_model(),
|
|
||||||
"logical_processors": os.cpu_count(),
|
|
||||||
},
|
|
||||||
"memory": _memory_inventory(),
|
|
||||||
"gpu": _gpu_inventory(),
|
|
||||||
"disk": {
|
|
||||||
"total_bytes": disk.total,
|
|
||||||
"free_bytes": disk.free,
|
|
||||||
},
|
|
||||||
"components": _component_inventory(profile, source_root),
|
|
||||||
}
|
|
||||||
if worker_inventory["target"]["wsl_distribution"] != target_host["wsl_distribution"]:
|
|
||||||
raise EvidenceError("worker distribution does not match the profile")
|
|
||||||
|
|
||||||
mutable_paths = profile["storage"]["mutable_paths"]
|
|
||||||
placement: list[dict[str, Any]] = []
|
|
||||||
for item in mutable_paths:
|
|
||||||
path = Path(str(item["wsl_path"])).resolve(strict=True)
|
|
||||||
if not path.is_relative_to(d_root):
|
|
||||||
raise EvidenceError(f"mutable path escaped D-only root: {path}")
|
|
||||||
placement.append(
|
|
||||||
{
|
|
||||||
"id": item["id"],
|
|
||||||
"windows_path": item["windows_path"],
|
|
||||||
"wsl_path": str(path),
|
|
||||||
"exists": path.is_dir(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
d_only = {
|
|
||||||
"redacted": True,
|
|
||||||
"profile_sha256": profile_sha256,
|
|
||||||
"wsl_distribution_windows_path": args.wsl_windows_path,
|
|
||||||
"wsl_mount_root": str(d_root),
|
|
||||||
"container_runtime": target_host["container_runtime"],
|
|
||||||
"existing_docker_desktop_used_or_modified": False,
|
|
||||||
"mutable_paths": placement,
|
|
||||||
"disk": {
|
|
||||||
"total_bytes": disk.total,
|
|
||||||
"free_bytes": disk.free,
|
|
||||||
"minimum_free_gib": profile["storage"]["minimum_free_gib"],
|
|
||||||
"stop_below_gib": profile["storage"]["stop_below_gib"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
px4_launch = {
|
|
||||||
"repository_commit": args.repository_commit,
|
|
||||||
"runs": [
|
|
||||||
{
|
|
||||||
"run_id": result_1x["run_id"],
|
|
||||||
"speed_factor": 1,
|
|
||||||
"identity": _world_model_from_log(run_1x / "px4-rover.log"),
|
|
||||||
"result": result_1x,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"run_id": result_2x["run_id"],
|
|
||||||
"speed_factor": 2,
|
|
||||||
"identity": _world_model_from_log(run_2x / "px4-rover.log"),
|
|
||||||
"result": result_2x,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
telemetry = {
|
|
||||||
"runs": [
|
|
||||||
{
|
|
||||||
"run_id": result["run_id"],
|
|
||||||
"vehicle_status": _load_yaml(run / "vehicle-status.yaml"),
|
|
||||||
"vehicle_status_topic_present": (
|
|
||||||
"/fmu/out/vehicle_status_v1"
|
|
||||||
in (run / "ros-topics.txt").read_text(encoding="utf-8")
|
|
||||||
),
|
|
||||||
}
|
|
||||||
for result, run in ((result_1x, run_1x), (result_2x, run_2x))
|
|
||||||
]
|
|
||||||
}
|
|
||||||
authoritative_clock = {
|
|
||||||
"authority": profile["clock"],
|
|
||||||
"runs": [
|
|
||||||
{
|
|
||||||
"run_id": result["run_id"],
|
|
||||||
"clock_sample": _load_yaml(run / "clock.yaml"),
|
|
||||||
"pause_advance_ns": report["pause"]["observed_advance_ns"],
|
|
||||||
}
|
|
||||||
for result, run, report in (
|
|
||||||
(result_1x, run_1x, time_1x),
|
|
||||||
(result_2x, run_2x, time_2x),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
}
|
|
||||||
pause_step_speed = {
|
|
||||||
"contract": profile["runtime_acceptance"],
|
|
||||||
"runs": [
|
|
||||||
{
|
|
||||||
"run_id": result_1x["run_id"],
|
|
||||||
"speed_factor": 1,
|
|
||||||
"pause": time_1x["pause"],
|
|
||||||
"single_step": time_1x["single_step"],
|
|
||||||
"speed": time_1x["speed"],
|
|
||||||
"verdict": time_1x["verdict"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"run_id": result_2x["run_id"],
|
|
||||||
"speed_factor": 2,
|
|
||||||
"pause": time_2x["pause"],
|
|
||||||
"single_step": time_2x["single_step"],
|
|
||||||
"speed": time_2x["speed"],
|
|
||||||
"verdict": time_2x["verdict"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
clean_stop = {
|
|
||||||
"runs": [
|
|
||||||
{
|
|
||||||
"run_id": result_1x["run_id"],
|
|
||||||
"clean_lifecycle": result_1x["clean_lifecycle"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"run_id": result_2x["run_id"],
|
|
||||||
"clean_lifecycle": result_2x["clean_lifecycle"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"owned_process_residue": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
artifacts: dict[str, tuple[str, dict[str, Any]]] = {
|
|
||||||
"worker-inventory": (
|
|
||||||
"worker-inventory.redacted.json",
|
|
||||||
worker_inventory,
|
|
||||||
),
|
|
||||||
"d-only-physical-placement": (
|
|
||||||
"d-only-physical-placement.redacted.json",
|
|
||||||
d_only,
|
|
||||||
),
|
|
||||||
"px4-stock-rover-launch": (
|
|
||||||
"px4-stock-rover-launch.redacted.json",
|
|
||||||
px4_launch,
|
|
||||||
),
|
|
||||||
"ros2-telemetry": (
|
|
||||||
"ros2-telemetry.redacted.json",
|
|
||||||
telemetry,
|
|
||||||
),
|
|
||||||
"authoritative-simulation-clock": (
|
|
||||||
"authoritative-simulation-clock.redacted.json",
|
|
||||||
authoritative_clock,
|
|
||||||
),
|
|
||||||
"pause-step-speed": (
|
|
||||||
"pause-step-speed.redacted.json",
|
|
||||||
pause_step_speed,
|
|
||||||
),
|
|
||||||
"clean-stop-no-orphans": (
|
|
||||||
"clean-stop-no-orphans.redacted.json",
|
|
||||||
clean_stop,
|
|
||||||
),
|
|
||||||
"resource-baseline-1x": (
|
|
||||||
"resource-baseline-1x.redacted.json",
|
|
||||||
resource_1x,
|
|
||||||
),
|
|
||||||
"resource-baseline-2x": (
|
|
||||||
"resource-baseline-2x.redacted.json",
|
|
||||||
resource_2x,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
required = profile.get("required_evidence")
|
|
||||||
if not isinstance(required, list) or set(required) != set(artifacts):
|
|
||||||
raise EvidenceError("profile required_evidence does not match the generator")
|
|
||||||
|
|
||||||
manifest_checks: list[dict[str, str]] = []
|
|
||||||
for identifier in required:
|
|
||||||
filename, payload = artifacts[str(identifier)]
|
|
||||||
artifact_path = output_dir / filename
|
|
||||||
_write_json(artifact_path, payload)
|
|
||||||
manifest_checks.append(
|
|
||||||
{
|
|
||||||
"id": str(identifier),
|
|
||||||
"status": "pass",
|
|
||||||
"artifact": filename,
|
|
||||||
"sha256": _sha256(artifact_path),
|
|
||||||
"note": f"Redacted factual worker evidence for {identifier}.",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
manifest = {
|
|
||||||
"schema_version": EVIDENCE_SCHEMA,
|
|
||||||
"profile_sha256": profile_sha256,
|
|
||||||
"checks": manifest_checks,
|
|
||||||
}
|
|
||||||
manifest_path = output_dir / "evidence.yaml"
|
|
||||||
manifest_path.write_text(
|
|
||||||
yaml.safe_dump(manifest, sort_keys=False),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
print(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"evidence_manifest": str(manifest_path),
|
|
||||||
"profile_sha256": profile_sha256,
|
|
||||||
"checks": len(manifest_checks),
|
|
||||||
},
|
|
||||||
sort_keys=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
|
|
@ -1,487 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Qualify Gazebo pause, single-step, speed and resource behavior on SIM S0."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from dataclasses import asdict, dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
TIME_SCHEMA = "missioncore.simulation-time-control/v1"
|
|
||||||
RESOURCE_SCHEMA = "missioncore.simulation-resource-baseline/v1"
|
|
||||||
MIB = 1024**2
|
|
||||||
GIB = 1024**3
|
|
||||||
|
|
||||||
|
|
||||||
class ProbeError(RuntimeError):
|
|
||||||
"""A runtime observation could not satisfy the reviewed S0 contract."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class WorldStats:
|
|
||||||
sim_time_ns: int
|
|
||||||
real_time_ns: int
|
|
||||||
iterations: int
|
|
||||||
paused: bool
|
|
||||||
real_time_factor: float
|
|
||||||
step_size_ns: int
|
|
||||||
|
|
||||||
|
|
||||||
def _run(command: list[str], *, timeout: float) -> subprocess.CompletedProcess[str]:
|
|
||||||
result = subprocess.run(
|
|
||||||
command,
|
|
||||||
check=False,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise ProbeError(
|
|
||||||
f"command failed ({result.returncode}): {' '.join(command)}; "
|
|
||||||
f"stderr={result.stderr.strip()!r}"
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _time_field(message: str, field: str) -> int:
|
|
||||||
match = re.search(rf"\b{re.escape(field)}\s*\{{(?P<body>.*?)\}}", message, re.DOTALL)
|
|
||||||
if match is None:
|
|
||||||
raise ProbeError(f"Gazebo stats omitted {field}")
|
|
||||||
body = match.group("body")
|
|
||||||
seconds = re.search(r"\bsec:\s*(-?\d+)", body)
|
|
||||||
nanoseconds = re.search(r"\bnsec:\s*(-?\d+)", body)
|
|
||||||
sec_value = int(seconds.group(1)) if seconds is not None else 0
|
|
||||||
nsec_value = int(nanoseconds.group(1)) if nanoseconds is not None else 0
|
|
||||||
return sec_value * 1_000_000_000 + nsec_value
|
|
||||||
|
|
||||||
|
|
||||||
def _scalar_field(
|
|
||||||
message: str,
|
|
||||||
field: str,
|
|
||||||
converter: type[int] | type[float],
|
|
||||||
*,
|
|
||||||
default: int | float | None = None,
|
|
||||||
) -> int | float:
|
|
||||||
match = re.search(rf"\b{re.escape(field)}:\s*([^\s]+)", message)
|
|
||||||
if match is None:
|
|
||||||
if default is None:
|
|
||||||
raise ProbeError(f"Gazebo stats omitted {field}")
|
|
||||||
return default
|
|
||||||
return converter(match.group(1))
|
|
||||||
|
|
||||||
|
|
||||||
def _stats_snapshot() -> WorldStats:
|
|
||||||
result = _run(
|
|
||||||
["gz", "topic", "--echo", "--topic", "/stats", "-n", "1"],
|
|
||||||
timeout=8,
|
|
||||||
)
|
|
||||||
paused_match = re.search(r"\bpaused:\s*(true|false)", result.stdout)
|
|
||||||
paused = paused_match is not None and paused_match.group(1) == "true"
|
|
||||||
return WorldStats(
|
|
||||||
sim_time_ns=_time_field(result.stdout, "sim_time"),
|
|
||||||
real_time_ns=_time_field(result.stdout, "real_time"),
|
|
||||||
iterations=int(_scalar_field(result.stdout, "iterations", int)),
|
|
||||||
paused=paused,
|
|
||||||
real_time_factor=float(
|
|
||||||
_scalar_field(result.stdout, "real_time_factor", float, default=0.0)
|
|
||||||
),
|
|
||||||
step_size_ns=_time_field(result.stdout, "step_size"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _control(world: str, request: str) -> str:
|
|
||||||
result = _run(
|
|
||||||
[
|
|
||||||
"gz",
|
|
||||||
"service",
|
|
||||||
"-s",
|
|
||||||
f"/world/{world}/control",
|
|
||||||
"--reqtype",
|
|
||||||
"gz.msgs.WorldControl",
|
|
||||||
"--reptype",
|
|
||||||
"gz.msgs.Boolean",
|
|
||||||
"--timeout",
|
|
||||||
"5000",
|
|
||||||
"--req",
|
|
||||||
request,
|
|
||||||
],
|
|
||||||
timeout=8,
|
|
||||||
)
|
|
||||||
if "data: true" not in result.stdout:
|
|
||||||
raise ProbeError(
|
|
||||||
f"Gazebo rejected world control request {request!r}: {result.stdout.strip()!r}"
|
|
||||||
)
|
|
||||||
return result.stdout.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _wait_for_pause_state(expected: bool, *, timeout: float = 6) -> WorldStats:
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
observed: WorldStats | None = None
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
observed = _stats_snapshot()
|
|
||||||
if observed.paused is expected:
|
|
||||||
return observed
|
|
||||||
raise ProbeError(f"Gazebo pause state did not become {expected}; last={observed!r}")
|
|
||||||
|
|
||||||
|
|
||||||
def _wait_for_iteration(minimum: int, *, timeout: float = 6) -> WorldStats:
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
observed: WorldStats | None = None
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
observed = _stats_snapshot()
|
|
||||||
if observed.iterations >= minimum:
|
|
||||||
return observed
|
|
||||||
raise ProbeError(f"Gazebo iteration did not reach {minimum}; last={observed!r}")
|
|
||||||
|
|
||||||
|
|
||||||
def _meminfo() -> dict[str, int]:
|
|
||||||
values: dict[str, int] = {}
|
|
||||||
for line in Path("/proc/meminfo").read_text(encoding="utf-8").splitlines():
|
|
||||||
key, raw = line.split(":", maxsplit=1)
|
|
||||||
amount = int(raw.strip().split()[0])
|
|
||||||
values[key] = amount * 1024
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
def _owned_processes(
|
|
||||||
*,
|
|
||||||
pids: set[int],
|
|
||||||
process_groups: set[int],
|
|
||||||
) -> list[dict[str, int | float | str]]:
|
|
||||||
result = _run(
|
|
||||||
[
|
|
||||||
"ps",
|
|
||||||
"-eo",
|
|
||||||
"pid=,ppid=,pgid=,rss=,pcpu=,comm=",
|
|
||||||
],
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
processes: list[dict[str, int | float | str]] = []
|
|
||||||
for line in result.stdout.splitlines():
|
|
||||||
fields = line.split(maxsplit=5)
|
|
||||||
if len(fields) != 6:
|
|
||||||
continue
|
|
||||||
pid, ppid, pgid, rss_kib, cpu_percent, command = fields
|
|
||||||
pid_value = int(pid)
|
|
||||||
pgid_value = int(pgid)
|
|
||||||
if pid_value not in pids and pgid_value not in process_groups:
|
|
||||||
continue
|
|
||||||
processes.append(
|
|
||||||
{
|
|
||||||
"pid": pid_value,
|
|
||||||
"ppid": int(ppid),
|
|
||||||
"pgid": pgid_value,
|
|
||||||
"rss_bytes": int(rss_kib) * 1024,
|
|
||||||
"cpu_percent": float(cpu_percent),
|
|
||||||
"command": command,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return processes
|
|
||||||
|
|
||||||
|
|
||||||
def _gpu_sample() -> dict[str, int] | None:
|
|
||||||
if shutil.which("nvidia-smi") is None:
|
|
||||||
return None
|
|
||||||
result = _run(
|
|
||||||
[
|
|
||||||
"nvidia-smi",
|
|
||||||
"--query-gpu=memory.used,utilization.gpu",
|
|
||||||
"--format=csv,noheader,nounits",
|
|
||||||
],
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
first_line = result.stdout.strip().splitlines()[0]
|
|
||||||
memory_mib, utilization_percent = (int(value.strip()) for value in first_line.split(","))
|
|
||||||
return {
|
|
||||||
"global_memory_used_mib": memory_mib,
|
|
||||||
"global_utilization_percent": utilization_percent,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ResourceSampler:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
owned_pids: set[int],
|
|
||||||
owned_process_groups: set[int],
|
|
||||||
storage_root: Path,
|
|
||||||
interval_seconds: float,
|
|
||||||
) -> None:
|
|
||||||
self._owned_pids = owned_pids
|
|
||||||
self._owned_process_groups = owned_process_groups
|
|
||||||
self._storage_root = storage_root
|
|
||||||
self._interval_seconds = interval_seconds
|
|
||||||
self._stop = threading.Event()
|
|
||||||
self._thread = threading.Thread(target=self._run, name="s0-resource-sampler")
|
|
||||||
self.samples: list[dict[str, Any]] = []
|
|
||||||
self.error: str | None = None
|
|
||||||
|
|
||||||
def start(self) -> None:
|
|
||||||
self._thread.start()
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
self._stop.set()
|
|
||||||
self._thread.join(timeout=max(5.0, self._interval_seconds * 4))
|
|
||||||
if self._thread.is_alive():
|
|
||||||
raise ProbeError("resource sampler did not stop")
|
|
||||||
if self.error is not None:
|
|
||||||
raise ProbeError(f"resource sampler failed: {self.error}")
|
|
||||||
|
|
||||||
def _run(self) -> None:
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
processes = _owned_processes(
|
|
||||||
pids=self._owned_pids,
|
|
||||||
process_groups=self._owned_process_groups,
|
|
||||||
)
|
|
||||||
memory = _meminfo()
|
|
||||||
disk = shutil.disk_usage(self._storage_root)
|
|
||||||
self.samples.append(
|
|
||||||
{
|
|
||||||
"monotonic_ns": time.monotonic_ns(),
|
|
||||||
"owned_rss_bytes": sum(int(process["rss_bytes"]) for process in processes),
|
|
||||||
"owned_cpu_percent": sum(
|
|
||||||
float(process["cpu_percent"]) for process in processes
|
|
||||||
),
|
|
||||||
"memory_available_bytes": memory["MemAvailable"],
|
|
||||||
"swap_used_bytes": memory["SwapTotal"] - memory["SwapFree"],
|
|
||||||
"disk_free_bytes": disk.free,
|
|
||||||
"gpu": _gpu_sample(),
|
|
||||||
"processes": processes,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if self._stop.wait(self._interval_seconds):
|
|
||||||
break
|
|
||||||
except Exception as error: # noqa: BLE001 - preserve factual sampler failure
|
|
||||||
self.error = f"{type(error).__name__}: {error}"
|
|
||||||
|
|
||||||
|
|
||||||
def _load_contract(profile_path: Path, speed_factor: int) -> dict[str, int | str]:
|
|
||||||
profile = yaml.safe_load(profile_path.read_text(encoding="utf-8"))
|
|
||||||
if not isinstance(profile, dict):
|
|
||||||
raise ProbeError("qualification profile must be a mapping")
|
|
||||||
runtime = profile.get("runtime_acceptance")
|
|
||||||
storage = profile.get("storage")
|
|
||||||
if not isinstance(runtime, dict) or not isinstance(storage, dict):
|
|
||||||
raise ProbeError("qualification profile omitted runtime_acceptance or storage")
|
|
||||||
factors = runtime.get("speed_factors")
|
|
||||||
if not isinstance(factors, list) or speed_factor not in factors:
|
|
||||||
raise ProbeError(f"speed factor {speed_factor} is not declared by the profile")
|
|
||||||
return {
|
|
||||||
"world": str(runtime["world"]),
|
|
||||||
"model": str(runtime["model"]),
|
|
||||||
"physics_step_ns": int(runtime["physics_step_ns"]),
|
|
||||||
"pause_max_advance_ns": int(runtime["pause_max_advance_ns"]),
|
|
||||||
"rtf_tolerance_percent": int(runtime["rtf_tolerance_percent"]),
|
|
||||||
"measurement_window_seconds": int(runtime["measurement_window_seconds"]),
|
|
||||||
"resource_sample_interval_milliseconds": int(
|
|
||||||
runtime["resource_sample_interval_milliseconds"]
|
|
||||||
),
|
|
||||||
"max_owned_rss_mib": int(runtime["max_owned_rss_mib"]),
|
|
||||||
"max_owned_cpu_percent": int(runtime["max_owned_cpu_percent"]),
|
|
||||||
"minimum_available_memory_gib": int(runtime["minimum_available_memory_gib"]),
|
|
||||||
"stop_below_gib": int(storage["stop_below_gib"]),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _resource_report(
|
|
||||||
*,
|
|
||||||
samples: list[dict[str, Any]],
|
|
||||||
contract: dict[str, int | str],
|
|
||||||
speed_factor: int,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if not samples:
|
|
||||||
raise ProbeError("resource sampler produced no samples")
|
|
||||||
peak_rss = max(int(sample["owned_rss_bytes"]) for sample in samples)
|
|
||||||
peak_cpu = max(float(sample["owned_cpu_percent"]) for sample in samples)
|
|
||||||
minimum_memory = min(int(sample["memory_available_bytes"]) for sample in samples)
|
|
||||||
minimum_disk = min(int(sample["disk_free_bytes"]) for sample in samples)
|
|
||||||
maximum_swap = max(int(sample["swap_used_bytes"]) for sample in samples)
|
|
||||||
gpu_samples = [sample["gpu"] for sample in samples if sample["gpu"] is not None]
|
|
||||||
checks = {
|
|
||||||
"sample_count": len(samples) >= 6,
|
|
||||||
"owned_rss": peak_rss <= int(contract["max_owned_rss_mib"]) * MIB,
|
|
||||||
"owned_cpu": peak_cpu <= int(contract["max_owned_cpu_percent"]),
|
|
||||||
"available_memory": (minimum_memory >= int(contract["minimum_available_memory_gib"]) * GIB),
|
|
||||||
"disk_floor": minimum_disk >= int(contract["stop_below_gib"]) * GIB,
|
|
||||||
}
|
|
||||||
summary: dict[str, Any] = {
|
|
||||||
"sample_count": len(samples),
|
|
||||||
"peak_owned_rss_bytes": peak_rss,
|
|
||||||
"peak_owned_cpu_percent": peak_cpu,
|
|
||||||
"minimum_memory_available_bytes": minimum_memory,
|
|
||||||
"minimum_disk_free_bytes": minimum_disk,
|
|
||||||
"maximum_swap_used_bytes": maximum_swap,
|
|
||||||
"gpu_metrics_are_global_and_include_co_tenants": True,
|
|
||||||
}
|
|
||||||
if gpu_samples:
|
|
||||||
summary["peak_global_gpu_memory_used_mib"] = max(
|
|
||||||
int(sample["global_memory_used_mib"]) for sample in gpu_samples
|
|
||||||
)
|
|
||||||
summary["peak_global_gpu_utilization_percent"] = max(
|
|
||||||
int(sample["global_utilization_percent"]) for sample in gpu_samples
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"schema_version": RESOURCE_SCHEMA,
|
|
||||||
"speed_factor": speed_factor,
|
|
||||||
"actuator_authority": False,
|
|
||||||
"checks": checks,
|
|
||||||
"summary": summary,
|
|
||||||
"samples": samples,
|
|
||||||
"verdict": "pass" if all(checks.values()) else "fail",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _arguments() -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--profile", type=Path, required=True)
|
|
||||||
parser.add_argument("--speed-factor", type=int, required=True)
|
|
||||||
parser.add_argument("--time-output", type=Path, required=True)
|
|
||||||
parser.add_argument("--resource-output", type=Path, required=True)
|
|
||||||
parser.add_argument("--storage-root", type=Path, required=True)
|
|
||||||
parser.add_argument("--owned-pid", action="append", type=int, default=[])
|
|
||||||
parser.add_argument("--owned-pgid", action="append", type=int, default=[])
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
args = _arguments()
|
|
||||||
contract = _load_contract(args.profile, args.speed_factor)
|
|
||||||
sampler = ResourceSampler(
|
|
||||||
owned_pids=set(args.owned_pid),
|
|
||||||
owned_process_groups=set(args.owned_pgid),
|
|
||||||
storage_root=args.storage_root,
|
|
||||||
interval_seconds=int(contract["resource_sample_interval_milliseconds"]) / 1000,
|
|
||||||
)
|
|
||||||
time_report: dict[str, Any] = {
|
|
||||||
"schema_version": TIME_SCHEMA,
|
|
||||||
"speed_factor": args.speed_factor,
|
|
||||||
"world": contract["world"],
|
|
||||||
"model": contract["model"],
|
|
||||||
"actuator_authority": False,
|
|
||||||
"contract": contract,
|
|
||||||
}
|
|
||||||
exit_code = 1
|
|
||||||
sampler.start()
|
|
||||||
try:
|
|
||||||
world = str(contract["world"])
|
|
||||||
_control(world, "pause: true")
|
|
||||||
paused_start = _wait_for_pause_state(True)
|
|
||||||
time.sleep(1.0)
|
|
||||||
paused_end = _stats_snapshot()
|
|
||||||
pause_delta = paused_end.sim_time_ns - paused_start.sim_time_ns
|
|
||||||
pause_pass = paused_end.paused and 0 <= pause_delta <= int(contract["pause_max_advance_ns"])
|
|
||||||
|
|
||||||
step_before = paused_end
|
|
||||||
_control(world, "pause: true, multi_step: 1")
|
|
||||||
step_after = _wait_for_iteration(step_before.iterations + 1)
|
|
||||||
step_delta = step_after.sim_time_ns - step_before.sim_time_ns
|
|
||||||
iteration_delta = step_after.iterations - step_before.iterations
|
|
||||||
step_pass = (
|
|
||||||
step_after.paused
|
|
||||||
and step_delta == int(contract["physics_step_ns"])
|
|
||||||
and iteration_delta == 1
|
|
||||||
)
|
|
||||||
|
|
||||||
_control(world, "pause: false")
|
|
||||||
_wait_for_pause_state(False)
|
|
||||||
time.sleep(0.75)
|
|
||||||
speed_start = _stats_snapshot()
|
|
||||||
time.sleep(int(contract["measurement_window_seconds"]))
|
|
||||||
speed_end = _stats_snapshot()
|
|
||||||
sim_delta = speed_end.sim_time_ns - speed_start.sim_time_ns
|
|
||||||
real_delta = speed_end.real_time_ns - speed_start.real_time_ns
|
|
||||||
if real_delta <= 0:
|
|
||||||
raise ProbeError("Gazebo real-time delta was not positive")
|
|
||||||
measured_rtf = sim_delta / real_delta
|
|
||||||
tolerance = int(contract["rtf_tolerance_percent"]) / 100
|
|
||||||
lower = args.speed_factor * (1 - tolerance)
|
|
||||||
upper = args.speed_factor * (1 + tolerance)
|
|
||||||
speed_pass = lower <= measured_rtf <= upper
|
|
||||||
|
|
||||||
time_report.update(
|
|
||||||
{
|
|
||||||
"pause": {
|
|
||||||
"before": asdict(paused_start),
|
|
||||||
"after": asdict(paused_end),
|
|
||||||
"observed_advance_ns": pause_delta,
|
|
||||||
"pass": pause_pass,
|
|
||||||
},
|
|
||||||
"single_step": {
|
|
||||||
"before": asdict(step_before),
|
|
||||||
"after": asdict(step_after),
|
|
||||||
"observed_advance_ns": step_delta,
|
|
||||||
"observed_iteration_delta": iteration_delta,
|
|
||||||
"pass": step_pass,
|
|
||||||
},
|
|
||||||
"speed": {
|
|
||||||
"before": asdict(speed_start),
|
|
||||||
"after": asdict(speed_end),
|
|
||||||
"simulation_advance_ns": sim_delta,
|
|
||||||
"real_advance_ns": real_delta,
|
|
||||||
"measured_rtf": measured_rtf,
|
|
||||||
"accepted_range": [lower, upper],
|
|
||||||
"pass": speed_pass,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
time_report["verdict"] = "pass" if pause_pass and step_pass and speed_pass else "fail"
|
|
||||||
except Exception as error: # noqa: BLE001 - persist the factual probe failure
|
|
||||||
time_report["verdict"] = "fail"
|
|
||||||
time_report["error"] = f"{type(error).__name__}: {error}"
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
_control(str(contract["world"]), "pause: false")
|
|
||||||
except Exception as resume_error: # noqa: BLE001 - report cleanup failure
|
|
||||||
time_report["resume_error"] = f"{type(resume_error).__name__}: {resume_error}"
|
|
||||||
time_report["verdict"] = "fail"
|
|
||||||
try:
|
|
||||||
sampler.stop()
|
|
||||||
resource_report = _resource_report(
|
|
||||||
samples=sampler.samples,
|
|
||||||
contract=contract,
|
|
||||||
speed_factor=args.speed_factor,
|
|
||||||
)
|
|
||||||
except Exception as resource_error: # noqa: BLE001
|
|
||||||
resource_report = {
|
|
||||||
"schema_version": RESOURCE_SCHEMA,
|
|
||||||
"speed_factor": args.speed_factor,
|
|
||||||
"actuator_authority": False,
|
|
||||||
"verdict": "fail",
|
|
||||||
"error": f"{type(resource_error).__name__}: {resource_error}",
|
|
||||||
"samples": sampler.samples,
|
|
||||||
}
|
|
||||||
_write_json(args.time_output, time_report)
|
|
||||||
_write_json(args.resource_output, resource_report)
|
|
||||||
|
|
||||||
if time_report["verdict"] == "pass" and resource_report["verdict"] == "pass":
|
|
||||||
exit_code = 0
|
|
||||||
print(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"time_verdict": time_report["verdict"],
|
|
||||||
"resource_verdict": resource_report["verdict"],
|
|
||||||
"speed_factor": args.speed_factor,
|
|
||||||
},
|
|
||||||
sort_keys=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return exit_code
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
schema_version: missioncore.simulation-s0-profile/v1
|
|
||||||
profile_id: ai-worker-px4-gazebo-v1
|
|
||||||
|
|
||||||
target_host:
|
|
||||||
windows_release: windows-11
|
|
||||||
virtualization: wsl2
|
|
||||||
wsl_distribution: MissionCore-Sim
|
|
||||||
linux_distribution: ubuntu-24.04
|
|
||||||
architecture: x86_64
|
|
||||||
container_runtime: none
|
|
||||||
|
|
||||||
storage:
|
|
||||||
windows_root: 'D:\NDC_MISSIONCORE'
|
|
||||||
wsl_root: /mnt/d/NDC_MISSIONCORE
|
|
||||||
minimum_free_gib: 300
|
|
||||||
stop_below_gib: 200
|
|
||||||
forbid_windows_system_drive: true
|
|
||||||
mutable_paths:
|
|
||||||
- id: wsl
|
|
||||||
windows_path: 'D:\NDC_MISSIONCORE\simulation\wsl'
|
|
||||||
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/wsl
|
|
||||||
- id: source
|
|
||||||
windows_path: 'D:\NDC_MISSIONCORE\simulation\source'
|
|
||||||
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/source
|
|
||||||
- id: build
|
|
||||||
windows_path: 'D:\NDC_MISSIONCORE\simulation\build'
|
|
||||||
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/build
|
|
||||||
- id: cache
|
|
||||||
windows_path: 'D:\NDC_MISSIONCORE\simulation\cache'
|
|
||||||
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/cache
|
|
||||||
- id: runtime
|
|
||||||
windows_path: 'D:\NDC_MISSIONCORE\simulation\runtime'
|
|
||||||
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/runtime
|
|
||||||
- id: artifacts
|
|
||||||
windows_path: 'D:\NDC_MISSIONCORE\simulation\artifacts'
|
|
||||||
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/artifacts
|
|
||||||
- id: datasets
|
|
||||||
windows_path: 'D:\NDC_MISSIONCORE\simulation\datasets'
|
|
||||||
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/datasets
|
|
||||||
|
|
||||||
clock:
|
|
||||||
authority: gazebo:/clock
|
|
||||||
ros_use_sim_time: true
|
|
||||||
px4_uxrce_dds_sync_enabled: false
|
|
||||||
canonical_unit: nanoseconds
|
|
||||||
utc_role: provenance-only
|
|
||||||
|
|
||||||
authority:
|
|
||||||
simulation_or_shadow_only: true
|
|
||||||
actuator_authority: false
|
|
||||||
navigation_or_safety_accepted: false
|
|
||||||
direct_actuator_setpoints_allowed: false
|
|
||||||
allowed_setpoint_profiles:
|
|
||||||
- rover-speed-steering/v1
|
|
||||||
- rover-speed-yaw-rate/v1
|
|
||||||
|
|
||||||
ros:
|
|
||||||
domain_id: 0
|
|
||||||
namespace: /missioncore/polygon/s0
|
|
||||||
|
|
||||||
runtime_acceptance:
|
|
||||||
world: rover
|
|
||||||
model: rover_ackermann_0
|
|
||||||
physics_step_ns: 2000000
|
|
||||||
pause_max_advance_ns: 0
|
|
||||||
speed_factors:
|
|
||||||
- 1
|
|
||||||
- 2
|
|
||||||
rtf_tolerance_percent: 20
|
|
||||||
measurement_window_seconds: 4
|
|
||||||
resource_sample_interval_milliseconds: 500
|
|
||||||
max_owned_rss_mib: 2048
|
|
||||||
max_owned_cpu_percent: 800
|
|
||||||
minimum_available_memory_gib: 8
|
|
||||||
|
|
||||||
components:
|
|
||||||
- id: ros2
|
|
||||||
source_url: https://docs.ros.org/en/jazzy/
|
|
||||||
requested_ref: jazzy
|
|
||||||
resolved_version: ros-base=0.11.0-1noble.20260616.084325;ros-gz=1.0.22-1noble.20260616.074726
|
|
||||||
resolved_commit:
|
|
||||||
license: mixed-apache-2.0-bsd
|
|
||||||
qualification_state: accepted
|
|
||||||
- id: gazebo
|
|
||||||
source_url: https://gazebosim.org/docs/harmonic/
|
|
||||||
requested_ref: harmonic
|
|
||||||
resolved_version: gz-harmonic=1.0.0-1~noble;gz-sim=8.14.0-1~noble;sdformat=14.9.0-1~noble
|
|
||||||
resolved_commit:
|
|
||||||
license: apache-2.0
|
|
||||||
qualification_state: accepted
|
|
||||||
- id: px4-autopilot
|
|
||||||
source_url: https://github.com/PX4/PX4-Autopilot
|
|
||||||
requested_ref: v1.17.0
|
|
||||||
resolved_version: v1.17.0
|
|
||||||
resolved_commit: d6f12ad1c4f70ad3230afd7d86e971421e02fef4
|
|
||||||
license: bsd-3-clause
|
|
||||||
qualification_state: accepted
|
|
||||||
- id: px4-msgs
|
|
||||||
source_url: https://github.com/PX4/px4_msgs
|
|
||||||
requested_ref: v1.17.0
|
|
||||||
resolved_version: v1.17.0
|
|
||||||
resolved_commit: 86d8239e962f6939e05c3737784f60c02fa884db
|
|
||||||
license: bsd-3-clause
|
|
||||||
qualification_state: accepted
|
|
||||||
- id: px4-gazebo-models
|
|
||||||
source_url: https://github.com/PX4/PX4-gazebo-models
|
|
||||||
requested_ref: PX4-Autopilot/v1.17.0-submodule
|
|
||||||
resolved_version: px4-v1.17.0-submodule
|
|
||||||
resolved_commit: b6127f4ec20de867e215fb5f78ae88b80f371909
|
|
||||||
license: bsd-3-clause
|
|
||||||
qualification_state: accepted
|
|
||||||
- id: micro-xrce-dds-agent
|
|
||||||
source_url: https://github.com/eProsima/Micro-XRCE-DDS-Agent
|
|
||||||
requested_ref: v2.4.3
|
|
||||||
resolved_version: v2.4.3
|
|
||||||
resolved_commit: 73622810d984349b80bbac0ef55fc0b694d62222
|
|
||||||
license: apache-2.0
|
|
||||||
qualification_state: accepted
|
|
||||||
- id: nav2
|
|
||||||
source_url: https://github.com/ros-navigation/navigation2
|
|
||||||
requested_ref: jazzy@2026-07-24
|
|
||||||
resolved_version: navigation2=1.3.12-1noble.20260615.181551;nav2-bringup=1.3.12-1noble.20260616.082701
|
|
||||||
resolved_commit: c92ea2fd9008f50c0ec8447610800214b2a0dafb
|
|
||||||
license: apache-2.0
|
|
||||||
qualification_state: accepted
|
|
||||||
|
|
||||||
ports:
|
|
||||||
- id: micro-xrce-dds
|
|
||||||
host_scope: loopback-only-netns
|
|
||||||
protocol: udp
|
|
||||||
bind: 0.0.0.0
|
|
||||||
port: 8888
|
|
||||||
owner: micro-xrce-dds-agent
|
|
||||||
- id: px4-mavlink-onboard
|
|
||||||
host_scope: loopback-only-netns
|
|
||||||
protocol: udp
|
|
||||||
bind: 0.0.0.0
|
|
||||||
port: 14580
|
|
||||||
owner: px4-sitl
|
|
||||||
- id: px4-mavlink-normal
|
|
||||||
host_scope: loopback-only-netns
|
|
||||||
protocol: udp
|
|
||||||
bind: 0.0.0.0
|
|
||||||
port: 18570
|
|
||||||
owner: px4-sitl
|
|
||||||
- id: simulation-orchestrator-api
|
|
||||||
host_scope: loopback-only-netns
|
|
||||||
protocol: tcp
|
|
||||||
bind: 127.0.0.1
|
|
||||||
port: 18080
|
|
||||||
owner: simulation-orchestrator
|
|
||||||
- id: simulation-telemetry
|
|
||||||
host_scope: loopback-only-netns
|
|
||||||
protocol: tcp
|
|
||||||
bind: 127.0.0.1
|
|
||||||
port: 18081
|
|
||||||
owner: simulation-orchestrator
|
|
||||||
|
|
||||||
processes:
|
|
||||||
- id: simulation-orchestrator
|
|
||||||
parent:
|
|
||||||
shutdown_order: 60
|
|
||||||
- id: gazebo-server
|
|
||||||
parent: simulation-orchestrator
|
|
||||||
shutdown_order: 50
|
|
||||||
- id: px4-sitl
|
|
||||||
parent: simulation-orchestrator
|
|
||||||
shutdown_order: 40
|
|
||||||
- id: micro-xrce-dds-agent
|
|
||||||
parent: simulation-orchestrator
|
|
||||||
shutdown_order: 30
|
|
||||||
- id: ros2-bridge
|
|
||||||
parent: simulation-orchestrator
|
|
||||||
shutdown_order: 20
|
|
||||||
- id: nav2
|
|
||||||
parent: simulation-orchestrator
|
|
||||||
shutdown_order: 10
|
|
||||||
|
|
||||||
required_tools:
|
|
||||||
- git
|
|
||||||
- gz
|
|
||||||
- make
|
|
||||||
- cmake
|
|
||||||
- ninja
|
|
||||||
- colcon
|
|
||||||
- ros2
|
|
||||||
- MicroXRCEAgent
|
|
||||||
|
|
||||||
required_evidence:
|
|
||||||
- worker-inventory
|
|
||||||
- d-only-physical-placement
|
|
||||||
- px4-stock-rover-launch
|
|
||||||
- ros2-telemetry
|
|
||||||
- authoritative-simulation-clock
|
|
||||||
- pause-step-speed
|
|
||||||
- clean-stop-no-orphans
|
|
||||||
- resource-baseline-1x
|
|
||||||
- resource-baseline-2x
|
|
||||||
|
|
@ -1,296 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Run the SIM S0 stock-rover smoke case inside a loopback-only network
|
|
||||||
# namespace. The script writes factual runtime artifacts to D and never grants
|
|
||||||
# actuator authority.
|
|
||||||
|
|
||||||
set -Eeuo pipefail
|
|
||||||
|
|
||||||
readonly EXPECTED_DISTRO="MissionCore-Sim"
|
|
||||||
readonly D_ROOT="${MISSIONCORE_SIM_D_ROOT:-/mnt/d/NDC_MISSIONCORE/simulation}"
|
|
||||||
readonly PX4_ROOT="${MISSIONCORE_SIM_PX4_ROOT:-${D_ROOT}/source/PX4-Autopilot}"
|
|
||||||
readonly AGENT_PREFIX="${MISSIONCORE_SIM_AGENT_PREFIX:-${D_ROOT}/runtime/micro-xrce-dds-agent/2.4.3}"
|
|
||||||
readonly PX4_MSGS_SETUP="${MISSIONCORE_SIM_PX4_MSGS_SETUP:-${D_ROOT}/build/ros2/px4_msgs/install/setup.bash}"
|
|
||||||
readonly ARTIFACT_ROOT="${MISSIONCORE_SIM_ARTIFACT_ROOT:-${D_ROOT}/artifacts/s0}"
|
|
||||||
readonly PROFILE_PATH="${MISSIONCORE_SIM_PROFILE_PATH:-${D_ROOT}/source/qualification-profile.yaml}"
|
|
||||||
readonly TIME_PROBE="${MISSIONCORE_SIM_TIME_PROBE:-${D_ROOT}/source/gazebo_time_control.py}"
|
|
||||||
readonly RUN_ID="${1:-$(date -u +%Y%m%dT%H%M%SZ)}"
|
|
||||||
readonly SPEED_FACTOR="${2:-1}"
|
|
||||||
readonly RUN_DIR="${ARTIFACT_ROOT}/${RUN_ID}"
|
|
||||||
|
|
||||||
if [[ "${SPEED_FACTOR}" != "1" && "${SPEED_FACTOR}" != "2" ]]; then
|
|
||||||
echo "error: speed factor must be one of the reviewed values: 1 or 2" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${MISSIONCORE_S0_NETNS:-0}" != "1" ]]; then
|
|
||||||
if [[ "${EUID}" -ne 0 ]]; then
|
|
||||||
echo "error: initial worker-smoke invocation must run as root to create the network namespace" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
if [[ "${WSL_DISTRO_NAME:-}" != "${EXPECTED_DISTRO}" ]]; then
|
|
||||||
echo "error: expected WSL distro ${EXPECTED_DISTRO}, observed ${WSL_DISTRO_NAME:-unset}" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec unshare --net -- bash -c '
|
|
||||||
set -e
|
|
||||||
ip link set lo up
|
|
||||||
exec runuser -u missioncore -- env MISSIONCORE_S0_NETNS=1 "$@"
|
|
||||||
' bash "$0" "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${WSL_DISTRO_NAME:-}" != "${EXPECTED_DISTRO}" ]]; then
|
|
||||||
echo "error: smoke case escaped the reviewed WSL distribution" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
for required_path in "${PX4_ROOT}" "${AGENT_PREFIX}" "${PX4_MSGS_SETUP}" "${ARTIFACT_ROOT}"; do
|
|
||||||
if [[ "${required_path}" != /mnt/d/NDC_MISSIONCORE/* ]]; then
|
|
||||||
echo "error: mutable or executable path escaped D-only root: ${required_path}" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
readonly AGENT_BIN="${AGENT_PREFIX}/bin/MicroXRCEAgent"
|
|
||||||
readonly AGENT_LOG="${RUN_DIR}/micro-xrce-dds-agent.log"
|
|
||||||
readonly PX4_LOG="${RUN_DIR}/px4-rover.log"
|
|
||||||
readonly BRIDGE_LOG="${RUN_DIR}/ros-gz-clock-bridge.log"
|
|
||||||
readonly TOPICS_LOG="${RUN_DIR}/ros-topics.txt"
|
|
||||||
readonly VEHICLE_STATUS_LOG="${RUN_DIR}/vehicle-status.yaml"
|
|
||||||
readonly CLOCK_LOG="${RUN_DIR}/clock.yaml"
|
|
||||||
readonly NETWORK_LOG="${RUN_DIR}/network.txt"
|
|
||||||
readonly RESOURCE_LOG="${RUN_DIR}/resource-snapshot.txt"
|
|
||||||
readonly GZ_SERVICES_LOG="${RUN_DIR}/gazebo-services.txt"
|
|
||||||
readonly GZ_TOPICS_LOG="${RUN_DIR}/gazebo-topics.txt"
|
|
||||||
readonly TIME_CONTROL_LOG="${RUN_DIR}/time-control.json"
|
|
||||||
readonly RESOURCE_BASELINE_LOG="${RUN_DIR}/resource-baseline.json"
|
|
||||||
readonly TIME_PROBE_STDOUT="${RUN_DIR}/time-probe.stdout"
|
|
||||||
readonly RESULT_LOG="${RUN_DIR}/result.env"
|
|
||||||
|
|
||||||
for required_file in \
|
|
||||||
"${PX4_ROOT}/build/px4_sitl_default/bin/px4" \
|
|
||||||
"${PX4_ROOT}/Tools/simulation/gz/worlds/rover.sdf" \
|
|
||||||
"${AGENT_BIN}" \
|
|
||||||
"/opt/ros/jazzy/setup.bash" \
|
|
||||||
"${PX4_MSGS_SETUP}" \
|
|
||||||
"${PROFILE_PATH}" \
|
|
||||||
"${TIME_PROBE}"; do
|
|
||||||
if [[ ! -e "${required_file}" ]]; then
|
|
||||||
echo "error: required S0 runtime input is missing: ${required_file}" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
mkdir -p "${RUN_DIR}"
|
|
||||||
|
|
||||||
agent_pid=""
|
|
||||||
px4_pgid=""
|
|
||||||
bridge_pgid=""
|
|
||||||
|
|
||||||
stop_process_group() {
|
|
||||||
local pgid="${1:-}"
|
|
||||||
if [[ -z "${pgid}" ]]; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
if kill -0 -- "-${pgid}" 2>/dev/null; then
|
|
||||||
kill -INT -- "-${pgid}" 2>/dev/null || true
|
|
||||||
for _ in {1..20}; do
|
|
||||||
if ! kill -0 -- "-${pgid}" 2>/dev/null; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
sleep 0.25
|
|
||||||
done
|
|
||||||
kill -TERM -- "-${pgid}" 2>/dev/null || true
|
|
||||||
sleep 1
|
|
||||||
kill -KILL -- "-${pgid}" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
set +e
|
|
||||||
exec 3>&-
|
|
||||||
stop_process_group "${bridge_pgid}"
|
|
||||||
stop_process_group "${px4_pgid}"
|
|
||||||
if [[ -n "${agent_pid}" ]] && kill -0 "${agent_pid}" 2>/dev/null; then
|
|
||||||
kill -TERM "${agent_pid}" 2>/dev/null
|
|
||||||
wait "${agent_pid}" 2>/dev/null
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
trap cleanup EXIT INT TERM
|
|
||||||
|
|
||||||
wait_for_pattern() {
|
|
||||||
local file="$1"
|
|
||||||
local pattern="$2"
|
|
||||||
local attempts="$3"
|
|
||||||
for ((attempt = 0; attempt < attempts; attempt += 1)); do
|
|
||||||
if grep -Fq "${pattern}" "${file}" 2>/dev/null; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
sleep 0.5
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
env \
|
|
||||||
LD_LIBRARY_PATH="${AGENT_PREFIX}/lib" \
|
|
||||||
"${AGENT_BIN}" udp4 -p 8888 -v 4 >"${AGENT_LOG}" 2>&1 &
|
|
||||||
agent_pid="$!"
|
|
||||||
sleep 1
|
|
||||||
if ! kill -0 "${agent_pid}" 2>/dev/null; then
|
|
||||||
echo "error: Micro XRCE-DDS Agent exited during startup" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
{
|
|
||||||
echo "network_namespace=loopback-only"
|
|
||||||
ip -brief address
|
|
||||||
ss -lunp
|
|
||||||
} >"${NETWORK_LOG}"
|
|
||||||
|
|
||||||
coproc PX4_PROCESS {
|
|
||||||
exec setsid env HEADLESS=1 PX4_SIM_SPEED_FACTOR="${SPEED_FACTOR}" make -C "${PX4_ROOT}" \
|
|
||||||
px4_sitl gz_rover_ackermann >"${PX4_LOG}" 2>&1
|
|
||||||
}
|
|
||||||
px4_pgid="${PX4_PROCESS_PID}"
|
|
||||||
exec 3>&"${PX4_PROCESS[1]}"
|
|
||||||
|
|
||||||
if ! wait_for_pattern "${PX4_LOG}" "Startup script returned successfully" 120; then
|
|
||||||
echo "error: PX4 stock rover did not reach startup completion" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
sleep 3
|
|
||||||
printf '%s\n' \
|
|
||||||
"ver all" \
|
|
||||||
"commander status" >&3
|
|
||||||
|
|
||||||
if ! wait_for_pattern "${AGENT_LOG}" "session established" 60 \
|
|
||||||
|| ! wait_for_pattern \
|
|
||||||
"${PX4_LOG}" \
|
|
||||||
"successfully created rt/fmu/out/vehicle_status_v1 data writer" \
|
|
||||||
60; then
|
|
||||||
echo "error: PX4 uXRCE-DDS client did not connect to the loopback agent" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
printf '%s\n' "uxrce_dds_client status" >&3
|
|
||||||
if ! wait_for_pattern "${PX4_LOG}" "Running, connected" 20; then
|
|
||||||
echo "error: PX4 did not report the established uXRCE-DDS session" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
{
|
|
||||||
echo
|
|
||||||
echo "runtime_listeners=loopback-only-netns"
|
|
||||||
ss -lunp
|
|
||||||
} >>"${NETWORK_LOG}"
|
|
||||||
gz service -l >"${GZ_SERVICES_LOG}"
|
|
||||||
gz topic -l >"${GZ_TOPICS_LOG}"
|
|
||||||
|
|
||||||
set +u
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
source /opt/ros/jazzy/setup.bash
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
source "${PX4_MSGS_SETUP}"
|
|
||||||
set -u
|
|
||||||
export ROS_DOMAIN_ID=0
|
|
||||||
export ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST
|
|
||||||
unset ROS_LOCALHOST_ONLY
|
|
||||||
|
|
||||||
setsid ros2 run ros_gz_bridge parameter_bridge \
|
|
||||||
'/clock@rosgraph_msgs/msg/Clock[gz.msgs.Clock' >"${BRIDGE_LOG}" 2>&1 &
|
|
||||||
bridge_pgid="$!"
|
|
||||||
|
|
||||||
timeout 20s ros2 topic list -t >"${TOPICS_LOG}"
|
|
||||||
vehicle_status_topic="/fmu/out/vehicle_status"
|
|
||||||
if grep -Fq "/fmu/out/vehicle_status_v1" "${TOPICS_LOG}"; then
|
|
||||||
vehicle_status_topic="/fmu/out/vehicle_status_v1"
|
|
||||||
fi
|
|
||||||
timeout 20s ros2 topic echo --once \
|
|
||||||
"${vehicle_status_topic}" px4_msgs/msg/VehicleStatus >"${VEHICLE_STATUS_LOG}"
|
|
||||||
timeout 20s ros2 topic echo --once \
|
|
||||||
/clock rosgraph_msgs/msg/Clock >"${CLOCK_LOG}"
|
|
||||||
|
|
||||||
python3 "${TIME_PROBE}" \
|
|
||||||
--profile "${PROFILE_PATH}" \
|
|
||||||
--speed-factor "${SPEED_FACTOR}" \
|
|
||||||
--time-output "${TIME_CONTROL_LOG}" \
|
|
||||||
--resource-output "${RESOURCE_BASELINE_LOG}" \
|
|
||||||
--storage-root "${D_ROOT}" \
|
|
||||||
--owned-pid "${agent_pid}" \
|
|
||||||
--owned-pgid "${px4_pgid}" \
|
|
||||||
--owned-pgid "${bridge_pgid}" >"${TIME_PROBE_STDOUT}"
|
|
||||||
|
|
||||||
{
|
|
||||||
echo "sample=non-acceptance-point-snapshot"
|
|
||||||
free -b
|
|
||||||
ps -g "${px4_pgid}" -o pid,ppid,pgid,rss,vsz,pcpu,comm,args
|
|
||||||
ps -p "${agent_pid}" -o pid,ppid,pgid,rss,vsz,pcpu,comm,args
|
|
||||||
ps -g "${bridge_pgid}" -o pid,ppid,pgid,rss,vsz,pcpu,comm,args
|
|
||||||
if command -v nvidia-smi >/dev/null; then
|
|
||||||
nvidia-smi \
|
|
||||||
--query-gpu=memory.used,utilization.gpu \
|
|
||||||
--format=csv,noheader,nounits
|
|
||||||
fi
|
|
||||||
} >"${RESOURCE_LOG}"
|
|
||||||
|
|
||||||
printf '%s\n' "shutdown" >&3
|
|
||||||
if ! wait_for_pattern "${PX4_LOG}" "Exiting NOW." 20; then
|
|
||||||
echo "error: PX4 did not acknowledge the shutdown command" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
cleanup
|
|
||||||
trap - EXIT INT TERM
|
|
||||||
|
|
||||||
px4_status="fail"
|
|
||||||
dds_status="fail"
|
|
||||||
vehicle_status="fail"
|
|
||||||
clock_status="fail"
|
|
||||||
time_control_status="fail"
|
|
||||||
resource_baseline_status="fail"
|
|
||||||
lifecycle_status="fail"
|
|
||||||
|
|
||||||
grep -Fq "world: rover, model: rover_ackermann_0" "${PX4_LOG}" && px4_status="pass"
|
|
||||||
if grep -Fq "session established" "${AGENT_LOG}" \
|
|
||||||
&& grep -Fq \
|
|
||||||
"successfully created rt/fmu/out/vehicle_status_v1 data writer" \
|
|
||||||
"${PX4_LOG}"; then
|
|
||||||
dds_status="pass"
|
|
||||||
fi
|
|
||||||
grep -Fq "arming_state:" "${VEHICLE_STATUS_LOG}" && vehicle_status="pass"
|
|
||||||
grep -Fq "clock:" "${CLOCK_LOG}" && clock_status="pass"
|
|
||||||
grep -Fq '"verdict": "pass"' "${TIME_CONTROL_LOG}" && time_control_status="pass"
|
|
||||||
grep -Fq '"verdict": "pass"' "${RESOURCE_BASELINE_LOG}" \
|
|
||||||
&& resource_baseline_status="pass"
|
|
||||||
if ! pgrep -f "${PX4_ROOT}/build/px4_sitl_default/bin/px4" >/dev/null \
|
|
||||||
&& ! pgrep -f "gz sim.*rover.sdf" >/dev/null \
|
|
||||||
&& ! pgrep -f "${AGENT_BIN}" >/dev/null; then
|
|
||||||
lifecycle_status="pass"
|
|
||||||
fi
|
|
||||||
|
|
||||||
{
|
|
||||||
echo "run_id=${RUN_ID}"
|
|
||||||
echo "network_scope=loopback-only-netns"
|
|
||||||
echo "actuator_authority=false"
|
|
||||||
echo "speed_factor=${SPEED_FACTOR}"
|
|
||||||
echo "px4_stock_rover=${px4_status}"
|
|
||||||
echo "uxrce_dds=${dds_status}"
|
|
||||||
echo "ros2_vehicle_status=${vehicle_status}"
|
|
||||||
echo "gazebo_clock=${clock_status}"
|
|
||||||
echo "pause_step_speed=${time_control_status}"
|
|
||||||
echo "resource_baseline=${resource_baseline_status}"
|
|
||||||
echo "clean_lifecycle=${lifecycle_status}"
|
|
||||||
} >"${RESULT_LOG}"
|
|
||||||
|
|
||||||
if [[ "${px4_status}" != "pass" \
|
|
||||||
|| "${dds_status}" != "pass" \
|
|
||||||
|| "${vehicle_status}" != "pass" \
|
|
||||||
|| "${clock_status}" != "pass" \
|
|
||||||
|| "${time_control_status}" != "pass" \
|
|
||||||
|| "${resource_baseline_status}" != "pass" \
|
|
||||||
|| "${lifecycle_status}" != "pass" ]]; then
|
|
||||||
echo "error: one or more S0 smoke checks failed; inspect ${RUN_DIR}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "SIM S0 smoke PASS: ${RUN_DIR}"
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
schema_version: missioncore.stock-rover-lifecycle/v1
|
|
||||||
profile_id: s1b-stock-rover-lifecycle-v1
|
|
||||||
speed_factor: 1
|
|
||||||
dwell_seconds: 5
|
|
||||||
|
|
||||||
authority:
|
|
||||||
simulation_or_shadow_only: true
|
|
||||||
actuator_authority: false
|
|
||||||
navigation_or_safety_accepted: false
|
|
||||||
direct_actuator_setpoints_allowed: false
|
|
||||||
|
|
||||||
agent_ready_log_patterns:
|
|
||||||
- running...
|
|
||||||
|
|
||||||
px4_ready_log_patterns:
|
|
||||||
- 'world: rover, model: rover_ackermann_0'
|
|
||||||
- Startup script returned successfully
|
|
||||||
- successfully created rt/fmu/out/vehicle_status_v1 data writer
|
|
||||||
|
|
@ -1,368 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import socket
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from k1link.artifacts import write_json_atomic
|
|
||||||
from k1link.simulation import (
|
|
||||||
AuthorityProfile,
|
|
||||||
LocalProcessWorkerAdapter,
|
|
||||||
PosixProcessSupervisor,
|
|
||||||
ProviderPin,
|
|
||||||
QualificationRun,
|
|
||||||
QualificationRunStore,
|
|
||||||
ReproducibilityTier,
|
|
||||||
RunKind,
|
|
||||||
RunState,
|
|
||||||
S0WorkerGuard,
|
|
||||||
SimulationApplicationService,
|
|
||||||
StockRoverTargetPaths,
|
|
||||||
load_stock_rover_lifecycle_profile,
|
|
||||||
stock_rover_process_environment,
|
|
||||||
stock_rover_process_specs,
|
|
||||||
)
|
|
||||||
from k1link.simulation.s0 import ComponentPin, S0Profile
|
|
||||||
from k1link.simulation.worker import SimulationWorldControl
|
|
||||||
|
|
||||||
EXPECTED_DISTRO = "MissionCore-Sim"
|
|
||||||
COMMIT_PATTERN = re.compile(r"^[a-f0-9]{40}$")
|
|
||||||
PROVIDER_IDS = (
|
|
||||||
"px4-autopilot",
|
|
||||||
"gazebo",
|
|
||||||
"px4-gazebo-models",
|
|
||||||
"micro-xrce-dds-agent",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _LifecycleOnlyWorldControl(SimulationWorldControl):
|
|
||||||
def pause(self, run_id: str) -> None:
|
|
||||||
raise RuntimeError(f"world pause is not admitted by S1B lifecycle run {run_id}")
|
|
||||||
|
|
||||||
def resume(self, run_id: str) -> None:
|
|
||||||
raise RuntimeError(f"world resume is not admitted by S1B lifecycle run {run_id}")
|
|
||||||
|
|
||||||
def step(self, run_id: str, step_count: int) -> int:
|
|
||||||
raise RuntimeError(f"world step is not admitted by S1B lifecycle run {run_id}:{step_count}")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Run the accepted PX4/Gazebo stock rover under Mission Core S1B ownership."
|
|
||||||
)
|
|
||||||
parser.add_argument("--run-id", required=True)
|
|
||||||
parser.add_argument("--mission-core-commit", required=True)
|
|
||||||
parser.add_argument("--lifecycle-profile", type=Path, required=True)
|
|
||||||
parser.add_argument(
|
|
||||||
"--s0-profile",
|
|
||||||
type=Path,
|
|
||||||
default=Path("/mnt/d/NDC_MISSIONCORE/simulation/source/qualification-profile.yaml"),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--d-root",
|
|
||||||
type=Path,
|
|
||||||
default=Path("/mnt/d/NDC_MISSIONCORE/simulation"),
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if not COMMIT_PATTERN.fullmatch(args.mission_core_commit):
|
|
||||||
parser.error("--mission-core-commit must be an exact 40-character Git revision")
|
|
||||||
if os.geteuid() == 0:
|
|
||||||
parser.error("the lifecycle harness must run as the unprivileged missioncore user")
|
|
||||||
if os.environ.get("WSL_DISTRO_NAME") != EXPECTED_DISTRO:
|
|
||||||
parser.error(f"the lifecycle harness requires WSL distribution {EXPECTED_DISTRO}")
|
|
||||||
|
|
||||||
paths = StockRoverTargetPaths.from_d_root(args.d_root)
|
|
||||||
evidence_root = paths.d_root / "artifacts/s1/lifecycle" / args.run_id
|
|
||||||
evidence_root.mkdir(mode=0o700, parents=True, exist_ok=False)
|
|
||||||
lifecycle_profile_path = args.lifecycle_profile.expanduser().resolve()
|
|
||||||
s0_profile_path = args.s0_profile.expanduser().resolve()
|
|
||||||
try:
|
|
||||||
lifecycle_profile = load_stock_rover_lifecycle_profile(lifecycle_profile_path)
|
|
||||||
guard = S0WorkerGuard(s0_profile_path)
|
|
||||||
_preflight(paths, guard.profile)
|
|
||||||
interface_names = _loopback_only_interfaces()
|
|
||||||
except Exception as exc:
|
|
||||||
write_json_atomic(
|
|
||||||
evidence_root / "result.json",
|
|
||||||
{
|
|
||||||
"schema_version": "missioncore.s1b-stock-rover-result/v1",
|
|
||||||
"verdict": "fail",
|
|
||||||
"phase": "preflight",
|
|
||||||
"detail": f"{type(exc).__name__}: {exc}",
|
|
||||||
"run_id": args.run_id,
|
|
||||||
"mission_core_commit": args.mission_core_commit,
|
|
||||||
"network_scope": "loopback-only-netns-required",
|
|
||||||
"actuator_authority": False,
|
|
||||||
"direct_actuator_setpoints_allowed": False,
|
|
||||||
"finished_at_utc": _utc_now(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
_write_digest_index(evidence_root)
|
|
||||||
print("verdict=fail")
|
|
||||||
print(f"evidence_root={evidence_root}")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
artifact_runs_root = paths.d_root / "artifacts/s1/runs"
|
|
||||||
runtime_runs_root = paths.d_root / "runtime/s1/runs"
|
|
||||||
write_json_atomic(
|
|
||||||
evidence_root / "network.json",
|
|
||||||
{
|
|
||||||
"schema_version": "missioncore.s1b-network-evidence/v1",
|
|
||||||
"run_id": args.run_id,
|
|
||||||
"namespace": "product-owned-unshare-net",
|
|
||||||
"interfaces": list(interface_names),
|
|
||||||
"loopback_only": interface_names == ("lo",),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
run = QualificationRun(
|
|
||||||
run_id=args.run_id,
|
|
||||||
episode_id=f"episode-{args.run_id}",
|
|
||||||
kind=RunKind.SIMULATION_CLOSED_LOOP,
|
|
||||||
state=RunState.ADMITTED,
|
|
||||||
scenario_generation="px4-v1.17.0-stock-rover-ackermann",
|
|
||||||
scenario_sha256=_sha256(paths.scenario_path),
|
|
||||||
profile_generation=lifecycle_profile.profile_id,
|
|
||||||
profile_sha256=_sha256(lifecycle_profile_path),
|
|
||||||
mission_core_commit=args.mission_core_commit,
|
|
||||||
providers=_provider_pins(guard.profile),
|
|
||||||
host_profile_id=guard.profile.profile_id,
|
|
||||||
host_profile_sha256=guard.profile_sha256,
|
|
||||||
seed=42,
|
|
||||||
reproducibility_tier=ReproducibilityTier.R1,
|
|
||||||
authority=AuthorityProfile(
|
|
||||||
generation=1,
|
|
||||||
command_ttl_max_ns=250_000_000,
|
|
||||||
heartbeat_timeout_monotonic_ns=500_000_000,
|
|
||||||
),
|
|
||||||
clock_domain="gazebo:/clock",
|
|
||||||
created_at_utc=_utc_now(),
|
|
||||||
)
|
|
||||||
store = QualificationRunStore(artifact_runs_root)
|
|
||||||
store.create(run)
|
|
||||||
supervisor = PosixProcessSupervisor(
|
|
||||||
runtime_runs_root,
|
|
||||||
base_environment=stock_rover_process_environment(paths),
|
|
||||||
)
|
|
||||||
adapter = LocalProcessWorkerAdapter(
|
|
||||||
guard,
|
|
||||||
supervisor,
|
|
||||||
stock_rover_process_specs(paths, lifecycle_profile),
|
|
||||||
_LifecycleOnlyWorldControl(),
|
|
||||||
)
|
|
||||||
service = SimulationApplicationService(store, adapter)
|
|
||||||
|
|
||||||
verdict = "fail"
|
|
||||||
detail = "lifecycle did not start"
|
|
||||||
observed_provider_ids: tuple[str, ...] = ()
|
|
||||||
try:
|
|
||||||
running = service.start(
|
|
||||||
run.run_id,
|
|
||||||
idempotency_key=f"{run.run_id}:start",
|
|
||||||
observed_at_utc=_utc_now(),
|
|
||||||
host_monotonic_ns=time.monotonic_ns(),
|
|
||||||
)
|
|
||||||
if running.state is not RunState.RUNNING:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Mission Core did not admit provider readiness: {running.state.value}"
|
|
||||||
)
|
|
||||||
observed_provider_ids = tuple(record.process_id for record in supervisor.snapshot())
|
|
||||||
time.sleep(lifecycle_profile.dwell_seconds)
|
|
||||||
supervisor.snapshot()
|
|
||||||
terminal = service.stop(
|
|
||||||
run.run_id,
|
|
||||||
idempotency_key=f"{run.run_id}:stop",
|
|
||||||
observed_at_utc=_utc_now(),
|
|
||||||
host_monotonic_ns=time.monotonic_ns(),
|
|
||||||
sim_time_ns=None,
|
|
||||||
)
|
|
||||||
if terminal.state is not RunState.COMPLETED:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Mission Core provider shutdown was not clean: {terminal.state.value}"
|
|
||||||
)
|
|
||||||
residue = _provider_residue()
|
|
||||||
if supervisor.residue() or residue:
|
|
||||||
raise RuntimeError("provider process residue remained after Mission Core stop")
|
|
||||||
if store.list_commands(run.run_id):
|
|
||||||
raise RuntimeError("S1B lifecycle unexpectedly contains control commands")
|
|
||||||
verdict = "pass"
|
|
||||||
detail = "real stock-rover providers reached declared readiness and stopped cleanly"
|
|
||||||
except BaseException as exc:
|
|
||||||
detail = f"{type(exc).__name__}: {exc}"
|
|
||||||
_abort_active_run(service, store, run.run_id)
|
|
||||||
supervisor.stop()
|
|
||||||
finally:
|
|
||||||
terminal_run = store.load(run.run_id)
|
|
||||||
residue = _provider_residue()
|
|
||||||
_capture_directory(
|
|
||||||
artifact_runs_root / run.run_id,
|
|
||||||
evidence_root / "qualification-run",
|
|
||||||
)
|
|
||||||
runtime_run_root = runtime_runs_root / run.run_id
|
|
||||||
if runtime_run_root.is_dir():
|
|
||||||
_capture_directory(runtime_run_root, evidence_root / "provider-runtime")
|
|
||||||
write_json_atomic(
|
|
||||||
evidence_root / "result.json",
|
|
||||||
{
|
|
||||||
"schema_version": "missioncore.s1b-stock-rover-result/v1",
|
|
||||||
"verdict": verdict,
|
|
||||||
"detail": detail,
|
|
||||||
"run_id": run.run_id,
|
|
||||||
"run_state": terminal_run.state.value,
|
|
||||||
"terminal_reason": terminal_run.terminal_reason,
|
|
||||||
"mission_core_commit": run.mission_core_commit,
|
|
||||||
"host_profile_id": run.host_profile_id,
|
|
||||||
"host_profile_sha256": run.host_profile_sha256,
|
|
||||||
"lifecycle_profile_id": lifecycle_profile.profile_id,
|
|
||||||
"lifecycle_profile_sha256": run.profile_sha256,
|
|
||||||
"scenario_sha256": run.scenario_sha256,
|
|
||||||
"provider_ids": list(observed_provider_ids),
|
|
||||||
"event_count": len(store.list_events(run.run_id)),
|
|
||||||
"command_count": len(store.list_commands(run.run_id)),
|
|
||||||
"process_residue": list(residue),
|
|
||||||
"network_interfaces": list(interface_names),
|
|
||||||
"network_scope": "loopback-only-netns",
|
|
||||||
"actuator_authority": False,
|
|
||||||
"direct_actuator_setpoints_allowed": False,
|
|
||||||
"finished_at_utc": _utc_now(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
_write_digest_index(evidence_root)
|
|
||||||
|
|
||||||
print(f"verdict={verdict}")
|
|
||||||
print(f"evidence_root={evidence_root}")
|
|
||||||
return 0 if verdict == "pass" else 1
|
|
||||||
|
|
||||||
|
|
||||||
def _preflight(paths: StockRoverTargetPaths, profile: S0Profile) -> None:
|
|
||||||
required = (
|
|
||||||
paths.px4_root,
|
|
||||||
paths.agent_binary,
|
|
||||||
paths.scenario_path,
|
|
||||||
)
|
|
||||||
missing = tuple(str(path) for path in required if not path.exists())
|
|
||||||
if missing:
|
|
||||||
raise RuntimeError(f"S1B target input is missing: {', '.join(missing)}")
|
|
||||||
free_bytes = shutil.disk_usage(paths.d_root).free
|
|
||||||
stop_floor_bytes = profile.storage.stop_below_gib * 1024**3
|
|
||||||
if free_bytes < stop_floor_bytes:
|
|
||||||
raise RuntimeError("D free space is below the accepted S0 stop floor")
|
|
||||||
if not all(component.qualification_state == "accepted" for component in profile.components):
|
|
||||||
raise RuntimeError("an S0 provider generation is no longer accepted")
|
|
||||||
|
|
||||||
|
|
||||||
def _loopback_only_interfaces() -> tuple[str, ...]:
|
|
||||||
interfaces = tuple(sorted(name for _, name in socket.if_nameindex()))
|
|
||||||
if interfaces != ("lo",):
|
|
||||||
raise RuntimeError(f"S1B must run in a loopback-only namespace; observed {interfaces}")
|
|
||||||
return interfaces
|
|
||||||
|
|
||||||
|
|
||||||
def _provider_pins(profile: S0Profile) -> tuple[ProviderPin, ...]:
|
|
||||||
components = {component.identifier: component for component in profile.components}
|
|
||||||
missing = tuple(identifier for identifier in PROVIDER_IDS if identifier not in components)
|
|
||||||
if missing:
|
|
||||||
raise RuntimeError(f"S0 profile is missing provider pins: {', '.join(missing)}")
|
|
||||||
return tuple(_provider_pin(components[identifier]) for identifier in PROVIDER_IDS)
|
|
||||||
|
|
||||||
|
|
||||||
def _provider_pin(component: ComponentPin) -> ProviderPin:
|
|
||||||
version = component.resolved_version or component.requested_ref
|
|
||||||
revision = component.resolved_commit or version
|
|
||||||
return ProviderPin(
|
|
||||||
identifier=component.identifier,
|
|
||||||
version=version,
|
|
||||||
revision=revision,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _abort_active_run(
|
|
||||||
service: SimulationApplicationService,
|
|
||||||
store: QualificationRunStore,
|
|
||||||
run_id: str,
|
|
||||||
) -> None:
|
|
||||||
current = store.load(run_id)
|
|
||||||
if current.state not in {RunState.RUNNING, RunState.PAUSED, RunState.STOPPING}:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
service.stop(
|
|
||||||
run_id,
|
|
||||||
idempotency_key=f"{run_id}:harness-abort",
|
|
||||||
observed_at_utc=_utc_now(),
|
|
||||||
host_monotonic_ns=time.monotonic_ns(),
|
|
||||||
sim_time_ns=None,
|
|
||||||
terminal_state=RunState.ABORTED,
|
|
||||||
reason="target-harness-failure",
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def _provider_residue() -> tuple[str, ...]:
|
|
||||||
tokens = (
|
|
||||||
"MicroXRCEAgent",
|
|
||||||
"/build/px4_sitl_default/bin/px4",
|
|
||||||
"gz sim",
|
|
||||||
"rover.sdf",
|
|
||||||
)
|
|
||||||
residue: list[str] = []
|
|
||||||
for process_dir in Path("/proc").iterdir():
|
|
||||||
if not process_dir.name.isdigit() or int(process_dir.name) == os.getpid():
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
command = (process_dir / "cmdline").read_bytes().replace(b"\x00", b" ").decode()
|
|
||||||
except (FileNotFoundError, PermissionError, UnicodeDecodeError):
|
|
||||||
continue
|
|
||||||
if command and any(token in command for token in tokens):
|
|
||||||
residue.append(f"{process_dir.name}:{command.strip()}")
|
|
||||||
return tuple(sorted(residue))
|
|
||||||
|
|
||||||
|
|
||||||
def _capture_directory(source: Path, destination: Path) -> None:
|
|
||||||
if destination.exists():
|
|
||||||
raise RuntimeError(f"evidence destination already exists: {destination}")
|
|
||||||
shutil.copytree(source, destination)
|
|
||||||
|
|
||||||
|
|
||||||
def _write_digest_index(evidence_root: Path) -> None:
|
|
||||||
records = []
|
|
||||||
for path in sorted(evidence_root.rglob("*")):
|
|
||||||
if path.is_file() and path.name != "sha256-index.json":
|
|
||||||
records.append(
|
|
||||||
{
|
|
||||||
"path": path.relative_to(evidence_root).as_posix(),
|
|
||||||
"sha256": _sha256(path),
|
|
||||||
"bytes": path.stat().st_size,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
write_json_atomic(
|
|
||||||
evidence_root / "sha256-index.json",
|
|
||||||
{
|
|
||||||
"schema_version": "missioncore.evidence-digest-index/v1",
|
|
||||||
"files": records,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _sha256(path: Path) -> str:
|
|
||||||
digest = hashlib.sha256()
|
|
||||||
with path.open("rb") as stream:
|
|
||||||
while chunk := stream.read(1024 * 1024):
|
|
||||||
digest.update(chunk)
|
|
||||||
return digest.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _utc_now() -> str:
|
|
||||||
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Run the persistent Simulation Worker agent in a fresh loopback-only network
|
|
||||||
# namespace. The Unix socket remains reachable from Mission Core because the
|
|
||||||
# worker shares the host mount namespace while PX4/Gazebo have no routed NIC.
|
|
||||||
|
|
||||||
set -Eeuo pipefail
|
|
||||||
|
|
||||||
readonly EXPECTED_DISTRO="MissionCore-Sim"
|
|
||||||
readonly D_ROOT="/mnt/d/NDC_MISSIONCORE/simulation"
|
|
||||||
SOURCE_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
|
||||||
readonly SOURCE_ROOT
|
|
||||||
readonly MISSION_CORE_COMMIT="${1:-}"
|
|
||||||
readonly SOCKET_ROOT="/run/missioncore-sim"
|
|
||||||
readonly SOCKET_PATH="${SOCKET_ROOT}/worker.sock"
|
|
||||||
readonly ROS_SETUP="/opt/ros/jazzy/setup.bash"
|
|
||||||
readonly PX4_MSGS_SETUP="${D_ROOT}/build/ros2/px4_msgs/install/setup.bash"
|
|
||||||
|
|
||||||
if [[ -z "${MISSION_CORE_COMMIT}" ]]; then
|
|
||||||
echo "usage: $0 <exact-40-character-mission-core-commit>" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
if [[ "${SOURCE_ROOT}" != "${D_ROOT}/source/"* ]]; then
|
|
||||||
echo "error: worker source generation must be staged below the reviewed D root" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
if ! [[ "${MISSION_CORE_COMMIT}" =~ ^[a-f0-9]{40}$ ]]; then
|
|
||||||
echo "error: Mission Core commit must contain exactly 40 lowercase hex characters" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${MISSIONCORE_S1_NETNS:-0}" != "1" ]]; then
|
|
||||||
if [[ "${EUID}" -ne 0 ]]; then
|
|
||||||
echo "error: initial worker invocation must run as root to create the namespace" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
if [[ "${WSL_DISTRO_NAME:-}" != "${EXPECTED_DISTRO}" ]]; then
|
|
||||||
echo "error: expected WSL distro ${EXPECTED_DISTRO}" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
install -d -m 0700 -o missioncore -g missioncore -- "${SOCKET_ROOT}"
|
|
||||||
if [[ -e "${SOCKET_PATH}" && ! -S "${SOCKET_PATH}" ]]; then
|
|
||||||
echo "error: worker socket path is occupied by a non-socket" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
exec unshare --net -- bash -c '
|
|
||||||
set -e
|
|
||||||
ip link set lo up
|
|
||||||
exec runuser -u missioncore -- env MISSIONCORE_S1_NETNS=1 "$@"
|
|
||||||
' bash "$0" "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${EUID}" -eq 0 || "${WSL_DISTRO_NAME:-}" != "${EXPECTED_DISTRO}" ]]; then
|
|
||||||
echo "error: worker agent escaped the reviewed worker identity" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
if [[ ! -f "${ROS_SETUP}" || ! -f "${PX4_MSGS_SETUP}" ]]; then
|
|
||||||
echo "error: reviewed ROS 2 Jazzy/px4_msgs runtime is incomplete" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
set +u
|
|
||||||
# shellcheck disable=SC1090
|
|
||||||
source "${ROS_SETUP}"
|
|
||||||
# shellcheck disable=SC1090
|
|
||||||
source "${PX4_MSGS_SETUP}"
|
|
||||||
set -u
|
|
||||||
|
|
||||||
exec env \
|
|
||||||
ROS_DOMAIN_ID=0 \
|
|
||||||
PYTHONPATH="${SOURCE_ROOT}/src:${PYTHONPATH:-}" \
|
|
||||||
python3 -m k1link.simulation.worker_agent \
|
|
||||||
--socket "${SOCKET_PATH}" \
|
|
||||||
--mission-core-commit "${MISSION_CORE_COMMIT}" \
|
|
||||||
--lifecycle-profile "${SOURCE_ROOT}/simulation/s1/stock-rover-lifecycle.yaml"
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Enter a fresh loopback-only namespace and execute the S1B stock-rover
|
|
||||||
# lifecycle as the unprivileged missioncore worker identity.
|
|
||||||
|
|
||||||
set -Eeuo pipefail
|
|
||||||
|
|
||||||
readonly EXPECTED_DISTRO="MissionCore-Sim"
|
|
||||||
readonly D_ROOT="/mnt/d/NDC_MISSIONCORE/simulation"
|
|
||||||
readonly SOURCE_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
|
||||||
readonly RUN_ID="${1:-}"
|
|
||||||
readonly MISSION_CORE_COMMIT="${2:-}"
|
|
||||||
|
|
||||||
if [[ -z "${RUN_ID}" || -z "${MISSION_CORE_COMMIT}" ]]; then
|
|
||||||
echo "usage: $0 <run-id> <exact-40-character-mission-core-commit>" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
if [[ "${SOURCE_ROOT}" != "${D_ROOT}/source/"* ]]; then
|
|
||||||
echo "error: S1B source generation must be staged below the reviewed D root" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
if ! [[ "${MISSION_CORE_COMMIT}" =~ ^[a-f0-9]{40}$ ]]; then
|
|
||||||
echo "error: Mission Core commit must contain exactly 40 lowercase hex characters" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${MISSIONCORE_S1_NETNS:-0}" != "1" ]]; then
|
|
||||||
if [[ "${EUID}" -ne 0 ]]; then
|
|
||||||
echo "error: initial S1B invocation must run as root to create the network namespace" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
if [[ "${WSL_DISTRO_NAME:-}" != "${EXPECTED_DISTRO}" ]]; then
|
|
||||||
echo "error: expected WSL distro ${EXPECTED_DISTRO}" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
exec unshare --net -- bash -c '
|
|
||||||
set -e
|
|
||||||
ip link set lo up
|
|
||||||
exec runuser -u missioncore -- env MISSIONCORE_S1_NETNS=1 "$@"
|
|
||||||
' bash "$0" "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${EUID}" -eq 0 || "${WSL_DISTRO_NAME:-}" != "${EXPECTED_DISTRO}" ]]; then
|
|
||||||
echo "error: S1B inner lifecycle escaped the reviewed worker identity" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec env \
|
|
||||||
PYTHONPATH="${SOURCE_ROOT}/src" \
|
|
||||||
python3 "${SOURCE_ROOT}/simulation/s1/stock_rover_lifecycle.py" \
|
|
||||||
--run-id "${RUN_ID}" \
|
|
||||||
--mission-core-commit "${MISSION_CORE_COMMIT}" \
|
|
||||||
--lifecycle-profile "${SOURCE_ROOT}/simulation/s1/stock-rover-lifecycle.yaml"
|
|
||||||
|
|
@ -48,26 +48,6 @@ from .lab_instances import (
|
||||||
publish_e26_lab_instance,
|
publish_e26_lab_instance,
|
||||||
publish_integrated_lab_instance,
|
publish_integrated_lab_instance,
|
||||||
)
|
)
|
||||||
from .lidar_contract import (
|
|
||||||
K1_LAB_LIDAR_PACK_V1_PROFILE,
|
|
||||||
K1_LIVE_LIDAR_PROFILE,
|
|
||||||
LIDAR_EVIDENCE_PROFILE_SCHEMA,
|
|
||||||
LIDAR_READINESS_SCHEMA,
|
|
||||||
LidarContractError,
|
|
||||||
LidarCoordinateSpace,
|
|
||||||
LidarEvidenceProfile,
|
|
||||||
LidarPipelineStage,
|
|
||||||
LidarPointField,
|
|
||||||
LidarPoseStatus,
|
|
||||||
LidarQualityMonitor,
|
|
||||||
LidarReadiness,
|
|
||||||
LidarRepresentation,
|
|
||||||
LidarStageAssessment,
|
|
||||||
LidarTimeBasis,
|
|
||||||
assess_lidar_profile,
|
|
||||||
lidar_readiness_document,
|
|
||||||
sensor_frame_xyzi,
|
|
||||||
)
|
|
||||||
from .live_perception import (
|
from .live_perception import (
|
||||||
LIVE_INGRESS_SCHEMA,
|
LIVE_INGRESS_SCHEMA,
|
||||||
LIVE_INGRESS_WIRE_SCHEMA,
|
LIVE_INGRESS_WIRE_SCHEMA,
|
||||||
|
|
@ -138,23 +118,10 @@ __all__ = [
|
||||||
"EvaluationFrameRequest",
|
"EvaluationFrameRequest",
|
||||||
"EvaluationPackFrame",
|
"EvaluationPackFrame",
|
||||||
"LatestWinsQueue",
|
"LatestWinsQueue",
|
||||||
"LIDAR_EVIDENCE_PROFILE_SCHEMA",
|
|
||||||
"LIDAR_READINESS_SCHEMA",
|
|
||||||
"LIVE_INGRESS_SCHEMA",
|
"LIVE_INGRESS_SCHEMA",
|
||||||
"LIVE_INGRESS_WIRE_SCHEMA",
|
"LIVE_INGRESS_WIRE_SCHEMA",
|
||||||
"LiveIngressEvent",
|
"LiveIngressEvent",
|
||||||
"LivePerceptionIngress",
|
"LivePerceptionIngress",
|
||||||
"LidarContractError",
|
|
||||||
"LidarCoordinateSpace",
|
|
||||||
"LidarEvidenceProfile",
|
|
||||||
"LidarPipelineStage",
|
|
||||||
"LidarPointField",
|
|
||||||
"LidarPoseStatus",
|
|
||||||
"LidarQualityMonitor",
|
|
||||||
"LidarReadiness",
|
|
||||||
"LidarRepresentation",
|
|
||||||
"LidarStageAssessment",
|
|
||||||
"LidarTimeBasis",
|
|
||||||
"IntegratedPerceptionOverlayStore",
|
"IntegratedPerceptionOverlayStore",
|
||||||
"PublishedIntegratedLabInstance",
|
"PublishedIntegratedLabInstance",
|
||||||
"PublishedCameraEgoMotionLabInstance",
|
"PublishedCameraEgoMotionLabInstance",
|
||||||
|
|
@ -166,8 +133,6 @@ __all__ = [
|
||||||
"MultiratePerceptionArtifact",
|
"MultiratePerceptionArtifact",
|
||||||
"MultiratePerceptionQualificationResult",
|
"MultiratePerceptionQualificationResult",
|
||||||
"QUALIFICATION_POLICY",
|
"QUALIFICATION_POLICY",
|
||||||
"K1_LAB_LIDAR_PACK_V1_PROFILE",
|
|
||||||
"K1_LIVE_LIDAR_PROFILE",
|
|
||||||
"QueueSnapshot",
|
"QueueSnapshot",
|
||||||
"RecordedCalibratedFusion",
|
"RecordedCalibratedFusion",
|
||||||
"RecordedCalibratedFusionStore",
|
"RecordedCalibratedFusionStore",
|
||||||
|
|
@ -200,7 +165,6 @@ __all__ = [
|
||||||
"publish_integrated_lab_instance",
|
"publish_integrated_lab_instance",
|
||||||
"validate_multirate_perception_qualification_result",
|
"validate_multirate_perception_qualification_result",
|
||||||
"prepare_recorded_qualification_slice",
|
"prepare_recorded_qualification_slice",
|
||||||
"assess_lidar_profile",
|
|
||||||
"DetectionFrame",
|
"DetectionFrame",
|
||||||
"ObjectDetection",
|
"ObjectDetection",
|
||||||
"RecordedPerceptionOverlayError",
|
"RecordedPerceptionOverlayError",
|
||||||
|
|
@ -220,6 +184,4 @@ __all__ = [
|
||||||
"validate_tracking_qualification_result",
|
"validate_tracking_qualification_result",
|
||||||
"validate_tracked_fusion_qualification_result",
|
"validate_tracked_fusion_qualification_result",
|
||||||
"validate_annotation_workspace",
|
"validate_annotation_workspace",
|
||||||
"lidar_readiness_document",
|
|
||||||
"sensor_frame_xyzi",
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,623 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import threading
|
|
||||||
from collections import deque
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import StrEnum
|
|
||||||
from typing import Any, Final
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import numpy.typing as npt
|
|
||||||
|
|
||||||
from k1link.data_plane import DecodedPointCloudView, DecodedPoseView
|
|
||||||
from k1link.device_plugins.xgrids_k1.analyze.calibrated_projection import (
|
|
||||||
CalibratedProjectionError,
|
|
||||||
map_points_to_lidar,
|
|
||||||
)
|
|
||||||
|
|
||||||
LIDAR_EVIDENCE_PROFILE_SCHEMA: Final = "missioncore.lidar-evidence-profile/v1"
|
|
||||||
LIDAR_READINESS_SCHEMA: Final = "missioncore.lidar-readiness/v1"
|
|
||||||
_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$")
|
|
||||||
|
|
||||||
Float32Array = npt.NDArray[np.float32]
|
|
||||||
|
|
||||||
|
|
||||||
class LidarContractError(ValueError):
|
|
||||||
"""A LiDAR evidence profile or conversion violates the admitted contract."""
|
|
||||||
|
|
||||||
|
|
||||||
class LidarRepresentation(StrEnum):
|
|
||||||
SENSOR_SCAN = "sensor-scan"
|
|
||||||
VENDOR_MAP_INCREMENT = "vendor-map-increment"
|
|
||||||
ACCUMULATED_MAP = "accumulated-map"
|
|
||||||
|
|
||||||
|
|
||||||
class LidarCoordinateSpace(StrEnum):
|
|
||||||
SENSOR = "sensor"
|
|
||||||
MAP = "map"
|
|
||||||
|
|
||||||
|
|
||||||
class LidarTimeBasis(StrEnum):
|
|
||||||
SENSOR = "sensor"
|
|
||||||
HOST_ARRIVAL = "host-arrival"
|
|
||||||
CAMERA_BOUND_HOST_ARRIVAL = "camera-bound-host-arrival"
|
|
||||||
|
|
||||||
|
|
||||||
class LidarPointField(StrEnum):
|
|
||||||
XYZ = "xyz"
|
|
||||||
INTENSITY = "intensity"
|
|
||||||
RING = "ring"
|
|
||||||
RELATIVE_TIME = "relative-time"
|
|
||||||
|
|
||||||
|
|
||||||
class LidarPoseStatus(StrEnum):
|
|
||||||
NONE = "none"
|
|
||||||
BEST_EFFORT = "best-effort"
|
|
||||||
SENSOR_SYNCHRONIZED = "sensor-synchronized"
|
|
||||||
|
|
||||||
|
|
||||||
class LidarPipelineStage(StrEnum):
|
|
||||||
SCANNER_QUALITY = "scanner-quality"
|
|
||||||
GROUND_SEGMENTATION = "ground-segmentation"
|
|
||||||
LIDAR_3D_DETECTION = "lidar-3d-detection"
|
|
||||||
NVIDIA_NVBLOX = "nvidia-nvblox"
|
|
||||||
LIDAR_ODOMETRY = "lidar-odometry"
|
|
||||||
LIDAR_INERTIAL_SLAM = "lidar-inertial-slam"
|
|
||||||
CAMERA_LIDAR_FUSION = "camera-lidar-fusion"
|
|
||||||
|
|
||||||
|
|
||||||
class LidarReadiness(StrEnum):
|
|
||||||
READY = "ready"
|
|
||||||
DEGRADED = "degraded"
|
|
||||||
BLOCKED = "blocked"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class LidarEvidenceProfile:
|
|
||||||
"""Describe what one LiDAR source actually provides before model selection."""
|
|
||||||
|
|
||||||
profile_id: str
|
|
||||||
representation: LidarRepresentation
|
|
||||||
coordinate_space: LidarCoordinateSpace
|
|
||||||
coordinate_frame: str
|
|
||||||
frame_time_basis: LidarTimeBasis
|
|
||||||
point_fields: tuple[LidarPointField, ...]
|
|
||||||
pose_status: LidarPoseStatus
|
|
||||||
scan_geometry_known: bool
|
|
||||||
imu_samples_available: bool
|
|
||||||
lidar_imu_extrinsic_available: bool
|
|
||||||
camera_extrinsic_available: bool
|
|
||||||
commands_enabled: bool = False
|
|
||||||
navigation_or_safety_accepted: bool = False
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_safe_identifier(self.profile_id, "LiDAR profile id")
|
|
||||||
_safe_identifier(self.coordinate_frame, "LiDAR coordinate frame")
|
|
||||||
if not self.point_fields or LidarPointField.XYZ not in self.point_fields:
|
|
||||||
raise LidarContractError("LiDAR evidence must contain xyz")
|
|
||||||
if len(self.point_fields) != len(set(self.point_fields)):
|
|
||||||
raise LidarContractError("LiDAR point fields must be unique")
|
|
||||||
if (
|
|
||||||
self.coordinate_space is LidarCoordinateSpace.MAP
|
|
||||||
and self.pose_status is LidarPoseStatus.NONE
|
|
||||||
):
|
|
||||||
raise LidarContractError("map-frame LiDAR evidence requires a sensor pose")
|
|
||||||
if self.lidar_imu_extrinsic_available and not self.imu_samples_available:
|
|
||||||
raise LidarContractError("LiDAR/IMU extrinsic has no admitted IMU samples")
|
|
||||||
if self.commands_enabled or self.navigation_or_safety_accepted:
|
|
||||||
raise LidarContractError("v1 LiDAR evidence is diagnostic-only")
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"schema_version": LIDAR_EVIDENCE_PROFILE_SCHEMA,
|
|
||||||
"profile_id": self.profile_id,
|
|
||||||
"representation": self.representation.value,
|
|
||||||
"coordinates": {
|
|
||||||
"space": self.coordinate_space.value,
|
|
||||||
"frame": self.coordinate_frame,
|
|
||||||
},
|
|
||||||
"time": {
|
|
||||||
"frame_basis": self.frame_time_basis.value,
|
|
||||||
"point_relative_time": LidarPointField.RELATIVE_TIME in self.point_fields,
|
|
||||||
},
|
|
||||||
"point_fields": [field.value for field in self.point_fields],
|
|
||||||
"pose_status": self.pose_status.value,
|
|
||||||
"scan_geometry_known": self.scan_geometry_known,
|
|
||||||
"imu": {
|
|
||||||
"samples_available": self.imu_samples_available,
|
|
||||||
"lidar_extrinsic_available": self.lidar_imu_extrinsic_available,
|
|
||||||
},
|
|
||||||
"camera_extrinsic_available": self.camera_extrinsic_available,
|
|
||||||
"authority": {
|
|
||||||
"commands_enabled": self.commands_enabled,
|
|
||||||
"navigation_or_safety_accepted": self.navigation_or_safety_accepted,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, value: object) -> LidarEvidenceProfile:
|
|
||||||
document = _object(value, "LiDAR evidence profile")
|
|
||||||
_exact_keys(
|
|
||||||
document,
|
|
||||||
{
|
|
||||||
"schema_version",
|
|
||||||
"profile_id",
|
|
||||||
"representation",
|
|
||||||
"coordinates",
|
|
||||||
"time",
|
|
||||||
"point_fields",
|
|
||||||
"pose_status",
|
|
||||||
"scan_geometry_known",
|
|
||||||
"imu",
|
|
||||||
"camera_extrinsic_available",
|
|
||||||
"authority",
|
|
||||||
},
|
|
||||||
"LiDAR evidence profile",
|
|
||||||
)
|
|
||||||
if document.get("schema_version") != LIDAR_EVIDENCE_PROFILE_SCHEMA:
|
|
||||||
raise LidarContractError("LiDAR evidence profile schema is incompatible")
|
|
||||||
coordinates = _object(document.get("coordinates"), "LiDAR coordinates")
|
|
||||||
time = _object(document.get("time"), "LiDAR time")
|
|
||||||
imu = _object(document.get("imu"), "LiDAR IMU evidence")
|
|
||||||
authority = _object(document.get("authority"), "LiDAR authority")
|
|
||||||
_exact_keys(coordinates, {"space", "frame"}, "LiDAR coordinates")
|
|
||||||
_exact_keys(time, {"frame_basis", "point_relative_time"}, "LiDAR time")
|
|
||||||
_exact_keys(
|
|
||||||
imu,
|
|
||||||
{"samples_available", "lidar_extrinsic_available"},
|
|
||||||
"LiDAR IMU evidence",
|
|
||||||
)
|
|
||||||
_exact_keys(
|
|
||||||
authority,
|
|
||||||
{"commands_enabled", "navigation_or_safety_accepted"},
|
|
||||||
"LiDAR authority",
|
|
||||||
)
|
|
||||||
point_fields = _array(document, "point_fields")
|
|
||||||
try:
|
|
||||||
parsed_fields = tuple(
|
|
||||||
LidarPointField(_string_value(field, "LiDAR point field"))
|
|
||||||
for field in point_fields
|
|
||||||
)
|
|
||||||
profile = cls(
|
|
||||||
profile_id=_string(document, "profile_id"),
|
|
||||||
representation=LidarRepresentation(
|
|
||||||
_string(document, "representation")
|
|
||||||
),
|
|
||||||
coordinate_space=LidarCoordinateSpace(_string(coordinates, "space")),
|
|
||||||
coordinate_frame=_string(coordinates, "frame"),
|
|
||||||
frame_time_basis=LidarTimeBasis(_string(time, "frame_basis")),
|
|
||||||
point_fields=parsed_fields,
|
|
||||||
pose_status=LidarPoseStatus(_string(document, "pose_status")),
|
|
||||||
scan_geometry_known=_bool(document, "scan_geometry_known"),
|
|
||||||
imu_samples_available=_bool(imu, "samples_available"),
|
|
||||||
lidar_imu_extrinsic_available=_bool(imu, "lidar_extrinsic_available"),
|
|
||||||
camera_extrinsic_available=_bool(
|
|
||||||
document,
|
|
||||||
"camera_extrinsic_available",
|
|
||||||
),
|
|
||||||
commands_enabled=_bool(authority, "commands_enabled"),
|
|
||||||
navigation_or_safety_accepted=_bool(
|
|
||||||
authority,
|
|
||||||
"navigation_or_safety_accepted",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise LidarContractError("LiDAR evidence profile enum is unknown") from exc
|
|
||||||
if (
|
|
||||||
time.get("point_relative_time")
|
|
||||||
is not (LidarPointField.RELATIVE_TIME in parsed_fields)
|
|
||||||
):
|
|
||||||
raise LidarContractError("LiDAR point-time declarations disagree")
|
|
||||||
return profile
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class LidarStageAssessment:
|
|
||||||
stage: LidarPipelineStage
|
|
||||||
readiness: LidarReadiness
|
|
||||||
reasons: tuple[str, ...]
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"stage": self.stage.value,
|
|
||||||
"readiness": self.readiness.value,
|
|
||||||
"reasons": list(self.reasons),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class LidarQualityMonitor:
|
|
||||||
"""Bounded scanner telemetry over decoded point frames.
|
|
||||||
|
|
||||||
The monitor reports observed distributions and field coverage. It does not
|
|
||||||
turn those measurements into navigation or safety acceptance.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
profile: LidarEvidenceProfile,
|
|
||||||
*,
|
|
||||||
frame_sample_capacity: int = 512,
|
|
||||||
point_sample_capacity: int = 32_768,
|
|
||||||
points_sampled_per_frame: int = 256,
|
|
||||||
) -> None:
|
|
||||||
if (
|
|
||||||
not 2 <= frame_sample_capacity <= 16_384
|
|
||||||
or not 256 <= point_sample_capacity <= 1_048_576
|
|
||||||
or not 1 <= points_sampled_per_frame <= 4096
|
|
||||||
):
|
|
||||||
raise LidarContractError("LiDAR quality monitor bounds are invalid")
|
|
||||||
self.profile = profile
|
|
||||||
self._frame_points: deque[int] = deque(maxlen=frame_sample_capacity)
|
|
||||||
self._frame_intervals_ms: deque[float] = deque(maxlen=frame_sample_capacity)
|
|
||||||
self._range_samples_m: deque[float] = deque(maxlen=point_sample_capacity)
|
|
||||||
self._intensity_samples: deque[float] = deque(maxlen=point_sample_capacity)
|
|
||||||
self._points_sampled_per_frame = points_sampled_per_frame
|
|
||||||
self._frames = 0
|
|
||||||
self._points = 0
|
|
||||||
self._intensity_frames = 0
|
|
||||||
self._range_frames = 0
|
|
||||||
self._range_unavailable_frames = 0
|
|
||||||
self._nonincreasing_frame_times = 0
|
|
||||||
self._last_frame_time_ns: int | None = None
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
|
|
||||||
def observe(
|
|
||||||
self,
|
|
||||||
point_cloud: DecodedPointCloudView,
|
|
||||||
*,
|
|
||||||
pose: DecodedPoseView | None = None,
|
|
||||||
) -> None:
|
|
||||||
if point_cloud.frame_id != self.profile.coordinate_frame:
|
|
||||||
raise LidarContractError("LiDAR quality frame differs from its evidence profile")
|
|
||||||
points = np.asarray(point_cloud.positions_xyz, dtype=np.float64).reshape((-1, 3))
|
|
||||||
sample_indices = _uniform_sample_indices(
|
|
||||||
point_cloud.point_count,
|
|
||||||
self._points_sampled_per_frame,
|
|
||||||
)
|
|
||||||
sampled_ranges: npt.NDArray[np.float64] | None = None
|
|
||||||
if self.profile.coordinate_space is LidarCoordinateSpace.SENSOR:
|
|
||||||
sampled_ranges = np.linalg.norm(points[sample_indices], axis=1)
|
|
||||||
elif pose is not None:
|
|
||||||
if pose.frame_id != point_cloud.frame_id:
|
|
||||||
raise LidarContractError("LiDAR quality pose uses another map frame")
|
|
||||||
try:
|
|
||||||
points_sensor = map_points_to_lidar(
|
|
||||||
points[sample_indices],
|
|
||||||
position_map_xyz=pose.position_xyz,
|
|
||||||
orientation_map_from_lidar_xyzw=pose.orientation_xyzw,
|
|
||||||
)
|
|
||||||
except CalibratedProjectionError as exc:
|
|
||||||
raise LidarContractError("LiDAR quality pose is invalid") from exc
|
|
||||||
sampled_ranges = np.linalg.norm(points_sensor, axis=1)
|
|
||||||
|
|
||||||
intensity_samples: npt.NDArray[np.float64] | None = None
|
|
||||||
if point_cloud.intensities is not None:
|
|
||||||
intensities = np.frombuffer(point_cloud.intensities, dtype=np.uint8)
|
|
||||||
intensity_samples = intensities[sample_indices].astype(np.float64) / 255.0
|
|
||||||
elif LidarPointField.INTENSITY in self.profile.point_fields:
|
|
||||||
raise LidarContractError("LiDAR frame dropped profile-required intensity")
|
|
||||||
|
|
||||||
frame_time_ns = point_cloud.context.captured_at_epoch_ns
|
|
||||||
with self._lock:
|
|
||||||
if self._last_frame_time_ns is not None:
|
|
||||||
delta_ns = frame_time_ns - self._last_frame_time_ns
|
|
||||||
if delta_ns <= 0:
|
|
||||||
self._nonincreasing_frame_times += 1
|
|
||||||
else:
|
|
||||||
self._frame_intervals_ms.append(delta_ns / 1_000_000)
|
|
||||||
self._last_frame_time_ns = frame_time_ns
|
|
||||||
self._frames += 1
|
|
||||||
self._points += point_cloud.point_count
|
|
||||||
self._frame_points.append(point_cloud.point_count)
|
|
||||||
if intensity_samples is not None:
|
|
||||||
self._intensity_frames += 1
|
|
||||||
self._intensity_samples.extend(float(value) for value in intensity_samples)
|
|
||||||
if sampled_ranges is None:
|
|
||||||
self._range_unavailable_frames += 1
|
|
||||||
else:
|
|
||||||
self._range_frames += 1
|
|
||||||
self._range_samples_m.extend(float(value) for value in sampled_ranges)
|
|
||||||
|
|
||||||
def snapshot(self) -> dict[str, object]:
|
|
||||||
with self._lock:
|
|
||||||
return {
|
|
||||||
"schema_version": "missioncore.lidar-quality-report/v1",
|
|
||||||
"profile_id": self.profile.profile_id,
|
|
||||||
"frames_observed": self._frames,
|
|
||||||
"points_observed": self._points,
|
|
||||||
"intensity_frames": self._intensity_frames,
|
|
||||||
"range_frames": self._range_frames,
|
|
||||||
"sensor_range_unavailable_frames": self._range_unavailable_frames,
|
|
||||||
"nonincreasing_frame_times": self._nonincreasing_frame_times,
|
|
||||||
"sample_bounds": {
|
|
||||||
"frame_capacity": self._frame_points.maxlen,
|
|
||||||
"point_capacity": self._range_samples_m.maxlen,
|
|
||||||
"points_sampled_per_frame": self._points_sampled_per_frame,
|
|
||||||
},
|
|
||||||
"point_count_per_frame": _distribution(self._frame_points),
|
|
||||||
"frame_interval_ms": _distribution(self._frame_intervals_ms),
|
|
||||||
"sensor_range_m": _distribution(self._range_samples_m),
|
|
||||||
"intensity_0_1": _distribution(self._intensity_samples),
|
|
||||||
"authority": {
|
|
||||||
"commands_enabled": False,
|
|
||||||
"navigation_or_safety_accepted": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def assess_lidar_profile(
|
|
||||||
profile: LidarEvidenceProfile,
|
|
||||||
) -> tuple[LidarStageAssessment, ...]:
|
|
||||||
"""Return deterministic readiness without inferring missing sensor evidence."""
|
|
||||||
|
|
||||||
assessments = [
|
|
||||||
_quality_assessment(profile),
|
|
||||||
_ground_assessment(profile),
|
|
||||||
_detector_assessment(profile),
|
|
||||||
_nvblox_assessment(profile),
|
|
||||||
_odometry_assessment(profile),
|
|
||||||
_lio_assessment(profile),
|
|
||||||
_fusion_assessment(profile),
|
|
||||||
]
|
|
||||||
return tuple(assessments)
|
|
||||||
|
|
||||||
|
|
||||||
def lidar_readiness_document(profile: LidarEvidenceProfile) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"schema_version": LIDAR_READINESS_SCHEMA,
|
|
||||||
"profile": profile.to_dict(),
|
|
||||||
"stages": [assessment.to_dict() for assessment in assess_lidar_profile(profile)],
|
|
||||||
"authority": {
|
|
||||||
"commands_enabled": False,
|
|
||||||
"navigation_or_safety_accepted": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def sensor_frame_xyzi(
|
|
||||||
point_cloud: DecodedPointCloudView,
|
|
||||||
pose: DecodedPoseView | None = None,
|
|
||||||
) -> Float32Array:
|
|
||||||
"""Build the finite sensor-frame XYZI tensor expected by LiDAR detectors.
|
|
||||||
|
|
||||||
K1 `lio_pcl` positions are already in the canonical `map` frame; they are not raw
|
|
||||||
sensor-frame sweeps. The matching `T_map_from_lidar` pose is therefore
|
|
||||||
required to invert them. Intensity is normalized from the verified uint8
|
|
||||||
low byte to the [0, 1] reflectance interval used by the admitted
|
|
||||||
PointPillars baseline.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if point_cloud.intensities is None:
|
|
||||||
raise LidarContractError("sensor-frame XYZI requires intensity")
|
|
||||||
points = np.asarray(point_cloud.positions_xyz, dtype=np.float64).reshape((-1, 3))
|
|
||||||
if point_cloud.frame_id == (pose.child_frame_id if pose is not None else None):
|
|
||||||
points_sensor = points
|
|
||||||
elif pose is not None and point_cloud.frame_id == pose.frame_id:
|
|
||||||
try:
|
|
||||||
points_sensor = map_points_to_lidar(
|
|
||||||
points,
|
|
||||||
position_map_xyz=pose.position_xyz,
|
|
||||||
orientation_map_from_lidar_xyzw=pose.orientation_xyzw,
|
|
||||||
)
|
|
||||||
except CalibratedProjectionError as exc:
|
|
||||||
raise LidarContractError("map-frame LiDAR pose is invalid") from exc
|
|
||||||
else:
|
|
||||||
raise LidarContractError(
|
|
||||||
"LiDAR coordinates cannot be bound to the supplied sensor pose"
|
|
||||||
)
|
|
||||||
intensity = np.frombuffer(point_cloud.intensities, dtype=np.uint8).astype(np.float32)
|
|
||||||
xyzi = np.empty((point_cloud.point_count, 4), dtype=np.float32)
|
|
||||||
xyzi[:, :3] = points_sensor.astype(np.float32)
|
|
||||||
xyzi[:, 3] = intensity / 255.0
|
|
||||||
if not np.isfinite(xyzi).all():
|
|
||||||
raise LidarContractError("sensor-frame XYZI contains non-finite values")
|
|
||||||
return xyzi
|
|
||||||
|
|
||||||
|
|
||||||
def _quality_assessment(profile: LidarEvidenceProfile) -> LidarStageAssessment:
|
|
||||||
reasons: list[str] = []
|
|
||||||
if LidarPointField.INTENSITY not in profile.point_fields:
|
|
||||||
reasons.append("intensity-unavailable")
|
|
||||||
if profile.frame_time_basis is not LidarTimeBasis.SENSOR:
|
|
||||||
reasons.append("sensor-clock-unproven")
|
|
||||||
return _assessment(LidarPipelineStage.SCANNER_QUALITY, reasons, blocked=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _ground_assessment(profile: LidarEvidenceProfile) -> LidarStageAssessment:
|
|
||||||
reasons: list[str] = []
|
|
||||||
if profile.representation is not LidarRepresentation.SENSOR_SCAN:
|
|
||||||
reasons.append("vendor-mapped-points-are-not-raw-returns")
|
|
||||||
if profile.coordinate_space is LidarCoordinateSpace.MAP:
|
|
||||||
reasons.append("sensor-frame-conversion-required")
|
|
||||||
return _assessment(LidarPipelineStage.GROUND_SEGMENTATION, reasons, blocked=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _detector_assessment(profile: LidarEvidenceProfile) -> LidarStageAssessment:
|
|
||||||
reasons: list[str] = []
|
|
||||||
blocked = False
|
|
||||||
if LidarPointField.INTENSITY not in profile.point_fields:
|
|
||||||
reasons.append("admitted-pointpillars-baseline-requires-intensity")
|
|
||||||
blocked = True
|
|
||||||
if (
|
|
||||||
profile.coordinate_space is LidarCoordinateSpace.MAP
|
|
||||||
and profile.pose_status is LidarPoseStatus.NONE
|
|
||||||
):
|
|
||||||
reasons.append("sensor-frame-conversion-has-no-pose")
|
|
||||||
blocked = True
|
|
||||||
elif profile.coordinate_space is LidarCoordinateSpace.MAP:
|
|
||||||
reasons.append("sensor-frame-conversion-required")
|
|
||||||
if profile.representation is not LidarRepresentation.SENSOR_SCAN:
|
|
||||||
reasons.append("pretrained-domain-expects-sensor-scan")
|
|
||||||
return _assessment(LidarPipelineStage.LIDAR_3D_DETECTION, reasons, blocked)
|
|
||||||
|
|
||||||
|
|
||||||
def _nvblox_assessment(profile: LidarEvidenceProfile) -> LidarStageAssessment:
|
|
||||||
reasons: list[str] = []
|
|
||||||
blocked = False
|
|
||||||
if not profile.scan_geometry_known:
|
|
||||||
reasons.append("lidar-intrinsics-or-scan-geometry-unknown")
|
|
||||||
blocked = True
|
|
||||||
if profile.pose_status is LidarPoseStatus.NONE:
|
|
||||||
reasons.append("pose-unavailable")
|
|
||||||
blocked = True
|
|
||||||
elif profile.pose_status is not LidarPoseStatus.SENSOR_SYNCHRONIZED:
|
|
||||||
reasons.append("pose-is-best-effort")
|
|
||||||
if profile.frame_time_basis is not LidarTimeBasis.SENSOR:
|
|
||||||
reasons.append("sensor-clock-unproven")
|
|
||||||
return _assessment(LidarPipelineStage.NVIDIA_NVBLOX, reasons, blocked)
|
|
||||||
|
|
||||||
|
|
||||||
def _odometry_assessment(profile: LidarEvidenceProfile) -> LidarStageAssessment:
|
|
||||||
reasons: list[str] = []
|
|
||||||
blocked = False
|
|
||||||
if profile.representation is not LidarRepresentation.SENSOR_SCAN:
|
|
||||||
reasons.append("odometry-requires-unregistered-sensor-scans")
|
|
||||||
blocked = True
|
|
||||||
if profile.frame_time_basis is not LidarTimeBasis.SENSOR:
|
|
||||||
reasons.append("sensor-clock-unproven")
|
|
||||||
return _assessment(LidarPipelineStage.LIDAR_ODOMETRY, reasons, blocked)
|
|
||||||
|
|
||||||
|
|
||||||
def _lio_assessment(profile: LidarEvidenceProfile) -> LidarStageAssessment:
|
|
||||||
reasons: list[str] = []
|
|
||||||
if profile.representation is not LidarRepresentation.SENSOR_SCAN:
|
|
||||||
reasons.append("lio-requires-unregistered-sensor-scans")
|
|
||||||
if LidarPointField.RELATIVE_TIME not in profile.point_fields:
|
|
||||||
reasons.append("per-point-time-unavailable")
|
|
||||||
if not profile.imu_samples_available:
|
|
||||||
reasons.append("imu-samples-unavailable")
|
|
||||||
if not profile.lidar_imu_extrinsic_available:
|
|
||||||
reasons.append("lidar-imu-extrinsic-unavailable")
|
|
||||||
if profile.frame_time_basis is not LidarTimeBasis.SENSOR:
|
|
||||||
reasons.append("sensor-clock-unproven")
|
|
||||||
return _assessment(
|
|
||||||
LidarPipelineStage.LIDAR_INERTIAL_SLAM,
|
|
||||||
reasons,
|
|
||||||
blocked=bool(reasons),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _fusion_assessment(profile: LidarEvidenceProfile) -> LidarStageAssessment:
|
|
||||||
reasons: list[str] = []
|
|
||||||
blocked = False
|
|
||||||
if not profile.camera_extrinsic_available:
|
|
||||||
reasons.append("camera-extrinsic-unavailable")
|
|
||||||
blocked = True
|
|
||||||
if profile.pose_status is LidarPoseStatus.NONE:
|
|
||||||
reasons.append("pose-unavailable")
|
|
||||||
blocked = True
|
|
||||||
if profile.frame_time_basis is not LidarTimeBasis.SENSOR:
|
|
||||||
reasons.append("camera-lidar-synchronization-is-best-effort")
|
|
||||||
return _assessment(LidarPipelineStage.CAMERA_LIDAR_FUSION, reasons, blocked)
|
|
||||||
|
|
||||||
|
|
||||||
def _assessment(
|
|
||||||
stage: LidarPipelineStage,
|
|
||||||
reasons: list[str],
|
|
||||||
blocked: bool,
|
|
||||||
) -> LidarStageAssessment:
|
|
||||||
if blocked:
|
|
||||||
readiness = LidarReadiness.BLOCKED
|
|
||||||
elif reasons:
|
|
||||||
readiness = LidarReadiness.DEGRADED
|
|
||||||
else:
|
|
||||||
readiness = LidarReadiness.READY
|
|
||||||
return LidarStageAssessment(stage, readiness, tuple(reasons))
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_identifier(value: str, label: str) -> str:
|
|
||||||
if not isinstance(value, str) or _IDENTIFIER.fullmatch(value) is None:
|
|
||||||
raise LidarContractError(f"{label} is not a safe identifier")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _uniform_sample_indices(point_count: int, maximum: int) -> npt.NDArray[np.int64]:
|
|
||||||
if point_count <= maximum:
|
|
||||||
return np.arange(point_count, dtype=np.int64)
|
|
||||||
return np.linspace(0, point_count - 1, maximum, dtype=np.int64)
|
|
||||||
|
|
||||||
|
|
||||||
def _distribution(values: deque[int] | deque[float]) -> dict[str, float | int | None]:
|
|
||||||
if not values:
|
|
||||||
return {
|
|
||||||
"sample_count": 0,
|
|
||||||
"minimum": None,
|
|
||||||
"mean": None,
|
|
||||||
"p50": None,
|
|
||||||
"p95": None,
|
|
||||||
"maximum": None,
|
|
||||||
}
|
|
||||||
array = np.asarray(values, dtype=np.float64)
|
|
||||||
return {
|
|
||||||
"sample_count": int(array.size),
|
|
||||||
"minimum": float(np.min(array)),
|
|
||||||
"mean": float(np.mean(array)),
|
|
||||||
"p50": float(np.percentile(array, 50)),
|
|
||||||
"p95": float(np.percentile(array, 95)),
|
|
||||||
"maximum": float(np.max(array)),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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 LidarContractError(f"{label} must be an object")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _exact_keys(document: dict[str, Any], expected: set[str], label: str) -> None:
|
|
||||||
if set(document) != expected:
|
|
||||||
raise LidarContractError(f"{label} fields are incompatible")
|
|
||||||
|
|
||||||
|
|
||||||
def _array(document: dict[str, Any], key: str) -> list[object]:
|
|
||||||
value = document.get(key)
|
|
||||||
if not isinstance(value, list):
|
|
||||||
raise LidarContractError(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 LidarContractError(f"{label} must be a nonempty string")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _bool(document: dict[str, Any], key: str) -> bool:
|
|
||||||
value = document.get(key)
|
|
||||||
if not isinstance(value, bool):
|
|
||||||
raise LidarContractError(f"{key} must be a boolean")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
K1_LIVE_LIDAR_PROFILE: Final = LidarEvidenceProfile(
|
|
||||||
profile_id="xgrids-k1-live-lio-pcl/v1",
|
|
||||||
representation=LidarRepresentation.VENDOR_MAP_INCREMENT,
|
|
||||||
coordinate_space=LidarCoordinateSpace.MAP,
|
|
||||||
coordinate_frame="map",
|
|
||||||
frame_time_basis=LidarTimeBasis.HOST_ARRIVAL,
|
|
||||||
point_fields=(LidarPointField.XYZ, LidarPointField.INTENSITY),
|
|
||||||
pose_status=LidarPoseStatus.BEST_EFFORT,
|
|
||||||
scan_geometry_known=False,
|
|
||||||
imu_samples_available=False,
|
|
||||||
lidar_imu_extrinsic_available=False,
|
|
||||||
camera_extrinsic_available=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
K1_LAB_LIDAR_PACK_V1_PROFILE: Final = LidarEvidenceProfile(
|
|
||||||
profile_id="xgrids-k1-e10-lidar-replay-pack/v1",
|
|
||||||
representation=LidarRepresentation.VENDOR_MAP_INCREMENT,
|
|
||||||
coordinate_space=LidarCoordinateSpace.MAP,
|
|
||||||
coordinate_frame="map",
|
|
||||||
frame_time_basis=LidarTimeBasis.CAMERA_BOUND_HOST_ARRIVAL,
|
|
||||||
point_fields=(LidarPointField.XYZ,),
|
|
||||||
pose_status=LidarPoseStatus.BEST_EFFORT,
|
|
||||||
scan_geometry_known=False,
|
|
||||||
imu_samples_available=False,
|
|
||||||
lidar_imu_extrinsic_available=False,
|
|
||||||
camera_extrinsic_available=True,
|
|
||||||
)
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
"""Mission Core qualification and simulation boundaries."""
|
|
||||||
|
|
||||||
from k1link.simulation.contracts import (
|
|
||||||
AckermannControlSetpoint,
|
|
||||||
AuthorityProfile,
|
|
||||||
CommandAuthorityScope,
|
|
||||||
ControlProfile,
|
|
||||||
ControlSetpoint,
|
|
||||||
DifferentialControlSetpoint,
|
|
||||||
ProviderPin,
|
|
||||||
QualificationArtifact,
|
|
||||||
QualificationEvent,
|
|
||||||
QualificationRun,
|
|
||||||
ReproducibilityTier,
|
|
||||||
RunKind,
|
|
||||||
RunState,
|
|
||||||
SimulationContractError,
|
|
||||||
)
|
|
||||||
from k1link.simulation.orchestrator import (
|
|
||||||
ActiveQualificationRunError,
|
|
||||||
SimulationApplicationService,
|
|
||||||
SimulationOrchestratorError,
|
|
||||||
SimulationWorkerPort,
|
|
||||||
WorkerStartResult,
|
|
||||||
WorkerStopResult,
|
|
||||||
)
|
|
||||||
from k1link.simulation.process_supervisor import (
|
|
||||||
OwnedProcess,
|
|
||||||
PosixProcessSupervisor,
|
|
||||||
ProcessSpec,
|
|
||||||
ProcessStopResult,
|
|
||||||
ProcessSupervisorError,
|
|
||||||
)
|
|
||||||
from k1link.simulation.provider_contract import (
|
|
||||||
PROVIDER_PROFILE_SCHEMA,
|
|
||||||
ProviderRole,
|
|
||||||
SimulationClockDescriptor,
|
|
||||||
SimulationProviderContractError,
|
|
||||||
SimulationProviderDescriptor,
|
|
||||||
SimulationProviderProfile,
|
|
||||||
)
|
|
||||||
from k1link.simulation.run_store import (
|
|
||||||
QualificationRunConflictError,
|
|
||||||
QualificationRunIntegrityError,
|
|
||||||
QualificationRunNotFoundError,
|
|
||||||
QualificationRunStore,
|
|
||||||
QualificationRunStoreError,
|
|
||||||
QualificationRunTransitionError,
|
|
||||||
)
|
|
||||||
from k1link.simulation.s0 import (
|
|
||||||
CheckStatus,
|
|
||||||
DoctorVerdict,
|
|
||||||
RuntimeAcceptance,
|
|
||||||
S0DoctorReport,
|
|
||||||
S0Profile,
|
|
||||||
S0ProfileError,
|
|
||||||
load_s0_profile,
|
|
||||||
run_s0_doctor,
|
|
||||||
)
|
|
||||||
from k1link.simulation.stock_rover import (
|
|
||||||
LIFECYCLE_PROFILE_SCHEMA,
|
|
||||||
StockRoverLifecycleProfile,
|
|
||||||
StockRoverProfileError,
|
|
||||||
StockRoverTargetPaths,
|
|
||||||
load_stock_rover_lifecycle_profile,
|
|
||||||
stock_rover_process_environment,
|
|
||||||
stock_rover_process_specs,
|
|
||||||
)
|
|
||||||
from k1link.simulation.worker import (
|
|
||||||
LocalProcessWorkerAdapter,
|
|
||||||
S0WorkerGuard,
|
|
||||||
SimulationWorldControl,
|
|
||||||
WorkerAdmission,
|
|
||||||
WorkerAdmissionError,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"AckermannControlSetpoint",
|
|
||||||
"ActiveQualificationRunError",
|
|
||||||
"AuthorityProfile",
|
|
||||||
"CheckStatus",
|
|
||||||
"CommandAuthorityScope",
|
|
||||||
"ControlProfile",
|
|
||||||
"ControlSetpoint",
|
|
||||||
"DifferentialControlSetpoint",
|
|
||||||
"DoctorVerdict",
|
|
||||||
"LocalProcessWorkerAdapter",
|
|
||||||
"LIFECYCLE_PROFILE_SCHEMA",
|
|
||||||
"OwnedProcess",
|
|
||||||
"PosixProcessSupervisor",
|
|
||||||
"ProcessSpec",
|
|
||||||
"ProcessStopResult",
|
|
||||||
"ProcessSupervisorError",
|
|
||||||
"PROVIDER_PROFILE_SCHEMA",
|
|
||||||
"ProviderPin",
|
|
||||||
"ProviderRole",
|
|
||||||
"QualificationArtifact",
|
|
||||||
"QualificationEvent",
|
|
||||||
"QualificationRun",
|
|
||||||
"QualificationRunConflictError",
|
|
||||||
"QualificationRunIntegrityError",
|
|
||||||
"QualificationRunNotFoundError",
|
|
||||||
"QualificationRunStore",
|
|
||||||
"QualificationRunStoreError",
|
|
||||||
"QualificationRunTransitionError",
|
|
||||||
"ReproducibilityTier",
|
|
||||||
"RuntimeAcceptance",
|
|
||||||
"RunKind",
|
|
||||||
"RunState",
|
|
||||||
"S0WorkerGuard",
|
|
||||||
"S0DoctorReport",
|
|
||||||
"S0Profile",
|
|
||||||
"S0ProfileError",
|
|
||||||
"StockRoverLifecycleProfile",
|
|
||||||
"StockRoverProfileError",
|
|
||||||
"StockRoverTargetPaths",
|
|
||||||
"SimulationContractError",
|
|
||||||
"SimulationClockDescriptor",
|
|
||||||
"SimulationApplicationService",
|
|
||||||
"SimulationOrchestratorError",
|
|
||||||
"SimulationProviderContractError",
|
|
||||||
"SimulationProviderDescriptor",
|
|
||||||
"SimulationProviderProfile",
|
|
||||||
"SimulationWorkerPort",
|
|
||||||
"SimulationWorldControl",
|
|
||||||
"WorkerAdmission",
|
|
||||||
"WorkerAdmissionError",
|
|
||||||
"WorkerStartResult",
|
|
||||||
"WorkerStopResult",
|
|
||||||
"load_s0_profile",
|
|
||||||
"load_stock_rover_lifecycle_profile",
|
|
||||||
"run_s0_doctor",
|
|
||||||
"stock_rover_process_environment",
|
|
||||||
"stock_rover_process_specs",
|
|
||||||
]
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
import typer
|
|
||||||
from rich.console import Console
|
|
||||||
from rich.table import Table
|
|
||||||
|
|
||||||
from k1link.simulation import S0ProfileError, run_s0_doctor
|
|
||||||
|
|
||||||
app = typer.Typer(
|
|
||||||
name="missioncore-sim",
|
|
||||||
help="Fail-closed qualification tooling for the Mission Core Polygon.",
|
|
||||||
no_args_is_help=True,
|
|
||||||
)
|
|
||||||
s0_app = typer.Typer(
|
|
||||||
help="SIM S0 compatibility and infrastructure qualification.",
|
|
||||||
no_args_is_help=True,
|
|
||||||
)
|
|
||||||
app.add_typer(s0_app, name="s0")
|
|
||||||
console = Console()
|
|
||||||
|
|
||||||
|
|
||||||
def _default_profile() -> Path:
|
|
||||||
return Path(__file__).resolve().parents[3] / "simulation" / "s0" / "qualification-profile.yaml"
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_PROFILE = _default_profile()
|
|
||||||
|
|
||||||
|
|
||||||
@s0_app.command("doctor")
|
|
||||||
def s0_doctor(
|
|
||||||
profile: Annotated[
|
|
||||||
Path,
|
|
||||||
typer.Option(
|
|
||||||
"--profile",
|
|
||||||
help="Strict SIM S0 qualification profile.",
|
|
||||||
exists=True,
|
|
||||||
dir_okay=False,
|
|
||||||
readable=True,
|
|
||||||
),
|
|
||||||
] = DEFAULT_PROFILE,
|
|
||||||
evidence: Annotated[
|
|
||||||
Path | None,
|
|
||||||
typer.Option(
|
|
||||||
"--evidence",
|
|
||||||
help="Optional digest-bound target-worker evidence manifest.",
|
|
||||||
exists=True,
|
|
||||||
dir_okay=False,
|
|
||||||
readable=True,
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
json_output: Annotated[
|
|
||||||
bool,
|
|
||||||
typer.Option("--json", help="Emit the complete report as JSON."),
|
|
||||||
] = False,
|
|
||||||
) -> None:
|
|
||||||
"""Inspect contracts and local target readiness without changing the host."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
report = run_s0_doctor(profile, evidence_path=evidence)
|
|
||||||
except (OSError, S0ProfileError) as exc:
|
|
||||||
if json_output:
|
|
||||||
console.print_json(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"schema_version": "missioncore.simulation-s0-doctor/v1",
|
|
||||||
"verdict": "blocked",
|
|
||||||
"error": str(exc),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
console.print(f"[red]SIM S0 profile rejected:[/red] {exc}")
|
|
||||||
raise typer.Exit(code=2) from exc
|
|
||||||
|
|
||||||
payload = report.to_dict()
|
|
||||||
if json_output:
|
|
||||||
console.print_json(json.dumps(payload))
|
|
||||||
return
|
|
||||||
|
|
||||||
table = Table(title=f"SIM S0 doctor — {report.profile_id}")
|
|
||||||
table.add_column("Check")
|
|
||||||
table.add_column("Status")
|
|
||||||
table.add_column("Detail")
|
|
||||||
colors = {"pass": "green", "fail": "red", "unknown": "yellow"}
|
|
||||||
for check in report.checks:
|
|
||||||
status = check.status.value
|
|
||||||
styled_status = f"[{colors[status]}]{status}[/{colors[status]}]"
|
|
||||||
table.add_row(check.identifier, styled_status, check.detail)
|
|
||||||
console.print(table)
|
|
||||||
console.print(f"Verdict: [bold]{report.verdict.value.upper()}[/bold]")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app()
|
|
||||||
|
|
@ -1,683 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass, replace
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from enum import StrEnum
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
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):
|
|
||||||
"""A Polygon domain object violates the admitted S1 contract."""
|
|
||||||
|
|
||||||
|
|
||||||
class RunKind(StrEnum):
|
|
||||||
SIMULATION_CLOSED_LOOP = "simulation_closed_loop"
|
|
||||||
REPLAY_SHADOW = "replay_shadow"
|
|
||||||
DIGITAL_TWIN_CLOSED_LOOP = "digital_twin_closed_loop"
|
|
||||||
CONTROLLER_IN_LOOP = "controller_in_loop"
|
|
||||||
HIL = "hil"
|
|
||||||
PHYSICAL_SHADOW = "physical_shadow"
|
|
||||||
|
|
||||||
|
|
||||||
class RunState(StrEnum):
|
|
||||||
ADMITTED = "admitted"
|
|
||||||
STARTING = "starting"
|
|
||||||
RUNNING = "running"
|
|
||||||
PAUSED = "paused"
|
|
||||||
STOPPING = "stopping"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
FAILED = "failed"
|
|
||||||
ABORTED = "aborted"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def terminal(self) -> bool:
|
|
||||||
return self in {RunState.COMPLETED, RunState.FAILED, RunState.ABORTED}
|
|
||||||
|
|
||||||
|
|
||||||
class ReproducibilityTier(StrEnum):
|
|
||||||
R0 = "R0"
|
|
||||||
R1 = "R1"
|
|
||||||
R2 = "R2"
|
|
||||||
|
|
||||||
|
|
||||||
class ControlProfile(StrEnum):
|
|
||||||
ROVER_SPEED_STEERING_V1 = "rover-speed-steering/v1"
|
|
||||||
ROVER_SPEED_YAW_RATE_V1 = "rover-speed-yaw-rate/v1"
|
|
||||||
|
|
||||||
|
|
||||||
class CommandAuthorityScope(StrEnum):
|
|
||||||
VIRTUAL_ONLY = "virtual-only"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ProviderPin:
|
|
||||||
identifier: str
|
|
||||||
version: str
|
|
||||||
revision: str
|
|
||||||
digest: str | None = None
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_identifier(self.identifier, "provider identifier")
|
|
||||||
if not self.version.strip():
|
|
||||||
raise SimulationContractError("provider version must not be empty")
|
|
||||||
if not self.revision.strip():
|
|
||||||
raise SimulationContractError("provider revision must not be empty")
|
|
||||||
if self.digest is not None:
|
|
||||||
_sha256(self.digest, "provider digest")
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"identifier": self.identifier,
|
|
||||||
"version": self.version,
|
|
||||||
"revision": self.revision,
|
|
||||||
"digest": self.digest,
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, value: object) -> ProviderPin:
|
|
||||||
document = _object(value, "provider pin")
|
|
||||||
return cls(
|
|
||||||
identifier=_string(document, "identifier"),
|
|
||||||
version=_string(document, "version"),
|
|
||||||
revision=_string(document, "revision"),
|
|
||||||
digest=_optional_string(document, "digest"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class AuthorityProfile:
|
|
||||||
"""The only command authority admitted before a physical safety gate."""
|
|
||||||
|
|
||||||
generation: int
|
|
||||||
command_ttl_max_ns: int
|
|
||||||
heartbeat_timeout_monotonic_ns: int
|
|
||||||
simulation_or_shadow_only: bool = True
|
|
||||||
actuator_authority: bool = False
|
|
||||||
navigation_or_safety_accepted: bool = False
|
|
||||||
direct_actuator_setpoints_allowed: bool = False
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if self.generation < 1:
|
|
||||||
raise SimulationContractError("authority generation must be positive")
|
|
||||||
if self.command_ttl_max_ns < 1:
|
|
||||||
raise SimulationContractError("command TTL limit must be positive")
|
|
||||||
if self.heartbeat_timeout_monotonic_ns < 1:
|
|
||||||
raise SimulationContractError("heartbeat timeout must be positive")
|
|
||||||
if (
|
|
||||||
not self.simulation_or_shadow_only
|
|
||||||
or self.actuator_authority
|
|
||||||
or self.navigation_or_safety_accepted
|
|
||||||
or self.direct_actuator_setpoints_allowed
|
|
||||||
):
|
|
||||||
raise SimulationContractError(
|
|
||||||
"S1 authority must remain simulation/shadow-only without actuator authority"
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"generation": self.generation,
|
|
||||||
"command_ttl_max_ns": self.command_ttl_max_ns,
|
|
||||||
"heartbeat_timeout_monotonic_ns": self.heartbeat_timeout_monotonic_ns,
|
|
||||||
"simulation_or_shadow_only": self.simulation_or_shadow_only,
|
|
||||||
"actuator_authority": self.actuator_authority,
|
|
||||||
"navigation_or_safety_accepted": self.navigation_or_safety_accepted,
|
|
||||||
"direct_actuator_setpoints_allowed": self.direct_actuator_setpoints_allowed,
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, value: object) -> AuthorityProfile:
|
|
||||||
document = _object(value, "authority profile")
|
|
||||||
return cls(
|
|
||||||
generation=_integer(document, "generation"),
|
|
||||||
command_ttl_max_ns=_integer(document, "command_ttl_max_ns"),
|
|
||||||
heartbeat_timeout_monotonic_ns=_integer(
|
|
||||||
document,
|
|
||||||
"heartbeat_timeout_monotonic_ns",
|
|
||||||
),
|
|
||||||
simulation_or_shadow_only=_boolean(document, "simulation_or_shadow_only"),
|
|
||||||
actuator_authority=_boolean(document, "actuator_authority"),
|
|
||||||
navigation_or_safety_accepted=_boolean(
|
|
||||||
document,
|
|
||||||
"navigation_or_safety_accepted",
|
|
||||||
),
|
|
||||||
direct_actuator_setpoints_allowed=_boolean(
|
|
||||||
document,
|
|
||||||
"direct_actuator_setpoints_allowed",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class QualificationArtifact:
|
|
||||||
artifact_id: str
|
|
||||||
kind: str
|
|
||||||
relative_path: str
|
|
||||||
sha256: str
|
|
||||||
byte_length: int
|
|
||||||
source_of_record: bool
|
|
||||||
sequence: int = 0
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_identifier(self.artifact_id, "artifact id")
|
|
||||||
_identifier(self.kind, "artifact kind")
|
|
||||||
_relative_path(self.relative_path)
|
|
||||||
_sha256(self.sha256, "artifact digest")
|
|
||||||
if self.byte_length < 0:
|
|
||||||
raise SimulationContractError("artifact byte length must not be negative")
|
|
||||||
if self.sequence < 0:
|
|
||||||
raise SimulationContractError("artifact sequence must not be negative")
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"artifact_id": self.artifact_id,
|
|
||||||
"kind": self.kind,
|
|
||||||
"relative_path": self.relative_path,
|
|
||||||
"sha256": self.sha256,
|
|
||||||
"byte_length": self.byte_length,
|
|
||||||
"source_of_record": self.source_of_record,
|
|
||||||
"sequence": self.sequence,
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, value: object) -> QualificationArtifact:
|
|
||||||
document = _object(value, "qualification artifact")
|
|
||||||
return cls(
|
|
||||||
artifact_id=_string(document, "artifact_id"),
|
|
||||||
kind=_string(document, "kind"),
|
|
||||||
relative_path=_string(document, "relative_path"),
|
|
||||||
sha256=_string(document, "sha256"),
|
|
||||||
byte_length=_integer(document, "byte_length"),
|
|
||||||
source_of_record=_boolean(document, "source_of_record"),
|
|
||||||
sequence=_integer(document, "sequence"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class QualificationRun:
|
|
||||||
run_id: str
|
|
||||||
episode_id: str
|
|
||||||
kind: RunKind
|
|
||||||
state: RunState
|
|
||||||
scenario_generation: str
|
|
||||||
scenario_sha256: str
|
|
||||||
profile_generation: str
|
|
||||||
profile_sha256: str
|
|
||||||
mission_core_commit: str
|
|
||||||
providers: tuple[ProviderPin, ...]
|
|
||||||
host_profile_id: str
|
|
||||||
host_profile_sha256: str
|
|
||||||
seed: int
|
|
||||||
reproducibility_tier: ReproducibilityTier
|
|
||||||
authority: AuthorityProfile
|
|
||||||
clock_domain: str
|
|
||||||
created_at_utc: str
|
|
||||||
started_at_utc: str | None = None
|
|
||||||
ended_at_utc: str | None = None
|
|
||||||
terminal_reason: str | None = None
|
|
||||||
parent_run_id: str | None = None
|
|
||||||
revision: int = 0
|
|
||||||
artifacts: tuple[QualificationArtifact, ...] = ()
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_identifier(self.run_id, "run id")
|
|
||||||
_identifier(self.episode_id, "episode id")
|
|
||||||
_identifier(self.scenario_generation, "scenario generation")
|
|
||||||
_sha256(self.scenario_sha256, "scenario digest")
|
|
||||||
_identifier(self.profile_generation, "profile generation")
|
|
||||||
_sha256(self.profile_sha256, "profile digest")
|
|
||||||
if not GIT_REVISION_PATTERN.fullmatch(self.mission_core_commit):
|
|
||||||
raise SimulationContractError("Mission Core commit must be a hexadecimal revision")
|
|
||||||
if not self.providers:
|
|
||||||
raise SimulationContractError("at least one provider pin is required")
|
|
||||||
provider_ids = [provider.identifier for provider in self.providers]
|
|
||||||
if len(provider_ids) != len(set(provider_ids)):
|
|
||||||
raise SimulationContractError("provider identifiers must be unique")
|
|
||||||
_identifier(self.host_profile_id, "host profile id")
|
|
||||||
_sha256(self.host_profile_sha256, "host profile digest")
|
|
||||||
if self.seed < 0:
|
|
||||||
raise SimulationContractError("run seed must not be negative")
|
|
||||||
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")
|
|
||||||
if self.ended_at_utc is not None:
|
|
||||||
_utc_timestamp(self.ended_at_utc, "ended timestamp")
|
|
||||||
if self.parent_run_id is not None:
|
|
||||||
_identifier(self.parent_run_id, "parent run id")
|
|
||||||
if self.parent_run_id == self.run_id:
|
|
||||||
raise SimulationContractError("a run cannot be its own parent")
|
|
||||||
if self.revision < 0:
|
|
||||||
raise SimulationContractError("run revision must not be negative")
|
|
||||||
if self.state.terminal != (self.ended_at_utc is not None):
|
|
||||||
raise SimulationContractError("terminal state and ended timestamp must agree")
|
|
||||||
if self.state.terminal != (self.terminal_reason is not None):
|
|
||||||
raise SimulationContractError("terminal state and terminal reason must agree")
|
|
||||||
if self.kind in {RunKind.CONTROLLER_IN_LOOP, RunKind.HIL}:
|
|
||||||
raise SimulationContractError(
|
|
||||||
f"{self.kind.value} requires a separate physical authority safety gate"
|
|
||||||
)
|
|
||||||
|
|
||||||
def with_runtime(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
state: RunState,
|
|
||||||
revision: int,
|
|
||||||
started_at_utc: str | None,
|
|
||||||
ended_at_utc: str | None,
|
|
||||||
terminal_reason: str | None,
|
|
||||||
artifacts: tuple[QualificationArtifact, ...],
|
|
||||||
) -> QualificationRun:
|
|
||||||
return replace(
|
|
||||||
self,
|
|
||||||
state=state,
|
|
||||||
revision=revision,
|
|
||||||
started_at_utc=started_at_utc,
|
|
||||||
ended_at_utc=ended_at_utc,
|
|
||||||
terminal_reason=terminal_reason,
|
|
||||||
artifacts=artifacts,
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_manifest_dict(self) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"schema_version": "missioncore.qualification-run/v1",
|
|
||||||
"run_id": self.run_id,
|
|
||||||
"episode_id": self.episode_id,
|
|
||||||
"kind": self.kind.value,
|
|
||||||
"initial_state": RunState.ADMITTED.value,
|
|
||||||
"scenario": {
|
|
||||||
"generation": self.scenario_generation,
|
|
||||||
"sha256": self.scenario_sha256,
|
|
||||||
},
|
|
||||||
"profile": {
|
|
||||||
"generation": self.profile_generation,
|
|
||||||
"sha256": self.profile_sha256,
|
|
||||||
},
|
|
||||||
"mission_core_commit": self.mission_core_commit,
|
|
||||||
"providers": [provider.to_dict() for provider in self.providers],
|
|
||||||
"host_profile": {
|
|
||||||
"id": self.host_profile_id,
|
|
||||||
"sha256": self.host_profile_sha256,
|
|
||||||
},
|
|
||||||
"seed": self.seed,
|
|
||||||
"reproducibility_tier": self.reproducibility_tier.value,
|
|
||||||
"authority": self.authority.to_dict(),
|
|
||||||
"clock_domain": self.clock_domain,
|
|
||||||
"created_at_utc": self.created_at_utc,
|
|
||||||
"parent_run_id": self.parent_run_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_manifest_dict(cls, value: object) -> QualificationRun:
|
|
||||||
document = _object(value, "qualification run")
|
|
||||||
if document.get("schema_version") != "missioncore.qualification-run/v1":
|
|
||||||
raise SimulationContractError("unsupported qualification-run schema")
|
|
||||||
if document.get("initial_state") != RunState.ADMITTED.value:
|
|
||||||
raise SimulationContractError("qualification run must be admitted immutably")
|
|
||||||
scenario = _object(document.get("scenario"), "scenario")
|
|
||||||
profile = _object(document.get("profile"), "profile")
|
|
||||||
host_profile = _object(document.get("host_profile"), "host profile")
|
|
||||||
providers = document.get("providers")
|
|
||||||
if not isinstance(providers, list):
|
|
||||||
raise SimulationContractError("providers must be an array")
|
|
||||||
try:
|
|
||||||
kind = RunKind(_string(document, "kind"))
|
|
||||||
tier = ReproducibilityTier(_string(document, "reproducibility_tier"))
|
|
||||||
except ValueError as exc:
|
|
||||||
raise SimulationContractError("qualification run contains an unknown enum") from exc
|
|
||||||
return cls(
|
|
||||||
run_id=_string(document, "run_id"),
|
|
||||||
episode_id=_string(document, "episode_id"),
|
|
||||||
kind=kind,
|
|
||||||
state=RunState.ADMITTED,
|
|
||||||
scenario_generation=_string(scenario, "generation"),
|
|
||||||
scenario_sha256=_string(scenario, "sha256"),
|
|
||||||
profile_generation=_string(profile, "generation"),
|
|
||||||
profile_sha256=_string(profile, "sha256"),
|
|
||||||
mission_core_commit=_string(document, "mission_core_commit"),
|
|
||||||
providers=tuple(ProviderPin.from_dict(provider) for provider in providers),
|
|
||||||
host_profile_id=_string(host_profile, "id"),
|
|
||||||
host_profile_sha256=_string(host_profile, "sha256"),
|
|
||||||
seed=_integer(document, "seed"),
|
|
||||||
reproducibility_tier=tier,
|
|
||||||
authority=AuthorityProfile.from_dict(document.get("authority")),
|
|
||||||
clock_domain=_string(document, "clock_domain"),
|
|
||||||
created_at_utc=_string(document, "created_at_utc"),
|
|
||||||
parent_run_id=_optional_string(document, "parent_run_id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class QualificationEvent:
|
|
||||||
run_id: str
|
|
||||||
sequence: int
|
|
||||||
event_type: str
|
|
||||||
observed_at_utc: str
|
|
||||||
host_monotonic_ns: int
|
|
||||||
sim_time_ns: int | None
|
|
||||||
payload: dict[str, Any]
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_identifier(self.run_id, "event run id")
|
|
||||||
_identifier(self.event_type, "event type")
|
|
||||||
if self.sequence < 1:
|
|
||||||
raise SimulationContractError("event sequence must be positive")
|
|
||||||
_utc_timestamp(self.observed_at_utc, "event timestamp")
|
|
||||||
if self.host_monotonic_ns < 0:
|
|
||||||
raise SimulationContractError("host monotonic timestamp must not be negative")
|
|
||||||
if self.sim_time_ns is not None and self.sim_time_ns < 0:
|
|
||||||
raise SimulationContractError("simulation timestamp must not be negative")
|
|
||||||
_json_object(self.payload, "event payload")
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"schema_version": "missioncore.qualification-event/v1",
|
|
||||||
"run_id": self.run_id,
|
|
||||||
"sequence": self.sequence,
|
|
||||||
"event_type": self.event_type,
|
|
||||||
"observed_at_utc": self.observed_at_utc,
|
|
||||||
"host_monotonic_ns": self.host_monotonic_ns,
|
|
||||||
"sim_time_ns": self.sim_time_ns,
|
|
||||||
"payload": self.payload,
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, value: object) -> QualificationEvent:
|
|
||||||
document = _object(value, "qualification event")
|
|
||||||
if document.get("schema_version") != "missioncore.qualification-event/v1":
|
|
||||||
raise SimulationContractError("unsupported qualification-event schema")
|
|
||||||
sim_time = document.get("sim_time_ns")
|
|
||||||
if sim_time is not None and (not isinstance(sim_time, int) or isinstance(sim_time, bool)):
|
|
||||||
raise SimulationContractError("sim_time_ns must be an integer or null")
|
|
||||||
payload = _object(document.get("payload"), "event payload")
|
|
||||||
return cls(
|
|
||||||
run_id=_string(document, "run_id"),
|
|
||||||
sequence=_integer(document, "sequence"),
|
|
||||||
event_type=_string(document, "event_type"),
|
|
||||||
observed_at_utc=_string(document, "observed_at_utc"),
|
|
||||||
host_monotonic_ns=_integer(document, "host_monotonic_ns"),
|
|
||||||
sim_time_ns=sim_time,
|
|
||||||
payload=payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class AckermannControlSetpoint:
|
|
||||||
run_id: str
|
|
||||||
command_id: str
|
|
||||||
sequence: int
|
|
||||||
issued_at_sim_ns: int
|
|
||||||
valid_until_sim_ns: int
|
|
||||||
authority_generation: int
|
|
||||||
speed_mps: float
|
|
||||||
steering_normalized: float
|
|
||||||
profile: ControlProfile = ControlProfile.ROVER_SPEED_STEERING_V1
|
|
||||||
authority_scope: CommandAuthorityScope = CommandAuthorityScope.VIRTUAL_ONLY
|
|
||||||
source: str = "simulation-orchestrator"
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_command_header(
|
|
||||||
self.run_id,
|
|
||||||
self.command_id,
|
|
||||||
self.sequence,
|
|
||||||
self.issued_at_sim_ns,
|
|
||||||
self.valid_until_sim_ns,
|
|
||||||
self.authority_generation,
|
|
||||||
)
|
|
||||||
_finite(self.speed_mps, "speed")
|
|
||||||
_finite(self.steering_normalized, "steering")
|
|
||||||
if not -1.0 <= self.steering_normalized <= 1.0:
|
|
||||||
raise SimulationContractError("normalized steering must be within -1..1")
|
|
||||||
if self.profile is not ControlProfile.ROVER_SPEED_STEERING_V1:
|
|
||||||
raise SimulationContractError("Ackermann command has an incompatible profile")
|
|
||||||
_command_provenance(self.authority_scope, self.source)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"schema_version": "missioncore.control-setpoint/v1",
|
|
||||||
"run_id": self.run_id,
|
|
||||||
"command_id": self.command_id,
|
|
||||||
"sequence": self.sequence,
|
|
||||||
"profile": self.profile.value,
|
|
||||||
"authority_scope": self.authority_scope.value,
|
|
||||||
"source": self.source,
|
|
||||||
"issued_at_sim_ns": self.issued_at_sim_ns,
|
|
||||||
"valid_until_sim_ns": self.valid_until_sim_ns,
|
|
||||||
"authority_generation": self.authority_generation,
|
|
||||||
"speed_mps": self.speed_mps,
|
|
||||||
"steering_normalized": self.steering_normalized,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class DifferentialControlSetpoint:
|
|
||||||
run_id: str
|
|
||||||
command_id: str
|
|
||||||
sequence: int
|
|
||||||
issued_at_sim_ns: int
|
|
||||||
valid_until_sim_ns: int
|
|
||||||
authority_generation: int
|
|
||||||
speed_mps: float
|
|
||||||
yaw_rate_rps: float
|
|
||||||
profile: ControlProfile = ControlProfile.ROVER_SPEED_YAW_RATE_V1
|
|
||||||
authority_scope: CommandAuthorityScope = CommandAuthorityScope.VIRTUAL_ONLY
|
|
||||||
source: str = "simulation-orchestrator"
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_command_header(
|
|
||||||
self.run_id,
|
|
||||||
self.command_id,
|
|
||||||
self.sequence,
|
|
||||||
self.issued_at_sim_ns,
|
|
||||||
self.valid_until_sim_ns,
|
|
||||||
self.authority_generation,
|
|
||||||
)
|
|
||||||
_finite(self.speed_mps, "speed")
|
|
||||||
_finite(self.yaw_rate_rps, "yaw rate")
|
|
||||||
if self.profile is not ControlProfile.ROVER_SPEED_YAW_RATE_V1:
|
|
||||||
raise SimulationContractError("differential command has an incompatible profile")
|
|
||||||
_command_provenance(self.authority_scope, self.source)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"schema_version": "missioncore.control-setpoint/v1",
|
|
||||||
"run_id": self.run_id,
|
|
||||||
"command_id": self.command_id,
|
|
||||||
"sequence": self.sequence,
|
|
||||||
"profile": self.profile.value,
|
|
||||||
"authority_scope": self.authority_scope.value,
|
|
||||||
"source": self.source,
|
|
||||||
"issued_at_sim_ns": self.issued_at_sim_ns,
|
|
||||||
"valid_until_sim_ns": self.valid_until_sim_ns,
|
|
||||||
"authority_generation": self.authority_generation,
|
|
||||||
"speed_mps": self.speed_mps,
|
|
||||||
"yaw_rate_rps": self.yaw_rate_rps,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
ControlSetpoint = AckermannControlSetpoint | DifferentialControlSetpoint
|
|
||||||
|
|
||||||
|
|
||||||
def control_setpoint_from_dict(value: object) -> ControlSetpoint:
|
|
||||||
document = _object(value, "control setpoint")
|
|
||||||
if document.get("schema_version") != "missioncore.control-setpoint/v1":
|
|
||||||
raise SimulationContractError("unsupported control-setpoint schema")
|
|
||||||
try:
|
|
||||||
profile = ControlProfile(_string(document, "profile"))
|
|
||||||
authority_scope = CommandAuthorityScope(_string(document, "authority_scope"))
|
|
||||||
except ValueError as exc:
|
|
||||||
raise SimulationContractError("unknown control profile or authority scope") from exc
|
|
||||||
run_id = _string(document, "run_id")
|
|
||||||
command_id = _string(document, "command_id")
|
|
||||||
sequence = _integer(document, "sequence")
|
|
||||||
issued_at_sim_ns = _integer(document, "issued_at_sim_ns")
|
|
||||||
valid_until_sim_ns = _integer(document, "valid_until_sim_ns")
|
|
||||||
authority_generation = _integer(document, "authority_generation")
|
|
||||||
speed_mps = _number(document, "speed_mps")
|
|
||||||
if profile is ControlProfile.ROVER_SPEED_STEERING_V1:
|
|
||||||
return AckermannControlSetpoint(
|
|
||||||
run_id=run_id,
|
|
||||||
command_id=command_id,
|
|
||||||
sequence=sequence,
|
|
||||||
issued_at_sim_ns=issued_at_sim_ns,
|
|
||||||
valid_until_sim_ns=valid_until_sim_ns,
|
|
||||||
authority_generation=authority_generation,
|
|
||||||
speed_mps=speed_mps,
|
|
||||||
steering_normalized=_number(document, "steering_normalized"),
|
|
||||||
authority_scope=authority_scope,
|
|
||||||
source=_string(document, "source"),
|
|
||||||
)
|
|
||||||
return DifferentialControlSetpoint(
|
|
||||||
run_id=run_id,
|
|
||||||
command_id=command_id,
|
|
||||||
sequence=sequence,
|
|
||||||
issued_at_sim_ns=issued_at_sim_ns,
|
|
||||||
valid_until_sim_ns=valid_until_sim_ns,
|
|
||||||
authority_generation=authority_generation,
|
|
||||||
speed_mps=speed_mps,
|
|
||||||
yaw_rate_rps=_number(document, "yaw_rate_rps"),
|
|
||||||
authority_scope=authority_scope,
|
|
||||||
source=_string(document, "source"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _command_header(
|
|
||||||
run_id: str,
|
|
||||||
command_id: str,
|
|
||||||
sequence: int,
|
|
||||||
issued_at_sim_ns: int,
|
|
||||||
valid_until_sim_ns: int,
|
|
||||||
authority_generation: int,
|
|
||||||
) -> None:
|
|
||||||
_identifier(run_id, "command run id")
|
|
||||||
_identifier(command_id, "command id")
|
|
||||||
if sequence < 1:
|
|
||||||
raise SimulationContractError("command sequence must be positive")
|
|
||||||
if issued_at_sim_ns < 0:
|
|
||||||
raise SimulationContractError("command issue time must not be negative")
|
|
||||||
if valid_until_sim_ns <= issued_at_sim_ns:
|
|
||||||
raise SimulationContractError("command validity deadline must follow issue time")
|
|
||||||
if authority_generation < 1:
|
|
||||||
raise SimulationContractError("command authority generation must be positive")
|
|
||||||
|
|
||||||
|
|
||||||
def _command_provenance(authority_scope: CommandAuthorityScope, source: str) -> None:
|
|
||||||
if authority_scope is not CommandAuthorityScope.VIRTUAL_ONLY:
|
|
||||||
raise SimulationContractError("S1 commands require virtual-only authority scope")
|
|
||||||
_identifier(source, "command source")
|
|
||||||
|
|
||||||
|
|
||||||
def _identifier(value: str, label: str) -> str:
|
|
||||||
if not IDENTIFIER_PATTERN.fullmatch(value):
|
|
||||||
raise SimulationContractError(f"{label} is not a safe identifier")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _sha256(value: str, label: str) -> str:
|
|
||||||
if not SHA256_PATTERN.fullmatch(value):
|
|
||||||
raise SimulationContractError(f"{label} must be a lowercase SHA-256 digest")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _relative_path(value: str) -> str:
|
|
||||||
path = PurePosixPath(value)
|
|
||||||
if (
|
|
||||||
not value
|
|
||||||
or value.startswith("/")
|
|
||||||
or "\\" in value
|
|
||||||
or path.is_absolute()
|
|
||||||
or path.as_posix() != value
|
|
||||||
or any(part in {"", ".", ".."} for part in path.parts)
|
|
||||||
):
|
|
||||||
raise SimulationContractError("artifact path must be a confined relative POSIX path")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _utc_timestamp(value: str, label: str) -> str:
|
|
||||||
if not value.endswith("Z") or "T" not in value:
|
|
||||||
raise SimulationContractError(f"{label} must be an explicit UTC timestamp")
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
|
|
||||||
except ValueError as exc:
|
|
||||||
raise SimulationContractError(f"{label} must be a valid explicit UTC timestamp") from exc
|
|
||||||
if parsed.tzinfo != UTC:
|
|
||||||
raise SimulationContractError(f"{label} must be an explicit UTC timestamp")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _finite(value: float, label: str) -> float:
|
|
||||||
if not math.isfinite(value):
|
|
||||||
raise SimulationContractError(f"{label} must be finite")
|
|
||||||
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 SimulationContractError(f"{label} must be an object")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _json_object(value: object, label: str) -> dict[str, Any]:
|
|
||||||
document = _object(value, label)
|
|
||||||
_json_value(document, label)
|
|
||||||
return document
|
|
||||||
|
|
||||||
|
|
||||||
def _json_value(value: object, label: str) -> None:
|
|
||||||
if value is None or isinstance(value, str | bool | int):
|
|
||||||
return
|
|
||||||
if isinstance(value, float):
|
|
||||||
_finite(value, label)
|
|
||||||
return
|
|
||||||
if isinstance(value, list):
|
|
||||||
for item in value:
|
|
||||||
_json_value(item, label)
|
|
||||||
return
|
|
||||||
if isinstance(value, dict) and all(isinstance(key, str) for key in value):
|
|
||||||
for item in value.values():
|
|
||||||
_json_value(item, label)
|
|
||||||
return
|
|
||||||
raise SimulationContractError(f"{label} must contain only finite JSON values")
|
|
||||||
|
|
||||||
|
|
||||||
def _string(document: dict[str, Any], key: str) -> str:
|
|
||||||
value = document.get(key)
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise SimulationContractError(f"{key} must be a string")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _optional_string(document: dict[str, Any], key: str) -> str | None:
|
|
||||||
value = document.get(key)
|
|
||||||
if value is not None and not isinstance(value, str):
|
|
||||||
raise SimulationContractError(f"{key} must be a string or null")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _integer(document: dict[str, Any], key: str) -> int:
|
|
||||||
value = document.get(key)
|
|
||||||
if not isinstance(value, int) or isinstance(value, bool):
|
|
||||||
raise SimulationContractError(f"{key} must be an integer")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _number(document: dict[str, Any], key: str) -> float:
|
|
||||||
value = document.get(key)
|
|
||||||
if not isinstance(value, int | float) or isinstance(value, bool):
|
|
||||||
raise SimulationContractError(f"{key} must be a number")
|
|
||||||
return float(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _boolean(document: dict[str, Any], key: str) -> bool:
|
|
||||||
value = document.get(key)
|
|
||||||
if not isinstance(value, bool):
|
|
||||||
raise SimulationContractError(f"{key} must be a boolean")
|
|
||||||
return value
|
|
||||||
|
|
@ -1,549 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import threading
|
|
||||||
from contextlib import suppress
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Protocol
|
|
||||||
|
|
||||||
from k1link.simulation.contracts import QualificationEvent, QualificationRun, RunState
|
|
||||||
from k1link.simulation.run_store import (
|
|
||||||
ACTIVE_RECOVERY_STATES,
|
|
||||||
QualificationRunConflictError,
|
|
||||||
QualificationRunStore,
|
|
||||||
QualificationRunTransitionError,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SimulationOrchestratorError(RuntimeError):
|
|
||||||
"""The server-owned S1 lifecycle could not be completed safely."""
|
|
||||||
|
|
||||||
|
|
||||||
class ActiveQualificationRunError(SimulationOrchestratorError):
|
|
||||||
"""Another qualification run already owns the worker authority."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class WorkerStartResult:
|
|
||||||
provider_ids: tuple[str, ...]
|
|
||||||
profile_sha256: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class WorkerStopResult:
|
|
||||||
stopped_provider_ids: tuple[str, ...]
|
|
||||||
residue_provider_ids: tuple[str, ...]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def clean(self) -> bool:
|
|
||||||
return not self.residue_provider_ids
|
|
||||||
|
|
||||||
|
|
||||||
class SimulationWorkerPort(Protocol):
|
|
||||||
"""Transport-neutral worker boundary; no browser/PX4 shortcut exists."""
|
|
||||||
|
|
||||||
def start(self, run: QualificationRun) -> WorkerStartResult: ...
|
|
||||||
|
|
||||||
def pause(self, run: QualificationRun) -> None: ...
|
|
||||||
|
|
||||||
def resume(self, run: QualificationRun) -> None: ...
|
|
||||||
|
|
||||||
def step(self, run: QualificationRun, step_count: int) -> int: ...
|
|
||||||
|
|
||||||
def stop(self, run: QualificationRun) -> WorkerStopResult: ...
|
|
||||||
|
|
||||||
def reconcile(self, run: QualificationRun) -> WorkerStopResult: ...
|
|
||||||
|
|
||||||
|
|
||||||
class SimulationApplicationService:
|
|
||||||
"""Sole owner of one worker's persisted qualification-run lifecycle."""
|
|
||||||
|
|
||||||
def __init__(self, store: QualificationRunStore, worker: SimulationWorkerPort) -> None:
|
|
||||||
self.store = store
|
|
||||||
self.worker = worker
|
|
||||||
self._lock = threading.RLock()
|
|
||||||
|
|
||||||
def start(
|
|
||||||
self,
|
|
||||||
run_id: str,
|
|
||||||
*,
|
|
||||||
idempotency_key: str,
|
|
||||||
observed_at_utc: str,
|
|
||||||
host_monotonic_ns: int,
|
|
||||||
) -> QualificationRun:
|
|
||||||
key = _idempotency_key(idempotency_key)
|
|
||||||
with self._lock:
|
|
||||||
run = self.store.load(run_id)
|
|
||||||
existing = self._start_request(run_id, key)
|
|
||||||
if existing:
|
|
||||||
if run.state is RunState.ADMITTED:
|
|
||||||
return self._continue_start(
|
|
||||||
run,
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
)
|
|
||||||
return run
|
|
||||||
if self._has_other_start_request(run_id):
|
|
||||||
raise QualificationRunConflictError(
|
|
||||||
"qualification run is already bound to another start request"
|
|
||||||
)
|
|
||||||
active = tuple(
|
|
||||||
candidate
|
|
||||||
for candidate in self.store.list_runs()
|
|
||||||
if candidate.run_id != run_id and candidate.state in ACTIVE_RECOVERY_STATES
|
|
||||||
)
|
|
||||||
if active:
|
|
||||||
raise ActiveQualificationRunError(
|
|
||||||
f"worker authority is owned by {active[0].run_id}"
|
|
||||||
)
|
|
||||||
if run.state is not RunState.ADMITTED:
|
|
||||||
raise QualificationRunTransitionError(
|
|
||||||
"only an admitted qualification run can start"
|
|
||||||
)
|
|
||||||
event = self.store.append_event(
|
|
||||||
run_id,
|
|
||||||
event_type="orchestrator.start-requested",
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
payload={"idempotency_key": key},
|
|
||||||
expected_revision=run.revision,
|
|
||||||
)
|
|
||||||
run = self.store.load(run_id)
|
|
||||||
if run.revision != event.sequence:
|
|
||||||
raise QualificationRunConflictError("start request journal changed")
|
|
||||||
return self._continue_start(
|
|
||||||
run,
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
)
|
|
||||||
|
|
||||||
def pause(
|
|
||||||
self,
|
|
||||||
run_id: str,
|
|
||||||
*,
|
|
||||||
observed_at_utc: str,
|
|
||||||
host_monotonic_ns: int,
|
|
||||||
sim_time_ns: int,
|
|
||||||
) -> QualificationRun:
|
|
||||||
with self._lock:
|
|
||||||
run = self.store.load(run_id)
|
|
||||||
if run.state is not RunState.RUNNING:
|
|
||||||
raise QualificationRunTransitionError("only a running run can pause")
|
|
||||||
try:
|
|
||||||
self.worker.pause(run)
|
|
||||||
except Exception as exc:
|
|
||||||
return self._fail_worker_operation(
|
|
||||||
run,
|
|
||||||
operation="pause",
|
|
||||||
exc=exc,
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=sim_time_ns,
|
|
||||||
)
|
|
||||||
return self.store.transition(
|
|
||||||
run_id,
|
|
||||||
RunState.PAUSED,
|
|
||||||
expected_revision=run.revision,
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=sim_time_ns,
|
|
||||||
)
|
|
||||||
|
|
||||||
def resume(
|
|
||||||
self,
|
|
||||||
run_id: str,
|
|
||||||
*,
|
|
||||||
observed_at_utc: str,
|
|
||||||
host_monotonic_ns: int,
|
|
||||||
sim_time_ns: int,
|
|
||||||
) -> QualificationRun:
|
|
||||||
with self._lock:
|
|
||||||
run = self.store.load(run_id)
|
|
||||||
if run.state is not RunState.PAUSED:
|
|
||||||
raise QualificationRunTransitionError("only a paused run can resume")
|
|
||||||
try:
|
|
||||||
self.worker.resume(run)
|
|
||||||
except Exception as exc:
|
|
||||||
return self._fail_worker_operation(
|
|
||||||
run,
|
|
||||||
operation="resume",
|
|
||||||
exc=exc,
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=sim_time_ns,
|
|
||||||
)
|
|
||||||
return self.store.transition(
|
|
||||||
run_id,
|
|
||||||
RunState.RUNNING,
|
|
||||||
expected_revision=run.revision,
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=sim_time_ns,
|
|
||||||
)
|
|
||||||
|
|
||||||
def step(
|
|
||||||
self,
|
|
||||||
run_id: str,
|
|
||||||
*,
|
|
||||||
step_count: int,
|
|
||||||
observed_at_utc: str,
|
|
||||||
host_monotonic_ns: int,
|
|
||||||
sim_time_ns: int,
|
|
||||||
) -> int:
|
|
||||||
if step_count < 1:
|
|
||||||
raise ValueError("step count must be positive")
|
|
||||||
with self._lock:
|
|
||||||
run = self.store.load(run_id)
|
|
||||||
if run.state is not RunState.PAUSED:
|
|
||||||
raise QualificationRunTransitionError("single-step requires a paused run")
|
|
||||||
try:
|
|
||||||
advanced_ns = self.worker.step(run, step_count)
|
|
||||||
except Exception as exc:
|
|
||||||
self._fail_worker_operation(
|
|
||||||
run,
|
|
||||||
operation="step",
|
|
||||||
exc=exc,
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=sim_time_ns,
|
|
||||||
)
|
|
||||||
raise SimulationOrchestratorError("worker step failed") from exc
|
|
||||||
if advanced_ns < 1:
|
|
||||||
self._fail_worker_operation(
|
|
||||||
run,
|
|
||||||
operation="step-invalid-advance",
|
|
||||||
exc=ValueError("worker returned a non-positive clock advance"),
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=sim_time_ns,
|
|
||||||
)
|
|
||||||
raise SimulationOrchestratorError(
|
|
||||||
"worker step returned a non-positive clock advance"
|
|
||||||
)
|
|
||||||
self.store.append_event(
|
|
||||||
run_id,
|
|
||||||
event_type="clock.single-step",
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=sim_time_ns + advanced_ns,
|
|
||||||
payload={"step_count": step_count, "advanced_ns": advanced_ns},
|
|
||||||
expected_revision=run.revision,
|
|
||||||
)
|
|
||||||
return advanced_ns
|
|
||||||
|
|
||||||
def stop(
|
|
||||||
self,
|
|
||||||
run_id: str,
|
|
||||||
*,
|
|
||||||
idempotency_key: str,
|
|
||||||
observed_at_utc: str,
|
|
||||||
host_monotonic_ns: int,
|
|
||||||
sim_time_ns: int | None,
|
|
||||||
terminal_state: RunState = RunState.COMPLETED,
|
|
||||||
reason: str = "operator-stop-clean",
|
|
||||||
) -> QualificationRun:
|
|
||||||
key = _idempotency_key(idempotency_key)
|
|
||||||
if terminal_state not in {RunState.COMPLETED, RunState.ABORTED}:
|
|
||||||
raise ValueError("operator stop terminal state must be completed or aborted")
|
|
||||||
with self._lock:
|
|
||||||
run = self.store.load(run_id)
|
|
||||||
existing = self._stop_request(run_id, key)
|
|
||||||
if existing is not None and (
|
|
||||||
existing.payload.get("terminal_state") != terminal_state.value
|
|
||||||
or existing.payload.get("reason") != reason
|
|
||||||
):
|
|
||||||
raise QualificationRunConflictError(
|
|
||||||
"idempotency key is bound to a different stop request"
|
|
||||||
)
|
|
||||||
if existing is not None and run.state.terminal:
|
|
||||||
return run
|
|
||||||
if existing is None:
|
|
||||||
if self._has_other_stop_request(run_id):
|
|
||||||
raise QualificationRunConflictError(
|
|
||||||
"qualification run is already bound to another stop request"
|
|
||||||
)
|
|
||||||
event = self.store.append_event(
|
|
||||||
run_id,
|
|
||||||
event_type="orchestrator.stop-requested",
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=sim_time_ns,
|
|
||||||
payload={
|
|
||||||
"idempotency_key": key,
|
|
||||||
"terminal_state": terminal_state.value,
|
|
||||||
"reason": reason,
|
|
||||||
},
|
|
||||||
expected_revision=run.revision,
|
|
||||||
)
|
|
||||||
run = self.store.load(run_id)
|
|
||||||
if run.revision != event.sequence:
|
|
||||||
raise QualificationRunConflictError("stop request journal changed")
|
|
||||||
if run.state not in {RunState.RUNNING, RunState.PAUSED, RunState.STOPPING}:
|
|
||||||
raise QualificationRunTransitionError(
|
|
||||||
"only a running, paused or stopping run can stop"
|
|
||||||
)
|
|
||||||
if run.state is not RunState.STOPPING:
|
|
||||||
run = self.store.transition(
|
|
||||||
run_id,
|
|
||||||
RunState.STOPPING,
|
|
||||||
expected_revision=run.revision,
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=sim_time_ns,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
outcome = self.worker.stop(run)
|
|
||||||
except Exception as exc:
|
|
||||||
return self._terminal_failure(
|
|
||||||
run,
|
|
||||||
operation="stop",
|
|
||||||
detail=type(exc).__name__,
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=sim_time_ns,
|
|
||||||
)
|
|
||||||
if not outcome.clean:
|
|
||||||
return self._terminal_failure(
|
|
||||||
run,
|
|
||||||
operation="stop-residue",
|
|
||||||
detail=",".join(outcome.residue_provider_ids),
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=sim_time_ns,
|
|
||||||
)
|
|
||||||
event = self.store.append_event(
|
|
||||||
run_id,
|
|
||||||
event_type="orchestrator.providers-stopped",
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=sim_time_ns,
|
|
||||||
payload={"provider_ids": list(outcome.stopped_provider_ids)},
|
|
||||||
expected_revision=run.revision,
|
|
||||||
)
|
|
||||||
return self.store.transition(
|
|
||||||
run_id,
|
|
||||||
terminal_state,
|
|
||||||
expected_revision=event.sequence,
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=sim_time_ns,
|
|
||||||
reason=reason,
|
|
||||||
)
|
|
||||||
|
|
||||||
def reset(
|
|
||||||
self,
|
|
||||||
run_id: str,
|
|
||||||
*,
|
|
||||||
idempotency_key: str,
|
|
||||||
new_run_id: str,
|
|
||||||
new_episode_id: str,
|
|
||||||
observed_at_utc: str,
|
|
||||||
host_monotonic_ns: int,
|
|
||||||
sim_time_ns: int | None,
|
|
||||||
) -> QualificationRun:
|
|
||||||
with self._lock:
|
|
||||||
key = _idempotency_key(idempotency_key)
|
|
||||||
if len(key) > 155:
|
|
||||||
raise ValueError("reset idempotency key must contain at most 155 characters")
|
|
||||||
terminal = self.stop(
|
|
||||||
run_id,
|
|
||||||
idempotency_key=f"{key}:stop",
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=sim_time_ns,
|
|
||||||
terminal_state=RunState.ABORTED,
|
|
||||||
reason="operator-reset",
|
|
||||||
)
|
|
||||||
return self.store.create_reset_episode(
|
|
||||||
terminal.run_id,
|
|
||||||
run_id=new_run_id,
|
|
||||||
episode_id=new_episode_id,
|
|
||||||
created_at_utc=observed_at_utc,
|
|
||||||
)
|
|
||||||
|
|
||||||
def reconcile(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
observed_at_utc: str,
|
|
||||||
host_monotonic_ns: int,
|
|
||||||
) -> tuple[QualificationRun, ...]:
|
|
||||||
reconciled: list[QualificationRun] = []
|
|
||||||
with self._lock:
|
|
||||||
for run in self.store.list_runs():
|
|
||||||
if run.state not in ACTIVE_RECOVERY_STATES:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
outcome = self.worker.reconcile(run)
|
|
||||||
residue = outcome.residue_provider_ids
|
|
||||||
except Exception as exc:
|
|
||||||
residue = (f"reconcile-error:{type(exc).__name__}",)
|
|
||||||
if residue:
|
|
||||||
event = self.store.append_event(
|
|
||||||
run.run_id,
|
|
||||||
event_type="orchestrator.recovery-residue",
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
payload={"provider_ids": list(residue)},
|
|
||||||
expected_revision=run.revision,
|
|
||||||
)
|
|
||||||
run = self.store.load(run.run_id)
|
|
||||||
if run.revision != event.sequence:
|
|
||||||
raise QualificationRunConflictError("recovery event journal changed")
|
|
||||||
reconciled.append(
|
|
||||||
self.store.reconcile_interrupted(
|
|
||||||
run.run_id,
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return tuple(reconciled)
|
|
||||||
|
|
||||||
def _continue_start(
|
|
||||||
self,
|
|
||||||
run: QualificationRun,
|
|
||||||
*,
|
|
||||||
observed_at_utc: str,
|
|
||||||
host_monotonic_ns: int,
|
|
||||||
) -> QualificationRun:
|
|
||||||
starting = self.store.transition(
|
|
||||||
run.run_id,
|
|
||||||
RunState.STARTING,
|
|
||||||
expected_revision=run.revision,
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
outcome = self.worker.start(starting)
|
|
||||||
except Exception as exc:
|
|
||||||
with suppress(Exception):
|
|
||||||
self.worker.stop(starting)
|
|
||||||
return self._terminal_failure(
|
|
||||||
starting,
|
|
||||||
operation="start",
|
|
||||||
detail=_exception_detail(exc),
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=0,
|
|
||||||
)
|
|
||||||
if outcome.profile_sha256 != starting.host_profile_sha256:
|
|
||||||
with suppress(Exception):
|
|
||||||
self.worker.stop(starting)
|
|
||||||
return self._terminal_failure(
|
|
||||||
starting,
|
|
||||||
operation="profile-drift",
|
|
||||||
detail=outcome.profile_sha256,
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=0,
|
|
||||||
)
|
|
||||||
event = self.store.append_event(
|
|
||||||
run.run_id,
|
|
||||||
event_type="orchestrator.providers-started",
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=0,
|
|
||||||
payload={
|
|
||||||
"provider_ids": list(outcome.provider_ids),
|
|
||||||
"profile_sha256": outcome.profile_sha256,
|
|
||||||
},
|
|
||||||
expected_revision=starting.revision,
|
|
||||||
)
|
|
||||||
return self.store.transition(
|
|
||||||
run.run_id,
|
|
||||||
RunState.RUNNING,
|
|
||||||
expected_revision=event.sequence,
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _fail_worker_operation(
|
|
||||||
self,
|
|
||||||
run: QualificationRun,
|
|
||||||
*,
|
|
||||||
operation: str,
|
|
||||||
exc: Exception,
|
|
||||||
observed_at_utc: str,
|
|
||||||
host_monotonic_ns: int,
|
|
||||||
sim_time_ns: int | None,
|
|
||||||
) -> QualificationRun:
|
|
||||||
with suppress(Exception):
|
|
||||||
self.worker.stop(run)
|
|
||||||
return self._terminal_failure(
|
|
||||||
run,
|
|
||||||
operation=operation,
|
|
||||||
detail=_exception_detail(exc),
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=sim_time_ns,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _terminal_failure(
|
|
||||||
self,
|
|
||||||
run: QualificationRun,
|
|
||||||
*,
|
|
||||||
operation: str,
|
|
||||||
detail: str,
|
|
||||||
observed_at_utc: str,
|
|
||||||
host_monotonic_ns: int,
|
|
||||||
sim_time_ns: int | None,
|
|
||||||
) -> QualificationRun:
|
|
||||||
event = self.store.append_event(
|
|
||||||
run.run_id,
|
|
||||||
event_type="orchestrator.operation-failed",
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=sim_time_ns,
|
|
||||||
payload={"operation": operation, "detail": detail[:256]},
|
|
||||||
expected_revision=run.revision,
|
|
||||||
)
|
|
||||||
return self.store.transition(
|
|
||||||
run.run_id,
|
|
||||||
RunState.FAILED,
|
|
||||||
expected_revision=event.sequence,
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=sim_time_ns,
|
|
||||||
reason=f"orchestrator-{operation}-failed",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _start_request(self, run_id: str, key: str) -> bool:
|
|
||||||
return any(
|
|
||||||
event.event_type == "orchestrator.start-requested"
|
|
||||||
and event.payload.get("idempotency_key") == key
|
|
||||||
for event in self.store.list_events(run_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _has_other_start_request(self, run_id: str) -> bool:
|
|
||||||
return any(
|
|
||||||
event.event_type == "orchestrator.start-requested"
|
|
||||||
for event in self.store.list_events(run_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _stop_request(self, run_id: str, key: str) -> QualificationEvent | None:
|
|
||||||
return next(
|
|
||||||
(
|
|
||||||
event
|
|
||||||
for event in self.store.list_events(run_id)
|
|
||||||
if event.event_type == "orchestrator.stop-requested"
|
|
||||||
and event.payload.get("idempotency_key") == key
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _has_other_stop_request(self, run_id: str) -> bool:
|
|
||||||
return any(
|
|
||||||
event.event_type == "orchestrator.stop-requested"
|
|
||||||
for event in self.store.list_events(run_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _exception_detail(exc: Exception) -> str:
|
|
||||||
message = " ".join(str(exc).split())
|
|
||||||
return type(exc).__name__ if not message else f"{type(exc).__name__}: {message}"
|
|
||||||
|
|
||||||
|
|
||||||
def _idempotency_key(value: str) -> str:
|
|
||||||
normalized = value.strip()
|
|
||||||
if not 1 <= len(normalized) <= 160:
|
|
||||||
raise ValueError("idempotency key must contain 1..160 characters")
|
|
||||||
return normalized
|
|
||||||
|
|
@ -1,391 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import signal
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
from k1link.artifacts import write_json_atomic
|
|
||||||
|
|
||||||
PROCESS_ID_PATTERN: Final = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessSupervisorError(RuntimeError):
|
|
||||||
"""A provider process graph could not be owned or stopped safely."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ProcessSpec:
|
|
||||||
process_id: str
|
|
||||||
argv: tuple[str, ...]
|
|
||||||
start_order: int
|
|
||||||
shutdown_order: int
|
|
||||||
environment: tuple[tuple[str, str], ...] = ()
|
|
||||||
startup_grace_seconds: float = 0.05
|
|
||||||
ready_log_patterns: tuple[str, ...] = ()
|
|
||||||
health_timeout_seconds: float = 0.0
|
|
||||||
health_poll_interval_seconds: float = 0.05
|
|
||||||
keep_stdin_open: bool = False
|
|
||||||
interrupt_timeout_seconds: float = 2.0
|
|
||||||
terminate_timeout_seconds: float = 1.0
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if not PROCESS_ID_PATTERN.fullmatch(self.process_id):
|
|
||||||
raise ValueError("process id must be a safe lowercase identifier")
|
|
||||||
if not self.argv or any(not item or "\x00" in item for item in self.argv):
|
|
||||||
raise ValueError("process argv must contain nonempty NUL-free values")
|
|
||||||
if self.start_order < 0 or self.shutdown_order < 0:
|
|
||||||
raise ValueError("process order must not be negative")
|
|
||||||
if (
|
|
||||||
self.startup_grace_seconds < 0
|
|
||||||
or self.health_timeout_seconds < 0
|
|
||||||
or self.health_poll_interval_seconds <= 0
|
|
||||||
or self.interrupt_timeout_seconds < 0
|
|
||||||
or self.terminate_timeout_seconds < 0
|
|
||||||
):
|
|
||||||
raise ValueError("process timeouts must not be negative")
|
|
||||||
if any(not pattern or "\x00" in pattern for pattern in self.ready_log_patterns):
|
|
||||||
raise ValueError("ready log patterns must be nonempty and NUL-free")
|
|
||||||
if len(self.ready_log_patterns) != len(set(self.ready_log_patterns)):
|
|
||||||
raise ValueError("ready log patterns must be unique")
|
|
||||||
if self.ready_log_patterns and self.health_timeout_seconds <= 0:
|
|
||||||
raise ValueError("ready log patterns require a positive health timeout")
|
|
||||||
keys = [key for key, _ in self.environment]
|
|
||||||
if len(keys) != len(set(keys)) or any(
|
|
||||||
not key or "=" in key or "\x00" in key or "\x00" in value
|
|
||||||
for key, value in self.environment
|
|
||||||
):
|
|
||||||
raise ValueError("process environment must contain unique safe entries")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class OwnedProcess:
|
|
||||||
process_id: str
|
|
||||||
pid: int
|
|
||||||
pgid: int
|
|
||||||
start_order: int
|
|
||||||
shutdown_order: int
|
|
||||||
stdout_path: Path
|
|
||||||
stderr_path: Path
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"process_id": self.process_id,
|
|
||||||
"pid": self.pid,
|
|
||||||
"pgid": self.pgid,
|
|
||||||
"start_order": self.start_order,
|
|
||||||
"shutdown_order": self.shutdown_order,
|
|
||||||
"stdout_path": self.stdout_path.name,
|
|
||||||
"stderr_path": self.stderr_path.name,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ProcessStopResult:
|
|
||||||
stopped_process_ids: tuple[str, ...]
|
|
||||||
residue_process_ids: tuple[str, ...]
|
|
||||||
|
|
||||||
|
|
||||||
class PosixProcessSupervisor:
|
|
||||||
"""Own provider PGIDs and stop them in explicit reverse dependency order."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
runtime_root: Path,
|
|
||||||
*,
|
|
||||||
base_environment: dict[str, str] | None = None,
|
|
||||||
) -> None:
|
|
||||||
if os.name != "posix":
|
|
||||||
raise ProcessSupervisorError("S1 process supervisor requires a POSIX worker")
|
|
||||||
self.runtime_root = runtime_root.expanduser().resolve()
|
|
||||||
self.runtime_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
||||||
self.base_environment = dict(base_environment or {})
|
|
||||||
self._lock = threading.RLock()
|
|
||||||
self._run_id: str | None = None
|
|
||||||
self._processes: dict[str, tuple[ProcessSpec, subprocess.Popen[bytes]]] = {}
|
|
||||||
|
|
||||||
def start(self, run_id: str, specs: tuple[ProcessSpec, ...]) -> tuple[OwnedProcess, ...]:
|
|
||||||
if not PROCESS_ID_PATTERN.fullmatch(run_id):
|
|
||||||
raise ProcessSupervisorError("run id must be a safe lowercase identifier")
|
|
||||||
if not specs:
|
|
||||||
raise ProcessSupervisorError("provider graph must not be empty")
|
|
||||||
identifiers = [spec.process_id for spec in specs]
|
|
||||||
if len(identifiers) != len(set(identifiers)):
|
|
||||||
raise ProcessSupervisorError("provider process ids must be unique")
|
|
||||||
start_orders = [spec.start_order for spec in specs]
|
|
||||||
shutdown_orders = [spec.shutdown_order for spec in specs]
|
|
||||||
if len(start_orders) != len(set(start_orders)):
|
|
||||||
raise ProcessSupervisorError("provider start orders must be unique")
|
|
||||||
if len(shutdown_orders) != len(set(shutdown_orders)):
|
|
||||||
raise ProcessSupervisorError("provider shutdown orders must be unique")
|
|
||||||
with self._lock:
|
|
||||||
if self._processes:
|
|
||||||
raise ProcessSupervisorError(
|
|
||||||
f"process authority is already owned by {self._run_id}"
|
|
||||||
)
|
|
||||||
run_root = self.runtime_root / run_id
|
|
||||||
if run_root.exists():
|
|
||||||
raise ProcessSupervisorError("run-scoped process directory already exists")
|
|
||||||
run_root.mkdir(mode=0o700)
|
|
||||||
self._run_id = run_id
|
|
||||||
try:
|
|
||||||
for spec in sorted(specs, key=lambda item: item.start_order):
|
|
||||||
self._start_one(run_root, spec)
|
|
||||||
records = self.snapshot()
|
|
||||||
self._persist_registry(run_root, records, terminal=False)
|
|
||||||
return records
|
|
||||||
except BaseException:
|
|
||||||
self.stop()
|
|
||||||
raise
|
|
||||||
|
|
||||||
def snapshot(self) -> tuple[OwnedProcess, ...]:
|
|
||||||
with self._lock:
|
|
||||||
records: list[OwnedProcess] = []
|
|
||||||
for process_id, (spec, process) in self._processes.items():
|
|
||||||
return_code = process.poll()
|
|
||||||
if return_code is not None:
|
|
||||||
raise ProcessSupervisorError(f"provider {process_id} exited with {return_code}")
|
|
||||||
records.append(
|
|
||||||
OwnedProcess(
|
|
||||||
process_id=process_id,
|
|
||||||
pid=process.pid,
|
|
||||||
pgid=os.getpgid(process.pid),
|
|
||||||
start_order=spec.start_order,
|
|
||||||
shutdown_order=spec.shutdown_order,
|
|
||||||
stdout_path=self.runtime_root
|
|
||||||
/ str(self._run_id)
|
|
||||||
/ f"{process_id}.stdout.log",
|
|
||||||
stderr_path=self.runtime_root
|
|
||||||
/ str(self._run_id)
|
|
||||||
/ f"{process_id}.stderr.log",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return tuple(sorted(records, key=lambda item: item.start_order))
|
|
||||||
|
|
||||||
def stop(self) -> ProcessStopResult:
|
|
||||||
with self._lock:
|
|
||||||
stopped: list[str] = []
|
|
||||||
residue: list[str] = []
|
|
||||||
remaining: dict[str, tuple[ProcessSpec, subprocess.Popen[bytes]]] = {}
|
|
||||||
run_id = self._run_id
|
|
||||||
for process_id, (spec, process) in sorted(
|
|
||||||
self._processes.items(),
|
|
||||||
key=lambda item: item[1][0].shutdown_order,
|
|
||||||
):
|
|
||||||
pgid = process.pid
|
|
||||||
if self._stop_group(process, pgid, spec):
|
|
||||||
stopped.append(process_id)
|
|
||||||
else:
|
|
||||||
residue.append(process_id)
|
|
||||||
remaining[process_id] = (spec, process)
|
|
||||||
if run_id is not None:
|
|
||||||
run_root = self.runtime_root / run_id
|
|
||||||
records = tuple(
|
|
||||||
OwnedProcess(
|
|
||||||
process_id=process_id,
|
|
||||||
pid=process.pid,
|
|
||||||
pgid=process.pid,
|
|
||||||
start_order=spec.start_order,
|
|
||||||
shutdown_order=spec.shutdown_order,
|
|
||||||
stdout_path=run_root / f"{process_id}.stdout.log",
|
|
||||||
stderr_path=run_root / f"{process_id}.stderr.log",
|
|
||||||
)
|
|
||||||
for process_id, (spec, process) in self._processes.items()
|
|
||||||
)
|
|
||||||
self._persist_registry(
|
|
||||||
run_root,
|
|
||||||
records,
|
|
||||||
terminal=True,
|
|
||||||
residue=tuple(residue),
|
|
||||||
)
|
|
||||||
self._processes = remaining
|
|
||||||
if not remaining:
|
|
||||||
self._run_id = None
|
|
||||||
return ProcessStopResult(tuple(stopped), tuple(residue))
|
|
||||||
|
|
||||||
def residue(self) -> tuple[str, ...]:
|
|
||||||
with self._lock:
|
|
||||||
return tuple(
|
|
||||||
process_id
|
|
||||||
for process_id, (_, process) in self._processes.items()
|
|
||||||
if _group_exists(process.pid)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _start_one(self, run_root: Path, spec: ProcessSpec) -> None:
|
|
||||||
stdout_path = run_root / f"{spec.process_id}.stdout.log"
|
|
||||||
stderr_path = run_root / f"{spec.process_id}.stderr.log"
|
|
||||||
environment = dict(self.base_environment)
|
|
||||||
environment.update(spec.environment)
|
|
||||||
with stdout_path.open("xb") as stdout, stderr_path.open("xb") as stderr:
|
|
||||||
process = subprocess.Popen(
|
|
||||||
spec.argv,
|
|
||||||
cwd=run_root,
|
|
||||||
env=environment,
|
|
||||||
stdin=subprocess.PIPE if spec.keep_stdin_open else subprocess.DEVNULL,
|
|
||||||
stdout=stdout,
|
|
||||||
stderr=stderr,
|
|
||||||
start_new_session=True,
|
|
||||||
close_fds=True,
|
|
||||||
)
|
|
||||||
self._processes[spec.process_id] = (spec, process)
|
|
||||||
return_code = process.poll()
|
|
||||||
if return_code is not None:
|
|
||||||
phase = "before readiness" if spec.ready_log_patterns else "during startup"
|
|
||||||
raise ProcessSupervisorError(
|
|
||||||
f"provider {spec.process_id} exited {phase} with {return_code}"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
process_group_id = os.getpgid(process.pid)
|
|
||||||
except ProcessLookupError as exc:
|
|
||||||
return_code = process.poll()
|
|
||||||
phase = "before readiness" if spec.ready_log_patterns else "during startup"
|
|
||||||
raise ProcessSupervisorError(
|
|
||||||
f"provider {spec.process_id} exited {phase} with {return_code}"
|
|
||||||
) from exc
|
|
||||||
if process_group_id != process.pid:
|
|
||||||
raise ProcessSupervisorError(
|
|
||||||
f"provider {spec.process_id} did not acquire a dedicated process group"
|
|
||||||
)
|
|
||||||
if spec.ready_log_patterns:
|
|
||||||
self._wait_until_ready(
|
|
||||||
process,
|
|
||||||
spec,
|
|
||||||
stdout_path=stdout_path,
|
|
||||||
stderr_path=stderr_path,
|
|
||||||
)
|
|
||||||
elif spec.startup_grace_seconds:
|
|
||||||
time.sleep(spec.startup_grace_seconds)
|
|
||||||
return_code = process.poll()
|
|
||||||
if return_code is not None:
|
|
||||||
raise ProcessSupervisorError(
|
|
||||||
f"provider {spec.process_id} exited during startup with {return_code}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _wait_until_ready(
|
|
||||||
self,
|
|
||||||
process: subprocess.Popen[bytes],
|
|
||||||
spec: ProcessSpec,
|
|
||||||
*,
|
|
||||||
stdout_path: Path,
|
|
||||||
stderr_path: Path,
|
|
||||||
) -> None:
|
|
||||||
pending = {pattern: pattern.encode("utf-8") for pattern in spec.ready_log_patterns}
|
|
||||||
maximum_tail_bytes = max(len(pattern) for pattern in pending.values()) - 1
|
|
||||||
deadline = time.monotonic() + spec.health_timeout_seconds
|
|
||||||
with (
|
|
||||||
stdout_path.open("rb", buffering=0) as stdout,
|
|
||||||
stderr_path.open("rb", buffering=0) as stderr,
|
|
||||||
):
|
|
||||||
streams = (stdout, stderr)
|
|
||||||
tails = [b"", b""]
|
|
||||||
while pending:
|
|
||||||
return_code = process.poll()
|
|
||||||
if return_code is not None:
|
|
||||||
raise ProcessSupervisorError(
|
|
||||||
f"provider {spec.process_id} exited before readiness with {return_code}; "
|
|
||||||
f"missing markers: {', '.join(pending)}"
|
|
||||||
)
|
|
||||||
for index, stream in enumerate(streams):
|
|
||||||
content = tails[index] + stream.read()
|
|
||||||
if content:
|
|
||||||
pending = {
|
|
||||||
pattern: encoded
|
|
||||||
for pattern, encoded in pending.items()
|
|
||||||
if encoded not in content
|
|
||||||
}
|
|
||||||
tails[index] = content[-maximum_tail_bytes:] if maximum_tail_bytes else b""
|
|
||||||
if not pending:
|
|
||||||
return
|
|
||||||
remaining = deadline - time.monotonic()
|
|
||||||
if remaining <= 0:
|
|
||||||
raise ProcessSupervisorError(
|
|
||||||
f"provider {spec.process_id} did not reach readiness within "
|
|
||||||
f"{spec.health_timeout_seconds:g} seconds; "
|
|
||||||
f"missing markers: {', '.join(pending)}"
|
|
||||||
)
|
|
||||||
time.sleep(min(spec.health_poll_interval_seconds, remaining))
|
|
||||||
|
|
||||||
def _stop_group(
|
|
||||||
self,
|
|
||||||
process: subprocess.Popen[bytes],
|
|
||||||
pgid: int,
|
|
||||||
spec: ProcessSpec,
|
|
||||||
) -> bool:
|
|
||||||
group_signal_supported = True
|
|
||||||
for sent_signal, timeout in (
|
|
||||||
(signal.SIGINT, spec.interrupt_timeout_seconds),
|
|
||||||
(signal.SIGTERM, spec.terminate_timeout_seconds),
|
|
||||||
(signal.SIGKILL, 1.0),
|
|
||||||
):
|
|
||||||
if process.poll() is not None and not _group_exists(pgid):
|
|
||||||
process.wait(timeout=0)
|
|
||||||
_close_stdin(process)
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
if group_signal_supported:
|
|
||||||
os.killpg(pgid, sent_signal)
|
|
||||||
else:
|
|
||||||
process.send_signal(sent_signal)
|
|
||||||
except ProcessLookupError:
|
|
||||||
_close_stdin(process)
|
|
||||||
return True
|
|
||||||
except PermissionError:
|
|
||||||
# Some macOS application sandboxes deny killpg for an owned
|
|
||||||
# child session. The reviewed WSL target retains group signals;
|
|
||||||
# local development falls back to the group leader and does
|
|
||||||
# not claim target residue acceptance from this path.
|
|
||||||
group_signal_supported = False
|
|
||||||
process.send_signal(sent_signal)
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
if process.poll() is not None:
|
|
||||||
process.wait(timeout=0)
|
|
||||||
if not group_signal_supported or not _group_exists(pgid):
|
|
||||||
_close_stdin(process)
|
|
||||||
return True
|
|
||||||
time.sleep(0.01)
|
|
||||||
stopped = process.poll() is not None and (
|
|
||||||
not group_signal_supported or not _group_exists(pgid)
|
|
||||||
)
|
|
||||||
if stopped:
|
|
||||||
_close_stdin(process)
|
|
||||||
return stopped
|
|
||||||
|
|
||||||
def _persist_registry(
|
|
||||||
self,
|
|
||||||
run_root: Path,
|
|
||||||
records: tuple[OwnedProcess, ...],
|
|
||||||
*,
|
|
||||||
terminal: bool,
|
|
||||||
residue: tuple[str, ...] = (),
|
|
||||||
) -> None:
|
|
||||||
write_json_atomic(
|
|
||||||
run_root / "process-registry.json",
|
|
||||||
{
|
|
||||||
"schema_version": "missioncore.simulation-process-registry/v1",
|
|
||||||
"run_id": run_root.name,
|
|
||||||
"terminal": terminal,
|
|
||||||
"processes": [record.to_dict() for record in records],
|
|
||||||
"residue_process_ids": list(residue),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _group_exists(pgid: int) -> bool:
|
|
||||||
try:
|
|
||||||
os.killpg(pgid, 0)
|
|
||||||
except ProcessLookupError:
|
|
||||||
return False
|
|
||||||
except PermissionError:
|
|
||||||
return True
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _close_stdin(process: subprocess.Popen[bytes]) -> None:
|
|
||||||
if process.stdin is not None and not process.stdin.closed:
|
|
||||||
process.stdin.close()
|
|
||||||
|
|
@ -1,264 +0,0 @@
|
||||||
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")
|
|
||||||
|
|
@ -1,347 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
import math
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from contextlib import suppress
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Final
|
|
||||||
|
|
||||||
from k1link.simulation.contracts import AckermannControlSetpoint
|
|
||||||
|
|
||||||
OFFBOARD_HEARTBEAT_SECONDS: Final = 0.1
|
|
||||||
OFFBOARD_WARMUP_HEARTBEATS: Final = 12
|
|
||||||
ROS_PUBLISHER_STARTUP_TIMEOUT_SECONDS: Final = 15.0
|
|
||||||
OFFBOARD_ADMISSION_TIMEOUT_SECONDS: Final = 60.0
|
|
||||||
STOCK_ROVER_MAX_THROTTLE_SPEED_MPS: Final = 3.1
|
|
||||||
VEHICLE_COMMAND_DO_SET_MODE: Final = 176
|
|
||||||
VEHICLE_COMMAND_COMPONENT_ARM_DISARM: Final = 400
|
|
||||||
|
|
||||||
|
|
||||||
class Px4RoverControlError(RuntimeError):
|
|
||||||
"""The virtual-only PX4 rover command boundary failed closed."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Px4RoverControlSnapshot:
|
|
||||||
run_id: str
|
|
||||||
armed: bool
|
|
||||||
offboard: bool
|
|
||||||
ttl_expired_count: int
|
|
||||||
|
|
||||||
|
|
||||||
class Ros2Px4AckermannControl:
|
|
||||||
"""Publish canonical speed+steering through the PX4 ROS 2 offboard boundary."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
heartbeat_seconds: float = OFFBOARD_HEARTBEAT_SECONDS,
|
|
||||||
admission_timeout_seconds: float = OFFBOARD_ADMISSION_TIMEOUT_SECONDS,
|
|
||||||
) -> None:
|
|
||||||
if not 0.05 <= heartbeat_seconds <= 0.25:
|
|
||||||
raise ValueError("PX4 heartbeat period must remain within 4..20 Hz")
|
|
||||||
if admission_timeout_seconds <= 0:
|
|
||||||
raise ValueError("PX4 admission timeout must be positive")
|
|
||||||
self.heartbeat_seconds = heartbeat_seconds
|
|
||||||
self.admission_timeout_seconds = admission_timeout_seconds
|
|
||||||
self._lock = threading.RLock()
|
|
||||||
self._started = threading.Event()
|
|
||||||
self._control_ready = threading.Event()
|
|
||||||
self._disarmed = threading.Event()
|
|
||||||
self._stop = threading.Event()
|
|
||||||
self._thread: threading.Thread | None = None
|
|
||||||
self._run_id: str | None = None
|
|
||||||
self._latest_speed_mps = 0.0
|
|
||||||
self._latest_steering_normalized = 0.0
|
|
||||||
self._valid_until_monotonic_ns = 0
|
|
||||||
self._ttl_expired_count = 0
|
|
||||||
self._ttl_expired_for_latest = False
|
|
||||||
self._armed = False
|
|
||||||
self._offboard = False
|
|
||||||
self._disarm_requested = False
|
|
||||||
self._failure: BaseException | None = None
|
|
||||||
|
|
||||||
def start(self, run_id: str) -> Px4RoverControlSnapshot:
|
|
||||||
with self._lock:
|
|
||||||
if self._thread is not None:
|
|
||||||
raise Px4RoverControlError("PX4 command controller already owns a run")
|
|
||||||
self._run_id = run_id
|
|
||||||
self._latest_speed_mps = 0.0
|
|
||||||
self._latest_steering_normalized = 0.0
|
|
||||||
self._valid_until_monotonic_ns = 0
|
|
||||||
self._ttl_expired_for_latest = False
|
|
||||||
self._failure = None
|
|
||||||
self._stop.clear()
|
|
||||||
self._started.clear()
|
|
||||||
self._control_ready.clear()
|
|
||||||
self._disarmed.clear()
|
|
||||||
self._disarm_requested = False
|
|
||||||
self._thread = threading.Thread(
|
|
||||||
target=self._run,
|
|
||||||
name=f"px4-rover-control:{run_id}",
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
self._thread.start()
|
|
||||||
if not self._started.wait(timeout=ROS_PUBLISHER_STARTUP_TIMEOUT_SECONDS):
|
|
||||||
self._abort_thread()
|
|
||||||
raise Px4RoverControlError("PX4 ROS 2 command publisher did not start")
|
|
||||||
self._raise_failure()
|
|
||||||
if not self._control_ready.wait(timeout=self.admission_timeout_seconds):
|
|
||||||
self._abort_thread()
|
|
||||||
self._raise_failure()
|
|
||||||
raise Px4RoverControlError("PX4 did not confirm armed Offboard rover control")
|
|
||||||
self._raise_failure()
|
|
||||||
return self.snapshot(run_id)
|
|
||||||
|
|
||||||
def submit(self, command: AckermannControlSetpoint) -> Px4RoverControlSnapshot:
|
|
||||||
with self._lock:
|
|
||||||
if self._run_id != command.run_id or self._thread is None:
|
|
||||||
raise Px4RoverControlError("PX4 command belongs to another or inactive run")
|
|
||||||
self._raise_failure_locked()
|
|
||||||
ttl_ns = command.valid_until_sim_ns - command.issued_at_sim_ns
|
|
||||||
self._latest_speed_mps = command.speed_mps
|
|
||||||
self._latest_steering_normalized = command.steering_normalized
|
|
||||||
self._valid_until_monotonic_ns = time.monotonic_ns() + ttl_ns
|
|
||||||
self._ttl_expired_for_latest = False
|
|
||||||
return self.snapshot(command.run_id)
|
|
||||||
|
|
||||||
def stop(self, run_id: str) -> Px4RoverControlSnapshot:
|
|
||||||
with self._lock:
|
|
||||||
if self._run_id != run_id or self._thread is None:
|
|
||||||
raise Px4RoverControlError("PX4 stop belongs to another or inactive run")
|
|
||||||
self._latest_speed_mps = 0.0
|
|
||||||
self._latest_steering_normalized = 0.0
|
|
||||||
self._valid_until_monotonic_ns = 0
|
|
||||||
self._disarm_requested = True
|
|
||||||
self._disarmed.clear()
|
|
||||||
self._disarmed.wait(timeout=2)
|
|
||||||
snapshot = self.snapshot(run_id)
|
|
||||||
self._stop.set()
|
|
||||||
thread = self._thread
|
|
||||||
thread.join(timeout=3)
|
|
||||||
if thread.is_alive():
|
|
||||||
raise Px4RoverControlError("PX4 command publisher did not stop")
|
|
||||||
self._raise_failure()
|
|
||||||
with self._lock:
|
|
||||||
self._thread = None
|
|
||||||
self._run_id = None
|
|
||||||
return snapshot
|
|
||||||
|
|
||||||
def snapshot(self, run_id: str) -> Px4RoverControlSnapshot:
|
|
||||||
with self._lock:
|
|
||||||
if self._run_id != run_id:
|
|
||||||
raise Px4RoverControlError("PX4 snapshot belongs to another run")
|
|
||||||
return Px4RoverControlSnapshot(
|
|
||||||
run_id=run_id,
|
|
||||||
armed=self._armed,
|
|
||||||
offboard=self._offboard,
|
|
||||||
ttl_expired_count=self._ttl_expired_count,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _abort_thread(self) -> None:
|
|
||||||
self._stop.set()
|
|
||||||
thread = self._thread
|
|
||||||
if thread is not None:
|
|
||||||
thread.join(timeout=3)
|
|
||||||
with self._lock:
|
|
||||||
self._thread = None
|
|
||||||
self._run_id = None
|
|
||||||
|
|
||||||
def _raise_failure(self) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._raise_failure_locked()
|
|
||||||
|
|
||||||
def _raise_failure_locked(self) -> None:
|
|
||||||
if self._failure is not None:
|
|
||||||
raise Px4RoverControlError(
|
|
||||||
f"PX4 ROS 2 command publisher failed: {type(self._failure).__name__}"
|
|
||||||
) from self._failure
|
|
||||||
|
|
||||||
def _run(self) -> None:
|
|
||||||
rclpy: Any | None = None
|
|
||||||
node: Any | None = None
|
|
||||||
try:
|
|
||||||
rclpy = importlib.import_module("rclpy")
|
|
||||||
qos_module = importlib.import_module("rclpy.qos")
|
|
||||||
messages = importlib.import_module("px4_msgs.msg")
|
|
||||||
rclpy.init(args=None)
|
|
||||||
node = rclpy.create_node("missioncore_px4_ackermann_control")
|
|
||||||
qos = qos_module.QoSProfile(
|
|
||||||
reliability=qos_module.ReliabilityPolicy.BEST_EFFORT,
|
|
||||||
durability=qos_module.DurabilityPolicy.TRANSIENT_LOCAL,
|
|
||||||
history=qos_module.HistoryPolicy.KEEP_LAST,
|
|
||||||
depth=1,
|
|
||||||
)
|
|
||||||
offboard_publisher = node.create_publisher(
|
|
||||||
messages.OffboardControlMode,
|
|
||||||
"/fmu/in/offboard_control_mode",
|
|
||||||
qos,
|
|
||||||
)
|
|
||||||
throttle_publisher = node.create_publisher(
|
|
||||||
messages.RoverThrottleSetpoint,
|
|
||||||
"/fmu/in/rover_throttle_setpoint",
|
|
||||||
qos,
|
|
||||||
)
|
|
||||||
steering_publisher = node.create_publisher(
|
|
||||||
messages.RoverSteeringSetpoint,
|
|
||||||
"/fmu/in/rover_steering_setpoint",
|
|
||||||
qos,
|
|
||||||
)
|
|
||||||
command_publisher = node.create_publisher(
|
|
||||||
messages.VehicleCommand,
|
|
||||||
"/fmu/in/vehicle_command",
|
|
||||||
qos,
|
|
||||||
)
|
|
||||||
|
|
||||||
def receive_status(status: Any) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._armed = status.arming_state == messages.VehicleStatus.ARMING_STATE_ARMED
|
|
||||||
self._offboard = (
|
|
||||||
status.nav_state == messages.VehicleStatus.NAVIGATION_STATE_OFFBOARD
|
|
||||||
)
|
|
||||||
if self._armed and self._offboard:
|
|
||||||
self._control_ready.set()
|
|
||||||
|
|
||||||
node.create_subscription(
|
|
||||||
messages.VehicleStatus,
|
|
||||||
"/fmu/out/vehicle_status_v1",
|
|
||||||
receive_status,
|
|
||||||
qos,
|
|
||||||
)
|
|
||||||
self._started.set()
|
|
||||||
heartbeat_count = 0
|
|
||||||
last_mode_request_monotonic = 0.0
|
|
||||||
while not self._stop.is_set():
|
|
||||||
now_monotonic = time.monotonic()
|
|
||||||
now_monotonic_ns = time.monotonic_ns()
|
|
||||||
rclpy.spin_once(node, timeout_sec=0)
|
|
||||||
with self._lock:
|
|
||||||
expired = (
|
|
||||||
self._valid_until_monotonic_ns > 0
|
|
||||||
and now_monotonic_ns >= self._valid_until_monotonic_ns
|
|
||||||
)
|
|
||||||
if expired:
|
|
||||||
self._latest_speed_mps = 0.0
|
|
||||||
self._latest_steering_normalized = 0.0
|
|
||||||
self._valid_until_monotonic_ns = 0
|
|
||||||
if not self._ttl_expired_for_latest:
|
|
||||||
self._ttl_expired_count += 1
|
|
||||||
self._ttl_expired_for_latest = True
|
|
||||||
speed_mps = self._latest_speed_mps
|
|
||||||
steering_normalized = self._latest_steering_normalized
|
|
||||||
disarm_requested = self._disarm_requested
|
|
||||||
control_confirmed = self._armed and self._offboard
|
|
||||||
timestamp_us = int(node.get_clock().now().nanoseconds // 1_000)
|
|
||||||
self._publish_setpoint(
|
|
||||||
messages,
|
|
||||||
offboard_publisher,
|
|
||||||
throttle_publisher,
|
|
||||||
steering_publisher,
|
|
||||||
timestamp_us,
|
|
||||||
speed_mps,
|
|
||||||
steering_normalized,
|
|
||||||
)
|
|
||||||
heartbeat_count += 1
|
|
||||||
if (
|
|
||||||
heartbeat_count >= OFFBOARD_WARMUP_HEARTBEATS
|
|
||||||
and not control_confirmed
|
|
||||||
and now_monotonic - last_mode_request_monotonic >= 1.0
|
|
||||||
):
|
|
||||||
self._publish_vehicle_command(
|
|
||||||
messages,
|
|
||||||
command_publisher,
|
|
||||||
timestamp_us,
|
|
||||||
VEHICLE_COMMAND_DO_SET_MODE,
|
|
||||||
param1=1.0,
|
|
||||||
param2=6.0,
|
|
||||||
)
|
|
||||||
self._publish_vehicle_command(
|
|
||||||
messages,
|
|
||||||
command_publisher,
|
|
||||||
timestamp_us,
|
|
||||||
VEHICLE_COMMAND_COMPONENT_ARM_DISARM,
|
|
||||||
param1=1.0,
|
|
||||||
)
|
|
||||||
last_mode_request_monotonic = now_monotonic
|
|
||||||
if disarm_requested:
|
|
||||||
self._publish_vehicle_command(
|
|
||||||
messages,
|
|
||||||
command_publisher,
|
|
||||||
timestamp_us,
|
|
||||||
VEHICLE_COMMAND_COMPONENT_ARM_DISARM,
|
|
||||||
param1=0.0,
|
|
||||||
)
|
|
||||||
with self._lock:
|
|
||||||
self._armed = False
|
|
||||||
self._offboard = False
|
|
||||||
self._disarm_requested = False
|
|
||||||
self._disarmed.set()
|
|
||||||
self._stop.wait(self.heartbeat_seconds)
|
|
||||||
except BaseException as exc:
|
|
||||||
with self._lock:
|
|
||||||
self._failure = exc
|
|
||||||
self._started.set()
|
|
||||||
self._control_ready.set()
|
|
||||||
self._disarmed.set()
|
|
||||||
finally:
|
|
||||||
if node is not None:
|
|
||||||
node.destroy_node()
|
|
||||||
if rclpy is not None:
|
|
||||||
with suppress(Exception):
|
|
||||||
rclpy.shutdown()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _publish_setpoint(
|
|
||||||
messages: Any,
|
|
||||||
offboard_publisher: Any,
|
|
||||||
throttle_publisher: Any,
|
|
||||||
steering_publisher: Any,
|
|
||||||
timestamp_us: int,
|
|
||||||
speed_mps: float,
|
|
||||||
steering_normalized: float,
|
|
||||||
) -> None:
|
|
||||||
offboard = messages.OffboardControlMode()
|
|
||||||
offboard.timestamp = timestamp_us
|
|
||||||
offboard.position = False
|
|
||||||
offboard.velocity = False
|
|
||||||
offboard.acceleration = False
|
|
||||||
offboard.attitude = False
|
|
||||||
offboard.body_rate = False
|
|
||||||
offboard.thrust_and_torque = True
|
|
||||||
offboard.direct_actuator = False
|
|
||||||
offboard_publisher.publish(offboard)
|
|
||||||
|
|
||||||
throttle = messages.RoverThrottleSetpoint()
|
|
||||||
throttle.timestamp = timestamp_us
|
|
||||||
throttle.throttle_body_x = float(speed_mps / STOCK_ROVER_MAX_THROTTLE_SPEED_MPS)
|
|
||||||
throttle.throttle_body_y = math.nan
|
|
||||||
throttle_publisher.publish(throttle)
|
|
||||||
|
|
||||||
steering = messages.RoverSteeringSetpoint()
|
|
||||||
steering.timestamp = timestamp_us
|
|
||||||
steering.normalized_steering_setpoint = float(steering_normalized)
|
|
||||||
steering_publisher.publish(steering)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _publish_vehicle_command(
|
|
||||||
messages: Any,
|
|
||||||
publisher: Any,
|
|
||||||
timestamp_us: int,
|
|
||||||
command: int,
|
|
||||||
*,
|
|
||||||
param1: float,
|
|
||||||
param2: float = 0.0,
|
|
||||||
) -> None:
|
|
||||||
message = messages.VehicleCommand()
|
|
||||||
message.timestamp = timestamp_us
|
|
||||||
message.param1 = param1
|
|
||||||
message.param2 = param2
|
|
||||||
message.command = command
|
|
||||||
message.target_system = 1
|
|
||||||
message.target_component = 1
|
|
||||||
message.source_system = 1
|
|
||||||
message.source_component = 1
|
|
||||||
message.from_external = True
|
|
||||||
publisher.publish(message)
|
|
||||||
|
|
@ -1,641 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import stat
|
|
||||||
import threading
|
|
||||||
from contextlib import suppress
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Final
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from k1link.artifacts import write_json_atomic
|
|
||||||
from k1link.simulation.contracts import (
|
|
||||||
ControlSetpoint,
|
|
||||||
QualificationArtifact,
|
|
||||||
QualificationEvent,
|
|
||||||
QualificationRun,
|
|
||||||
RunKind,
|
|
||||||
RunState,
|
|
||||||
SimulationContractError,
|
|
||||||
control_setpoint_from_dict,
|
|
||||||
)
|
|
||||||
|
|
||||||
MAX_RECORD_BYTES: Final = 1024 * 1024
|
|
||||||
SEQUENCE_WIDTH: Final = 20
|
|
||||||
ACTIVE_RECOVERY_STATES: Final = {
|
|
||||||
RunState.STARTING,
|
|
||||||
RunState.RUNNING,
|
|
||||||
RunState.PAUSED,
|
|
||||||
RunState.STOPPING,
|
|
||||||
}
|
|
||||||
COMMANDABLE_RUN_KINDS: Final = {
|
|
||||||
RunKind.SIMULATION_CLOSED_LOOP,
|
|
||||||
RunKind.DIGITAL_TWIN_CLOSED_LOOP,
|
|
||||||
}
|
|
||||||
TRANSITIONS: Final[dict[RunState, frozenset[RunState]]] = {
|
|
||||||
RunState.ADMITTED: frozenset({RunState.STARTING, RunState.ABORTED}),
|
|
||||||
RunState.STARTING: frozenset({RunState.RUNNING, RunState.FAILED, RunState.ABORTED}),
|
|
||||||
RunState.RUNNING: frozenset(
|
|
||||||
{RunState.PAUSED, RunState.STOPPING, RunState.FAILED, RunState.ABORTED}
|
|
||||||
),
|
|
||||||
RunState.PAUSED: frozenset(
|
|
||||||
{RunState.RUNNING, RunState.STOPPING, RunState.FAILED, RunState.ABORTED}
|
|
||||||
),
|
|
||||||
RunState.STOPPING: frozenset({RunState.COMPLETED, RunState.FAILED, RunState.ABORTED}),
|
|
||||||
RunState.COMPLETED: frozenset(),
|
|
||||||
RunState.FAILED: frozenset(),
|
|
||||||
RunState.ABORTED: frozenset(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class QualificationRunStoreError(RuntimeError):
|
|
||||||
"""Base error for the append-only qualification-run repository."""
|
|
||||||
|
|
||||||
|
|
||||||
class QualificationRunNotFoundError(QualificationRunStoreError):
|
|
||||||
"""The requested qualification run is not present."""
|
|
||||||
|
|
||||||
|
|
||||||
class QualificationRunConflictError(QualificationRunStoreError):
|
|
||||||
"""The caller used stale revision/sequence state or an existing identity."""
|
|
||||||
|
|
||||||
|
|
||||||
class QualificationRunIntegrityError(QualificationRunStoreError):
|
|
||||||
"""Stored qualification evidence violates its immutable contract."""
|
|
||||||
|
|
||||||
|
|
||||||
class QualificationRunTransitionError(QualificationRunStoreError):
|
|
||||||
"""A requested lifecycle transition is not allowed."""
|
|
||||||
|
|
||||||
|
|
||||||
class QualificationRunStore:
|
|
||||||
"""Single-writer, crash-safe, append-only Polygon run repository.
|
|
||||||
|
|
||||||
The run manifest is immutable. Lifecycle, domain events, accepted commands
|
|
||||||
and artifact-index entries are one-file-per-record journals created with
|
|
||||||
exclusive filesystem semantics and fsynced before acknowledgement.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, root: Path, *, read_only: bool = False) -> None:
|
|
||||||
configured_root = root.expanduser()
|
|
||||||
if read_only:
|
|
||||||
if not configured_root.is_dir():
|
|
||||||
raise QualificationRunIntegrityError("qualification repository is missing")
|
|
||||||
_reject_symlink(configured_root, "qualification repository")
|
|
||||||
self.root = configured_root.resolve()
|
|
||||||
else:
|
|
||||||
self.root = configured_root.resolve()
|
|
||||||
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
||||||
_reject_symlink(self.root, "qualification repository")
|
|
||||||
_chmod_private(self.root)
|
|
||||||
self.read_only = read_only
|
|
||||||
self._lock = threading.RLock()
|
|
||||||
|
|
||||||
def create(self, run: QualificationRun) -> QualificationRun:
|
|
||||||
self._require_writable()
|
|
||||||
if (
|
|
||||||
run.state is not RunState.ADMITTED
|
|
||||||
or run.revision != 0
|
|
||||||
or run.artifacts
|
|
||||||
or run.started_at_utc is not None
|
|
||||||
or run.ended_at_utc is not None
|
|
||||||
or run.terminal_reason is not None
|
|
||||||
):
|
|
||||||
raise QualificationRunTransitionError(
|
|
||||||
"a new qualification run must be admitted at revision zero"
|
|
||||||
)
|
|
||||||
final = self._run_path(run.run_id)
|
|
||||||
with self._lock:
|
|
||||||
if final.exists():
|
|
||||||
raise QualificationRunConflictError("qualification run identity already exists")
|
|
||||||
staging = self.root / f".{run.run_id}.{uuid4().hex}.tmp"
|
|
||||||
try:
|
|
||||||
staging.mkdir(mode=0o700)
|
|
||||||
for name in ("events", "commands", "artifacts"):
|
|
||||||
(staging / name).mkdir(mode=0o700)
|
|
||||||
write_json_atomic(staging / "manifest.json", run.to_manifest_dict())
|
|
||||||
_fsync_directory(staging)
|
|
||||||
try:
|
|
||||||
staging.rename(final)
|
|
||||||
except FileExistsError as exc:
|
|
||||||
raise QualificationRunConflictError(
|
|
||||||
"qualification run identity already exists"
|
|
||||||
) from exc
|
|
||||||
_fsync_directory(self.root)
|
|
||||||
finally:
|
|
||||||
if staging.exists():
|
|
||||||
shutil.rmtree(staging)
|
|
||||||
return self.load(run.run_id)
|
|
||||||
|
|
||||||
def load(self, run_id: str) -> QualificationRun:
|
|
||||||
path = self._existing_run_path(run_id)
|
|
||||||
with self._lock:
|
|
||||||
manifest = QualificationRun.from_manifest_dict(
|
|
||||||
_read_json(path / "manifest.json", "run manifest")
|
|
||||||
)
|
|
||||||
if manifest.run_id != run_id:
|
|
||||||
raise QualificationRunIntegrityError("manifest identity does not match its path")
|
|
||||||
events = self._read_events(path, run_id)
|
|
||||||
artifacts = self._read_artifacts(path, run_id)
|
|
||||||
return _replay_runtime(manifest, events, artifacts)
|
|
||||||
|
|
||||||
def list_runs(self) -> tuple[QualificationRun, ...]:
|
|
||||||
with self._lock:
|
|
||||||
run_ids = tuple(
|
|
||||||
child.name
|
|
||||||
for child in sorted(self.root.iterdir(), key=lambda item: item.name)
|
|
||||||
if child.is_dir() and not child.name.startswith(".")
|
|
||||||
)
|
|
||||||
return tuple(self.load(run_id) for run_id in run_ids)
|
|
||||||
|
|
||||||
def transition(
|
|
||||||
self,
|
|
||||||
run_id: str,
|
|
||||||
new_state: RunState,
|
|
||||||
*,
|
|
||||||
expected_revision: int,
|
|
||||||
observed_at_utc: str,
|
|
||||||
host_monotonic_ns: int,
|
|
||||||
sim_time_ns: int | None = None,
|
|
||||||
reason: str | None = None,
|
|
||||||
) -> QualificationRun:
|
|
||||||
self._require_writable()
|
|
||||||
with self._lock:
|
|
||||||
current = self.load(run_id)
|
|
||||||
if current.revision != expected_revision:
|
|
||||||
raise QualificationRunConflictError(
|
|
||||||
f"run revision is {current.revision}, not {expected_revision}"
|
|
||||||
)
|
|
||||||
if new_state not in TRANSITIONS[current.state]:
|
|
||||||
raise QualificationRunTransitionError(
|
|
||||||
f"transition {current.state.value} -> {new_state.value} is not allowed"
|
|
||||||
)
|
|
||||||
if new_state.terminal and (reason is None or not reason.strip()):
|
|
||||||
raise QualificationRunTransitionError("terminal transition requires a reason")
|
|
||||||
if not new_state.terminal and reason is not None:
|
|
||||||
raise QualificationRunTransitionError(
|
|
||||||
"a nonterminal transition cannot set a terminal reason"
|
|
||||||
)
|
|
||||||
self._append_event_locked(
|
|
||||||
current,
|
|
||||||
event_type="lifecycle.state-changed",
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=sim_time_ns,
|
|
||||||
payload={
|
|
||||||
"from": current.state.value,
|
|
||||||
"to": new_state.value,
|
|
||||||
"reason": reason,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return self.load(run_id)
|
|
||||||
|
|
||||||
def append_event(
|
|
||||||
self,
|
|
||||||
run_id: str,
|
|
||||||
*,
|
|
||||||
event_type: str,
|
|
||||||
observed_at_utc: str,
|
|
||||||
host_monotonic_ns: int,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
sim_time_ns: int | None = None,
|
|
||||||
expected_revision: int | None = None,
|
|
||||||
) -> QualificationEvent:
|
|
||||||
self._require_writable()
|
|
||||||
if event_type == "lifecycle.state-changed":
|
|
||||||
raise QualificationRunTransitionError("lifecycle events must use transition()")
|
|
||||||
with self._lock:
|
|
||||||
current = self.load(run_id)
|
|
||||||
if current.state.terminal:
|
|
||||||
raise QualificationRunTransitionError(
|
|
||||||
"terminal qualification-run history is immutable"
|
|
||||||
)
|
|
||||||
if expected_revision is not None and current.revision != expected_revision:
|
|
||||||
raise QualificationRunConflictError(
|
|
||||||
f"run revision is {current.revision}, not {expected_revision}"
|
|
||||||
)
|
|
||||||
return self._append_event_locked(
|
|
||||||
current,
|
|
||||||
event_type=event_type,
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=sim_time_ns,
|
|
||||||
payload=payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
def submit_command(self, command: ControlSetpoint) -> ControlSetpoint:
|
|
||||||
self._require_writable()
|
|
||||||
with self._lock:
|
|
||||||
run = self.load(command.run_id)
|
|
||||||
_validate_command_admission(command, run)
|
|
||||||
directory = self._existing_run_path(command.run_id) / "commands"
|
|
||||||
expected_sequence = _next_sequence(directory)
|
|
||||||
if command.sequence != expected_sequence:
|
|
||||||
raise QualificationRunConflictError(f"command sequence must be {expected_sequence}")
|
|
||||||
_append_json_exclusive(
|
|
||||||
directory / _sequence_name(command.sequence),
|
|
||||||
command.to_dict(),
|
|
||||||
)
|
|
||||||
return command
|
|
||||||
|
|
||||||
def submit_admitted_command(
|
|
||||||
self,
|
|
||||||
command: ControlSetpoint,
|
|
||||||
*,
|
|
||||||
admitted_run: QualificationRun,
|
|
||||||
) -> ControlSetpoint:
|
|
||||||
"""Fsync one command using the worker's single-writer admitted run snapshot."""
|
|
||||||
|
|
||||||
self._require_writable()
|
|
||||||
with self._lock:
|
|
||||||
if admitted_run.run_id != command.run_id:
|
|
||||||
raise QualificationRunConflictError("command belongs to another admitted run")
|
|
||||||
_validate_command_admission(command, admitted_run)
|
|
||||||
directory = self._existing_run_path(command.run_id) / "commands"
|
|
||||||
if command.sequence < 1:
|
|
||||||
raise QualificationRunConflictError("command sequence must be positive")
|
|
||||||
previous = directory / _sequence_name(command.sequence - 1)
|
|
||||||
if command.sequence > 1 and not previous.is_file():
|
|
||||||
raise QualificationRunConflictError("command sequence would create a journal gap")
|
|
||||||
_append_json_exclusive(
|
|
||||||
directory / _sequence_name(command.sequence),
|
|
||||||
command.to_dict(),
|
|
||||||
)
|
|
||||||
return command
|
|
||||||
|
|
||||||
def list_commands(self, run_id: str) -> tuple[ControlSetpoint, ...]:
|
|
||||||
path = self._existing_run_path(run_id)
|
|
||||||
with self._lock:
|
|
||||||
records = _read_sequence_directory(path / "commands", "command")
|
|
||||||
commands: list[ControlSetpoint] = []
|
|
||||||
for sequence, record in records:
|
|
||||||
try:
|
|
||||||
command = control_setpoint_from_dict(record)
|
|
||||||
except SimulationContractError as exc:
|
|
||||||
raise QualificationRunIntegrityError(str(exc)) from exc
|
|
||||||
if command.run_id != run_id or command.sequence != sequence:
|
|
||||||
raise QualificationRunIntegrityError(
|
|
||||||
"command identity/sequence does not match its journal path"
|
|
||||||
)
|
|
||||||
commands.append(command)
|
|
||||||
return tuple(commands)
|
|
||||||
|
|
||||||
def list_events(self, run_id: str) -> tuple[QualificationEvent, ...]:
|
|
||||||
path = self._existing_run_path(run_id)
|
|
||||||
with self._lock:
|
|
||||||
return self._read_events(path, run_id)
|
|
||||||
|
|
||||||
def register_artifact(
|
|
||||||
self,
|
|
||||||
run_id: str,
|
|
||||||
artifact: QualificationArtifact,
|
|
||||||
) -> QualificationArtifact:
|
|
||||||
self._require_writable()
|
|
||||||
with self._lock:
|
|
||||||
run = self.load(run_id)
|
|
||||||
if run.state.terminal:
|
|
||||||
raise QualificationRunTransitionError(
|
|
||||||
"terminal qualification-run artifacts are sealed"
|
|
||||||
)
|
|
||||||
directory = self._existing_run_path(run_id) / "artifacts"
|
|
||||||
if any(existing.artifact_id == artifact.artifact_id for existing in run.artifacts):
|
|
||||||
raise QualificationRunConflictError("artifact id is already registered")
|
|
||||||
sequence = _next_sequence(directory)
|
|
||||||
if artifact.sequence not in {0, sequence}:
|
|
||||||
raise QualificationRunConflictError(f"artifact sequence must be zero or {sequence}")
|
|
||||||
admitted = QualificationArtifact(
|
|
||||||
artifact_id=artifact.artifact_id,
|
|
||||||
kind=artifact.kind,
|
|
||||||
relative_path=artifact.relative_path,
|
|
||||||
sha256=artifact.sha256,
|
|
||||||
byte_length=artifact.byte_length,
|
|
||||||
source_of_record=artifact.source_of_record,
|
|
||||||
sequence=sequence,
|
|
||||||
)
|
|
||||||
record = {
|
|
||||||
"schema_version": "missioncore.qualification-artifact/v1",
|
|
||||||
"run_id": run_id,
|
|
||||||
**admitted.to_dict(),
|
|
||||||
}
|
|
||||||
_append_json_exclusive(directory / _sequence_name(sequence), record)
|
|
||||||
return admitted
|
|
||||||
|
|
||||||
def create_reset_episode(
|
|
||||||
self,
|
|
||||||
previous_run_id: str,
|
|
||||||
*,
|
|
||||||
run_id: str,
|
|
||||||
episode_id: str,
|
|
||||||
created_at_utc: str,
|
|
||||||
) -> QualificationRun:
|
|
||||||
self._require_writable()
|
|
||||||
previous = self.load(previous_run_id)
|
|
||||||
if not previous.state.terminal:
|
|
||||||
raise QualificationRunTransitionError(
|
|
||||||
"reset requires the previous run to be terminal and sealed"
|
|
||||||
)
|
|
||||||
replacement = QualificationRun(
|
|
||||||
run_id=run_id,
|
|
||||||
episode_id=episode_id,
|
|
||||||
kind=previous.kind,
|
|
||||||
state=RunState.ADMITTED,
|
|
||||||
scenario_generation=previous.scenario_generation,
|
|
||||||
scenario_sha256=previous.scenario_sha256,
|
|
||||||
profile_generation=previous.profile_generation,
|
|
||||||
profile_sha256=previous.profile_sha256,
|
|
||||||
mission_core_commit=previous.mission_core_commit,
|
|
||||||
providers=previous.providers,
|
|
||||||
host_profile_id=previous.host_profile_id,
|
|
||||||
host_profile_sha256=previous.host_profile_sha256,
|
|
||||||
seed=previous.seed,
|
|
||||||
reproducibility_tier=previous.reproducibility_tier,
|
|
||||||
authority=previous.authority,
|
|
||||||
clock_domain=previous.clock_domain,
|
|
||||||
created_at_utc=created_at_utc,
|
|
||||||
parent_run_id=previous.run_id,
|
|
||||||
)
|
|
||||||
return self.create(replacement)
|
|
||||||
|
|
||||||
def reconcile_interrupted(
|
|
||||||
self,
|
|
||||||
run_id: str,
|
|
||||||
*,
|
|
||||||
observed_at_utc: str,
|
|
||||||
host_monotonic_ns: int,
|
|
||||||
) -> QualificationRun:
|
|
||||||
self._require_writable()
|
|
||||||
run = self.load(run_id)
|
|
||||||
if run.state not in ACTIVE_RECOVERY_STATES:
|
|
||||||
return run
|
|
||||||
return self.transition(
|
|
||||||
run_id,
|
|
||||||
RunState.FAILED,
|
|
||||||
expected_revision=run.revision,
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
reason="orchestrator-recovery-interrupted",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _append_event_locked(
|
|
||||||
self,
|
|
||||||
run: QualificationRun,
|
|
||||||
*,
|
|
||||||
event_type: str,
|
|
||||||
observed_at_utc: str,
|
|
||||||
host_monotonic_ns: int,
|
|
||||||
sim_time_ns: int | None,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
) -> QualificationEvent:
|
|
||||||
sequence = run.revision + 1
|
|
||||||
event = QualificationEvent(
|
|
||||||
run_id=run.run_id,
|
|
||||||
sequence=sequence,
|
|
||||||
event_type=event_type,
|
|
||||||
observed_at_utc=observed_at_utc,
|
|
||||||
host_monotonic_ns=host_monotonic_ns,
|
|
||||||
sim_time_ns=sim_time_ns,
|
|
||||||
payload=payload,
|
|
||||||
)
|
|
||||||
directory = self._existing_run_path(run.run_id) / "events"
|
|
||||||
if _next_sequence(directory) != sequence:
|
|
||||||
raise QualificationRunConflictError("event journal changed concurrently")
|
|
||||||
_append_json_exclusive(directory / _sequence_name(sequence), event.to_dict())
|
|
||||||
return event
|
|
||||||
|
|
||||||
def _read_events(self, run_path: Path, run_id: str) -> tuple[QualificationEvent, ...]:
|
|
||||||
events: list[QualificationEvent] = []
|
|
||||||
for sequence, record in _read_sequence_directory(run_path / "events", "event"):
|
|
||||||
try:
|
|
||||||
event = QualificationEvent.from_dict(record)
|
|
||||||
except SimulationContractError as exc:
|
|
||||||
raise QualificationRunIntegrityError(str(exc)) from exc
|
|
||||||
if event.run_id != run_id or event.sequence != sequence:
|
|
||||||
raise QualificationRunIntegrityError(
|
|
||||||
"event identity/sequence does not match its journal path"
|
|
||||||
)
|
|
||||||
events.append(event)
|
|
||||||
return tuple(events)
|
|
||||||
|
|
||||||
def _read_artifacts(
|
|
||||||
self,
|
|
||||||
run_path: Path,
|
|
||||||
run_id: str,
|
|
||||||
) -> tuple[QualificationArtifact, ...]:
|
|
||||||
artifacts: list[QualificationArtifact] = []
|
|
||||||
identifiers: set[str] = set()
|
|
||||||
for sequence, record in _read_sequence_directory(
|
|
||||||
run_path / "artifacts",
|
|
||||||
"artifact",
|
|
||||||
):
|
|
||||||
if record.get("schema_version") != "missioncore.qualification-artifact/v1":
|
|
||||||
raise QualificationRunIntegrityError("unsupported qualification-artifact schema")
|
|
||||||
if record.get("run_id") != run_id:
|
|
||||||
raise QualificationRunIntegrityError("artifact run identity does not match")
|
|
||||||
try:
|
|
||||||
artifact = QualificationArtifact.from_dict(record)
|
|
||||||
except SimulationContractError as exc:
|
|
||||||
raise QualificationRunIntegrityError(str(exc)) from exc
|
|
||||||
if artifact.sequence != sequence:
|
|
||||||
raise QualificationRunIntegrityError(
|
|
||||||
"artifact sequence does not match its journal path"
|
|
||||||
)
|
|
||||||
if artifact.artifact_id in identifiers:
|
|
||||||
raise QualificationRunIntegrityError("artifact journal contains a duplicate id")
|
|
||||||
identifiers.add(artifact.artifact_id)
|
|
||||||
artifacts.append(artifact)
|
|
||||||
return tuple(artifacts)
|
|
||||||
|
|
||||||
def _run_path(self, run_id: str) -> Path:
|
|
||||||
try:
|
|
||||||
QualificationEvent(
|
|
||||||
run_id=run_id,
|
|
||||||
sequence=1,
|
|
||||||
event_type="identity.check",
|
|
||||||
observed_at_utc="1970-01-01T00:00:00Z",
|
|
||||||
host_monotonic_ns=0,
|
|
||||||
sim_time_ns=None,
|
|
||||||
payload={},
|
|
||||||
)
|
|
||||||
except SimulationContractError as exc:
|
|
||||||
raise QualificationRunIntegrityError(str(exc)) from exc
|
|
||||||
return self.root / run_id
|
|
||||||
|
|
||||||
def _existing_run_path(self, run_id: str) -> Path:
|
|
||||||
path = self._run_path(run_id)
|
|
||||||
if not path.is_dir():
|
|
||||||
raise QualificationRunNotFoundError("qualification run was not found")
|
|
||||||
_reject_symlink(path, "qualification run")
|
|
||||||
for name in ("events", "commands", "artifacts"):
|
|
||||||
directory = path / name
|
|
||||||
if not directory.is_dir():
|
|
||||||
raise QualificationRunIntegrityError(f"run {name} journal is missing")
|
|
||||||
_reject_symlink(directory, f"run {name} journal")
|
|
||||||
return path
|
|
||||||
|
|
||||||
def _require_writable(self) -> None:
|
|
||||||
if self.read_only:
|
|
||||||
raise QualificationRunTransitionError(
|
|
||||||
"the qualification repository is open read-only"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _replay_runtime(
|
|
||||||
manifest: QualificationRun,
|
|
||||||
events: tuple[QualificationEvent, ...],
|
|
||||||
artifacts: tuple[QualificationArtifact, ...],
|
|
||||||
) -> QualificationRun:
|
|
||||||
state = RunState.ADMITTED
|
|
||||||
started_at_utc: str | None = None
|
|
||||||
ended_at_utc: str | None = None
|
|
||||||
terminal_reason: str | None = None
|
|
||||||
for event in events:
|
|
||||||
if state.terminal:
|
|
||||||
raise QualificationRunIntegrityError("event exists after terminal lifecycle state")
|
|
||||||
if event.event_type != "lifecycle.state-changed":
|
|
||||||
continue
|
|
||||||
source = event.payload.get("from")
|
|
||||||
target = event.payload.get("to")
|
|
||||||
reason = event.payload.get("reason")
|
|
||||||
if source != state.value or not isinstance(target, str):
|
|
||||||
raise QualificationRunIntegrityError("lifecycle journal does not match current state")
|
|
||||||
try:
|
|
||||||
new_state = RunState(target)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise QualificationRunIntegrityError("lifecycle journal has an unknown state") from exc
|
|
||||||
if new_state not in TRANSITIONS[state]:
|
|
||||||
raise QualificationRunIntegrityError("lifecycle journal contains an illegal transition")
|
|
||||||
if new_state is RunState.RUNNING and started_at_utc is None:
|
|
||||||
started_at_utc = event.observed_at_utc
|
|
||||||
if new_state.terminal:
|
|
||||||
if not isinstance(reason, str) or not reason.strip():
|
|
||||||
raise QualificationRunIntegrityError("terminal lifecycle event has no reason")
|
|
||||||
ended_at_utc = event.observed_at_utc
|
|
||||||
terminal_reason = reason
|
|
||||||
elif reason is not None:
|
|
||||||
raise QualificationRunIntegrityError("nonterminal lifecycle event has a reason")
|
|
||||||
state = new_state
|
|
||||||
try:
|
|
||||||
return manifest.with_runtime(
|
|
||||||
state=state,
|
|
||||||
revision=len(events),
|
|
||||||
started_at_utc=started_at_utc,
|
|
||||||
ended_at_utc=ended_at_utc,
|
|
||||||
terminal_reason=terminal_reason,
|
|
||||||
artifacts=artifacts,
|
|
||||||
)
|
|
||||||
except SimulationContractError as exc:
|
|
||||||
raise QualificationRunIntegrityError(str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _read_sequence_directory(
|
|
||||||
directory: Path,
|
|
||||||
label: str,
|
|
||||||
) -> tuple[tuple[int, dict[str, Any]], ...]:
|
|
||||||
_reject_symlink(directory, f"{label} journal")
|
|
||||||
records: list[tuple[int, dict[str, Any]]] = []
|
|
||||||
expected = 1
|
|
||||||
for path in sorted(directory.iterdir(), key=lambda item: item.name):
|
|
||||||
_reject_regular_file(path, f"{label} record")
|
|
||||||
if path.name != _sequence_name(expected):
|
|
||||||
raise QualificationRunIntegrityError(f"{label} journal is not a contiguous sequence")
|
|
||||||
records.append((expected, _read_json(path, f"{label} record")))
|
|
||||||
expected += 1
|
|
||||||
return tuple(records)
|
|
||||||
|
|
||||||
|
|
||||||
def _next_sequence(directory: Path) -> int:
|
|
||||||
return len(_read_sequence_directory(directory, "journal")) + 1
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_command_admission(
|
|
||||||
command: ControlSetpoint,
|
|
||||||
run: QualificationRun,
|
|
||||||
) -> None:
|
|
||||||
if run.kind not in COMMANDABLE_RUN_KINDS:
|
|
||||||
raise QualificationRunTransitionError(
|
|
||||||
f"{run.kind.value} cannot causally accept control commands"
|
|
||||||
)
|
|
||||||
if run.state is not RunState.RUNNING:
|
|
||||||
raise QualificationRunTransitionError(
|
|
||||||
"control commands are accepted only while the run is running"
|
|
||||||
)
|
|
||||||
if command.authority_generation != run.authority.generation:
|
|
||||||
raise QualificationRunConflictError("command authority generation is stale")
|
|
||||||
ttl_ns = command.valid_until_sim_ns - command.issued_at_sim_ns
|
|
||||||
if ttl_ns > run.authority.command_ttl_max_ns:
|
|
||||||
raise QualificationRunTransitionError("command TTL exceeds the authority limit")
|
|
||||||
|
|
||||||
|
|
||||||
def _sequence_name(sequence: int) -> str:
|
|
||||||
return f"{sequence:0{SEQUENCE_WIDTH}d}.json"
|
|
||||||
|
|
||||||
|
|
||||||
def _append_json_exclusive(path: Path, payload: object) -> None:
|
|
||||||
serialized = (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode()
|
|
||||||
if len(serialized) > MAX_RECORD_BYTES:
|
|
||||||
raise QualificationRunIntegrityError("journal record exceeds the size limit")
|
|
||||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
||||||
if hasattr(os, "O_NOFOLLOW"):
|
|
||||||
flags |= os.O_NOFOLLOW
|
|
||||||
try:
|
|
||||||
descriptor = os.open(path, flags, 0o600)
|
|
||||||
except FileExistsError as exc:
|
|
||||||
raise QualificationRunConflictError("journal sequence already exists") from exc
|
|
||||||
try:
|
|
||||||
with os.fdopen(descriptor, "wb") as stream:
|
|
||||||
stream.write(serialized)
|
|
||||||
stream.flush()
|
|
||||||
os.fsync(stream.fileno())
|
|
||||||
except BaseException:
|
|
||||||
path.unlink(missing_ok=True)
|
|
||||||
raise
|
|
||||||
_fsync_directory(path.parent)
|
|
||||||
|
|
||||||
|
|
||||||
def _read_json(path: Path, label: str) -> dict[str, Any]:
|
|
||||||
_reject_regular_file(path, label)
|
|
||||||
if path.stat().st_size > MAX_RECORD_BYTES:
|
|
||||||
raise QualificationRunIntegrityError(f"{label} exceeds the size limit")
|
|
||||||
try:
|
|
||||||
value = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
||||||
raise QualificationRunIntegrityError(f"{label} is not valid UTF-8 JSON") from exc
|
|
||||||
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
|
||||||
raise QualificationRunIntegrityError(f"{label} must be a JSON object")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _reject_regular_file(path: Path, label: str) -> None:
|
|
||||||
try:
|
|
||||||
mode = path.lstat().st_mode
|
|
||||||
except FileNotFoundError as exc:
|
|
||||||
raise QualificationRunIntegrityError(f"{label} is missing") from exc
|
|
||||||
if not stat.S_ISREG(mode):
|
|
||||||
raise QualificationRunIntegrityError(f"{label} must be a regular file")
|
|
||||||
|
|
||||||
|
|
||||||
def _reject_symlink(path: Path, label: str) -> None:
|
|
||||||
try:
|
|
||||||
mode = path.lstat().st_mode
|
|
||||||
except FileNotFoundError as exc:
|
|
||||||
raise QualificationRunIntegrityError(f"{label} is missing") from exc
|
|
||||||
if stat.S_ISLNK(mode):
|
|
||||||
raise QualificationRunIntegrityError(f"{label} must not be a symlink")
|
|
||||||
|
|
||||||
|
|
||||||
def _chmod_private(path: Path) -> None:
|
|
||||||
with suppress(OSError):
|
|
||||||
path.chmod(0o700)
|
|
||||||
|
|
||||||
|
|
||||||
def _fsync_directory(path: Path) -> None:
|
|
||||||
flags = os.O_RDONLY
|
|
||||||
if hasattr(os, "O_DIRECTORY"):
|
|
||||||
flags |= os.O_DIRECTORY
|
|
||||||
descriptor = os.open(path, flags)
|
|
||||||
try:
|
|
||||||
os.fsync(descriptor)
|
|
||||||
finally:
|
|
||||||
os.close(descriptor)
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,235 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
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):
|
|
||||||
"""The reviewed stock-rover lifecycle profile is unsafe or incomplete."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class StockRoverLifecycleProfile:
|
|
||||||
profile_id: str
|
|
||||||
speed_factor: int
|
|
||||||
dwell_seconds: float
|
|
||||||
agent_ready_log_patterns: tuple[str, ...]
|
|
||||||
px4_ready_log_patterns: tuple[str, ...]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class StockRoverTargetPaths:
|
|
||||||
d_root: Path
|
|
||||||
px4_root: Path
|
|
||||||
agent_prefix: Path
|
|
||||||
process_home: Path
|
|
||||||
xdg_cache_home: Path
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_d_root(cls, d_root: Path) -> StockRoverTargetPaths:
|
|
||||||
root = d_root.expanduser().resolve()
|
|
||||||
if root != EXPECTED_D_ROOT:
|
|
||||||
raise StockRoverProfileError(
|
|
||||||
f"stock-rover lifecycle requires exact D root {EXPECTED_D_ROOT}"
|
|
||||||
)
|
|
||||||
return cls(
|
|
||||||
d_root=root,
|
|
||||||
px4_root=root / "source/PX4-Autopilot",
|
|
||||||
agent_prefix=root / "runtime/micro-xrce-dds-agent/2.4.3",
|
|
||||||
process_home=root / "runtime/s1/home",
|
|
||||||
xdg_cache_home=root / "cache/s1/xdg",
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def agent_binary(self) -> Path:
|
|
||||||
return self.agent_prefix / "bin/MicroXRCEAgent"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def scenario_path(self) -> Path:
|
|
||||||
return self.px4_root / "Tools/simulation/gz/worlds/rover.sdf"
|
|
||||||
|
|
||||||
|
|
||||||
def load_stock_rover_lifecycle_profile(path: Path) -> StockRoverLifecycleProfile:
|
|
||||||
try:
|
|
||||||
document = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, UnicodeDecodeError, yaml.YAMLError) as exc:
|
|
||||||
raise StockRoverProfileError("stock-rover profile is not valid UTF-8 YAML") from exc
|
|
||||||
if not isinstance(document, dict) or any(not isinstance(key, str) for key in document):
|
|
||||||
raise StockRoverProfileError("stock-rover profile must be a YAML mapping")
|
|
||||||
expected_keys = {
|
|
||||||
"schema_version",
|
|
||||||
"profile_id",
|
|
||||||
"speed_factor",
|
|
||||||
"dwell_seconds",
|
|
||||||
"authority",
|
|
||||||
"agent_ready_log_patterns",
|
|
||||||
"px4_ready_log_patterns",
|
|
||||||
}
|
|
||||||
if set(document) != expected_keys:
|
|
||||||
raise StockRoverProfileError("stock-rover profile keys do not match the v1 schema")
|
|
||||||
if document["schema_version"] != LIFECYCLE_PROFILE_SCHEMA:
|
|
||||||
raise StockRoverProfileError("stock-rover profile schema is incompatible")
|
|
||||||
authority = document["authority"]
|
|
||||||
if authority != {
|
|
||||||
"simulation_or_shadow_only": True,
|
|
||||||
"actuator_authority": False,
|
|
||||||
"navigation_or_safety_accepted": False,
|
|
||||||
"direct_actuator_setpoints_allowed": False,
|
|
||||||
}:
|
|
||||||
raise StockRoverProfileError("stock-rover lifecycle cannot grant actuator authority")
|
|
||||||
profile_id = document["profile_id"]
|
|
||||||
speed_factor = document["speed_factor"]
|
|
||||||
dwell_seconds = document["dwell_seconds"]
|
|
||||||
if not isinstance(profile_id, str) or not profile_id:
|
|
||||||
raise StockRoverProfileError("stock-rover profile id must be nonempty")
|
|
||||||
if speed_factor != 1:
|
|
||||||
raise StockRoverProfileError("S1B lifecycle admits only speed factor 1")
|
|
||||||
if not isinstance(dwell_seconds, int | float) or isinstance(dwell_seconds, bool):
|
|
||||||
raise StockRoverProfileError("stock-rover dwell must be numeric")
|
|
||||||
if not 1 <= float(dwell_seconds) <= 30:
|
|
||||||
raise StockRoverProfileError("stock-rover dwell must be between 1 and 30 seconds")
|
|
||||||
return StockRoverLifecycleProfile(
|
|
||||||
profile_id=profile_id,
|
|
||||||
speed_factor=speed_factor,
|
|
||||||
dwell_seconds=float(dwell_seconds),
|
|
||||||
agent_ready_log_patterns=_patterns(
|
|
||||||
document["agent_ready_log_patterns"],
|
|
||||||
"agent",
|
|
||||||
),
|
|
||||||
px4_ready_log_patterns=_patterns(
|
|
||||||
document["px4_ready_log_patterns"],
|
|
||||||
"PX4",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def stock_rover_process_environment(
|
|
||||||
paths: StockRoverTargetPaths,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
paths.process_home.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
||||||
paths.xdg_cache_home.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
||||||
return {
|
|
||||||
"HOME": str(paths.process_home),
|
|
||||||
"LANG": os.environ.get("LANG", "C.UTF-8"),
|
|
||||||
"LOGNAME": "missioncore",
|
|
||||||
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
|
||||||
"USER": "missioncore",
|
|
||||||
"XDG_CACHE_HOME": str(paths.xdg_cache_home),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def stock_rover_process_specs(
|
|
||||||
paths: StockRoverTargetPaths,
|
|
||||||
profile: StockRoverLifecycleProfile,
|
|
||||||
) -> tuple[ProcessSpec, ...]:
|
|
||||||
return (
|
|
||||||
ProcessSpec(
|
|
||||||
process_id="micro-xrce-dds-agent",
|
|
||||||
argv=(
|
|
||||||
str(paths.agent_binary),
|
|
||||||
"udp4",
|
|
||||||
"-p",
|
|
||||||
"8888",
|
|
||||||
"-v",
|
|
||||||
"4",
|
|
||||||
),
|
|
||||||
start_order=10,
|
|
||||||
shutdown_order=30,
|
|
||||||
environment=(("LD_LIBRARY_PATH", str(paths.agent_prefix / "lib")),),
|
|
||||||
startup_grace_seconds=0,
|
|
||||||
ready_log_patterns=profile.agent_ready_log_patterns,
|
|
||||||
health_timeout_seconds=15,
|
|
||||||
health_poll_interval_seconds=0.1,
|
|
||||||
interrupt_timeout_seconds=3,
|
|
||||||
terminate_timeout_seconds=2,
|
|
||||||
),
|
|
||||||
ProcessSpec(
|
|
||||||
process_id="px4-gazebo-stock-rover",
|
|
||||||
argv=(
|
|
||||||
"/usr/bin/make",
|
|
||||||
"-C",
|
|
||||||
str(paths.px4_root),
|
|
||||||
"px4_sitl",
|
|
||||||
"gz_rover_ackermann",
|
|
||||||
),
|
|
||||||
start_order=20,
|
|
||||||
shutdown_order=40,
|
|
||||||
environment=(
|
|
||||||
("HEADLESS", "1"),
|
|
||||||
("PX4_SIM_SPEED_FACTOR", str(profile.speed_factor)),
|
|
||||||
),
|
|
||||||
startup_grace_seconds=0,
|
|
||||||
ready_log_patterns=profile.px4_ready_log_patterns,
|
|
||||||
health_timeout_seconds=120,
|
|
||||||
health_poll_interval_seconds=0.25,
|
|
||||||
keep_stdin_open=True,
|
|
||||||
interrupt_timeout_seconds=10,
|
|
||||||
terminate_timeout_seconds=5,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _patterns(value: object, label: str) -> tuple[str, ...]:
|
|
||||||
if (
|
|
||||||
not isinstance(value, list)
|
|
||||||
or not value
|
|
||||||
or any(not isinstance(item, str) or not item or "\x00" in item for item in value)
|
|
||||||
):
|
|
||||||
raise StockRoverProfileError(f"{label} ready patterns must be nonempty strings")
|
|
||||||
patterns = tuple(value)
|
|
||||||
if len(patterns) != len(set(patterns)):
|
|
||||||
raise StockRoverProfileError(f"{label} ready patterns must be unique")
|
|
||||||
return patterns
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path, PurePosixPath
|
|
||||||
from typing import Protocol
|
|
||||||
|
|
||||||
from k1link.simulation.contracts import QualificationRun
|
|
||||||
from k1link.simulation.orchestrator import WorkerStartResult, WorkerStopResult
|
|
||||||
from k1link.simulation.process_supervisor import (
|
|
||||||
PosixProcessSupervisor,
|
|
||||||
ProcessSpec,
|
|
||||||
)
|
|
||||||
from k1link.simulation.s0 import S0Profile, load_s0_profile
|
|
||||||
|
|
||||||
|
|
||||||
class WorkerAdmissionError(RuntimeError):
|
|
||||||
"""The worker no longer matches the accepted S0 generation or D boundary."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class WorkerAdmission:
|
|
||||||
profile: S0Profile
|
|
||||||
profile_sha256: str
|
|
||||||
artifact_run_root: PurePosixPath
|
|
||||||
process_run_root: PurePosixPath
|
|
||||||
|
|
||||||
|
|
||||||
class SimulationWorldControl(Protocol):
|
|
||||||
def pause(self, run_id: str) -> None: ...
|
|
||||||
|
|
||||||
def resume(self, run_id: str) -> None: ...
|
|
||||||
|
|
||||||
def step(self, run_id: str, step_count: int) -> int: ...
|
|
||||||
|
|
||||||
|
|
||||||
class S0WorkerGuard:
|
|
||||||
"""Bind every S1 start to the exact accepted S0 profile and D artifact root."""
|
|
||||||
|
|
||||||
def __init__(self, profile_path: Path) -> None:
|
|
||||||
self.profile_path = profile_path.expanduser().resolve()
|
|
||||||
self.profile = load_s0_profile(self.profile_path)
|
|
||||||
self.profile_sha256 = hashlib.sha256(self.profile_path.read_bytes()).hexdigest()
|
|
||||||
|
|
||||||
def admit(
|
|
||||||
self,
|
|
||||||
run: QualificationRun,
|
|
||||||
artifact_run_root: PurePosixPath,
|
|
||||||
process_run_root: PurePosixPath,
|
|
||||||
) -> WorkerAdmission:
|
|
||||||
if run.host_profile_id != self.profile.profile_id:
|
|
||||||
raise WorkerAdmissionError("run host profile id does not match accepted S0")
|
|
||||||
if run.host_profile_sha256 != self.profile_sha256:
|
|
||||||
raise WorkerAdmissionError("run host profile digest does not match accepted S0")
|
|
||||||
artifact_roots = tuple(
|
|
||||||
item.wsl_path
|
|
||||||
for item in self.profile.storage.mutable_paths
|
|
||||||
if item.identifier == "artifacts"
|
|
||||||
)
|
|
||||||
if len(artifact_roots) != 1:
|
|
||||||
raise WorkerAdmissionError("accepted S0 profile has no unique artifact root")
|
|
||||||
runtime_roots = tuple(
|
|
||||||
item.wsl_path
|
|
||||||
for item in self.profile.storage.mutable_paths
|
|
||||||
if item.identifier == "runtime"
|
|
||||||
)
|
|
||||||
if len(runtime_roots) != 1:
|
|
||||||
raise WorkerAdmissionError("accepted S0 profile has no unique runtime root")
|
|
||||||
expected_artifact_parent = artifact_roots[0] / "s1" / "runs"
|
|
||||||
expected_runtime_parent = runtime_roots[0] / "s1" / "runs"
|
|
||||||
if artifact_run_root != expected_artifact_parent / run.run_id:
|
|
||||||
raise WorkerAdmissionError("S1 run root escapes the accepted D-only layout")
|
|
||||||
if process_run_root != expected_runtime_parent / run.run_id:
|
|
||||||
raise WorkerAdmissionError("S1 process root escapes the accepted D-only layout")
|
|
||||||
if not all(
|
|
||||||
component.qualification_state == "accepted" for component in self.profile.components
|
|
||||||
):
|
|
||||||
raise WorkerAdmissionError("S0 component generation is no longer accepted")
|
|
||||||
return WorkerAdmission(
|
|
||||||
profile=self.profile,
|
|
||||||
profile_sha256=self.profile_sha256,
|
|
||||||
artifact_run_root=artifact_run_root,
|
|
||||||
process_run_root=process_run_root,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LocalProcessWorkerAdapter:
|
|
||||||
"""Worker-local S1B adapter; transport and PX4 mapping remain separate ports."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
guard: S0WorkerGuard,
|
|
||||||
supervisor: PosixProcessSupervisor,
|
|
||||||
specs: tuple[ProcessSpec, ...],
|
|
||||||
world_control: SimulationWorldControl,
|
|
||||||
) -> None:
|
|
||||||
self.guard = guard
|
|
||||||
self.supervisor = supervisor
|
|
||||||
self.specs = specs
|
|
||||||
self.world_control = world_control
|
|
||||||
|
|
||||||
def start(self, run: QualificationRun) -> WorkerStartResult:
|
|
||||||
artifact_root = next(
|
|
||||||
item.wsl_path
|
|
||||||
for item in self.guard.profile.storage.mutable_paths
|
|
||||||
if item.identifier == "artifacts"
|
|
||||||
)
|
|
||||||
artifact_run_root = artifact_root / "s1" / "runs" / run.run_id
|
|
||||||
process_run_root = PurePosixPath(self.supervisor.runtime_root.as_posix()) / run.run_id
|
|
||||||
admission = self.guard.admit(run, artifact_run_root, process_run_root)
|
|
||||||
records = self.supervisor.start(run.run_id, self.specs)
|
|
||||||
return WorkerStartResult(
|
|
||||||
provider_ids=tuple(record.process_id for record in records),
|
|
||||||
profile_sha256=admission.profile_sha256,
|
|
||||||
)
|
|
||||||
|
|
||||||
def pause(self, run: QualificationRun) -> None:
|
|
||||||
self.world_control.pause(run.run_id)
|
|
||||||
|
|
||||||
def resume(self, run: QualificationRun) -> None:
|
|
||||||
self.world_control.resume(run.run_id)
|
|
||||||
|
|
||||||
def step(self, run: QualificationRun, step_count: int) -> int:
|
|
||||||
return self.world_control.step(run.run_id, step_count)
|
|
||||||
|
|
||||||
def stop(self, run: QualificationRun) -> WorkerStopResult:
|
|
||||||
result = self.supervisor.stop()
|
|
||||||
return WorkerStopResult(
|
|
||||||
stopped_provider_ids=result.stopped_process_ids,
|
|
||||||
residue_provider_ids=result.residue_process_ids,
|
|
||||||
)
|
|
||||||
|
|
||||||
def reconcile(self, run: QualificationRun) -> WorkerStopResult:
|
|
||||||
return self.stop(run)
|
|
||||||
|
|
@ -1,869 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import signal
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from contextlib import suppress
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from types import FrameType
|
|
||||||
from typing import Any, Final
|
|
||||||
|
|
||||||
from k1link.simulation.contracts import (
|
|
||||||
AckermannControlSetpoint,
|
|
||||||
AuthorityProfile,
|
|
||||||
ProviderPin,
|
|
||||||
QualificationRun,
|
|
||||||
ReproducibilityTier,
|
|
||||||
RunKind,
|
|
||||||
RunState,
|
|
||||||
)
|
|
||||||
from k1link.simulation.orchestrator import SimulationApplicationService
|
|
||||||
from k1link.simulation.process_supervisor import PosixProcessSupervisor
|
|
||||||
from k1link.simulation.px4_rover_control import (
|
|
||||||
Px4RoverControlSnapshot,
|
|
||||||
Ros2Px4AckermannControl,
|
|
||||||
)
|
|
||||||
from k1link.simulation.run_store import (
|
|
||||||
ACTIVE_RECOVERY_STATES,
|
|
||||||
QualificationRunNotFoundError,
|
|
||||||
QualificationRunStore,
|
|
||||||
)
|
|
||||||
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,
|
|
||||||
stock_rover_process_specs,
|
|
||||||
)
|
|
||||||
from k1link.simulation.worker import (
|
|
||||||
LocalProcessWorkerAdapter,
|
|
||||||
S0WorkerGuard,
|
|
||||||
SimulationWorldControl,
|
|
||||||
)
|
|
||||||
from k1link.simulation.worker_gateway import (
|
|
||||||
COMMAND_ACCEPTANCE_SCHEMA,
|
|
||||||
MAX_MESSAGE_BYTES,
|
|
||||||
OPERATIONS,
|
|
||||||
REQUEST_SCHEMA,
|
|
||||||
RESPONSE_SCHEMA,
|
|
||||||
STATUS_SCHEMA,
|
|
||||||
VEHICLE_STATE_SCHEMA,
|
|
||||||
)
|
|
||||||
|
|
||||||
EXPECTED_DISTRO: Final = "MissionCore-Sim"
|
|
||||||
COMMIT_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$")
|
|
||||||
SAFE_ID_PATTERN: Final = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
|
||||||
PROVIDER_IDS: Final = (
|
|
||||||
"px4-autopilot",
|
|
||||||
"gazebo",
|
|
||||||
"px4-gazebo-models",
|
|
||||||
"micro-xrce-dds-agent",
|
|
||||||
)
|
|
||||||
ACTIVE_STATES: Final = frozenset(ACTIVE_RECOVERY_STATES)
|
|
||||||
POSE_TOPIC: Final = "/world/rover/dynamic_pose/info"
|
|
||||||
SIM_CLOCK_CACHE_MAX_AGE_NS: Final = 2_000_000_000
|
|
||||||
|
|
||||||
|
|
||||||
class WorkerAgentError(RuntimeError):
|
|
||||||
"""The worker agent cannot safely satisfy a gateway request."""
|
|
||||||
|
|
||||||
|
|
||||||
class _LifecycleOnlyWorldControl(SimulationWorldControl):
|
|
||||||
def pause(self, run_id: str) -> None:
|
|
||||||
raise WorkerAgentError(f"pause is not admitted for {run_id}")
|
|
||||||
|
|
||||||
def resume(self, run_id: str) -> None:
|
|
||||||
raise WorkerAgentError(f"resume is not admitted for {run_id}")
|
|
||||||
|
|
||||||
def step(self, run_id: str, step_count: int) -> int:
|
|
||||||
raise WorkerAgentError(f"step is not admitted for {run_id}:{step_count}")
|
|
||||||
|
|
||||||
|
|
||||||
class GazeboPoseCollector:
|
|
||||||
"""Read one bounded ground-truth pose sample from Gazebo Transport."""
|
|
||||||
|
|
||||||
def __init__(self, environment: dict[str, str]) -> None:
|
|
||||||
self.environment = dict(environment)
|
|
||||||
self._sequence = 0
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._latest_sim_time_ns: int | None = None
|
|
||||||
self._latest_host_monotonic_ns: int | None = None
|
|
||||||
|
|
||||||
def collect(self, run_id: str) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
completed = subprocess.run(
|
|
||||||
(
|
|
||||||
"/usr/bin/gz",
|
|
||||||
"topic",
|
|
||||||
"-e",
|
|
||||||
"--json-output",
|
|
||||||
"-t",
|
|
||||||
POSE_TOPIC,
|
|
||||||
"-n",
|
|
||||||
"1",
|
|
||||||
),
|
|
||||||
check=True,
|
|
||||||
capture_output=True,
|
|
||||||
env=self.environment,
|
|
||||||
timeout=3,
|
|
||||||
)
|
|
||||||
except (OSError, subprocess.SubprocessError) as exc:
|
|
||||||
raise WorkerAgentError("Gazebo pose sample is unavailable") from exc
|
|
||||||
if len(completed.stdout) > MAX_MESSAGE_BYTES:
|
|
||||||
raise WorkerAgentError("Gazebo pose sample exceeds the size limit")
|
|
||||||
try:
|
|
||||||
document = json.loads(completed.stdout.decode("utf-8"))
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
||||||
raise WorkerAgentError("Gazebo pose sample is not valid JSON") from exc
|
|
||||||
if not isinstance(document, dict):
|
|
||||||
raise WorkerAgentError("Gazebo pose sample must be an object")
|
|
||||||
pose = _rover_pose(document)
|
|
||||||
stamp = _object(_object(document.get("header"), "header").get("stamp"), "stamp")
|
|
||||||
sim_time_ns = _integer(stamp.get("sec"), "stamp.sec") * 1_000_000_000 + _integer(
|
|
||||||
stamp.get("nsec"),
|
|
||||||
"stamp.nsec",
|
|
||||||
)
|
|
||||||
host_monotonic_ns = time.monotonic_ns()
|
|
||||||
with self._lock:
|
|
||||||
self._sequence += 1
|
|
||||||
sequence = self._sequence
|
|
||||||
self._latest_sim_time_ns = sim_time_ns
|
|
||||||
self._latest_host_monotonic_ns = host_monotonic_ns
|
|
||||||
return {
|
|
||||||
"schema_version": VEHICLE_STATE_SCHEMA,
|
|
||||||
"run_id": run_id,
|
|
||||||
"sequence": sequence,
|
|
||||||
"observed_at_utc": _utc_now(),
|
|
||||||
"host_monotonic_ns": host_monotonic_ns,
|
|
||||||
"sim_time_ns": sim_time_ns,
|
|
||||||
"frame_id": "map_enu",
|
|
||||||
"child_frame_id": "base_link_flu",
|
|
||||||
"pose": {
|
|
||||||
"position_m": {
|
|
||||||
"x": _number(_object(pose.get("position"), "position").get("x"), "position.x"),
|
|
||||||
"y": _number(_object(pose.get("position"), "position").get("y"), "position.y"),
|
|
||||||
"z": _number(_object(pose.get("position"), "position").get("z"), "position.z"),
|
|
||||||
},
|
|
||||||
"orientation_xyzw": {
|
|
||||||
"x": _number(
|
|
||||||
_object(pose.get("orientation"), "orientation").get("x"),
|
|
||||||
"orientation.x",
|
|
||||||
),
|
|
||||||
"y": _number(
|
|
||||||
_object(pose.get("orientation"), "orientation").get("y"),
|
|
||||||
"orientation.y",
|
|
||||||
),
|
|
||||||
"z": _number(
|
|
||||||
_object(pose.get("orientation"), "orientation").get("z"),
|
|
||||||
"orientation.z",
|
|
||||||
),
|
|
||||||
"w": _number(
|
|
||||||
_object(pose.get("orientation"), "orientation").get("w"),
|
|
||||||
"orientation.w",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"source": {
|
|
||||||
"provider": GAZEBO_STATE_PROVIDER_ID,
|
|
||||||
"topic": POSE_TOPIC,
|
|
||||||
"signal": "ground-truth",
|
|
||||||
"quality": "diagnostic",
|
|
||||||
},
|
|
||||||
"safety": {
|
|
||||||
"scope": "virtual-only",
|
|
||||||
"actuator_authority": False,
|
|
||||||
"navigation_or_safety_accepted": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def estimate_sim_time_ns(self) -> int:
|
|
||||||
"""Return a bounded speed-factor-1 estimate anchored to a Gazebo sample."""
|
|
||||||
|
|
||||||
now_monotonic_ns = time.monotonic_ns()
|
|
||||||
with self._lock:
|
|
||||||
sim_time_ns = self._latest_sim_time_ns
|
|
||||||
sampled_at_ns = self._latest_host_monotonic_ns
|
|
||||||
if sim_time_ns is None or sampled_at_ns is None:
|
|
||||||
raise WorkerAgentError("Gazebo simulation clock has not been admitted")
|
|
||||||
age_ns = now_monotonic_ns - sampled_at_ns
|
|
||||||
if not 0 <= age_ns <= SIM_CLOCK_CACHE_MAX_AGE_NS:
|
|
||||||
raise WorkerAgentError("Gazebo simulation clock sample is stale")
|
|
||||||
return sim_time_ns + age_ns
|
|
||||||
|
|
||||||
|
|
||||||
class SimulationWorkerAgent:
|
|
||||||
"""One loopback-only worker process owning provider lifecycle and live state."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
socket_path: Path,
|
|
||||||
mission_core_commit: str,
|
|
||||||
d_root: Path,
|
|
||||||
s0_profile_path: Path,
|
|
||||||
lifecycle_profile_path: Path,
|
|
||||||
) -> None:
|
|
||||||
if not COMMIT_PATTERN.fullmatch(mission_core_commit):
|
|
||||||
raise WorkerAgentError("Mission Core commit must be an exact 40-character revision")
|
|
||||||
if os.geteuid() == 0:
|
|
||||||
raise WorkerAgentError("worker agent must run as an unprivileged identity")
|
|
||||||
if os.environ.get("WSL_DISTRO_NAME") != EXPECTED_DISTRO:
|
|
||||||
raise WorkerAgentError(f"worker agent requires {EXPECTED_DISTRO}")
|
|
||||||
if tuple(sorted(name for _, name in socket.if_nameindex())) != ("lo",):
|
|
||||||
raise WorkerAgentError("worker agent requires a loopback-only network namespace")
|
|
||||||
self.socket_path = socket_path
|
|
||||||
self.mission_core_commit = mission_core_commit
|
|
||||||
self.paths = StockRoverTargetPaths.from_d_root(d_root)
|
|
||||||
self.lifecycle_profile_path = lifecycle_profile_path.expanduser().resolve()
|
|
||||||
self.lifecycle_profile = load_stock_rover_lifecycle_profile(self.lifecycle_profile_path)
|
|
||||||
self.guard = S0WorkerGuard(s0_profile_path)
|
|
||||||
self._preflight()
|
|
||||||
self.store = QualificationRunStore(self.paths.d_root / "artifacts/s1/runs")
|
|
||||||
self.supervisor = PosixProcessSupervisor(
|
|
||||||
self.paths.d_root / "runtime/s1/runs",
|
|
||||||
base_environment=stock_rover_process_environment(self.paths),
|
|
||||||
)
|
|
||||||
self.worker = LocalProcessWorkerAdapter(
|
|
||||||
self.guard,
|
|
||||||
self.supervisor,
|
|
||||||
stock_rover_process_specs(self.paths, self.lifecycle_profile),
|
|
||||||
_LifecycleOnlyWorldControl(),
|
|
||||||
)
|
|
||||||
self.service = SimulationApplicationService(self.store, self.worker)
|
|
||||||
self.collector = GazeboPoseCollector(stock_rover_process_environment(self.paths))
|
|
||||||
self.command_controller = Ros2Px4AckermannControl()
|
|
||||||
self._command_lock = threading.RLock()
|
|
||||||
self._command_history: dict[str, dict[str, AckermannControlSetpoint]] = {}
|
|
||||||
self._shutdown = threading.Event()
|
|
||||||
self._server_socket: socket.socket | None = None
|
|
||||||
self._connection_threads: set[threading.Thread] = set()
|
|
||||||
self.service.reconcile(
|
|
||||||
observed_at_utc=_utc_now(),
|
|
||||||
host_monotonic_ns=time.monotonic_ns(),
|
|
||||||
)
|
|
||||||
active = tuple(run for run in self.store.list_runs() if run.state in ACTIVE_STATES)
|
|
||||||
if len(active) > 1:
|
|
||||||
raise WorkerAgentError("multiple active qualification runs violate worker authority")
|
|
||||||
self._active_run_cache = active[0] if active else None
|
|
||||||
|
|
||||||
def serve_forever(self) -> None:
|
|
||||||
self.socket_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
||||||
if self.socket_path.exists():
|
|
||||||
if not self.socket_path.is_socket():
|
|
||||||
raise WorkerAgentError("worker socket path is occupied by a non-socket")
|
|
||||||
self.socket_path.unlink()
|
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
|
|
||||||
self._server_socket = server
|
|
||||||
server.bind(str(self.socket_path))
|
|
||||||
self.socket_path.chmod(0o600)
|
|
||||||
server.listen(8)
|
|
||||||
server.settimeout(0.5)
|
|
||||||
while not self._shutdown.is_set():
|
|
||||||
try:
|
|
||||||
connection, _ = server.accept()
|
|
||||||
except TimeoutError:
|
|
||||||
continue
|
|
||||||
self._connection_threads = {
|
|
||||||
thread for thread in self._connection_threads if thread.is_alive()
|
|
||||||
}
|
|
||||||
thread = threading.Thread(
|
|
||||||
target=self._serve_connection,
|
|
||||||
args=(connection,),
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
self._connection_threads.add(thread)
|
|
||||||
thread.start()
|
|
||||||
for thread in tuple(self._connection_threads):
|
|
||||||
thread.join(timeout=1)
|
|
||||||
self._server_socket = None
|
|
||||||
if self.socket_path.is_socket():
|
|
||||||
self.socket_path.unlink()
|
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
|
||||||
self._shutdown.set()
|
|
||||||
active = self._active_run()
|
|
||||||
if active is not None:
|
|
||||||
with suppress(Exception):
|
|
||||||
self.command_controller.stop(active.run_id)
|
|
||||||
try:
|
|
||||||
self.service.stop(
|
|
||||||
active.run_id,
|
|
||||||
idempotency_key=f"{active.run_id}:agent-shutdown",
|
|
||||||
observed_at_utc=_utc_now(),
|
|
||||||
host_monotonic_ns=time.monotonic_ns(),
|
|
||||||
sim_time_ns=None,
|
|
||||||
terminal_state=RunState.ABORTED,
|
|
||||||
reason="worker-agent-shutdown",
|
|
||||||
)
|
|
||||||
self._active_run_cache = None
|
|
||||||
except Exception:
|
|
||||||
self.supervisor.stop()
|
|
||||||
|
|
||||||
def _serve_connection(self, connection: socket.socket) -> None:
|
|
||||||
with connection:
|
|
||||||
self._handle_connection(connection)
|
|
||||||
|
|
||||||
def dispatch(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
if set(request) != {"schema_version", "request_id", "operation", "payload"}:
|
|
||||||
raise WorkerAgentError("request keys do not match v1")
|
|
||||||
request_id = request.get("request_id")
|
|
||||||
operation = request.get("operation")
|
|
||||||
payload = request.get("payload")
|
|
||||||
if (
|
|
||||||
request.get("schema_version") != REQUEST_SCHEMA
|
|
||||||
or not isinstance(request_id, str)
|
|
||||||
or not 1 <= len(request_id) <= 64
|
|
||||||
or operation not in OPERATIONS
|
|
||||||
or not isinstance(payload, dict)
|
|
||||||
):
|
|
||||||
raise WorkerAgentError("request envelope is invalid")
|
|
||||||
if operation == "status":
|
|
||||||
_exact_keys(payload, set(), "status payload")
|
|
||||||
return self.status()
|
|
||||||
if operation == "live":
|
|
||||||
_exact_keys(payload, set(), "live payload")
|
|
||||||
active = self._active_run()
|
|
||||||
if active is None or active.state is not RunState.RUNNING:
|
|
||||||
raise WorkerAgentError("no running simulation is available")
|
|
||||||
return self.collector.collect(active.run_id)
|
|
||||||
if operation == "start":
|
|
||||||
_exact_keys(
|
|
||||||
payload,
|
|
||||||
{"run_id", "mission_core_commit", "idempotency_key"},
|
|
||||||
"start payload",
|
|
||||||
)
|
|
||||||
return self.start(
|
|
||||||
run_id=_safe_id(payload["run_id"], "run id"),
|
|
||||||
mission_core_commit=_string(payload["mission_core_commit"], "commit", 40),
|
|
||||||
idempotency_key=_string(payload["idempotency_key"], "idempotency key", 160),
|
|
||||||
)
|
|
||||||
if operation == "command":
|
|
||||||
_exact_keys(
|
|
||||||
payload,
|
|
||||||
{"run_id", "speed_mps", "steering_normalized", "idempotency_key"},
|
|
||||||
"command payload",
|
|
||||||
)
|
|
||||||
return self.command(
|
|
||||||
run_id=_safe_id(payload["run_id"], "run id"),
|
|
||||||
speed_mps=_bounded_number(payload["speed_mps"], "speed", -1.5, 1.5),
|
|
||||||
steering_normalized=_bounded_number(
|
|
||||||
payload["steering_normalized"],
|
|
||||||
"steering",
|
|
||||||
-1.0,
|
|
||||||
1.0,
|
|
||||||
),
|
|
||||||
idempotency_key=_string(payload["idempotency_key"], "idempotency key", 160),
|
|
||||||
)
|
|
||||||
_exact_keys(payload, {"run_id", "idempotency_key"}, "stop payload")
|
|
||||||
return self.stop(
|
|
||||||
run_id=_safe_id(payload["run_id"], "run id"),
|
|
||||||
idempotency_key=_string(payload["idempotency_key"], "idempotency key", 160),
|
|
||||||
)
|
|
||||||
|
|
||||||
def status(self) -> dict[str, Any]:
|
|
||||||
active = self._active_run()
|
|
||||||
active_provider_ids: list[str] = []
|
|
||||||
if active is not None and active.state in {RunState.RUNNING, RunState.PAUSED}:
|
|
||||||
try:
|
|
||||||
active_provider_ids = [
|
|
||||||
record.process_id for record in self.supervisor.snapshot()
|
|
||||||
]
|
|
||||||
except Exception:
|
|
||||||
active_provider_ids = []
|
|
||||||
return {
|
|
||||||
"schema_version": STATUS_SCHEMA,
|
|
||||||
"worker_id": "mission-gpu-s1",
|
|
||||||
"transport": "unix",
|
|
||||||
"mode": "simulation",
|
|
||||||
"available": True,
|
|
||||||
"control_available": True,
|
|
||||||
"active_run_id": active.run_id if active else None,
|
|
||||||
"run_state": active.state.value if active else None,
|
|
||||||
"active_provider_ids": active_provider_ids,
|
|
||||||
"provider_profile": STOCK_ROVER_PROVIDER_PROFILE.to_dict(),
|
|
||||||
"isolation": {
|
|
||||||
"network": "loopback-only-netns",
|
|
||||||
"process_identity": "missioncore",
|
|
||||||
"artifact_policy": "d-only",
|
|
||||||
},
|
|
||||||
"authority": {
|
|
||||||
"scope": "virtual-only",
|
|
||||||
"actuator_authority": False,
|
|
||||||
"direct_actuator_setpoints_allowed": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def start(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
run_id: str,
|
|
||||||
mission_core_commit: str,
|
|
||||||
idempotency_key: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if mission_core_commit != self.mission_core_commit:
|
|
||||||
raise WorkerAgentError("requested commit does not match the staged worker generation")
|
|
||||||
try:
|
|
||||||
run = self.store.load(run_id)
|
|
||||||
except QualificationRunNotFoundError:
|
|
||||||
run = self.store.create(self._new_run(run_id))
|
|
||||||
if run.mission_core_commit != mission_core_commit:
|
|
||||||
raise WorkerAgentError("existing run belongs to another Mission Core generation")
|
|
||||||
running = self.service.start(
|
|
||||||
run_id,
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
observed_at_utc=_utc_now(),
|
|
||||||
host_monotonic_ns=time.monotonic_ns(),
|
|
||||||
)
|
|
||||||
if running.state is not RunState.RUNNING:
|
|
||||||
raise WorkerAgentError(f"provider start ended in terminal state {running.state.value}")
|
|
||||||
self._active_run_cache = running
|
|
||||||
try:
|
|
||||||
self.command_controller.start(run_id)
|
|
||||||
self.collector.collect(run_id)
|
|
||||||
except Exception as exc:
|
|
||||||
with suppress(Exception):
|
|
||||||
self.command_controller.stop(run_id)
|
|
||||||
try:
|
|
||||||
self.service.stop(
|
|
||||||
run_id,
|
|
||||||
idempotency_key=f"{run_id}:command-controller-admission-failed",
|
|
||||||
observed_at_utc=_utc_now(),
|
|
||||||
host_monotonic_ns=time.monotonic_ns(),
|
|
||||||
sim_time_ns=None,
|
|
||||||
terminal_state=RunState.ABORTED,
|
|
||||||
reason="px4-command-controller-admission-failed",
|
|
||||||
)
|
|
||||||
self._active_run_cache = None
|
|
||||||
finally:
|
|
||||||
raise WorkerAgentError("PX4 command controller admission failed") from exc
|
|
||||||
self._command_history[run_id] = {
|
|
||||||
command.command_id: command
|
|
||||||
for command in self.store.list_commands(run_id)
|
|
||||||
if isinstance(command, AckermannControlSetpoint)
|
|
||||||
}
|
|
||||||
return self.status()
|
|
||||||
|
|
||||||
def stop(self, *, run_id: str, idempotency_key: str) -> dict[str, Any]:
|
|
||||||
active = self._active_run()
|
|
||||||
if active is None or active.run_id != run_id:
|
|
||||||
raise WorkerAgentError("requested run does not own worker authority")
|
|
||||||
sim_time_ns: int | None
|
|
||||||
try:
|
|
||||||
sim_time_ns = int(self.collector.collect(run_id)["sim_time_ns"])
|
|
||||||
except Exception:
|
|
||||||
sim_time_ns = None
|
|
||||||
command_stop_error: Exception | None = None
|
|
||||||
try:
|
|
||||||
self.command_controller.stop(run_id)
|
|
||||||
except Exception as exc:
|
|
||||||
command_stop_error = exc
|
|
||||||
terminal = self.service.stop(
|
|
||||||
run_id,
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
observed_at_utc=_utc_now(),
|
|
||||||
host_monotonic_ns=time.monotonic_ns(),
|
|
||||||
sim_time_ns=sim_time_ns,
|
|
||||||
terminal_state=(
|
|
||||||
RunState.ABORTED if command_stop_error is not None else RunState.COMPLETED
|
|
||||||
),
|
|
||||||
reason=(
|
|
||||||
"px4-command-controller-stop-failed"
|
|
||||||
if command_stop_error is not None
|
|
||||||
else "operator-stop-clean"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if command_stop_error is not None:
|
|
||||||
raise WorkerAgentError(
|
|
||||||
"PX4 command controller did not stop cleanly"
|
|
||||||
) from command_stop_error
|
|
||||||
if terminal.state is not RunState.COMPLETED:
|
|
||||||
raise WorkerAgentError(f"provider stop ended in terminal state {terminal.state.value}")
|
|
||||||
self._active_run_cache = None
|
|
||||||
return self.status()
|
|
||||||
|
|
||||||
def command(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
run_id: str,
|
|
||||||
speed_mps: float,
|
|
||||||
steering_normalized: float,
|
|
||||||
idempotency_key: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
with self._command_lock:
|
|
||||||
return self._command_locked(
|
|
||||||
run_id=run_id,
|
|
||||||
speed_mps=speed_mps,
|
|
||||||
steering_normalized=steering_normalized,
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _command_locked(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
run_id: str,
|
|
||||||
speed_mps: float,
|
|
||||||
steering_normalized: float,
|
|
||||||
idempotency_key: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
active = self._active_run()
|
|
||||||
if active is None or active.run_id != run_id or active.state is not RunState.RUNNING:
|
|
||||||
raise WorkerAgentError("requested run does not own running command authority")
|
|
||||||
command_id = f"cmd-{hashlib.sha256(idempotency_key.encode()).hexdigest()[:24]}"
|
|
||||||
history = self._command_history_for(run_id)
|
|
||||||
existing = history.get(command_id)
|
|
||||||
if existing is not None:
|
|
||||||
if not isinstance(existing, AckermannControlSetpoint) or (
|
|
||||||
existing.speed_mps != speed_mps
|
|
||||||
or existing.steering_normalized != steering_normalized
|
|
||||||
):
|
|
||||||
raise WorkerAgentError("command idempotency key is bound to another setpoint")
|
|
||||||
snapshot = self.command_controller.snapshot(run_id)
|
|
||||||
return _command_acceptance(existing, snapshot)
|
|
||||||
issued_at_sim_ns = self.collector.estimate_sim_time_ns()
|
|
||||||
ttl_ns = min(active.authority.command_ttl_max_ns, 250_000_000)
|
|
||||||
command = AckermannControlSetpoint(
|
|
||||||
run_id=run_id,
|
|
||||||
command_id=command_id,
|
|
||||||
sequence=len(history) + 1,
|
|
||||||
issued_at_sim_ns=issued_at_sim_ns,
|
|
||||||
valid_until_sim_ns=issued_at_sim_ns + ttl_ns,
|
|
||||||
authority_generation=active.authority.generation,
|
|
||||||
speed_mps=speed_mps,
|
|
||||||
steering_normalized=steering_normalized,
|
|
||||||
source="mission-core-control-station",
|
|
||||||
)
|
|
||||||
self.store.submit_admitted_command(command, admitted_run=active)
|
|
||||||
history[command.command_id] = command
|
|
||||||
try:
|
|
||||||
snapshot = self.command_controller.submit(command)
|
|
||||||
except Exception as exc:
|
|
||||||
current = self.store.load(run_id)
|
|
||||||
self.store.append_event(
|
|
||||||
run_id,
|
|
||||||
event_type="command.delivery-failed",
|
|
||||||
observed_at_utc=_utc_now(),
|
|
||||||
host_monotonic_ns=time.monotonic_ns(),
|
|
||||||
sim_time_ns=issued_at_sim_ns,
|
|
||||||
payload={
|
|
||||||
"command_id": command.command_id,
|
|
||||||
"provider": PX4_COMMAND_PROVIDER_ID,
|
|
||||||
"detail": type(exc).__name__,
|
|
||||||
},
|
|
||||||
expected_revision=current.revision,
|
|
||||||
)
|
|
||||||
raise WorkerAgentError("PX4 rejected the canonical rover setpoint") from exc
|
|
||||||
return _command_acceptance(command, snapshot)
|
|
||||||
|
|
||||||
def _command_history_for(
|
|
||||||
self,
|
|
||||||
run_id: str,
|
|
||||||
) -> dict[str, AckermannControlSetpoint]:
|
|
||||||
histories = getattr(self, "_command_history", None)
|
|
||||||
if histories is None:
|
|
||||||
histories = {}
|
|
||||||
self._command_history = histories
|
|
||||||
history = histories.get(run_id)
|
|
||||||
if history is None:
|
|
||||||
commands = self.store.list_commands(run_id)
|
|
||||||
history = {
|
|
||||||
command.command_id: command
|
|
||||||
for command in commands
|
|
||||||
if isinstance(command, AckermannControlSetpoint)
|
|
||||||
}
|
|
||||||
if len(history) != len(commands):
|
|
||||||
raise WorkerAgentError("running command history contains another command contract")
|
|
||||||
histories[run_id] = history
|
|
||||||
return history
|
|
||||||
|
|
||||||
def _handle_connection(self, connection: socket.socket) -> None:
|
|
||||||
request_id: str | None = None
|
|
||||||
try:
|
|
||||||
request_bytes = _read_line(connection)
|
|
||||||
request = _json_object(request_bytes)
|
|
||||||
raw_request_id = request.get("request_id")
|
|
||||||
if isinstance(raw_request_id, str) and 1 <= len(raw_request_id) <= 64:
|
|
||||||
request_id = raw_request_id
|
|
||||||
result = self.dispatch(request)
|
|
||||||
response = {
|
|
||||||
"schema_version": RESPONSE_SCHEMA,
|
|
||||||
"request_id": request_id,
|
|
||||||
"ok": True,
|
|
||||||
"result": result,
|
|
||||||
"error": None,
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
response = {
|
|
||||||
"schema_version": RESPONSE_SCHEMA,
|
|
||||||
"request_id": request_id,
|
|
||||||
"ok": False,
|
|
||||||
"result": None,
|
|
||||||
"error": _safe_error(exc),
|
|
||||||
}
|
|
||||||
encoded = (
|
|
||||||
json.dumps(response, ensure_ascii=True, separators=(",", ":")).encode("utf-8") + b"\n"
|
|
||||||
)
|
|
||||||
if len(encoded) <= MAX_MESSAGE_BYTES:
|
|
||||||
connection.sendall(encoded)
|
|
||||||
|
|
||||||
def _new_run(self, run_id: str) -> QualificationRun:
|
|
||||||
return QualificationRun(
|
|
||||||
run_id=run_id,
|
|
||||||
episode_id=f"episode-{run_id}",
|
|
||||||
kind=RunKind.SIMULATION_CLOSED_LOOP,
|
|
||||||
state=RunState.ADMITTED,
|
|
||||||
scenario_generation="px4-v1.17.0-stock-rover-ackermann",
|
|
||||||
scenario_sha256=_sha256(self.paths.scenario_path),
|
|
||||||
profile_generation=self.lifecycle_profile.profile_id,
|
|
||||||
profile_sha256=_sha256(self.lifecycle_profile_path),
|
|
||||||
mission_core_commit=self.mission_core_commit,
|
|
||||||
providers=_provider_pins(self.guard),
|
|
||||||
host_profile_id=self.guard.profile.profile_id,
|
|
||||||
host_profile_sha256=self.guard.profile_sha256,
|
|
||||||
seed=42,
|
|
||||||
reproducibility_tier=ReproducibilityTier.R1,
|
|
||||||
authority=AuthorityProfile(
|
|
||||||
generation=1,
|
|
||||||
command_ttl_max_ns=250_000_000,
|
|
||||||
heartbeat_timeout_monotonic_ns=500_000_000,
|
|
||||||
),
|
|
||||||
clock_domain=STOCK_ROVER_PROVIDER_PROFILE.clock.domain,
|
|
||||||
created_at_utc=_utc_now(),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _active_run(self) -> QualificationRun | None:
|
|
||||||
if hasattr(self, "_active_run_cache"):
|
|
||||||
return self._active_run_cache
|
|
||||||
active = tuple(run for run in self.store.list_runs() if run.state in ACTIVE_STATES)
|
|
||||||
if len(active) > 1:
|
|
||||||
raise WorkerAgentError("multiple active qualification runs violate worker authority")
|
|
||||||
result = active[0] if active else None
|
|
||||||
self._active_run_cache = result
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _preflight(self) -> None:
|
|
||||||
required = (self.paths.px4_root, self.paths.agent_binary, self.paths.scenario_path)
|
|
||||||
if any(not path.exists() for path in required):
|
|
||||||
raise WorkerAgentError("worker inputs are incomplete")
|
|
||||||
if not all(
|
|
||||||
component.qualification_state == "accepted"
|
|
||||||
for component in self.guard.profile.components
|
|
||||||
):
|
|
||||||
raise WorkerAgentError("an S0 provider generation is no longer accepted")
|
|
||||||
|
|
||||||
|
|
||||||
def _read_line(connection: socket.socket) -> bytes:
|
|
||||||
chunks: list[bytes] = []
|
|
||||||
size = 0
|
|
||||||
while True:
|
|
||||||
chunk = connection.recv(min(4096, MAX_MESSAGE_BYTES + 1 - size))
|
|
||||||
if not chunk:
|
|
||||||
raise WorkerAgentError("gateway request ended before newline")
|
|
||||||
newline = chunk.find(b"\n")
|
|
||||||
if newline >= 0:
|
|
||||||
chunks.append(chunk[:newline])
|
|
||||||
size += newline
|
|
||||||
if size > MAX_MESSAGE_BYTES:
|
|
||||||
raise WorkerAgentError("gateway request exceeds the size limit")
|
|
||||||
return b"".join(chunks)
|
|
||||||
chunks.append(chunk)
|
|
||||||
size += len(chunk)
|
|
||||||
if size > MAX_MESSAGE_BYTES:
|
|
||||||
raise WorkerAgentError("gateway request exceeds the size limit")
|
|
||||||
|
|
||||||
|
|
||||||
def _json_object(value: bytes) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
document = json.loads(value.decode("utf-8"))
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
||||||
raise WorkerAgentError("gateway request is not valid UTF-8 JSON") from exc
|
|
||||||
if not isinstance(document, dict) or any(not isinstance(key, str) for key in document):
|
|
||||||
raise WorkerAgentError("gateway request must be a JSON object")
|
|
||||||
return document
|
|
||||||
|
|
||||||
|
|
||||||
def _rover_pose(document: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
poses = document.get("pose")
|
|
||||||
if not isinstance(poses, list) or not poses:
|
|
||||||
raise WorkerAgentError("Gazebo pose sample contains no poses")
|
|
||||||
for item in poses:
|
|
||||||
if isinstance(item, dict) and str(item.get("name", "")).startswith("rover_ackermann"):
|
|
||||||
return item
|
|
||||||
raise WorkerAgentError("Gazebo pose sample contains no stock rover")
|
|
||||||
|
|
||||||
|
|
||||||
def _provider_pins(guard: S0WorkerGuard) -> tuple[ProviderPin, ...]:
|
|
||||||
components = {component.identifier: component for component in guard.profile.components}
|
|
||||||
if any(identifier not in components for identifier in PROVIDER_IDS):
|
|
||||||
raise WorkerAgentError("S0 profile is missing a required provider")
|
|
||||||
return tuple(_provider_pin(components[identifier]) for identifier in PROVIDER_IDS)
|
|
||||||
|
|
||||||
|
|
||||||
def _provider_pin(component: ComponentPin) -> ProviderPin:
|
|
||||||
version = component.resolved_version or component.requested_ref
|
|
||||||
return ProviderPin(
|
|
||||||
identifier=component.identifier,
|
|
||||||
version=version,
|
|
||||||
revision=component.resolved_commit or version,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _exact_keys(value: dict[str, Any], expected: set[str], label: str) -> None:
|
|
||||||
if set(value) != expected:
|
|
||||||
raise WorkerAgentError(f"{label} keys do not match v1")
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_id(value: object, label: str) -> str:
|
|
||||||
result = _string(value, label, 64)
|
|
||||||
if not SAFE_ID_PATTERN.fullmatch(result):
|
|
||||||
raise WorkerAgentError(f"{label} is not a safe identifier")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _string(value: object, label: str, maximum: int) -> str:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise WorkerAgentError(f"{label} must be a string")
|
|
||||||
result = value.strip()
|
|
||||||
if not 1 <= len(result) <= maximum:
|
|
||||||
raise WorkerAgentError(f"{label} has an invalid length")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
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 WorkerAgentError(f"{label} must be an object")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _number(value: object, label: str) -> float:
|
|
||||||
if isinstance(value, bool) or not isinstance(value, int | float | str):
|
|
||||||
raise WorkerAgentError(f"{label} must be numeric")
|
|
||||||
try:
|
|
||||||
result = float(value)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise WorkerAgentError(f"{label} must be numeric") from exc
|
|
||||||
if not -1e9 < result < 1e9:
|
|
||||||
raise WorkerAgentError(f"{label} is outside the accepted range")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _bounded_number(
|
|
||||||
value: object,
|
|
||||||
label: str,
|
|
||||||
minimum: float,
|
|
||||||
maximum: float,
|
|
||||||
) -> float:
|
|
||||||
result = _number(value, label)
|
|
||||||
if not minimum <= result <= maximum:
|
|
||||||
raise WorkerAgentError(f"{label} is outside the admitted range")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _integer(value: object, label: str) -> int:
|
|
||||||
if isinstance(value, bool) or not isinstance(value, int | str):
|
|
||||||
raise WorkerAgentError(f"{label} must be an integer")
|
|
||||||
try:
|
|
||||||
result = int(value)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise WorkerAgentError(f"{label} must be an integer") from exc
|
|
||||||
if result < 0:
|
|
||||||
raise WorkerAgentError(f"{label} must not be negative")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _sha256(path: Path) -> str:
|
|
||||||
digest = hashlib.sha256()
|
|
||||||
with path.open("rb") as stream:
|
|
||||||
while chunk := stream.read(1024 * 1024):
|
|
||||||
digest.update(chunk)
|
|
||||||
return digest.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _utc_now() -> str:
|
|
||||||
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_error(exc: Exception) -> str:
|
|
||||||
if isinstance(exc, WorkerAgentError):
|
|
||||||
normalized = " ".join(str(exc).split())
|
|
||||||
return normalized[:512] or "worker request rejected"
|
|
||||||
return f"{type(exc).__name__}: worker operation failed"
|
|
||||||
|
|
||||||
|
|
||||||
def _command_acceptance(
|
|
||||||
command: AckermannControlSetpoint,
|
|
||||||
snapshot: Px4RoverControlSnapshot,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"schema_version": COMMAND_ACCEPTANCE_SCHEMA,
|
|
||||||
"run_id": command.run_id,
|
|
||||||
"command_id": command.command_id,
|
|
||||||
"sequence": command.sequence,
|
|
||||||
"issued_at_sim_ns": command.issued_at_sim_ns,
|
|
||||||
"valid_until_sim_ns": command.valid_until_sim_ns,
|
|
||||||
"speed_mps": command.speed_mps,
|
|
||||||
"steering_normalized": command.steering_normalized,
|
|
||||||
"authority_scope": command.authority_scope.value,
|
|
||||||
"delivery": {
|
|
||||||
"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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(description="Serve the Mission Core Simulation Worker agent.")
|
|
||||||
parser.add_argument("--socket", type=Path, required=True)
|
|
||||||
parser.add_argument("--mission-core-commit", required=True)
|
|
||||||
parser.add_argument(
|
|
||||||
"--d-root",
|
|
||||||
type=Path,
|
|
||||||
default=Path("/mnt/d/NDC_MISSIONCORE/simulation"),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--s0-profile",
|
|
||||||
type=Path,
|
|
||||||
default=Path("/mnt/d/NDC_MISSIONCORE/simulation/source/qualification-profile.yaml"),
|
|
||||||
)
|
|
||||||
parser.add_argument("--lifecycle-profile", type=Path, required=True)
|
|
||||||
args = parser.parse_args()
|
|
||||||
agent = SimulationWorkerAgent(
|
|
||||||
socket_path=args.socket,
|
|
||||||
mission_core_commit=args.mission_core_commit,
|
|
||||||
d_root=args.d_root,
|
|
||||||
s0_profile_path=args.s0_profile,
|
|
||||||
lifecycle_profile_path=args.lifecycle_profile,
|
|
||||||
)
|
|
||||||
|
|
||||||
def stop(_signum: int, _frame: FrameType | None) -> None:
|
|
||||||
agent.shutdown()
|
|
||||||
|
|
||||||
signal.signal(signal.SIGTERM, stop)
|
|
||||||
signal.signal(signal.SIGINT, stop)
|
|
||||||
try:
|
|
||||||
agent.serve_forever()
|
|
||||||
finally:
|
|
||||||
agent.shutdown()
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
|
|
@ -1,393 +0,0 @@
|
||||||
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/v2"
|
|
||||||
VEHICLE_STATE_SCHEMA: Final = "missioncore.vehicle-state/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):
|
|
||||||
"""The worker transport or response violated the private gateway contract."""
|
|
||||||
|
|
||||||
|
|
||||||
class SimulationWorkerUnavailableError(SimulationWorkerGatewayError):
|
|
||||||
"""The configured worker cannot currently be reached."""
|
|
||||||
|
|
||||||
|
|
||||||
class SimulationWorkerRejectedError(SimulationWorkerGatewayError):
|
|
||||||
"""The worker safely rejected a valid gateway request."""
|
|
||||||
|
|
||||||
|
|
||||||
class PolygonWorkerGateway(Protocol):
|
|
||||||
def status(self) -> dict[str, Any]: ...
|
|
||||||
|
|
||||||
def live(self) -> dict[str, Any]: ...
|
|
||||||
|
|
||||||
def start(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
run_id: str,
|
|
||||||
mission_core_commit: str,
|
|
||||||
idempotency_key: str,
|
|
||||||
) -> dict[str, Any]: ...
|
|
||||||
|
|
||||||
def stop(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
run_id: str,
|
|
||||||
idempotency_key: str,
|
|
||||||
) -> dict[str, Any]: ...
|
|
||||||
|
|
||||||
def command(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
run_id: str,
|
|
||||||
speed_mps: float,
|
|
||||||
steering_normalized: float,
|
|
||||||
idempotency_key: str,
|
|
||||||
) -> dict[str, Any]: ...
|
|
||||||
|
|
||||||
|
|
||||||
class UnixSocketWorkerGateway:
|
|
||||||
"""Bounded request/response transport between Mission Core and one local worker."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
socket_path: Path,
|
|
||||||
*,
|
|
||||||
request_timeout_seconds: float = 3.0,
|
|
||||||
lifecycle_timeout_seconds: float = 150.0,
|
|
||||||
) -> None:
|
|
||||||
path = socket_path.expanduser()
|
|
||||||
if not path.is_absolute():
|
|
||||||
raise ValueError("worker socket path must be absolute")
|
|
||||||
if request_timeout_seconds <= 0 or lifecycle_timeout_seconds <= 0:
|
|
||||||
raise ValueError("worker gateway timeouts must be positive")
|
|
||||||
self.socket_path = path
|
|
||||||
self.request_timeout_seconds = request_timeout_seconds
|
|
||||||
self.lifecycle_timeout_seconds = lifecycle_timeout_seconds
|
|
||||||
|
|
||||||
def status(self) -> dict[str, Any]:
|
|
||||||
result = self._request("status", {}, timeout_seconds=self.request_timeout_seconds)
|
|
||||||
_validate_status(result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def live(self) -> dict[str, Any]:
|
|
||||||
result = self._request("live", {}, timeout_seconds=self.request_timeout_seconds)
|
|
||||||
_validate_vehicle_state(result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def start(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
run_id: str,
|
|
||||||
mission_core_commit: str,
|
|
||||||
idempotency_key: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
result = self._request(
|
|
||||||
"start",
|
|
||||||
{
|
|
||||||
"run_id": run_id,
|
|
||||||
"mission_core_commit": mission_core_commit,
|
|
||||||
"idempotency_key": idempotency_key,
|
|
||||||
},
|
|
||||||
timeout_seconds=self.lifecycle_timeout_seconds,
|
|
||||||
)
|
|
||||||
_validate_status(result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def stop(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
run_id: str,
|
|
||||||
idempotency_key: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
result = self._request(
|
|
||||||
"stop",
|
|
||||||
{
|
|
||||||
"run_id": run_id,
|
|
||||||
"idempotency_key": idempotency_key,
|
|
||||||
},
|
|
||||||
timeout_seconds=self.lifecycle_timeout_seconds,
|
|
||||||
)
|
|
||||||
_validate_status(result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def command(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
run_id: str,
|
|
||||||
speed_mps: float,
|
|
||||||
steering_normalized: float,
|
|
||||||
idempotency_key: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
result = self._request(
|
|
||||||
"command",
|
|
||||||
{
|
|
||||||
"run_id": run_id,
|
|
||||||
"speed_mps": speed_mps,
|
|
||||||
"steering_normalized": steering_normalized,
|
|
||||||
"idempotency_key": idempotency_key,
|
|
||||||
},
|
|
||||||
timeout_seconds=self.request_timeout_seconds,
|
|
||||||
)
|
|
||||||
_validate_command_acceptance(result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _request(
|
|
||||||
self,
|
|
||||||
operation: str,
|
|
||||||
payload: Mapping[str, object],
|
|
||||||
*,
|
|
||||||
timeout_seconds: float,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if operation not in OPERATIONS:
|
|
||||||
raise ValueError("unsupported worker operation")
|
|
||||||
request_id = uuid4().hex
|
|
||||||
encoded = (
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"schema_version": REQUEST_SCHEMA,
|
|
||||||
"request_id": request_id,
|
|
||||||
"operation": operation,
|
|
||||||
"payload": dict(payload),
|
|
||||||
},
|
|
||||||
ensure_ascii=True,
|
|
||||||
separators=(",", ":"),
|
|
||||||
).encode("utf-8")
|
|
||||||
+ b"\n"
|
|
||||||
)
|
|
||||||
if len(encoded) > MAX_MESSAGE_BYTES:
|
|
||||||
raise ValueError("worker request exceeds the bounded message size")
|
|
||||||
try:
|
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
|
||||||
client.settimeout(timeout_seconds)
|
|
||||||
client.connect(str(self.socket_path))
|
|
||||||
client.sendall(encoded)
|
|
||||||
response_bytes = _read_line(client)
|
|
||||||
except (FileNotFoundError, ConnectionError, OSError, TimeoutError) as exc:
|
|
||||||
raise SimulationWorkerUnavailableError(
|
|
||||||
"Simulation Worker недоступен через локальный шлюз."
|
|
||||||
) from exc
|
|
||||||
response = _json_object(response_bytes, "worker response")
|
|
||||||
if set(response) != {"schema_version", "request_id", "ok", "result", "error"}:
|
|
||||||
raise SimulationWorkerGatewayError("worker response keys do not match v1")
|
|
||||||
if response["schema_version"] != RESPONSE_SCHEMA or response["request_id"] != request_id:
|
|
||||||
raise SimulationWorkerGatewayError("worker response identity does not match request")
|
|
||||||
if not isinstance(response["ok"], bool):
|
|
||||||
raise SimulationWorkerGatewayError("worker response ok flag is invalid")
|
|
||||||
if response["ok"]:
|
|
||||||
if response["error"] is not None or not isinstance(response["result"], dict):
|
|
||||||
raise SimulationWorkerGatewayError("successful worker response is malformed")
|
|
||||||
return response["result"]
|
|
||||||
error = response["error"]
|
|
||||||
if response["result"] is not None or not isinstance(error, str) or not error.strip():
|
|
||||||
raise SimulationWorkerGatewayError("rejected worker response is malformed")
|
|
||||||
raise SimulationWorkerRejectedError(error[:512])
|
|
||||||
|
|
||||||
|
|
||||||
def _read_line(connection: socket.socket) -> bytes:
|
|
||||||
chunks: list[bytes] = []
|
|
||||||
size = 0
|
|
||||||
while True:
|
|
||||||
chunk = connection.recv(min(4096, MAX_MESSAGE_BYTES + 1 - size))
|
|
||||||
if not chunk:
|
|
||||||
raise SimulationWorkerGatewayError("worker closed the gateway without a response")
|
|
||||||
newline = chunk.find(b"\n")
|
|
||||||
if newline >= 0:
|
|
||||||
chunks.append(chunk[:newline])
|
|
||||||
size += newline
|
|
||||||
if size > MAX_MESSAGE_BYTES:
|
|
||||||
raise SimulationWorkerGatewayError("worker response exceeds the size limit")
|
|
||||||
return b"".join(chunks)
|
|
||||||
chunks.append(chunk)
|
|
||||||
size += len(chunk)
|
|
||||||
if size > MAX_MESSAGE_BYTES:
|
|
||||||
raise SimulationWorkerGatewayError("worker response exceeds the size limit")
|
|
||||||
|
|
||||||
|
|
||||||
def _json_object(value: bytes, label: str) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
document = json.loads(value.decode("utf-8"))
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
||||||
raise SimulationWorkerGatewayError(f"{label} is not valid UTF-8 JSON") from exc
|
|
||||||
if not isinstance(document, dict) or any(not isinstance(key, str) for key in document):
|
|
||||||
raise SimulationWorkerGatewayError(f"{label} must be a JSON object")
|
|
||||||
return document
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_status(value: Mapping[str, Any]) -> None:
|
|
||||||
expected = {
|
|
||||||
"schema_version",
|
|
||||||
"worker_id",
|
|
||||||
"transport",
|
|
||||||
"mode",
|
|
||||||
"available",
|
|
||||||
"control_available",
|
|
||||||
"active_run_id",
|
|
||||||
"run_state",
|
|
||||||
"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 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(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):
|
|
||||||
raise SimulationWorkerGatewayError("worker run state is invalid")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_vehicle_state(value: Mapping[str, Any]) -> None:
|
|
||||||
expected = {
|
|
||||||
"schema_version",
|
|
||||||
"run_id",
|
|
||||||
"sequence",
|
|
||||||
"observed_at_utc",
|
|
||||||
"host_monotonic_ns",
|
|
||||||
"sim_time_ns",
|
|
||||||
"frame_id",
|
|
||||||
"child_frame_id",
|
|
||||||
"pose",
|
|
||||||
"source",
|
|
||||||
"safety",
|
|
||||||
}
|
|
||||||
if set(value) != expected or value.get("schema_version") != VEHICLE_STATE_SCHEMA:
|
|
||||||
raise SimulationWorkerGatewayError("vehicle state does not match v1")
|
|
||||||
if (
|
|
||||||
not isinstance(value.get("run_id"), str)
|
|
||||||
or not isinstance(value.get("sequence"), int)
|
|
||||||
or not isinstance(value.get("observed_at_utc"), str)
|
|
||||||
or not isinstance(value.get("host_monotonic_ns"), int)
|
|
||||||
or not isinstance(value.get("sim_time_ns"), int)
|
|
||||||
or value.get("frame_id") != "map_enu"
|
|
||||||
or value.get("child_frame_id") != "base_link_flu"
|
|
||||||
or not isinstance(value.get("pose"), dict)
|
|
||||||
or not isinstance(value.get("source"), dict)
|
|
||||||
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:
|
|
||||||
expected = {
|
|
||||||
"schema_version",
|
|
||||||
"run_id",
|
|
||||||
"command_id",
|
|
||||||
"sequence",
|
|
||||||
"issued_at_sim_ns",
|
|
||||||
"valid_until_sim_ns",
|
|
||||||
"speed_mps",
|
|
||||||
"steering_normalized",
|
|
||||||
"authority_scope",
|
|
||||||
"delivery",
|
|
||||||
}
|
|
||||||
if set(value) != expected or value.get("schema_version") != COMMAND_ACCEPTANCE_SCHEMA:
|
|
||||||
raise SimulationWorkerGatewayError("command acceptance does not match v2")
|
|
||||||
delivery = value.get("delivery")
|
|
||||||
numeric_fields = (
|
|
||||||
"sequence",
|
|
||||||
"issued_at_sim_ns",
|
|
||||||
"valid_until_sim_ns",
|
|
||||||
"speed_mps",
|
|
||||||
"steering_normalized",
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
not isinstance(value.get("run_id"), str)
|
|
||||||
or not isinstance(value.get("command_id"), str)
|
|
||||||
or any(
|
|
||||||
isinstance(value.get(field), bool)
|
|
||||||
or not isinstance(value.get(field), int | float)
|
|
||||||
or not math.isfinite(value[field])
|
|
||||||
for field in numeric_fields
|
|
||||||
)
|
|
||||||
or value.get("authority_scope") != "virtual-only"
|
|
||||||
or not isinstance(delivery, dict)
|
|
||||||
or set(delivery)
|
|
||||||
!= {
|
|
||||||
"provider_id",
|
|
||||||
"control_profile",
|
|
||||||
"accepted",
|
|
||||||
"controller_ready",
|
|
||||||
"ttl_expired_count",
|
|
||||||
"diagnostics",
|
|
||||||
}
|
|
||||||
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()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
@ -43,7 +43,6 @@ from k1link.web.plugin_runtime import (
|
||||||
PluginNotFoundError,
|
PluginNotFoundError,
|
||||||
PluginRuntimeUnavailableError,
|
PluginRuntimeUnavailableError,
|
||||||
)
|
)
|
||||||
from k1link.web.polygon_api import build_polygon_router
|
|
||||||
from k1link.web.session_api import build_session_router
|
from k1link.web.session_api import build_session_router
|
||||||
|
|
||||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
@ -397,7 +396,6 @@ app.include_router(
|
||||||
point_color_renderers=plugin_environment.point_color_renderers,
|
point_color_renderers=plugin_environment.point_color_renderers,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
app.include_router(build_polygon_router())
|
|
||||||
|
|
||||||
|
|
||||||
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
|
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
|
||||||
|
|
|
||||||
|
|
@ -1,397 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import secrets
|
|
||||||
from collections.abc import Callable
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Annotated, Any, Final
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Header, HTTPException, Query
|
|
||||||
from fastapi import Path as PathParameter
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
from k1link.simulation import (
|
|
||||||
QualificationRun,
|
|
||||||
QualificationRunIntegrityError,
|
|
||||||
QualificationRunNotFoundError,
|
|
||||||
QualificationRunStore,
|
|
||||||
)
|
|
||||||
from k1link.simulation.worker_gateway import (
|
|
||||||
STATUS_SCHEMA,
|
|
||||||
PolygonWorkerGateway,
|
|
||||||
SimulationWorkerGatewayError,
|
|
||||||
SimulationWorkerRejectedError,
|
|
||||||
SimulationWorkerUnavailableError,
|
|
||||||
UnixSocketWorkerGateway,
|
|
||||||
)
|
|
||||||
|
|
||||||
POLYGON_RUNS_ROOT_ENV: Final = "MISSIONCORE_POLYGON_RUNS_ROOT"
|
|
||||||
POLYGON_WORKER_SOCKET_ENV: Final = "MISSIONCORE_POLYGON_WORKER_SOCKET"
|
|
||||||
POLYGON_WORKER_CONTROL_ENV: Final = "MISSIONCORE_POLYGON_WORKER_CONTROL"
|
|
||||||
MISSION_CORE_COMMIT_ENV: Final = "MISSIONCORE_COMMIT"
|
|
||||||
CATALOG_SCHEMA: Final = "missioncore.polygon-run-catalog/v1"
|
|
||||||
DETAIL_SCHEMA: Final = "missioncore.polygon-run-detail/v1"
|
|
||||||
COMMIT_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$")
|
|
||||||
READ_ONLY_LIMITATIONS: Final[tuple[str, ...]] = (
|
|
||||||
"Архивный UI-0 публикует только квалификационные доказательства; "
|
|
||||||
"содержимое команд и mutation-маршруты в архиве отсутствуют.",
|
|
||||||
"Артефакты представлены относительными метаданными; их содержимое не публикуется этим API.",
|
|
||||||
"Терминальное состояние прогона не означает приёмку навигации, "
|
|
||||||
"восприятия или физической безопасности.",
|
|
||||||
)
|
|
||||||
|
|
||||||
RootProvider = Callable[[], Path | None]
|
|
||||||
WorkerProvider = Callable[[], PolygonWorkerGateway | None]
|
|
||||||
ControlProvider = Callable[[], bool]
|
|
||||||
CommitProvider = Callable[[], str | None]
|
|
||||||
|
|
||||||
|
|
||||||
class StartStockRoverRequest(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
||||||
|
|
||||||
scenario_id: str
|
|
||||||
|
|
||||||
|
|
||||||
class AckermannCommandRequest(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
||||||
|
|
||||||
speed_mps: float = Field(ge=-1.5, le=1.5, allow_inf_nan=False)
|
|
||||||
steering_normalized: float = Field(ge=-1.0, le=1.0, allow_inf_nan=False)
|
|
||||||
|
|
||||||
|
|
||||||
def configured_polygon_runs_root() -> Path | None:
|
|
||||||
raw = os.environ.get(POLYGON_RUNS_ROOT_ENV)
|
|
||||||
if raw is None or not raw.strip():
|
|
||||||
return None
|
|
||||||
return Path(raw.strip()).expanduser()
|
|
||||||
|
|
||||||
|
|
||||||
def configured_polygon_worker() -> PolygonWorkerGateway | None:
|
|
||||||
raw = os.environ.get(POLYGON_WORKER_SOCKET_ENV)
|
|
||||||
if raw is None or not raw.strip():
|
|
||||||
return None
|
|
||||||
return UnixSocketWorkerGateway(Path(raw.strip()).expanduser())
|
|
||||||
|
|
||||||
|
|
||||||
def configured_polygon_control() -> bool:
|
|
||||||
return os.environ.get(POLYGON_WORKER_CONTROL_ENV, "").strip() == "internal-virtual-only"
|
|
||||||
|
|
||||||
|
|
||||||
def configured_mission_core_commit() -> str | None:
|
|
||||||
raw = os.environ.get(MISSION_CORE_COMMIT_ENV)
|
|
||||||
if raw is None:
|
|
||||||
return None
|
|
||||||
normalized = raw.strip()
|
|
||||||
return normalized if COMMIT_PATTERN.fullmatch(normalized) else None
|
|
||||||
|
|
||||||
|
|
||||||
def build_polygon_router(
|
|
||||||
*,
|
|
||||||
root_provider: RootProvider = configured_polygon_runs_root,
|
|
||||||
worker_provider: WorkerProvider = configured_polygon_worker,
|
|
||||||
control_provider: ControlProvider = configured_polygon_control,
|
|
||||||
commit_provider: CommitProvider = configured_mission_core_commit,
|
|
||||||
) -> APIRouter:
|
|
||||||
router = APIRouter(prefix="/api/v1/polygon", tags=["polygon"])
|
|
||||||
|
|
||||||
@router.get("/runs")
|
|
||||||
def list_polygon_runs(
|
|
||||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
store = _open_read_only_store(root_provider)
|
|
||||||
try:
|
|
||||||
ordered = sorted(
|
|
||||||
store.list_runs(),
|
|
||||||
key=lambda run: (run.created_at_utc, run.run_id),
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
except QualificationRunIntegrityError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail="Журнал прогонов Полигона не прошёл проверку целостности.",
|
|
||||||
) from exc
|
|
||||||
return {
|
|
||||||
"schema_version": CATALOG_SCHEMA,
|
|
||||||
"access": "read-only",
|
|
||||||
"items": [_run_summary(run) for run in ordered[:limit]],
|
|
||||||
"total": len(ordered),
|
|
||||||
"limitations": list(READ_ONLY_LIMITATIONS),
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.get("/runs/{run_id}")
|
|
||||||
def get_polygon_run(
|
|
||||||
run_id: Annotated[
|
|
||||||
str,
|
|
||||||
PathParameter(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"),
|
|
||||||
],
|
|
||||||
event_limit: Annotated[int, Query(ge=1, le=500)] = 200,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
store = _open_read_only_store(root_provider)
|
|
||||||
try:
|
|
||||||
run = store.load(run_id)
|
|
||||||
events = store.list_events(run_id)
|
|
||||||
commands = store.list_commands(run_id)
|
|
||||||
except QualificationRunNotFoundError as exc:
|
|
||||||
raise HTTPException(status_code=404, detail="Прогон Полигона не найден.") from exc
|
|
||||||
except QualificationRunIntegrityError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail="Доказательства прогона не прошли проверку целостности.",
|
|
||||||
) from exc
|
|
||||||
visible_events = events[-event_limit:]
|
|
||||||
return {
|
|
||||||
"schema_version": DETAIL_SCHEMA,
|
|
||||||
"access": "read-only",
|
|
||||||
"run": {
|
|
||||||
**_run_summary(run),
|
|
||||||
"scenario_sha256": run.scenario_sha256,
|
|
||||||
"profile_sha256": run.profile_sha256,
|
|
||||||
"host_profile_sha256": run.host_profile_sha256,
|
|
||||||
"seed": run.seed,
|
|
||||||
"parent_run_id": run.parent_run_id,
|
|
||||||
"providers": [provider.to_dict() for provider in run.providers],
|
|
||||||
"authority": run.authority.to_dict(),
|
|
||||||
},
|
|
||||||
"events": [event.to_dict() for event in visible_events],
|
|
||||||
"events_total": len(events),
|
|
||||||
"events_truncated": len(visible_events) != len(events),
|
|
||||||
"commands": {
|
|
||||||
"count": len(commands),
|
|
||||||
"content_exposed": False,
|
|
||||||
},
|
|
||||||
"artifacts": [artifact.to_dict() for artifact in run.artifacts],
|
|
||||||
"limitations": list(READ_ONLY_LIMITATIONS),
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.get("/worker")
|
|
||||||
def get_polygon_worker() -> dict[str, Any]:
|
|
||||||
gateway = worker_provider()
|
|
||||||
if gateway is None:
|
|
||||||
return _unavailable_worker_status()
|
|
||||||
try:
|
|
||||||
status = gateway.status()
|
|
||||||
except SimulationWorkerGatewayError:
|
|
||||||
return _unavailable_worker_status()
|
|
||||||
return {
|
|
||||||
**status,
|
|
||||||
"control_available": bool(status["control_available"] and control_provider()),
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.get("/worker/live")
|
|
||||||
def get_polygon_worker_live() -> dict[str, Any]:
|
|
||||||
gateway = _required_worker(worker_provider)
|
|
||||||
try:
|
|
||||||
return gateway.live()
|
|
||||||
except SimulationWorkerUnavailableError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="Simulation Worker недоступен.",
|
|
||||||
) from exc
|
|
||||||
except SimulationWorkerRejectedError as exc:
|
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
||||||
except SimulationWorkerGatewayError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502,
|
|
||||||
detail="Simulation Worker вернул некорректное состояние.",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
@router.post("/worker/runs")
|
|
||||||
def start_polygon_worker_run(
|
|
||||||
request: StartStockRoverRequest,
|
|
||||||
idempotency_key: Annotated[
|
|
||||||
str,
|
|
||||||
Header(alias="Idempotency-Key", min_length=1, max_length=160),
|
|
||||||
],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if request.scenario_id != "stock-rover-ackermann":
|
|
||||||
raise HTTPException(status_code=422, detail="Сценарий Полигона не поддерживается.")
|
|
||||||
gateway, commit = _control_context(
|
|
||||||
worker_provider,
|
|
||||||
control_provider,
|
|
||||||
commit_provider,
|
|
||||||
)
|
|
||||||
run_id = _new_run_id(commit)
|
|
||||||
try:
|
|
||||||
return gateway.start(
|
|
||||||
run_id=run_id,
|
|
||||||
mission_core_commit=commit,
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
)
|
|
||||||
except SimulationWorkerUnavailableError as exc:
|
|
||||||
raise HTTPException(status_code=503, detail="Simulation Worker недоступен.") from exc
|
|
||||||
except SimulationWorkerRejectedError as exc:
|
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
||||||
except SimulationWorkerGatewayError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502,
|
|
||||||
detail="Simulation Worker не подтвердил запуск.",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
@router.post("/worker/runs/{run_id}/stop")
|
|
||||||
def stop_polygon_worker_run(
|
|
||||||
run_id: Annotated[
|
|
||||||
str,
|
|
||||||
PathParameter(pattern=r"^[a-z0-9][a-z0-9-]{0,63}$"),
|
|
||||||
],
|
|
||||||
idempotency_key: Annotated[
|
|
||||||
str,
|
|
||||||
Header(alias="Idempotency-Key", min_length=1, max_length=160),
|
|
||||||
],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
gateway, _ = _control_context(
|
|
||||||
worker_provider,
|
|
||||||
control_provider,
|
|
||||||
commit_provider,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
return gateway.stop(run_id=run_id, idempotency_key=idempotency_key)
|
|
||||||
except SimulationWorkerUnavailableError as exc:
|
|
||||||
raise HTTPException(status_code=503, detail="Simulation Worker недоступен.") from exc
|
|
||||||
except SimulationWorkerRejectedError as exc:
|
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
||||||
except SimulationWorkerGatewayError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502,
|
|
||||||
detail="Simulation Worker не подтвердил остановку.",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
@router.post("/worker/runs/{run_id}/commands")
|
|
||||||
def command_polygon_worker_run(
|
|
||||||
run_id: Annotated[
|
|
||||||
str,
|
|
||||||
PathParameter(pattern=r"^[a-z0-9][a-z0-9-]{0,63}$"),
|
|
||||||
],
|
|
||||||
request: AckermannCommandRequest,
|
|
||||||
idempotency_key: Annotated[
|
|
||||||
str,
|
|
||||||
Header(alias="Idempotency-Key", min_length=1, max_length=160),
|
|
||||||
],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
gateway, _ = _control_context(
|
|
||||||
worker_provider,
|
|
||||||
control_provider,
|
|
||||||
commit_provider,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
return gateway.command(
|
|
||||||
run_id=run_id,
|
|
||||||
speed_mps=request.speed_mps,
|
|
||||||
steering_normalized=request.steering_normalized,
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
)
|
|
||||||
except SimulationWorkerUnavailableError as exc:
|
|
||||||
raise HTTPException(status_code=503, detail="Simulation Worker недоступен.") from exc
|
|
||||||
except SimulationWorkerRejectedError as exc:
|
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
||||||
except SimulationWorkerGatewayError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502,
|
|
||||||
detail="Simulation Worker не подтвердил команду ровера.",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
return router
|
|
||||||
|
|
||||||
|
|
||||||
def _open_read_only_store(root_provider: RootProvider) -> QualificationRunStore:
|
|
||||||
root = root_provider()
|
|
||||||
if root is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="Источник журналов Полигона не настроен на этом экземпляре Mission Core.",
|
|
||||||
)
|
|
||||||
if not root.is_absolute():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="Источник журналов Полигона настроен некорректно.",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
return QualificationRunStore(root, read_only=True)
|
|
||||||
except QualificationRunIntegrityError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="Источник журналов Полигона недоступен или настроен некорректно.",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _run_summary(run: QualificationRun) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"run_id": run.run_id,
|
|
||||||
"episode_id": run.episode_id,
|
|
||||||
"kind": run.kind.value,
|
|
||||||
"state": run.state.value,
|
|
||||||
"created_at_utc": run.created_at_utc,
|
|
||||||
"started_at_utc": run.started_at_utc,
|
|
||||||
"ended_at_utc": run.ended_at_utc,
|
|
||||||
"terminal_reason": run.terminal_reason,
|
|
||||||
"scenario_generation": run.scenario_generation,
|
|
||||||
"profile_generation": run.profile_generation,
|
|
||||||
"mission_core_commit": run.mission_core_commit,
|
|
||||||
"host_profile_id": run.host_profile_id,
|
|
||||||
"reproducibility_tier": run.reproducibility_tier.value,
|
|
||||||
"clock_domain": run.clock_domain,
|
|
||||||
"revision": run.revision,
|
|
||||||
"provider_ids": [provider.identifier for provider in run.providers],
|
|
||||||
"artifact_count": len(run.artifacts),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _required_worker(provider: WorkerProvider) -> PolygonWorkerGateway:
|
|
||||||
gateway = provider()
|
|
||||||
if gateway is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="Simulation Worker не зарегистрирован на этом экземпляре Mission Core.",
|
|
||||||
)
|
|
||||||
return gateway
|
|
||||||
|
|
||||||
|
|
||||||
def _control_context(
|
|
||||||
worker_provider: WorkerProvider,
|
|
||||||
control_provider: ControlProvider,
|
|
||||||
commit_provider: CommitProvider,
|
|
||||||
) -> tuple[PolygonWorkerGateway, str]:
|
|
||||||
if not control_provider():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="Lifecycle Полигона отключён на этом экземпляре Mission Core.",
|
|
||||||
)
|
|
||||||
gateway = _required_worker(worker_provider)
|
|
||||||
commit = commit_provider()
|
|
||||||
if commit is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="Mission Core не привязан к точной Git-ревизии.",
|
|
||||||
)
|
|
||||||
return gateway, commit
|
|
||||||
|
|
||||||
|
|
||||||
def _unavailable_worker_status() -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"schema_version": STATUS_SCHEMA,
|
|
||||||
"worker_id": "mission-gpu-s1",
|
|
||||||
"transport": "unix",
|
|
||||||
"mode": "simulation",
|
|
||||||
"available": False,
|
|
||||||
"control_available": False,
|
|
||||||
"active_run_id": None,
|
|
||||||
"run_state": None,
|
|
||||||
"active_provider_ids": [],
|
|
||||||
"provider_profile": None,
|
|
||||||
"isolation": {
|
|
||||||
"network": "unavailable",
|
|
||||||
"process_identity": "missioncore",
|
|
||||||
"artifact_policy": "d-only",
|
|
||||||
},
|
|
||||||
"authority": {
|
|
||||||
"scope": "virtual-only",
|
|
||||||
"actuator_authority": False,
|
|
||||||
"direct_actuator_setpoints_allowed": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _new_run_id(commit: str) -> str:
|
|
||||||
stamp = datetime.now(UTC).strftime("%Y%m%dt%H%M%Sz").lower()
|
|
||||||
return f"s1c-{commit[:7]}-{stamp}-{secrets.token_hex(3)}"
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import replace
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from k1link.compute.lidar_contract import (
|
|
||||||
K1_LAB_LIDAR_PACK_V1_PROFILE,
|
|
||||||
K1_LIVE_LIDAR_PROFILE,
|
|
||||||
LidarContractError,
|
|
||||||
LidarCoordinateSpace,
|
|
||||||
LidarEvidenceProfile,
|
|
||||||
LidarPipelineStage,
|
|
||||||
LidarPointField,
|
|
||||||
LidarPoseStatus,
|
|
||||||
LidarQualityMonitor,
|
|
||||||
LidarReadiness,
|
|
||||||
LidarRepresentation,
|
|
||||||
LidarTimeBasis,
|
|
||||||
assess_lidar_profile,
|
|
||||||
lidar_readiness_document,
|
|
||||||
sensor_frame_xyzi,
|
|
||||||
)
|
|
||||||
from k1link.data_plane import (
|
|
||||||
ConsumerFrameContext,
|
|
||||||
DecodedPointCloudView,
|
|
||||||
DecodedPoseView,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _context(sequence: int = 1) -> ConsumerFrameContext:
|
|
||||||
return ConsumerFrameContext(
|
|
||||||
sequence=sequence,
|
|
||||||
captured_at_epoch_ns=10,
|
|
||||||
received_monotonic_ns=20,
|
|
||||||
processing_started_monotonic_ns=21,
|
|
||||||
encoded_size_bytes=100,
|
|
||||||
live=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _assessment(stage: LidarPipelineStage, *, lab_pack: bool = False) -> object:
|
|
||||||
profile = K1_LAB_LIDAR_PACK_V1_PROFILE if lab_pack else K1_LIVE_LIDAR_PROFILE
|
|
||||||
return next(item for item in assess_lidar_profile(profile) if item.stage is stage)
|
|
||||||
|
|
||||||
|
|
||||||
def test_k1_profiles_round_trip_and_do_not_claim_navigation_authority() -> None:
|
|
||||||
restored = type(K1_LIVE_LIDAR_PROFILE).from_dict(K1_LIVE_LIDAR_PROFILE.to_dict())
|
|
||||||
|
|
||||||
assert restored == K1_LIVE_LIDAR_PROFILE
|
|
||||||
document = lidar_readiness_document(restored)
|
|
||||||
assert document["authority"] == {
|
|
||||||
"commands_enabled": False,
|
|
||||||
"navigation_or_safety_accepted": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_current_k1_live_source_is_detector_degraded_but_odometry_blocked() -> None:
|
|
||||||
detector = _assessment(LidarPipelineStage.LIDAR_3D_DETECTION)
|
|
||||||
odometry = _assessment(LidarPipelineStage.LIDAR_ODOMETRY)
|
|
||||||
lio = _assessment(LidarPipelineStage.LIDAR_INERTIAL_SLAM)
|
|
||||||
|
|
||||||
assert detector.readiness is LidarReadiness.DEGRADED
|
|
||||||
assert "pretrained-domain-expects-sensor-scan" in detector.reasons
|
|
||||||
assert odometry.readiness is LidarReadiness.BLOCKED
|
|
||||||
assert lio.readiness is LidarReadiness.BLOCKED
|
|
||||||
assert "per-point-time-unavailable" in lio.reasons
|
|
||||||
|
|
||||||
|
|
||||||
def test_lidar_pack_v1_is_blocked_for_pointpillars_because_it_dropped_intensity() -> None:
|
|
||||||
detector = _assessment(LidarPipelineStage.LIDAR_3D_DETECTION, lab_pack=True)
|
|
||||||
|
|
||||||
assert detector.readiness is LidarReadiness.BLOCKED
|
|
||||||
assert "admitted-pointpillars-baseline-requires-intensity" in detector.reasons
|
|
||||||
|
|
||||||
|
|
||||||
def test_nvblox_is_blocked_until_k1_scan_geometry_is_known() -> None:
|
|
||||||
assessment = _assessment(LidarPipelineStage.NVIDIA_NVBLOX)
|
|
||||||
|
|
||||||
assert assessment.readiness is LidarReadiness.BLOCKED
|
|
||||||
assert "lidar-intrinsics-or-scan-geometry-unknown" in assessment.reasons
|
|
||||||
|
|
||||||
|
|
||||||
def test_complete_sensor_profile_is_ready_for_all_admitted_stages() -> None:
|
|
||||||
profile = LidarEvidenceProfile(
|
|
||||||
profile_id="reference-raw-lidar-imu/v1",
|
|
||||||
representation=LidarRepresentation.SENSOR_SCAN,
|
|
||||||
coordinate_space=LidarCoordinateSpace.SENSOR,
|
|
||||||
coordinate_frame="lidar",
|
|
||||||
frame_time_basis=LidarTimeBasis.SENSOR,
|
|
||||||
point_fields=(
|
|
||||||
LidarPointField.XYZ,
|
|
||||||
LidarPointField.INTENSITY,
|
|
||||||
LidarPointField.RING,
|
|
||||||
LidarPointField.RELATIVE_TIME,
|
|
||||||
),
|
|
||||||
pose_status=LidarPoseStatus.SENSOR_SYNCHRONIZED,
|
|
||||||
scan_geometry_known=True,
|
|
||||||
imu_samples_available=True,
|
|
||||||
lidar_imu_extrinsic_available=True,
|
|
||||||
camera_extrinsic_available=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert {
|
|
||||||
assessment.readiness for assessment in assess_lidar_profile(profile)
|
|
||||||
} == {LidarReadiness.READY}
|
|
||||||
assert len(assess_lidar_profile(profile)) == len(LidarPipelineStage)
|
|
||||||
|
|
||||||
|
|
||||||
def test_sensor_frame_xyzi_inverts_map_pose_and_normalizes_intensity() -> None:
|
|
||||||
cloud = DecodedPointCloudView(
|
|
||||||
context=_context(),
|
|
||||||
frame_id="map",
|
|
||||||
positions_xyz=((11.0, 2.0, 3.0), (10.0, 3.0, 3.0)),
|
|
||||||
intensities=bytes((0, 255)),
|
|
||||||
)
|
|
||||||
pose = DecodedPoseView(
|
|
||||||
context=_context(2),
|
|
||||||
frame_id="map",
|
|
||||||
child_frame_id="k1-lidar",
|
|
||||||
position_xyz=(10.0, 2.0, 3.0),
|
|
||||||
orientation_xyzw=(0.0, 0.0, 0.0, 1.0),
|
|
||||||
)
|
|
||||||
|
|
||||||
xyzi = sensor_frame_xyzi(cloud, pose)
|
|
||||||
|
|
||||||
np.testing.assert_allclose(
|
|
||||||
xyzi,
|
|
||||||
np.asarray(
|
|
||||||
[
|
|
||||||
[1.0, 0.0, 0.0, 0.0],
|
|
||||||
[0.0, 1.0, 0.0, 1.0],
|
|
||||||
],
|
|
||||||
dtype=np.float32,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_sensor_frame_xyzi_rejects_missing_intensity_and_unbound_frames() -> None:
|
|
||||||
cloud = DecodedPointCloudView(
|
|
||||||
context=_context(),
|
|
||||||
frame_id="map",
|
|
||||||
positions_xyz=((1.0, 2.0, 3.0),),
|
|
||||||
)
|
|
||||||
with pytest.raises(LidarContractError, match="requires intensity"):
|
|
||||||
sensor_frame_xyzi(cloud)
|
|
||||||
|
|
||||||
with pytest.raises(LidarContractError, match="diagnostic-only"):
|
|
||||||
replace(K1_LIVE_LIDAR_PROFILE, navigation_or_safety_accepted=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_quality_monitor_is_bounded_and_reports_sensor_relative_range() -> None:
|
|
||||||
monitor = LidarQualityMonitor(
|
|
||||||
K1_LIVE_LIDAR_PROFILE,
|
|
||||||
frame_sample_capacity=2,
|
|
||||||
point_sample_capacity=256,
|
|
||||||
points_sampled_per_frame=2,
|
|
||||||
)
|
|
||||||
pose = DecodedPoseView(
|
|
||||||
context=_context(20),
|
|
||||||
frame_id="map",
|
|
||||||
child_frame_id="k1-lidar",
|
|
||||||
position_xyz=(10.0, 2.0, 3.0),
|
|
||||||
orientation_xyzw=(0.0, 0.0, 0.0, 1.0),
|
|
||||||
)
|
|
||||||
for sequence, capture_ns in enumerate((10, 110_000_010, 210_000_010), start=1):
|
|
||||||
context = replace(_context(sequence), captured_at_epoch_ns=capture_ns)
|
|
||||||
monitor.observe(
|
|
||||||
DecodedPointCloudView(
|
|
||||||
context=context,
|
|
||||||
frame_id="map",
|
|
||||||
positions_xyz=((11.0, 2.0, 3.0), (10.0, 4.0, 3.0)),
|
|
||||||
intensities=bytes((0, 255)),
|
|
||||||
),
|
|
||||||
pose=pose,
|
|
||||||
)
|
|
||||||
|
|
||||||
report = monitor.snapshot()
|
|
||||||
|
|
||||||
assert report["frames_observed"] == 3
|
|
||||||
assert report["points_observed"] == 6
|
|
||||||
assert report["point_count_per_frame"]["sample_count"] == 2
|
|
||||||
assert report["sensor_range_m"]["minimum"] == pytest.approx(1.0)
|
|
||||||
assert report["sensor_range_m"]["maximum"] == pytest.approx(2.0)
|
|
||||||
assert report["intensity_0_1"]["minimum"] == pytest.approx(0.0)
|
|
||||||
assert report["intensity_0_1"]["maximum"] == pytest.approx(1.0)
|
|
||||||
assert report["authority"]["navigation_or_safety_accepted"] is False
|
|
||||||
|
|
@ -1,378 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from fastapi.routing import APIRoute
|
|
||||||
|
|
||||||
from k1link.simulation import (
|
|
||||||
AuthorityProfile,
|
|
||||||
ProviderPin,
|
|
||||||
QualificationArtifact,
|
|
||||||
QualificationRun,
|
|
||||||
QualificationRunStore,
|
|
||||||
ReproducibilityTier,
|
|
||||||
RunKind,
|
|
||||||
RunState,
|
|
||||||
)
|
|
||||||
from k1link.simulation.stock_rover import STOCK_ROVER_PROVIDER_PROFILE
|
|
||||||
from k1link.web.polygon_api import (
|
|
||||||
AckermannCommandRequest,
|
|
||||||
StartStockRoverRequest,
|
|
||||||
build_polygon_router,
|
|
||||||
)
|
|
||||||
|
|
||||||
SHA_A = "a" * 64
|
|
||||||
SHA_B = "b" * 64
|
|
||||||
SHA_C = "c" * 64
|
|
||||||
|
|
||||||
|
|
||||||
def _run(run_id: str = "s1b-ui0-001") -> QualificationRun:
|
|
||||||
return QualificationRun(
|
|
||||||
run_id=run_id,
|
|
||||||
episode_id=f"episode-{run_id}",
|
|
||||||
kind=RunKind.SIMULATION_CLOSED_LOOP,
|
|
||||||
state=RunState.ADMITTED,
|
|
||||||
scenario_generation="px4-v1.17.0-stock-rover-ackermann",
|
|
||||||
scenario_sha256=SHA_A,
|
|
||||||
profile_generation="stock-rover-lifecycle-v1",
|
|
||||||
profile_sha256=SHA_B,
|
|
||||||
mission_core_commit="6cb1495a1234567890abcdef1234567890abcdef",
|
|
||||||
providers=(
|
|
||||||
ProviderPin(
|
|
||||||
identifier="px4-autopilot",
|
|
||||||
version="v1.17.0",
|
|
||||||
revision="v1.17.0",
|
|
||||||
),
|
|
||||||
ProviderPin(
|
|
||||||
identifier="gazebo",
|
|
||||||
version="harmonic",
|
|
||||||
revision="8.9.0",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
host_profile_id="mission-gpu-s0",
|
|
||||||
host_profile_sha256=SHA_C,
|
|
||||||
seed=42,
|
|
||||||
reproducibility_tier=ReproducibilityTier.R1,
|
|
||||||
authority=AuthorityProfile(
|
|
||||||
generation=1,
|
|
||||||
command_ttl_max_ns=250_000_000,
|
|
||||||
heartbeat_timeout_monotonic_ns=500_000_000,
|
|
||||||
),
|
|
||||||
clock_domain="gazebo:/clock",
|
|
||||||
created_at_utc="2026-07-24T15:35:00Z",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _accepted_run(root: Path) -> QualificationRun:
|
|
||||||
store = QualificationRunStore(root)
|
|
||||||
admitted = store.create(_run())
|
|
||||||
starting = store.transition(
|
|
||||||
admitted.run_id,
|
|
||||||
RunState.STARTING,
|
|
||||||
expected_revision=0,
|
|
||||||
observed_at_utc="2026-07-24T15:35:01Z",
|
|
||||||
host_monotonic_ns=1,
|
|
||||||
)
|
|
||||||
running = store.transition(
|
|
||||||
admitted.run_id,
|
|
||||||
RunState.RUNNING,
|
|
||||||
expected_revision=starting.revision,
|
|
||||||
observed_at_utc="2026-07-24T15:35:02Z",
|
|
||||||
host_monotonic_ns=2,
|
|
||||||
sim_time_ns=0,
|
|
||||||
)
|
|
||||||
event = store.append_event(
|
|
||||||
admitted.run_id,
|
|
||||||
event_type="orchestrator.providers-started",
|
|
||||||
observed_at_utc="2026-07-24T15:35:03Z",
|
|
||||||
host_monotonic_ns=3,
|
|
||||||
sim_time_ns=1_000_000,
|
|
||||||
payload={"provider_ids": ["micro-xrce-dds-agent", "px4-gazebo-stock-rover"]},
|
|
||||||
expected_revision=running.revision,
|
|
||||||
)
|
|
||||||
store.register_artifact(
|
|
||||||
admitted.run_id,
|
|
||||||
QualificationArtifact(
|
|
||||||
artifact_id="provider-log-index",
|
|
||||||
kind="log-index",
|
|
||||||
relative_path="provider-runtime/processes.json",
|
|
||||||
sha256=SHA_A,
|
|
||||||
byte_length=512,
|
|
||||||
source_of_record=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
stopping = store.transition(
|
|
||||||
admitted.run_id,
|
|
||||||
RunState.STOPPING,
|
|
||||||
expected_revision=event.sequence,
|
|
||||||
observed_at_utc="2026-07-24T15:35:04Z",
|
|
||||||
host_monotonic_ns=4,
|
|
||||||
sim_time_ns=2_000_000,
|
|
||||||
)
|
|
||||||
return store.transition(
|
|
||||||
admitted.run_id,
|
|
||||||
RunState.COMPLETED,
|
|
||||||
expected_revision=stopping.revision,
|
|
||||||
observed_at_utc="2026-07-24T15:35:05Z",
|
|
||||||
host_monotonic_ns=5,
|
|
||||||
sim_time_ns=2_000_000,
|
|
||||||
reason="operator-stop-clean",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _endpoint(router: APIRouter, path: str, method: str) -> Callable[..., Any]:
|
|
||||||
for route in router.routes:
|
|
||||||
if isinstance(route, APIRoute) and route.path == path and method in route.methods:
|
|
||||||
return route.endpoint
|
|
||||||
raise AssertionError(f"{method} {path} route is missing")
|
|
||||||
|
|
||||||
|
|
||||||
def test_polygon_api_is_read_only_and_fails_closed_when_unconfigured() -> None:
|
|
||||||
router = build_polygon_router(root_provider=lambda: None)
|
|
||||||
routes = [route for route in router.routes if isinstance(route, APIRoute)]
|
|
||||||
|
|
||||||
archive_routes = [route for route in routes if "/worker" not in route.path]
|
|
||||||
assert all(route.methods <= {"GET", "HEAD"} for route in archive_routes)
|
|
||||||
with pytest.raises(HTTPException) as failure:
|
|
||||||
_endpoint(router, "/api/v1/polygon/runs", "GET")(limit=20)
|
|
||||||
assert failure.value.status_code == 503
|
|
||||||
assert "не настроен" in failure.value.detail
|
|
||||||
|
|
||||||
|
|
||||||
def test_polygon_api_publishes_bounded_path_free_qualification_evidence(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
root = tmp_path / "runs"
|
|
||||||
accepted = _accepted_run(root)
|
|
||||||
router = build_polygon_router(root_provider=lambda: root)
|
|
||||||
|
|
||||||
catalog = _endpoint(router, "/api/v1/polygon/runs", "GET")(limit=20)
|
|
||||||
detail = _endpoint(router, "/api/v1/polygon/runs/{run_id}", "GET")(
|
|
||||||
run_id=accepted.run_id,
|
|
||||||
event_limit=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert catalog["schema_version"] == "missioncore.polygon-run-catalog/v1"
|
|
||||||
assert catalog["access"] == "read-only"
|
|
||||||
assert catalog["total"] == 1
|
|
||||||
assert catalog["items"][0]["state"] == "completed"
|
|
||||||
assert catalog["items"][0]["terminal_reason"] == "operator-stop-clean"
|
|
||||||
assert detail["schema_version"] == "missioncore.polygon-run-detail/v1"
|
|
||||||
assert detail["access"] == "read-only"
|
|
||||||
assert detail["run"]["run_id"] == accepted.run_id
|
|
||||||
assert detail["run"]["authority"]["actuator_authority"] is False
|
|
||||||
assert detail["commands"] == {"count": 0, "content_exposed": False}
|
|
||||||
assert detail["events_total"] == accepted.revision
|
|
||||||
assert detail["events_truncated"] is True
|
|
||||||
assert len(detail["events"]) == 2
|
|
||||||
assert detail["artifacts"][0]["relative_path"] == "provider-runtime/processes.json"
|
|
||||||
serialized = repr({"catalog": catalog, "detail": detail})
|
|
||||||
assert str(tmp_path) not in serialized
|
|
||||||
assert "command_ttl_max_ns" in serialized
|
|
||||||
assert all("http" not in artifact for artifact in detail["artifacts"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_polygon_api_rejects_corrupt_evidence_without_partial_response(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
root = tmp_path / "runs"
|
|
||||||
accepted = _accepted_run(root)
|
|
||||||
event_path = root / accepted.run_id / "events" / "00000000000000000001.json"
|
|
||||||
event_path.write_text("{broken", encoding="utf-8")
|
|
||||||
router = build_polygon_router(root_provider=lambda: root)
|
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as failure:
|
|
||||||
_endpoint(router, "/api/v1/polygon/runs", "GET")(limit=20)
|
|
||||||
assert failure.value.status_code == 500
|
|
||||||
assert "целостности" in failure.value.detail
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeWorkerGateway:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.calls: list[tuple[str, str]] = []
|
|
||||||
self.active_run_id: str | None = None
|
|
||||||
|
|
||||||
def status(self) -> dict[str, Any]:
|
|
||||||
return self._status()
|
|
||||||
|
|
||||||
def live(self) -> dict[str, Any]:
|
|
||||||
if self.active_run_id is None:
|
|
||||||
raise AssertionError("test worker has no active run")
|
|
||||||
return {
|
|
||||||
"schema_version": "missioncore.vehicle-state/v1",
|
|
||||||
"run_id": self.active_run_id,
|
|
||||||
"sequence": 1,
|
|
||||||
"observed_at_utc": "2026-07-24T18:00:00Z",
|
|
||||||
"host_monotonic_ns": 123,
|
|
||||||
"sim_time_ns": 456,
|
|
||||||
"frame_id": "map_enu",
|
|
||||||
"child_frame_id": "base_link_flu",
|
|
||||||
"pose": {
|
|
||||||
"position_m": {"x": 1.0, "y": 2.0, "z": 0.1},
|
|
||||||
"orientation_xyzw": {"x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0},
|
|
||||||
},
|
|
||||||
"source": {
|
|
||||||
"provider": "gazebo",
|
|
||||||
"topic": "/world/rover/dynamic_pose/info",
|
|
||||||
"signal": "ground-truth",
|
|
||||||
"quality": "diagnostic",
|
|
||||||
},
|
|
||||||
"safety": {
|
|
||||||
"scope": "virtual-only",
|
|
||||||
"actuator_authority": False,
|
|
||||||
"navigation_or_safety_accepted": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def start(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
run_id: str,
|
|
||||||
mission_core_commit: str,
|
|
||||||
idempotency_key: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
self.calls.append(("start", idempotency_key))
|
|
||||||
assert mission_core_commit == "d" * 40
|
|
||||||
self.active_run_id = run_id
|
|
||||||
return self._status()
|
|
||||||
|
|
||||||
def stop(self, *, run_id: str, idempotency_key: str) -> dict[str, Any]:
|
|
||||||
self.calls.append(("stop", idempotency_key))
|
|
||||||
assert run_id == self.active_run_id
|
|
||||||
self.active_run_id = None
|
|
||||||
return self._status()
|
|
||||||
|
|
||||||
def command(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
run_id: str,
|
|
||||||
speed_mps: float,
|
|
||||||
steering_normalized: float,
|
|
||||||
idempotency_key: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
self.calls.append(("command", idempotency_key))
|
|
||||||
assert run_id == self.active_run_id
|
|
||||||
return {
|
|
||||||
"schema_version": "missioncore.command-acceptance/v2",
|
|
||||||
"run_id": run_id,
|
|
||||||
"command_id": "cmd-test",
|
|
||||||
"sequence": 1,
|
|
||||||
"issued_at_sim_ns": 1_000_000,
|
|
||||||
"valid_until_sim_ns": 251_000_000,
|
|
||||||
"speed_mps": speed_mps,
|
|
||||||
"steering_normalized": steering_normalized,
|
|
||||||
"authority_scope": "virtual-only",
|
|
||||||
"delivery": {
|
|
||||||
"provider_id": "px4-ros2-offboard",
|
|
||||||
"control_profile": "rover-speed-steering/v1",
|
|
||||||
"accepted": True,
|
|
||||||
"controller_ready": True,
|
|
||||||
"ttl_expired_count": 0,
|
|
||||||
"diagnostics": {"armed": True, "offboard": True},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def _status(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"schema_version": "missioncore.simulation-worker-status/v2",
|
|
||||||
"worker_id": "mission-gpu-s1",
|
|
||||||
"transport": "unix",
|
|
||||||
"mode": "simulation",
|
|
||||||
"available": True,
|
|
||||||
"control_available": True,
|
|
||||||
"active_run_id": self.active_run_id,
|
|
||||||
"run_state": "running" if self.active_run_id else None,
|
|
||||||
"active_provider_ids": (
|
|
||||||
["px4-gazebo-stock-rover"] if self.active_run_id else []
|
|
||||||
),
|
|
||||||
"provider_profile": STOCK_ROVER_PROVIDER_PROFILE.to_dict(),
|
|
||||||
"isolation": {
|
|
||||||
"network": "loopback-only-netns",
|
|
||||||
"process_identity": "missioncore",
|
|
||||||
"artifact_policy": "d-only",
|
|
||||||
},
|
|
||||||
"authority": {
|
|
||||||
"scope": "virtual-only",
|
|
||||||
"actuator_authority": False,
|
|
||||||
"direct_actuator_setpoints_allowed": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_polygon_worker_api_fails_closed_then_proxies_virtual_only_lifecycle() -> None:
|
|
||||||
worker = _FakeWorkerGateway()
|
|
||||||
disabled = build_polygon_router(
|
|
||||||
root_provider=lambda: None,
|
|
||||||
worker_provider=lambda: worker,
|
|
||||||
control_provider=lambda: False,
|
|
||||||
commit_provider=lambda: "d" * 40,
|
|
||||||
)
|
|
||||||
status = _endpoint(disabled, "/api/v1/polygon/worker", "GET")()
|
|
||||||
assert status["available"] is True
|
|
||||||
assert status["control_available"] is False
|
|
||||||
with pytest.raises(HTTPException) as failure:
|
|
||||||
_endpoint(disabled, "/api/v1/polygon/worker/runs", "POST")(
|
|
||||||
request=StartStockRoverRequest(scenario_id="stock-rover-ackermann"),
|
|
||||||
idempotency_key="start-disabled",
|
|
||||||
)
|
|
||||||
assert failure.value.status_code == 403
|
|
||||||
|
|
||||||
enabled = build_polygon_router(
|
|
||||||
root_provider=lambda: None,
|
|
||||||
worker_provider=lambda: worker,
|
|
||||||
control_provider=lambda: True,
|
|
||||||
commit_provider=lambda: "d" * 40,
|
|
||||||
)
|
|
||||||
running = _endpoint(enabled, "/api/v1/polygon/worker/runs", "POST")(
|
|
||||||
request=StartStockRoverRequest(scenario_id="stock-rover-ackermann"),
|
|
||||||
idempotency_key="start-001",
|
|
||||||
)
|
|
||||||
assert running["active_run_id"].startswith("s1c-ddddddd-")
|
|
||||||
live = _endpoint(enabled, "/api/v1/polygon/worker/live", "GET")()
|
|
||||||
assert live["run_id"] == running["active_run_id"]
|
|
||||||
assert live["source"]["quality"] == "diagnostic"
|
|
||||||
command = _endpoint(
|
|
||||||
enabled,
|
|
||||||
"/api/v1/polygon/worker/runs/{run_id}/commands",
|
|
||||||
"POST",
|
|
||||||
)(
|
|
||||||
run_id=running["active_run_id"],
|
|
||||||
request=AckermannCommandRequest(speed_mps=1.0, steering_normalized=-0.25),
|
|
||||||
idempotency_key="command-001",
|
|
||||||
)
|
|
||||||
assert command["authority_scope"] == "virtual-only"
|
|
||||||
assert command["delivery"]["controller_ready"] is True
|
|
||||||
stopped = _endpoint(enabled, "/api/v1/polygon/worker/runs/{run_id}/stop", "POST")(
|
|
||||||
run_id=running["active_run_id"],
|
|
||||||
idempotency_key="stop-001",
|
|
||||||
)
|
|
||||||
assert stopped["active_run_id"] is None
|
|
||||||
assert worker.calls == [
|
|
||||||
("start", "start-001"),
|
|
||||||
("command", "command-001"),
|
|
||||||
("stop", "stop-001"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_polygon_worker_command_rejects_values_outside_lab_envelope() -> None:
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
AckermannCommandRequest(speed_mps=1.51, steering_normalized=0)
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
AckermannCommandRequest(speed_mps=0, steering_normalized=-1.01)
|
|
||||||
|
|
||||||
|
|
||||||
def test_polygon_worker_status_is_explicitly_unavailable_when_not_registered() -> None:
|
|
||||||
router = build_polygon_router(
|
|
||||||
root_provider=lambda: None,
|
|
||||||
worker_provider=lambda: None,
|
|
||||||
)
|
|
||||||
status = _endpoint(router, "/api/v1/polygon/worker", "GET")()
|
|
||||||
assert status["schema_version"] == "missioncore.simulation-worker-status/v2"
|
|
||||||
assert status["available"] is False
|
|
||||||
assert status["control_available"] is False
|
|
||||||
assert status["provider_profile"] is None
|
|
||||||
assert status["authority"]["scope"] == "virtual-only"
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import replace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from k1link.simulation.contracts import ControlProfile
|
|
||||||
from k1link.simulation.provider_contract import (
|
|
||||||
ProviderRole,
|
|
||||||
SimulationClockDescriptor,
|
|
||||||
SimulationProviderContractError,
|
|
||||||
SimulationProviderDescriptor,
|
|
||||||
SimulationProviderProfile,
|
|
||||||
)
|
|
||||||
from k1link.simulation.stock_rover import STOCK_ROVER_PROVIDER_PROFILE
|
|
||||||
|
|
||||||
|
|
||||||
def _unreal_profile() -> SimulationProviderProfile:
|
|
||||||
return SimulationProviderProfile(
|
|
||||||
profile_id="unreal-native-rover-v1",
|
|
||||||
providers=(
|
|
||||||
SimulationProviderDescriptor(
|
|
||||||
provider_id="unreal-native",
|
|
||||||
roles=(
|
|
||||||
ProviderRole.WORLD,
|
|
||||||
ProviderRole.PHYSICS,
|
|
||||||
ProviderRole.STATE,
|
|
||||||
ProviderRole.SENSOR,
|
|
||||||
),
|
|
||||||
capabilities=(
|
|
||||||
"clock.simulation",
|
|
||||||
"state.vehicle-pose",
|
|
||||||
"truth.ground-truth",
|
|
||||||
"sensor.virtual",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SimulationProviderDescriptor(
|
|
||||||
provider_id="unreal-direct-control",
|
|
||||||
roles=(ProviderRole.CONTROLLER,),
|
|
||||||
capabilities=("command.rover-speed-steering/v1",),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
clock=SimulationClockDescriptor(
|
|
||||||
provider_id="unreal-native",
|
|
||||||
domain="unreal:fixed-step",
|
|
||||||
),
|
|
||||||
control_profiles=(ControlProfile.ROVER_SPEED_STEERING_V1,),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_stock_rover_provider_profile_round_trips_exactly() -> None:
|
|
||||||
restored = SimulationProviderProfile.from_dict(STOCK_ROVER_PROVIDER_PROFILE.to_dict())
|
|
||||||
|
|
||||||
assert restored == STOCK_ROVER_PROVIDER_PROFILE
|
|
||||||
assert restored.clock.domain == "gazebo:/clock"
|
|
||||||
assert any(ProviderRole.CONTROLLER in provider.roles for provider in restored.providers)
|
|
||||||
|
|
||||||
|
|
||||||
def test_provider_contract_accepts_unreal_without_changing_canonical_frames() -> None:
|
|
||||||
profile = SimulationProviderProfile.from_dict(_unreal_profile().to_dict())
|
|
||||||
|
|
||||||
assert profile.clock.domain == "unreal:fixed-step"
|
|
||||||
assert profile.world_frame == "map_enu"
|
|
||||||
assert profile.body_frame == "base_link_flu"
|
|
||||||
|
|
||||||
|
|
||||||
def test_provider_contract_rejects_missing_clock_capability() -> None:
|
|
||||||
profile = _unreal_profile()
|
|
||||||
world = profile.providers[0]
|
|
||||||
|
|
||||||
with pytest.raises(SimulationProviderContractError, match="clock.simulation"):
|
|
||||||
replace(
|
|
||||||
profile,
|
|
||||||
providers=(
|
|
||||||
replace(
|
|
||||||
world,
|
|
||||||
capabilities=tuple(
|
|
||||||
capability
|
|
||||||
for capability in world.capabilities
|
|
||||||
if capability != "clock.simulation"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
profile.providers[1],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_provider_contract_rejects_undeclared_command_capability() -> None:
|
|
||||||
profile = _unreal_profile()
|
|
||||||
controller = profile.providers[1]
|
|
||||||
|
|
||||||
with pytest.raises(SimulationProviderContractError, match="control profiles"):
|
|
||||||
replace(
|
|
||||||
profile,
|
|
||||||
providers=(
|
|
||||||
profile.providers[0],
|
|
||||||
replace(controller, capabilities=("transport.custom",)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
@ -1,268 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import yaml
|
|
||||||
from typer.testing import CliRunner
|
|
||||||
|
|
||||||
from k1link.simulation import (
|
|
||||||
CheckStatus,
|
|
||||||
DoctorVerdict,
|
|
||||||
S0ProfileError,
|
|
||||||
load_s0_profile,
|
|
||||||
run_s0_doctor,
|
|
||||||
)
|
|
||||||
from k1link.simulation.cli import app
|
|
||||||
|
|
||||||
PROFILE = Path(__file__).resolve().parents[1] / "simulation" / "s0" / "qualification-profile.yaml"
|
|
||||||
runner = CliRunner()
|
|
||||||
|
|
||||||
|
|
||||||
def _profile_document() -> dict[str, object]:
|
|
||||||
loaded = yaml.safe_load(PROFILE.read_text(encoding="utf-8"))
|
|
||||||
assert isinstance(loaded, dict)
|
|
||||||
return loaded
|
|
||||||
|
|
||||||
|
|
||||||
def _write_profile(tmp_path: Path, document: dict[str, object]) -> Path:
|
|
||||||
path = tmp_path / "profile.yaml"
|
|
||||||
path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8")
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def test_canonical_s0_profile_is_strict_and_safe() -> None:
|
|
||||||
profile = load_s0_profile(PROFILE)
|
|
||||||
|
|
||||||
assert profile.profile_id == "ai-worker-px4-gazebo-v1"
|
|
||||||
assert profile.target_host.wsl_distribution == "MissionCore-Sim"
|
|
||||||
assert profile.target_host.container_runtime == "none"
|
|
||||||
assert profile.storage.windows_root.drive.upper() == "D:"
|
|
||||||
assert profile.storage.wsl_root.as_posix() == "/mnt/d/NDC_MISSIONCORE"
|
|
||||||
assert profile.clock.authority == "gazebo:/clock"
|
|
||||||
assert profile.clock.ros_use_sim_time is True
|
|
||||||
assert profile.clock.px4_uxrce_dds_sync_enabled is False
|
|
||||||
assert profile.authority.simulation_or_shadow_only is True
|
|
||||||
assert profile.authority.actuator_authority is False
|
|
||||||
assert profile.authority.navigation_or_safety_accepted is False
|
|
||||||
assert profile.authority.direct_actuator_setpoints_allowed is False
|
|
||||||
assert profile.ros.domain_id == 0
|
|
||||||
assert profile.runtime_acceptance.world == "rover"
|
|
||||||
assert profile.runtime_acceptance.model == "rover_ackermann_0"
|
|
||||||
assert profile.runtime_acceptance.physics_step_ns == 2_000_000
|
|
||||||
assert profile.runtime_acceptance.pause_max_advance_ns == 0
|
|
||||||
assert profile.runtime_acceptance.speed_factors == (1, 2)
|
|
||||||
assert profile.runtime_acceptance.rtf_tolerance_percent == 20
|
|
||||||
assert "docker" not in profile.required_tools
|
|
||||||
assert {item.identifier for item in profile.components} >= {
|
|
||||||
"px4-autopilot",
|
|
||||||
"px4-msgs",
|
|
||||||
"gazebo",
|
|
||||||
"nav2",
|
|
||||||
}
|
|
||||||
components = {item.identifier: item for item in profile.components}
|
|
||||||
assert components["px4-gazebo-models"].resolved_commit == (
|
|
||||||
"b6127f4ec20de867e215fb5f78ae88b80f371909"
|
|
||||||
)
|
|
||||||
assert {component.qualification_state for component in components.values()} == {"accepted"}
|
|
||||||
micro_xrce = next(item for item in profile.ports if item.identifier == "micro-xrce-dds")
|
|
||||||
assert micro_xrce.host_scope == "loopback-only-netns"
|
|
||||||
assert micro_xrce.bind == "0.0.0.0"
|
|
||||||
assert {item.identifier for item in profile.processes} == {
|
|
||||||
"simulation-orchestrator",
|
|
||||||
"gazebo-server",
|
|
||||||
"px4-sitl",
|
|
||||||
"micro-xrce-dds-agent",
|
|
||||||
"ros2-bridge",
|
|
||||||
"nav2",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_profile_rejects_real_actuator_authority(tmp_path: Path) -> None:
|
|
||||||
document = _profile_document()
|
|
||||||
authority = document["authority"]
|
|
||||||
assert isinstance(authority, dict)
|
|
||||||
authority["actuator_authority"] = True
|
|
||||||
|
|
||||||
with pytest.raises(S0ProfileError, match="cannot grant real"):
|
|
||||||
load_s0_profile(_write_profile(tmp_path, document))
|
|
||||||
|
|
||||||
|
|
||||||
def test_profile_rejects_mutable_c_drive_path(tmp_path: Path) -> None:
|
|
||||||
document = _profile_document()
|
|
||||||
storage = document["storage"]
|
|
||||||
assert isinstance(storage, dict)
|
|
||||||
mutable = storage["mutable_paths"]
|
|
||||||
assert isinstance(mutable, list)
|
|
||||||
first = mutable[0]
|
|
||||||
assert isinstance(first, dict)
|
|
||||||
first["windows_path"] = r"C:\NDC_MISSIONCORE\wsl"
|
|
||||||
|
|
||||||
with pytest.raises(S0ProfileError, match="escapes D-only root"):
|
|
||||||
load_s0_profile(_write_profile(tmp_path, document))
|
|
||||||
|
|
||||||
|
|
||||||
def test_profile_rejects_duplicate_scoped_port(tmp_path: Path) -> None:
|
|
||||||
document = _profile_document()
|
|
||||||
ports = document["ports"]
|
|
||||||
assert isinstance(ports, list)
|
|
||||||
duplicate = dict(ports[0])
|
|
||||||
duplicate["id"] = "duplicate-port"
|
|
||||||
duplicate["owner"] = "simulation-orchestrator"
|
|
||||||
ports.append(duplicate)
|
|
||||||
|
|
||||||
with pytest.raises(S0ProfileError, match="duplicates a scoped binding"):
|
|
||||||
load_s0_profile(_write_profile(tmp_path, document))
|
|
||||||
|
|
||||||
|
|
||||||
def test_profile_rejects_wildcard_bind_outside_loopback_namespace(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
document = _profile_document()
|
|
||||||
ports = document["ports"]
|
|
||||||
assert isinstance(ports, list)
|
|
||||||
first = ports[0]
|
|
||||||
assert isinstance(first, dict)
|
|
||||||
first["host_scope"] = "worker-wsl"
|
|
||||||
|
|
||||||
with pytest.raises(S0ProfileError, match="wildcard bind requires loopback-only-netns"):
|
|
||||||
load_s0_profile(_write_profile(tmp_path, document))
|
|
||||||
|
|
||||||
|
|
||||||
def test_profile_rejects_runtime_speed_contract_drift(tmp_path: Path) -> None:
|
|
||||||
document = _profile_document()
|
|
||||||
runtime = document["runtime_acceptance"]
|
|
||||||
assert isinstance(runtime, dict)
|
|
||||||
runtime["speed_factors"] = [1, 4]
|
|
||||||
|
|
||||||
with pytest.raises(S0ProfileError, match="exactly the 1x and 2x"):
|
|
||||||
load_s0_profile(_write_profile(tmp_path, document))
|
|
||||||
|
|
||||||
|
|
||||||
def test_doctor_never_claims_go_without_target_evidence(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"k1link.simulation.s0._target_host_match",
|
|
||||||
lambda target: (False, "test host is not target"),
|
|
||||||
)
|
|
||||||
|
|
||||||
report = run_s0_doctor(PROFILE)
|
|
||||||
|
|
||||||
assert report.verdict is DoctorVerdict.INCOMPLETE
|
|
||||||
assert report.target_host_match is False
|
|
||||||
assert any(
|
|
||||||
check.identifier == "factual-evidence" and check.status is CheckStatus.UNKNOWN
|
|
||||||
for check in report.checks
|
|
||||||
)
|
|
||||||
payload = report.to_dict()
|
|
||||||
assert payload["authority"] == {
|
|
||||||
"simulation_or_shadow_only": True,
|
|
||||||
"actuator_authority": False,
|
|
||||||
"navigation_or_safety_accepted": False,
|
|
||||||
"direct_actuator_setpoints_allowed": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_doctor_validates_digest_bound_evidence(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"k1link.simulation.s0._target_host_match",
|
|
||||||
lambda target: (False, "test host is not target"),
|
|
||||||
)
|
|
||||||
profile = load_s0_profile(PROFILE)
|
|
||||||
checks: list[dict[str, object]] = []
|
|
||||||
for identifier in profile.required_evidence:
|
|
||||||
artifact = tmp_path / f"{identifier}.txt"
|
|
||||||
artifact.write_text(f"evidence for {identifier}\n", encoding="utf-8")
|
|
||||||
checks.append(
|
|
||||||
{
|
|
||||||
"id": identifier,
|
|
||||||
"status": "pass",
|
|
||||||
"artifact": artifact.name,
|
|
||||||
"sha256": hashlib.sha256(artifact.read_bytes()).hexdigest(),
|
|
||||||
"note": "synthetic unit-test evidence",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
evidence = tmp_path / "evidence.yaml"
|
|
||||||
evidence.write_text(
|
|
||||||
yaml.safe_dump(
|
|
||||||
{
|
|
||||||
"schema_version": "missioncore.simulation-s0-evidence/v1",
|
|
||||||
"profile_sha256": hashlib.sha256(PROFILE.read_bytes()).hexdigest(),
|
|
||||||
"checks": checks,
|
|
||||||
},
|
|
||||||
sort_keys=False,
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
report = run_s0_doctor(PROFILE, evidence_path=evidence)
|
|
||||||
|
|
||||||
assert report.verdict is DoctorVerdict.INCOMPLETE
|
|
||||||
assert any(
|
|
||||||
check.identifier == "factual-evidence" and check.status is CheckStatus.PASS
|
|
||||||
for check in report.checks
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_doctor_rejects_changed_evidence_artifact(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"k1link.simulation.s0._target_host_match",
|
|
||||||
lambda target: (False, "test host is not target"),
|
|
||||||
)
|
|
||||||
profile = load_s0_profile(PROFILE)
|
|
||||||
checks: list[dict[str, object]] = []
|
|
||||||
artifacts: list[Path] = []
|
|
||||||
for identifier in profile.required_evidence:
|
|
||||||
artifact = tmp_path / f"{identifier}.txt"
|
|
||||||
artifact.write_text("original\n", encoding="utf-8")
|
|
||||||
artifacts.append(artifact)
|
|
||||||
checks.append(
|
|
||||||
{
|
|
||||||
"id": identifier,
|
|
||||||
"status": "pass",
|
|
||||||
"artifact": artifact.name,
|
|
||||||
"sha256": hashlib.sha256(artifact.read_bytes()).hexdigest(),
|
|
||||||
"note": "synthetic unit-test evidence",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
evidence = tmp_path / "evidence.yaml"
|
|
||||||
evidence.write_text(
|
|
||||||
yaml.safe_dump(
|
|
||||||
{
|
|
||||||
"schema_version": "missioncore.simulation-s0-evidence/v1",
|
|
||||||
"profile_sha256": hashlib.sha256(PROFILE.read_bytes()).hexdigest(),
|
|
||||||
"checks": checks,
|
|
||||||
},
|
|
||||||
sort_keys=False,
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
artifacts[0].write_text("changed\n", encoding="utf-8")
|
|
||||||
|
|
||||||
report = run_s0_doctor(PROFILE, evidence_path=evidence)
|
|
||||||
|
|
||||||
assert report.verdict is DoctorVerdict.BLOCKED
|
|
||||||
assert any(
|
|
||||||
check.identifier == "factual-evidence"
|
|
||||||
and check.status is CheckStatus.FAIL
|
|
||||||
and "digest changed" in check.detail
|
|
||||||
for check in report.checks
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_simulation_doctor_cli_emits_json_without_side_effects() -> None:
|
|
||||||
result = runner.invoke(app, ["s0", "doctor", "--profile", str(PROFILE), "--json"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
payload = json.loads(result.stdout)
|
|
||||||
assert payload["schema_version"] == "missioncore.simulation-s0-doctor/v1"
|
|
||||||
assert payload["profile_id"] == "ai-worker-px4-gazebo-v1"
|
|
||||||
assert payload["verdict"] in {"incomplete", "blocked"}
|
|
||||||
assert payload["authority"]["actuator_authority"] is False
|
|
||||||
|
|
@ -1,477 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path, PurePosixPath
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from k1link.simulation import (
|
|
||||||
ActiveQualificationRunError,
|
|
||||||
AuthorityProfile,
|
|
||||||
PosixProcessSupervisor,
|
|
||||||
ProcessSpec,
|
|
||||||
ProcessSupervisorError,
|
|
||||||
ProviderPin,
|
|
||||||
QualificationRun,
|
|
||||||
QualificationRunConflictError,
|
|
||||||
QualificationRunStore,
|
|
||||||
ReproducibilityTier,
|
|
||||||
RunKind,
|
|
||||||
RunState,
|
|
||||||
S0WorkerGuard,
|
|
||||||
SimulationApplicationService,
|
|
||||||
StockRoverTargetPaths,
|
|
||||||
WorkerAdmissionError,
|
|
||||||
WorkerStartResult,
|
|
||||||
WorkerStopResult,
|
|
||||||
load_stock_rover_lifecycle_profile,
|
|
||||||
stock_rover_process_specs,
|
|
||||||
)
|
|
||||||
|
|
||||||
PROFILE = Path(__file__).resolve().parents[1] / "simulation/s0/qualification-profile.yaml"
|
|
||||||
S1B_PROFILE = Path(__file__).resolve().parents[1] / "simulation/s1/stock-rover-lifecycle.yaml"
|
|
||||||
SHA_A = "a" * 64
|
|
||||||
SHA_B = "b" * 64
|
|
||||||
|
|
||||||
|
|
||||||
def _run(
|
|
||||||
run_id: str = "run-s1b-001",
|
|
||||||
episode_id: str = "episode-s1b-001",
|
|
||||||
*,
|
|
||||||
host_profile_sha256: str | None = None,
|
|
||||||
) -> QualificationRun:
|
|
||||||
return QualificationRun(
|
|
||||||
run_id=run_id,
|
|
||||||
episode_id=episode_id,
|
|
||||||
kind=RunKind.SIMULATION_CLOSED_LOOP,
|
|
||||||
state=RunState.ADMITTED,
|
|
||||||
scenario_generation="stock-rover-v1",
|
|
||||||
scenario_sha256=SHA_A,
|
|
||||||
profile_generation="s1-ackermann-v1",
|
|
||||||
profile_sha256=SHA_B,
|
|
||||||
mission_core_commit="12fba7126d247f809f8d135c5f68922eb08d4683",
|
|
||||||
providers=(
|
|
||||||
ProviderPin("px4-autopilot", "v1.17.0", "v1.17.0"),
|
|
||||||
ProviderPin("gazebo", "harmonic", "8.14.0"),
|
|
||||||
),
|
|
||||||
host_profile_id="ai-worker-px4-gazebo-v1",
|
|
||||||
host_profile_sha256=host_profile_sha256 or ("c" * 64),
|
|
||||||
seed=42,
|
|
||||||
reproducibility_tier=ReproducibilityTier.R1,
|
|
||||||
authority=AuthorityProfile(
|
|
||||||
generation=1,
|
|
||||||
command_ttl_max_ns=250_000_000,
|
|
||||||
heartbeat_timeout_monotonic_ns=500_000_000,
|
|
||||||
),
|
|
||||||
clock_domain="gazebo:/clock",
|
|
||||||
created_at_utc="2026-07-24T18:00:00Z",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeWorker:
|
|
||||||
def __init__(self, profile_sha256: str = "c" * 64) -> None:
|
|
||||||
self.profile_sha256 = profile_sha256
|
|
||||||
self.calls: list[str] = []
|
|
||||||
self.fail_start = False
|
|
||||||
self.residue: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
def start(self, run: QualificationRun) -> WorkerStartResult:
|
|
||||||
self.calls.append(f"start:{run.run_id}")
|
|
||||||
if self.fail_start:
|
|
||||||
raise RuntimeError("synthetic start failure")
|
|
||||||
return WorkerStartResult(("gazebo-server", "px4-sitl"), self.profile_sha256)
|
|
||||||
|
|
||||||
def pause(self, run: QualificationRun) -> None:
|
|
||||||
self.calls.append(f"pause:{run.run_id}")
|
|
||||||
|
|
||||||
def resume(self, run: QualificationRun) -> None:
|
|
||||||
self.calls.append(f"resume:{run.run_id}")
|
|
||||||
|
|
||||||
def step(self, run: QualificationRun, step_count: int) -> int:
|
|
||||||
self.calls.append(f"step:{run.run_id}:{step_count}")
|
|
||||||
return step_count * 2_000_000
|
|
||||||
|
|
||||||
def stop(self, run: QualificationRun) -> WorkerStopResult:
|
|
||||||
self.calls.append(f"stop:{run.run_id}")
|
|
||||||
return WorkerStopResult(("px4-sitl", "gazebo-server"), self.residue)
|
|
||||||
|
|
||||||
def reconcile(self, run: QualificationRun) -> WorkerStopResult:
|
|
||||||
self.calls.append(f"reconcile:{run.run_id}")
|
|
||||||
return WorkerStopResult(("px4-sitl", "gazebo-server"), self.residue)
|
|
||||||
|
|
||||||
|
|
||||||
def _service(tmp_path: Path) -> tuple[SimulationApplicationService, _FakeWorker]:
|
|
||||||
store = QualificationRunStore(tmp_path)
|
|
||||||
store.create(_run())
|
|
||||||
worker = _FakeWorker()
|
|
||||||
return SimulationApplicationService(store, worker), worker
|
|
||||||
|
|
||||||
|
|
||||||
def test_application_service_owns_idempotent_start_pause_step_resume_stop(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
service, worker = _service(tmp_path)
|
|
||||||
running = service.start(
|
|
||||||
"run-s1b-001",
|
|
||||||
idempotency_key="start-001",
|
|
||||||
observed_at_utc="2026-07-24T18:00:01Z",
|
|
||||||
host_monotonic_ns=1,
|
|
||||||
)
|
|
||||||
repeated = service.start(
|
|
||||||
"run-s1b-001",
|
|
||||||
idempotency_key="start-001",
|
|
||||||
observed_at_utc="2026-07-24T18:00:02Z",
|
|
||||||
host_monotonic_ns=2,
|
|
||||||
)
|
|
||||||
paused = service.pause(
|
|
||||||
running.run_id,
|
|
||||||
observed_at_utc="2026-07-24T18:00:03Z",
|
|
||||||
host_monotonic_ns=3,
|
|
||||||
sim_time_ns=2_000_000,
|
|
||||||
)
|
|
||||||
advanced = service.step(
|
|
||||||
running.run_id,
|
|
||||||
step_count=2,
|
|
||||||
observed_at_utc="2026-07-24T18:00:04Z",
|
|
||||||
host_monotonic_ns=4,
|
|
||||||
sim_time_ns=2_000_000,
|
|
||||||
)
|
|
||||||
resumed = service.resume(
|
|
||||||
running.run_id,
|
|
||||||
observed_at_utc="2026-07-24T18:00:05Z",
|
|
||||||
host_monotonic_ns=5,
|
|
||||||
sim_time_ns=6_000_000,
|
|
||||||
)
|
|
||||||
completed = service.stop(
|
|
||||||
running.run_id,
|
|
||||||
idempotency_key="stop-001",
|
|
||||||
observed_at_utc="2026-07-24T18:00:06Z",
|
|
||||||
host_monotonic_ns=6,
|
|
||||||
sim_time_ns=6_000_000,
|
|
||||||
)
|
|
||||||
repeated_stop = service.stop(
|
|
||||||
running.run_id,
|
|
||||||
idempotency_key="stop-001",
|
|
||||||
observed_at_utc="2026-07-24T18:00:07Z",
|
|
||||||
host_monotonic_ns=7,
|
|
||||||
sim_time_ns=6_000_000,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert running.state is RunState.RUNNING
|
|
||||||
assert repeated.state is RunState.RUNNING
|
|
||||||
assert paused.state is RunState.PAUSED
|
|
||||||
assert advanced == 4_000_000
|
|
||||||
assert resumed.state is RunState.RUNNING
|
|
||||||
assert completed.state is RunState.COMPLETED
|
|
||||||
assert repeated_stop == completed
|
|
||||||
assert worker.calls.count("start:run-s1b-001") == 1
|
|
||||||
assert worker.calls.count("stop:run-s1b-001") == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_application_service_rejects_second_active_owner_and_rebound_keys(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
store = QualificationRunStore(tmp_path)
|
|
||||||
store.create(_run())
|
|
||||||
store.create(_run("run-s1b-002", "episode-s1b-002"))
|
|
||||||
worker = _FakeWorker()
|
|
||||||
service = SimulationApplicationService(store, worker)
|
|
||||||
service.start(
|
|
||||||
"run-s1b-001",
|
|
||||||
idempotency_key="start-001",
|
|
||||||
observed_at_utc="2026-07-24T18:00:01Z",
|
|
||||||
host_monotonic_ns=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ActiveQualificationRunError, match="owned"):
|
|
||||||
service.start(
|
|
||||||
"run-s1b-002",
|
|
||||||
idempotency_key="start-002",
|
|
||||||
observed_at_utc="2026-07-24T18:00:02Z",
|
|
||||||
host_monotonic_ns=2,
|
|
||||||
)
|
|
||||||
with pytest.raises(QualificationRunConflictError, match="another start"):
|
|
||||||
service.start(
|
|
||||||
"run-s1b-001",
|
|
||||||
idempotency_key="different",
|
|
||||||
observed_at_utc="2026-07-24T18:00:02Z",
|
|
||||||
host_monotonic_ns=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_failure_and_profile_drift_fail_terminal(tmp_path: Path) -> None:
|
|
||||||
service, worker = _service(tmp_path)
|
|
||||||
worker.fail_start = True
|
|
||||||
failed = service.start(
|
|
||||||
"run-s1b-001",
|
|
||||||
idempotency_key="start-fail",
|
|
||||||
observed_at_utc="2026-07-24T18:00:01Z",
|
|
||||||
host_monotonic_ns=1,
|
|
||||||
)
|
|
||||||
assert failed.state is RunState.FAILED
|
|
||||||
assert failed.terminal_reason == "orchestrator-start-failed"
|
|
||||||
assert any(
|
|
||||||
event.payload.get("detail") == "RuntimeError: synthetic start failure"
|
|
||||||
for event in service.store.list_events("run-s1b-001")
|
|
||||||
)
|
|
||||||
|
|
||||||
store = QualificationRunStore(tmp_path / "drift")
|
|
||||||
store.create(_run())
|
|
||||||
drift_worker = _FakeWorker("d" * 64)
|
|
||||||
drift_service = SimulationApplicationService(store, drift_worker)
|
|
||||||
drifted = drift_service.start(
|
|
||||||
"run-s1b-001",
|
|
||||||
idempotency_key="start-drift",
|
|
||||||
observed_at_utc="2026-07-24T18:00:01Z",
|
|
||||||
host_monotonic_ns=1,
|
|
||||||
)
|
|
||||||
assert drifted.state is RunState.FAILED
|
|
||||||
assert drifted.terminal_reason == "orchestrator-profile-drift-failed"
|
|
||||||
assert "stop:run-s1b-001" in drift_worker.calls
|
|
||||||
|
|
||||||
|
|
||||||
def test_reset_stops_parent_and_creates_new_identity(tmp_path: Path) -> None:
|
|
||||||
service, _ = _service(tmp_path)
|
|
||||||
service.start(
|
|
||||||
"run-s1b-001",
|
|
||||||
idempotency_key="start-001",
|
|
||||||
observed_at_utc="2026-07-24T18:00:01Z",
|
|
||||||
host_monotonic_ns=1,
|
|
||||||
)
|
|
||||||
reset = service.reset(
|
|
||||||
"run-s1b-001",
|
|
||||||
idempotency_key="reset-001",
|
|
||||||
new_run_id="run-s1b-002",
|
|
||||||
new_episode_id="episode-s1b-002",
|
|
||||||
observed_at_utc="2026-07-24T18:00:02Z",
|
|
||||||
host_monotonic_ns=2,
|
|
||||||
sim_time_ns=2_000_000,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert service.store.load("run-s1b-001").state is RunState.ABORTED
|
|
||||||
assert reset.state is RunState.ADMITTED
|
|
||||||
assert reset.parent_run_id == "run-s1b-001"
|
|
||||||
|
|
||||||
|
|
||||||
def test_restart_reconcile_never_resumes_and_records_residue(tmp_path: Path) -> None:
|
|
||||||
service, worker = _service(tmp_path)
|
|
||||||
service.start(
|
|
||||||
"run-s1b-001",
|
|
||||||
idempotency_key="start-001",
|
|
||||||
observed_at_utc="2026-07-24T18:00:01Z",
|
|
||||||
host_monotonic_ns=1,
|
|
||||||
)
|
|
||||||
worker.residue = ("px4-sitl",)
|
|
||||||
|
|
||||||
reconciled = service.reconcile(
|
|
||||||
observed_at_utc="2026-07-24T18:01:00Z",
|
|
||||||
host_monotonic_ns=60,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert reconciled[0].state is RunState.FAILED
|
|
||||||
assert reconciled[0].terminal_reason == "orchestrator-recovery-interrupted"
|
|
||||||
assert any(
|
|
||||||
event.event_type == "orchestrator.recovery-residue"
|
|
||||||
for event in service.store.list_events("run-s1b-001")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(os.name != "posix", reason="target supervisor is POSIX-only")
|
|
||||||
def test_process_supervisor_owns_groups_and_stops_in_declared_order(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
order_path = tmp_path / "order.log"
|
|
||||||
child = (
|
|
||||||
"import os,signal,time;"
|
|
||||||
"p=os.environ['ORDER_FILE'];n=os.environ['NAME'];"
|
|
||||||
"signal.signal(signal.SIGINT,lambda *_:(open(p,'a').write(n+'\\n'),exit(0)));"
|
|
||||||
"time.sleep(60)"
|
|
||||||
)
|
|
||||||
supervisor = PosixProcessSupervisor(tmp_path / "runtime")
|
|
||||||
records = supervisor.start(
|
|
||||||
"run-process-001",
|
|
||||||
(
|
|
||||||
ProcessSpec(
|
|
||||||
process_id="gazebo-server",
|
|
||||||
argv=(sys.executable, "-c", child),
|
|
||||||
start_order=1,
|
|
||||||
shutdown_order=20,
|
|
||||||
environment=(("ORDER_FILE", str(order_path)), ("NAME", "gazebo")),
|
|
||||||
),
|
|
||||||
ProcessSpec(
|
|
||||||
process_id="px4-sitl",
|
|
||||||
argv=(sys.executable, "-c", child),
|
|
||||||
start_order=2,
|
|
||||||
shutdown_order=10,
|
|
||||||
environment=(("ORDER_FILE", str(order_path)), ("NAME", "px4")),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert [record.process_id for record in records] == ["gazebo-server", "px4-sitl"]
|
|
||||||
assert all(record.pid == record.pgid for record in records)
|
|
||||||
result = supervisor.stop()
|
|
||||||
|
|
||||||
assert result.residue_process_ids == ()
|
|
||||||
assert result.stopped_process_ids == ("px4-sitl", "gazebo-server")
|
|
||||||
assert order_path.read_text(encoding="utf-8").splitlines() == ["px4", "gazebo"]
|
|
||||||
assert supervisor.residue() == ()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(os.name != "posix", reason="target supervisor is POSIX-only")
|
|
||||||
def test_process_supervisor_rolls_back_partial_start(tmp_path: Path) -> None:
|
|
||||||
supervisor = PosixProcessSupervisor(tmp_path / "runtime")
|
|
||||||
with pytest.raises(ProcessSupervisorError, match="exited during startup"):
|
|
||||||
supervisor.start(
|
|
||||||
"run-process-001",
|
|
||||||
(
|
|
||||||
ProcessSpec(
|
|
||||||
process_id="long-lived",
|
|
||||||
argv=(sys.executable, "-c", "import time;time.sleep(60)"),
|
|
||||||
start_order=1,
|
|
||||||
shutdown_order=10,
|
|
||||||
),
|
|
||||||
ProcessSpec(
|
|
||||||
process_id="failed",
|
|
||||||
argv=(sys.executable, "-c", "raise SystemExit(7)"),
|
|
||||||
start_order=2,
|
|
||||||
shutdown_order=20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assert supervisor.residue() == ()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(os.name != "posix", reason="target supervisor is POSIX-only")
|
|
||||||
def test_process_supervisor_waits_for_all_declared_ready_markers(tmp_path: Path) -> None:
|
|
||||||
child = (
|
|
||||||
"import signal,sys,time;"
|
|
||||||
"signal.signal(signal.SIGINT,lambda *_:exit(0));"
|
|
||||||
"print('provider booting',flush=True);"
|
|
||||||
"time.sleep(0.05);"
|
|
||||||
"sys.stdout.write('transport ');sys.stdout.flush();"
|
|
||||||
"time.sleep(0.05);"
|
|
||||||
"print('connected',flush=True);"
|
|
||||||
"time.sleep(60)"
|
|
||||||
)
|
|
||||||
supervisor = PosixProcessSupervisor(tmp_path / "runtime")
|
|
||||||
|
|
||||||
records = supervisor.start(
|
|
||||||
"run-ready-001",
|
|
||||||
(
|
|
||||||
ProcessSpec(
|
|
||||||
process_id="ready-provider",
|
|
||||||
argv=(sys.executable, "-c", child),
|
|
||||||
start_order=1,
|
|
||||||
shutdown_order=1,
|
|
||||||
ready_log_patterns=("provider booting", "transport connected"),
|
|
||||||
health_timeout_seconds=1,
|
|
||||||
health_poll_interval_seconds=0.01,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert tuple(record.process_id for record in records) == ("ready-provider",)
|
|
||||||
assert supervisor.stop().residue_process_ids == ()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(os.name != "posix", reason="target supervisor is POSIX-only")
|
|
||||||
def test_process_supervisor_readiness_timeout_rolls_back_without_residue(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
supervisor = PosixProcessSupervisor(tmp_path / "runtime")
|
|
||||||
|
|
||||||
with pytest.raises(ProcessSupervisorError, match="missing markers: never-ready"):
|
|
||||||
supervisor.start(
|
|
||||||
"run-ready-timeout-001",
|
|
||||||
(
|
|
||||||
ProcessSpec(
|
|
||||||
process_id="not-ready",
|
|
||||||
argv=(sys.executable, "-c", "import time;time.sleep(60)"),
|
|
||||||
start_order=1,
|
|
||||||
shutdown_order=1,
|
|
||||||
ready_log_patterns=("never-ready",),
|
|
||||||
health_timeout_seconds=0.05,
|
|
||||||
health_poll_interval_seconds=0.01,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert supervisor.residue() == ()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(os.name != "posix", reason="target supervisor is POSIX-only")
|
|
||||||
def test_process_supervisor_rejects_exit_before_readiness(tmp_path: Path) -> None:
|
|
||||||
supervisor = PosixProcessSupervisor(tmp_path / "runtime")
|
|
||||||
|
|
||||||
with pytest.raises(ProcessSupervisorError, match="exited before readiness"):
|
|
||||||
supervisor.start(
|
|
||||||
"run-ready-exit-001",
|
|
||||||
(
|
|
||||||
ProcessSpec(
|
|
||||||
process_id="early-exit",
|
|
||||||
argv=(sys.executable, "-c", "raise SystemExit(7)"),
|
|
||||||
start_order=1,
|
|
||||||
shutdown_order=1,
|
|
||||||
ready_log_patterns=("never-ready",),
|
|
||||||
health_timeout_seconds=1,
|
|
||||||
health_poll_interval_seconds=0.01,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert supervisor.residue() == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_stock_rover_profile_builds_exact_reviewed_provider_graph() -> None:
|
|
||||||
profile = load_stock_rover_lifecycle_profile(S1B_PROFILE)
|
|
||||||
paths = StockRoverTargetPaths.from_d_root(Path("/mnt/d/NDC_MISSIONCORE/simulation"))
|
|
||||||
|
|
||||||
specs = stock_rover_process_specs(paths, profile)
|
|
||||||
|
|
||||||
assert profile.profile_id == "s1b-stock-rover-lifecycle-v1"
|
|
||||||
assert tuple(spec.process_id for spec in specs) == (
|
|
||||||
"micro-xrce-dds-agent",
|
|
||||||
"px4-gazebo-stock-rover",
|
|
||||||
)
|
|
||||||
assert specs[0].ready_log_patterns == ("running...",)
|
|
||||||
assert specs[1].argv[-2:] == ("px4_sitl", "gz_rover_ackermann")
|
|
||||||
assert specs[1].ready_log_patterns[-1] == (
|
|
||||||
"successfully created rt/fmu/out/vehicle_status_v1 data writer"
|
|
||||||
)
|
|
||||||
assert specs[1].keep_stdin_open
|
|
||||||
assert dict(specs[1].environment) == {
|
|
||||||
"HEADLESS": "1",
|
|
||||||
"PX4_SIM_SPEED_FACTOR": "1",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_ready_patterns_require_a_positive_timeout() -> None:
|
|
||||||
with pytest.raises(ValueError, match="positive health timeout"):
|
|
||||||
ProcessSpec(
|
|
||||||
process_id="unsafe-readiness",
|
|
||||||
argv=(sys.executable, "-c", "pass"),
|
|
||||||
start_order=1,
|
|
||||||
shutdown_order=1,
|
|
||||||
ready_log_patterns=("ready",),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_s0_worker_guard_binds_exact_digest_and_d_only_run_path() -> None:
|
|
||||||
guard = S0WorkerGuard(PROFILE)
|
|
||||||
run = _run(host_profile_sha256=hashlib.sha256(PROFILE.read_bytes()).hexdigest())
|
|
||||||
root = PurePosixPath("/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/runs/run-s1b-001")
|
|
||||||
process_root = PurePosixPath("/mnt/d/NDC_MISSIONCORE/simulation/runtime/s1/runs/run-s1b-001")
|
|
||||||
|
|
||||||
admission = guard.admit(run, root, process_root)
|
|
||||||
|
|
||||||
assert admission.profile.profile_id == "ai-worker-px4-gazebo-v1"
|
|
||||||
assert admission.profile_sha256 == run.host_profile_sha256
|
|
||||||
with pytest.raises(WorkerAdmissionError, match="escapes"):
|
|
||||||
guard.admit(run, PurePosixPath("/tmp/run-s1b-001"), process_root)
|
|
||||||
with pytest.raises(WorkerAdmissionError, match="process root"):
|
|
||||||
guard.admit(run, root, PurePosixPath("/tmp/run-s1b-001"))
|
|
||||||
with pytest.raises(WorkerAdmissionError, match="digest"):
|
|
||||||
guard.admit(_run(), root, process_root)
|
|
||||||
|
|
@ -1,469 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from dataclasses import replace
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from k1link.simulation import (
|
|
||||||
AckermannControlSetpoint,
|
|
||||||
AuthorityProfile,
|
|
||||||
CommandAuthorityScope,
|
|
||||||
DifferentialControlSetpoint,
|
|
||||||
ProviderPin,
|
|
||||||
QualificationArtifact,
|
|
||||||
QualificationRun,
|
|
||||||
QualificationRunConflictError,
|
|
||||||
QualificationRunIntegrityError,
|
|
||||||
QualificationRunStore,
|
|
||||||
QualificationRunTransitionError,
|
|
||||||
ReproducibilityTier,
|
|
||||||
RunKind,
|
|
||||||
RunState,
|
|
||||||
SimulationContractError,
|
|
||||||
)
|
|
||||||
|
|
||||||
SHA_A = "a" * 64
|
|
||||||
SHA_B = "b" * 64
|
|
||||||
SHA_C = "c" * 64
|
|
||||||
|
|
||||||
|
|
||||||
def _run(
|
|
||||||
*,
|
|
||||||
run_id: str = "run-s1-001",
|
|
||||||
episode_id: str = "episode-s1-001",
|
|
||||||
kind: RunKind = RunKind.SIMULATION_CLOSED_LOOP,
|
|
||||||
) -> QualificationRun:
|
|
||||||
return QualificationRun(
|
|
||||||
run_id=run_id,
|
|
||||||
episode_id=episode_id,
|
|
||||||
kind=kind,
|
|
||||||
state=RunState.ADMITTED,
|
|
||||||
scenario_generation="stock-rover-v1",
|
|
||||||
scenario_sha256=SHA_A,
|
|
||||||
profile_generation="s1-ackermann-v1",
|
|
||||||
profile_sha256=SHA_B,
|
|
||||||
mission_core_commit="a19053dfd47577fb66cc63f605ba29ea2314f6aa",
|
|
||||||
providers=(
|
|
||||||
ProviderPin(
|
|
||||||
identifier="px4-autopilot",
|
|
||||||
version="v1.17.0",
|
|
||||||
revision="v1.17.0",
|
|
||||||
),
|
|
||||||
ProviderPin(
|
|
||||||
identifier="gazebo",
|
|
||||||
version="harmonic",
|
|
||||||
revision="8.9.0",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
host_profile_id="mission-gpu-s0",
|
|
||||||
host_profile_sha256=SHA_C,
|
|
||||||
seed=42,
|
|
||||||
reproducibility_tier=ReproducibilityTier.R1,
|
|
||||||
authority=AuthorityProfile(
|
|
||||||
generation=1,
|
|
||||||
command_ttl_max_ns=250_000_000,
|
|
||||||
heartbeat_timeout_monotonic_ns=500_000_000,
|
|
||||||
),
|
|
||||||
clock_domain="gazebo:/clock",
|
|
||||||
created_at_utc="2026-07-24T17:00:00Z",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _start(store: QualificationRunStore, run_id: str = "run-s1-001") -> QualificationRun:
|
|
||||||
starting = store.transition(
|
|
||||||
run_id,
|
|
||||||
RunState.STARTING,
|
|
||||||
expected_revision=0,
|
|
||||||
observed_at_utc="2026-07-24T17:00:01Z",
|
|
||||||
host_monotonic_ns=1,
|
|
||||||
)
|
|
||||||
return store.transition(
|
|
||||||
run_id,
|
|
||||||
RunState.RUNNING,
|
|
||||||
expected_revision=starting.revision,
|
|
||||||
observed_at_utc="2026-07-24T17:00:02Z",
|
|
||||||
host_monotonic_ns=2,
|
|
||||||
sim_time_ns=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_closed_loop_run_accepts_provider_neutral_simulation_clock() -> None:
|
|
||||||
run = replace(_run(), clock_domain="unreal:fixed-step")
|
|
||||||
|
|
||||||
assert run.clock_domain == "unreal:fixed-step"
|
|
||||||
with pytest.raises(SimulationContractError, match="clock domain"):
|
|
||||||
replace(run, clock_domain="../../host-clock")
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_manifest_is_immutable_and_runtime_replays_from_events(tmp_path: Path) -> None:
|
|
||||||
store = QualificationRunStore(tmp_path / "runs")
|
|
||||||
created = store.create(_run())
|
|
||||||
|
|
||||||
assert created.state is RunState.ADMITTED
|
|
||||||
manifest_before = (tmp_path / "runs/run-s1-001/manifest.json").read_bytes()
|
|
||||||
|
|
||||||
running = _start(store)
|
|
||||||
restored = QualificationRunStore(tmp_path / "runs").load(running.run_id)
|
|
||||||
|
|
||||||
assert restored.state is RunState.RUNNING
|
|
||||||
assert restored.revision == 2
|
|
||||||
assert restored.started_at_utc == "2026-07-24T17:00:02Z"
|
|
||||||
assert (tmp_path / "runs/run-s1-001/manifest.json").read_bytes() == manifest_before
|
|
||||||
assert len(tuple((tmp_path / "runs/run-s1-001/events").iterdir())) == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_read_only_store_replays_evidence_without_mutating_repository(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
root = tmp_path / "runs"
|
|
||||||
writer = QualificationRunStore(root)
|
|
||||||
writer.create(_run())
|
|
||||||
running = _start(writer)
|
|
||||||
mode_before = root.stat().st_mode
|
|
||||||
|
|
||||||
reader = QualificationRunStore(root, read_only=True)
|
|
||||||
|
|
||||||
assert reader.load(running.run_id).state is RunState.RUNNING
|
|
||||||
assert reader.list_runs()[0].run_id == running.run_id
|
|
||||||
assert root.stat().st_mode == mode_before
|
|
||||||
with pytest.raises(QualificationRunTransitionError, match="read-only"):
|
|
||||||
reader.transition(
|
|
||||||
running.run_id,
|
|
||||||
RunState.PAUSED,
|
|
||||||
expected_revision=running.revision,
|
|
||||||
observed_at_utc="2026-07-24T17:00:03Z",
|
|
||||||
host_monotonic_ns=3,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_read_only_store_requires_an_existing_regular_repository(tmp_path: Path) -> None:
|
|
||||||
with pytest.raises(QualificationRunIntegrityError, match="missing"):
|
|
||||||
QualificationRunStore(tmp_path / "missing", read_only=True)
|
|
||||||
|
|
||||||
linked = tmp_path / "linked"
|
|
||||||
linked.symlink_to(tmp_path, target_is_directory=True)
|
|
||||||
with pytest.raises(QualificationRunIntegrityError, match="symlink"):
|
|
||||||
QualificationRunStore(linked, read_only=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_lifecycle_rejects_stale_illegal_and_post_terminal_changes(tmp_path: Path) -> None:
|
|
||||||
store = QualificationRunStore(tmp_path)
|
|
||||||
store.create(_run())
|
|
||||||
|
|
||||||
with pytest.raises(QualificationRunTransitionError, match="not allowed"):
|
|
||||||
store.transition(
|
|
||||||
"run-s1-001",
|
|
||||||
RunState.RUNNING,
|
|
||||||
expected_revision=0,
|
|
||||||
observed_at_utc="2026-07-24T17:00:01Z",
|
|
||||||
host_monotonic_ns=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
running = _start(store)
|
|
||||||
with pytest.raises(QualificationRunConflictError, match="revision"):
|
|
||||||
store.transition(
|
|
||||||
running.run_id,
|
|
||||||
RunState.PAUSED,
|
|
||||||
expected_revision=0,
|
|
||||||
observed_at_utc="2026-07-24T17:00:03Z",
|
|
||||||
host_monotonic_ns=3,
|
|
||||||
)
|
|
||||||
|
|
||||||
stopping = store.transition(
|
|
||||||
running.run_id,
|
|
||||||
RunState.STOPPING,
|
|
||||||
expected_revision=running.revision,
|
|
||||||
observed_at_utc="2026-07-24T17:00:03Z",
|
|
||||||
host_monotonic_ns=3,
|
|
||||||
)
|
|
||||||
completed = store.transition(
|
|
||||||
running.run_id,
|
|
||||||
RunState.COMPLETED,
|
|
||||||
expected_revision=stopping.revision,
|
|
||||||
observed_at_utc="2026-07-24T17:00:04Z",
|
|
||||||
host_monotonic_ns=4,
|
|
||||||
reason="goal-reached",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert completed.terminal_reason == "goal-reached"
|
|
||||||
assert completed.ended_at_utc == "2026-07-24T17:00:04Z"
|
|
||||||
with pytest.raises(QualificationRunTransitionError, match="immutable"):
|
|
||||||
store.append_event(
|
|
||||||
completed.run_id,
|
|
||||||
event_type="metric.observed",
|
|
||||||
observed_at_utc="2026-07-24T17:00:05Z",
|
|
||||||
host_monotonic_ns=5,
|
|
||||||
payload={"name": "distance", "value": 1.0},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pause_resume_and_domain_event_share_monotonic_journal(tmp_path: Path) -> None:
|
|
||||||
store = QualificationRunStore(tmp_path)
|
|
||||||
store.create(_run())
|
|
||||||
running = _start(store)
|
|
||||||
event = store.append_event(
|
|
||||||
running.run_id,
|
|
||||||
event_type="watchdog.heartbeat",
|
|
||||||
observed_at_utc="2026-07-24T17:00:03Z",
|
|
||||||
host_monotonic_ns=3,
|
|
||||||
sim_time_ns=2_000_000,
|
|
||||||
payload={"authority_generation": 1},
|
|
||||||
expected_revision=2,
|
|
||||||
)
|
|
||||||
paused = store.transition(
|
|
||||||
running.run_id,
|
|
||||||
RunState.PAUSED,
|
|
||||||
expected_revision=event.sequence,
|
|
||||||
observed_at_utc="2026-07-24T17:00:04Z",
|
|
||||||
host_monotonic_ns=4,
|
|
||||||
sim_time_ns=2_000_000,
|
|
||||||
)
|
|
||||||
resumed = store.transition(
|
|
||||||
running.run_id,
|
|
||||||
RunState.RUNNING,
|
|
||||||
expected_revision=paused.revision,
|
|
||||||
observed_at_utc="2026-07-24T17:00:05Z",
|
|
||||||
host_monotonic_ns=5,
|
|
||||||
sim_time_ns=2_000_000,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert event.sequence == 3
|
|
||||||
assert resumed.revision == 5
|
|
||||||
assert resumed.started_at_utc == "2026-07-24T17:00:02Z"
|
|
||||||
|
|
||||||
|
|
||||||
def test_ackermann_and_differential_commands_are_typed_and_sequenced(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
store = QualificationRunStore(tmp_path)
|
|
||||||
store.create(_run())
|
|
||||||
_start(store)
|
|
||||||
|
|
||||||
first = AckermannControlSetpoint(
|
|
||||||
run_id="run-s1-001",
|
|
||||||
command_id="command-1",
|
|
||||||
sequence=1,
|
|
||||||
issued_at_sim_ns=10,
|
|
||||||
valid_until_sim_ns=200_000_010,
|
|
||||||
authority_generation=1,
|
|
||||||
speed_mps=1.5,
|
|
||||||
steering_normalized=-0.25,
|
|
||||||
)
|
|
||||||
second = DifferentialControlSetpoint(
|
|
||||||
run_id="run-s1-001",
|
|
||||||
command_id="command-2",
|
|
||||||
sequence=2,
|
|
||||||
issued_at_sim_ns=20,
|
|
||||||
valid_until_sim_ns=200_000_020,
|
|
||||||
authority_generation=1,
|
|
||||||
speed_mps=0.5,
|
|
||||||
yaw_rate_rps=0.2,
|
|
||||||
)
|
|
||||||
|
|
||||||
store.submit_command(first)
|
|
||||||
store.submit_command(second)
|
|
||||||
|
|
||||||
assert store.list_commands("run-s1-001") == (first, second)
|
|
||||||
assert first.to_dict()["authority_scope"] == "virtual-only"
|
|
||||||
assert first.to_dict()["source"] == "simulation-orchestrator"
|
|
||||||
|
|
||||||
|
|
||||||
def test_commands_fail_closed_on_state_sequence_authority_and_ttl(tmp_path: Path) -> None:
|
|
||||||
store = QualificationRunStore(tmp_path)
|
|
||||||
store.create(_run())
|
|
||||||
command = AckermannControlSetpoint(
|
|
||||||
run_id="run-s1-001",
|
|
||||||
command_id="command-1",
|
|
||||||
sequence=1,
|
|
||||||
issued_at_sim_ns=0,
|
|
||||||
valid_until_sim_ns=100,
|
|
||||||
authority_generation=1,
|
|
||||||
speed_mps=1.0,
|
|
||||||
steering_normalized=0.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(QualificationRunTransitionError, match="only while"):
|
|
||||||
store.submit_command(command)
|
|
||||||
_start(store)
|
|
||||||
with pytest.raises(QualificationRunConflictError, match="authority"):
|
|
||||||
store.submit_command(replace(command, authority_generation=2))
|
|
||||||
with pytest.raises(QualificationRunConflictError, match="sequence"):
|
|
||||||
store.submit_command(replace(command, sequence=2))
|
|
||||||
with pytest.raises(QualificationRunTransitionError, match="TTL"):
|
|
||||||
store.submit_command(replace(command, valid_until_sim_ns=250_000_001))
|
|
||||||
|
|
||||||
|
|
||||||
def test_command_contract_rejects_nonfinite_and_direct_actuator_authority() -> None:
|
|
||||||
with pytest.raises(SimulationContractError, match="finite"):
|
|
||||||
AckermannControlSetpoint(
|
|
||||||
run_id="run-s1-001",
|
|
||||||
command_id="command-1",
|
|
||||||
sequence=1,
|
|
||||||
issued_at_sim_ns=0,
|
|
||||||
valid_until_sim_ns=100,
|
|
||||||
authority_generation=1,
|
|
||||||
speed_mps=float("nan"),
|
|
||||||
steering_normalized=0.0,
|
|
||||||
)
|
|
||||||
with pytest.raises(SimulationContractError, match="-1..1"):
|
|
||||||
AckermannControlSetpoint(
|
|
||||||
run_id="run-s1-001",
|
|
||||||
command_id="command-1",
|
|
||||||
sequence=1,
|
|
||||||
issued_at_sim_ns=0,
|
|
||||||
valid_until_sim_ns=100,
|
|
||||||
authority_generation=1,
|
|
||||||
speed_mps=1.0,
|
|
||||||
steering_normalized=1.1,
|
|
||||||
)
|
|
||||||
with pytest.raises(SimulationContractError, match="without actuator"):
|
|
||||||
AuthorityProfile(
|
|
||||||
generation=1,
|
|
||||||
command_ttl_max_ns=100,
|
|
||||||
heartbeat_timeout_monotonic_ns=100,
|
|
||||||
direct_actuator_setpoints_allowed=True,
|
|
||||||
)
|
|
||||||
with pytest.raises(SimulationContractError, match="virtual-only"):
|
|
||||||
replace(
|
|
||||||
AckermannControlSetpoint(
|
|
||||||
run_id="run-s1-001",
|
|
||||||
command_id="command-scope",
|
|
||||||
sequence=1,
|
|
||||||
issued_at_sim_ns=0,
|
|
||||||
valid_until_sim_ns=100,
|
|
||||||
authority_generation=1,
|
|
||||||
speed_mps=0.0,
|
|
||||||
steering_normalized=0.0,
|
|
||||||
),
|
|
||||||
authority_scope="physical", # type: ignore[arg-type]
|
|
||||||
)
|
|
||||||
assert CommandAuthorityScope.VIRTUAL_ONLY.value == "virtual-only"
|
|
||||||
with pytest.raises(SimulationContractError, match="valid explicit UTC"):
|
|
||||||
replace(_run(), created_at_utc="2026-02-30T17:00:00Z")
|
|
||||||
|
|
||||||
|
|
||||||
def test_replay_and_ungated_hil_cannot_acquire_command_authority(tmp_path: Path) -> None:
|
|
||||||
store = QualificationRunStore(tmp_path)
|
|
||||||
store.create(_run(kind=RunKind.REPLAY_SHADOW))
|
|
||||||
_start(store)
|
|
||||||
command = AckermannControlSetpoint(
|
|
||||||
run_id="run-s1-001",
|
|
||||||
command_id="command-1",
|
|
||||||
sequence=1,
|
|
||||||
issued_at_sim_ns=0,
|
|
||||||
valid_until_sim_ns=100,
|
|
||||||
authority_generation=1,
|
|
||||||
speed_mps=1.0,
|
|
||||||
steering_normalized=0.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(QualificationRunTransitionError, match="cannot causally"):
|
|
||||||
store.submit_command(command)
|
|
||||||
with pytest.raises(SimulationContractError, match="separate physical"):
|
|
||||||
_run(kind=RunKind.HIL)
|
|
||||||
|
|
||||||
|
|
||||||
def test_artifact_index_is_append_only_confined_and_digest_bound(tmp_path: Path) -> None:
|
|
||||||
store = QualificationRunStore(tmp_path)
|
|
||||||
store.create(_run())
|
|
||||||
admitted = store.register_artifact(
|
|
||||||
"run-s1-001",
|
|
||||||
QualificationArtifact(
|
|
||||||
artifact_id="run-manifest-copy",
|
|
||||||
kind="source.manifest",
|
|
||||||
relative_path="source/run-manifest.json",
|
|
||||||
sha256=SHA_A,
|
|
||||||
byte_length=123,
|
|
||||||
source_of_record=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert admitted.sequence == 1
|
|
||||||
assert store.load("run-s1-001").artifacts == (admitted,)
|
|
||||||
with pytest.raises(QualificationRunConflictError, match="already registered"):
|
|
||||||
store.register_artifact("run-s1-001", admitted)
|
|
||||||
with pytest.raises(SimulationContractError, match="confined"):
|
|
||||||
QualificationArtifact(
|
|
||||||
artifact_id="escape",
|
|
||||||
kind="source.log",
|
|
||||||
relative_path="../outside.log",
|
|
||||||
sha256=SHA_A,
|
|
||||||
byte_length=1,
|
|
||||||
source_of_record=True,
|
|
||||||
)
|
|
||||||
with pytest.raises(SimulationContractError, match="confined"):
|
|
||||||
replace(admitted, artifact_id="noncanonical", relative_path="source//artifact.json")
|
|
||||||
|
|
||||||
|
|
||||||
def test_reset_creates_new_episode_identity_without_overwriting_parent(tmp_path: Path) -> None:
|
|
||||||
store = QualificationRunStore(tmp_path)
|
|
||||||
store.create(_run())
|
|
||||||
running = _start(store)
|
|
||||||
stopping = store.transition(
|
|
||||||
running.run_id,
|
|
||||||
RunState.STOPPING,
|
|
||||||
expected_revision=running.revision,
|
|
||||||
observed_at_utc="2026-07-24T17:00:03Z",
|
|
||||||
host_monotonic_ns=3,
|
|
||||||
)
|
|
||||||
store.transition(
|
|
||||||
running.run_id,
|
|
||||||
RunState.ABORTED,
|
|
||||||
expected_revision=stopping.revision,
|
|
||||||
observed_at_utc="2026-07-24T17:00:04Z",
|
|
||||||
host_monotonic_ns=4,
|
|
||||||
reason="operator-reset",
|
|
||||||
)
|
|
||||||
|
|
||||||
reset = store.create_reset_episode(
|
|
||||||
running.run_id,
|
|
||||||
run_id="run-s1-002",
|
|
||||||
episode_id="episode-s1-002",
|
|
||||||
created_at_utc="2026-07-24T17:00:05Z",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert reset.parent_run_id == "run-s1-001"
|
|
||||||
assert reset.state is RunState.ADMITTED
|
|
||||||
assert store.load("run-s1-001").state is RunState.ABORTED
|
|
||||||
assert {item.run_id for item in store.list_runs()} == {"run-s1-001", "run-s1-002"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_restart_reconciliation_fails_active_run_without_hidden_resume(tmp_path: Path) -> None:
|
|
||||||
store = QualificationRunStore(tmp_path)
|
|
||||||
store.create(_run())
|
|
||||||
_start(store)
|
|
||||||
|
|
||||||
recovered = QualificationRunStore(tmp_path).reconcile_interrupted(
|
|
||||||
"run-s1-001",
|
|
||||||
observed_at_utc="2026-07-24T17:01:00Z",
|
|
||||||
host_monotonic_ns=1_000,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert recovered.state is RunState.FAILED
|
|
||||||
assert recovered.terminal_reason == "orchestrator-recovery-interrupted"
|
|
||||||
assert len(QualificationRunStore(tmp_path).list_commands(recovered.run_id)) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_corrupt_or_noncontiguous_journal_fails_closed(tmp_path: Path) -> None:
|
|
||||||
store = QualificationRunStore(tmp_path)
|
|
||||||
store.create(_run())
|
|
||||||
_start(store)
|
|
||||||
events = tmp_path / "run-s1-001/events"
|
|
||||||
(events / "00000000000000000002.json").rename(events / "00000000000000000003.json")
|
|
||||||
|
|
||||||
with pytest.raises(QualificationRunIntegrityError, match="contiguous"):
|
|
||||||
store.load("run-s1-001")
|
|
||||||
|
|
||||||
|
|
||||||
def test_manifest_identity_tampering_is_detected(tmp_path: Path) -> None:
|
|
||||||
store = QualificationRunStore(tmp_path)
|
|
||||||
store.create(_run())
|
|
||||||
manifest = tmp_path / "run-s1-001/manifest.json"
|
|
||||||
document = json.loads(manifest.read_text(encoding="utf-8"))
|
|
||||||
document["run_id"] = "other-run"
|
|
||||||
manifest.write_text(json.dumps(document), encoding="utf-8")
|
|
||||||
|
|
||||||
with pytest.raises(QualificationRunIntegrityError, match="identity"):
|
|
||||||
store.load("run-s1-001")
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import threading
|
|
||||||
from math import isnan
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from k1link.simulation.contracts import (
|
|
||||||
AckermannControlSetpoint,
|
|
||||||
AuthorityProfile,
|
|
||||||
ProviderPin,
|
|
||||||
QualificationRun,
|
|
||||||
ReproducibilityTier,
|
|
||||||
RunKind,
|
|
||||||
RunState,
|
|
||||||
)
|
|
||||||
from k1link.simulation.px4_rover_control import (
|
|
||||||
Px4RoverControlSnapshot,
|
|
||||||
Ros2Px4AckermannControl,
|
|
||||||
)
|
|
||||||
from k1link.simulation.run_store import QualificationRunStore
|
|
||||||
from k1link.simulation.worker_agent import SimulationWorkerAgent, WorkerAgentError
|
|
||||||
|
|
||||||
|
|
||||||
class _Collector:
|
|
||||||
def collect(self, run_id: str) -> dict[str, object]:
|
|
||||||
return {"run_id": run_id, "sim_time_ns": 1_000_000_000}
|
|
||||||
|
|
||||||
def estimate_sim_time_ns(self) -> int:
|
|
||||||
return 1_000_000_000
|
|
||||||
|
|
||||||
|
|
||||||
class _Controller:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.commands: list[object] = []
|
|
||||||
|
|
||||||
def submit(self, command: AckermannControlSetpoint) -> Px4RoverControlSnapshot:
|
|
||||||
self.commands.append(command)
|
|
||||||
return Px4RoverControlSnapshot(
|
|
||||||
run_id=command.run_id,
|
|
||||||
armed=True,
|
|
||||||
offboard=True,
|
|
||||||
ttl_expired_count=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
def snapshot(self, run_id: str) -> Px4RoverControlSnapshot:
|
|
||||||
return Px4RoverControlSnapshot(
|
|
||||||
run_id=run_id,
|
|
||||||
armed=True,
|
|
||||||
offboard=True,
|
|
||||||
ttl_expired_count=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _Message:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class _Messages:
|
|
||||||
OffboardControlMode = _Message
|
|
||||||
RoverThrottleSetpoint = _Message
|
|
||||||
RoverSteeringSetpoint = _Message
|
|
||||||
|
|
||||||
|
|
||||||
class _Publisher:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.messages: list[_Message] = []
|
|
||||||
|
|
||||||
def publish(self, message: _Message) -> None:
|
|
||||||
self.messages.append(message)
|
|
||||||
|
|
||||||
|
|
||||||
def _running_store(root: Path) -> QualificationRunStore:
|
|
||||||
store = QualificationRunStore(root)
|
|
||||||
admitted = store.create(
|
|
||||||
QualificationRun(
|
|
||||||
run_id="s1c-command-test",
|
|
||||||
episode_id="episode-s1c-command-test",
|
|
||||||
kind=RunKind.SIMULATION_CLOSED_LOOP,
|
|
||||||
state=RunState.ADMITTED,
|
|
||||||
scenario_generation="stock-rover",
|
|
||||||
scenario_sha256="a" * 64,
|
|
||||||
profile_generation="stock-rover-lifecycle-v1",
|
|
||||||
profile_sha256="b" * 64,
|
|
||||||
mission_core_commit="c" * 40,
|
|
||||||
providers=(
|
|
||||||
ProviderPin("px4-autopilot", "v1.17.0", "v1.17.0"),
|
|
||||||
ProviderPin("gazebo", "harmonic", "8.14.0"),
|
|
||||||
),
|
|
||||||
host_profile_id="mission-gpu-s0",
|
|
||||||
host_profile_sha256="d" * 64,
|
|
||||||
seed=42,
|
|
||||||
reproducibility_tier=ReproducibilityTier.R1,
|
|
||||||
authority=AuthorityProfile(
|
|
||||||
generation=1,
|
|
||||||
command_ttl_max_ns=250_000_000,
|
|
||||||
heartbeat_timeout_monotonic_ns=500_000_000,
|
|
||||||
),
|
|
||||||
clock_domain="gazebo:/clock",
|
|
||||||
created_at_utc="2026-07-24T18:00:00Z",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
starting = store.transition(
|
|
||||||
admitted.run_id,
|
|
||||||
RunState.STARTING,
|
|
||||||
expected_revision=0,
|
|
||||||
observed_at_utc="2026-07-24T18:00:01Z",
|
|
||||||
host_monotonic_ns=1,
|
|
||||||
)
|
|
||||||
store.transition(
|
|
||||||
admitted.run_id,
|
|
||||||
RunState.RUNNING,
|
|
||||||
expected_revision=starting.revision,
|
|
||||||
observed_at_utc="2026-07-24T18:00:02Z",
|
|
||||||
host_monotonic_ns=2,
|
|
||||||
sim_time_ns=0,
|
|
||||||
)
|
|
||||||
return store
|
|
||||||
|
|
||||||
|
|
||||||
def test_worker_agent_journals_and_idempotently_delivers_virtual_command(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
agent = object.__new__(SimulationWorkerAgent)
|
|
||||||
agent.store = _running_store(tmp_path)
|
|
||||||
agent.collector = _Collector()
|
|
||||||
controller = _Controller()
|
|
||||||
agent.command_controller = controller
|
|
||||||
agent._command_lock = threading.RLock()
|
|
||||||
|
|
||||||
accepted = agent.command(
|
|
||||||
run_id="s1c-command-test",
|
|
||||||
speed_mps=1.0,
|
|
||||||
steering_normalized=-0.25,
|
|
||||||
idempotency_key="browser-intent-001",
|
|
||||||
)
|
|
||||||
repeated = agent.command(
|
|
||||||
run_id="s1c-command-test",
|
|
||||||
speed_mps=1.0,
|
|
||||||
steering_normalized=-0.25,
|
|
||||||
idempotency_key="browser-intent-001",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert accepted == repeated
|
|
||||||
assert accepted["authority_scope"] == "virtual-only"
|
|
||||||
assert accepted["delivery"]["provider_id"] == "px4-ros2-offboard"
|
|
||||||
assert accepted["delivery"]["controller_ready"] is True
|
|
||||||
assert accepted["delivery"]["diagnostics"]["armed"] is True
|
|
||||||
commands = agent.store.list_commands("s1c-command-test")
|
|
||||||
assert len(commands) == 1
|
|
||||||
assert commands[0].valid_until_sim_ns - commands[0].issued_at_sim_ns == 250_000_000
|
|
||||||
assert len(controller.commands) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_worker_agent_command_dispatch_fails_closed_on_unsafe_envelope() -> None:
|
|
||||||
agent = object.__new__(SimulationWorkerAgent)
|
|
||||||
|
|
||||||
with pytest.raises(WorkerAgentError, match="outside"):
|
|
||||||
agent.dispatch(
|
|
||||||
{
|
|
||||||
"schema_version": "missioncore.simulation-worker-request/v1",
|
|
||||||
"request_id": "request-001",
|
|
||||||
"operation": "command",
|
|
||||||
"payload": {
|
|
||||||
"run_id": "s1c-command-test",
|
|
||||||
"speed_mps": 1.51,
|
|
||||||
"steering_normalized": 0.0,
|
|
||||||
"idempotency_key": "browser-intent-002",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_worker_agent_caches_the_absence_of_an_active_run() -> None:
|
|
||||||
class EmptyStore:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.list_calls = 0
|
|
||||||
|
|
||||||
def list_runs(self) -> tuple[QualificationRun, ...]:
|
|
||||||
self.list_calls += 1
|
|
||||||
return ()
|
|
||||||
|
|
||||||
agent = object.__new__(SimulationWorkerAgent)
|
|
||||||
store = EmptyStore()
|
|
||||||
agent.__dict__["store"] = store
|
|
||||||
|
|
||||||
first = agent.status()
|
|
||||||
second = agent.status()
|
|
||||||
|
|
||||||
assert first["available"] is True
|
|
||||||
assert first["active_run_id"] is None
|
|
||||||
assert second["active_run_id"] is None
|
|
||||||
assert store.list_calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_px4_adapter_maps_speed_to_stock_rover_throttle_without_direct_actuators() -> None:
|
|
||||||
publishers = [_Publisher() for _ in range(3)]
|
|
||||||
|
|
||||||
Ros2Px4AckermannControl._publish_setpoint(
|
|
||||||
_Messages,
|
|
||||||
*publishers,
|
|
||||||
123,
|
|
||||||
1.0,
|
|
||||||
-0.55,
|
|
||||||
)
|
|
||||||
|
|
||||||
offboard = publishers[0].messages[0]
|
|
||||||
throttle = publishers[1].messages[0]
|
|
||||||
steering = publishers[2].messages[0]
|
|
||||||
assert offboard.velocity is False
|
|
||||||
assert offboard.thrust_and_torque is True
|
|
||||||
assert offboard.direct_actuator is False
|
|
||||||
assert throttle.throttle_body_x == pytest.approx(1.0 / 3.1)
|
|
||||||
assert isnan(throttle.throttle_body_y)
|
|
||||||
assert steering.normalized_steering_setpoint == -0.55
|
|
||||||
|
|
@ -1,217 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import socket
|
|
||||||
import threading
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from k1link.simulation.stock_rover import STOCK_ROVER_PROVIDER_PROFILE
|
|
||||||
from k1link.simulation.worker_gateway import (
|
|
||||||
REQUEST_SCHEMA,
|
|
||||||
RESPONSE_SCHEMA,
|
|
||||||
SimulationWorkerGatewayError,
|
|
||||||
SimulationWorkerUnavailableError,
|
|
||||||
UnixSocketWorkerGateway,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _status() -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"schema_version": "missioncore.simulation-worker-status/v2",
|
|
||||||
"worker_id": "mission-gpu-s1",
|
|
||||||
"transport": "unix",
|
|
||||||
"mode": "simulation",
|
|
||||||
"available": True,
|
|
||||||
"control_available": True,
|
|
||||||
"active_run_id": None,
|
|
||||||
"run_state": None,
|
|
||||||
"active_provider_ids": [],
|
|
||||||
"provider_profile": STOCK_ROVER_PROVIDER_PROFILE.to_dict(),
|
|
||||||
"isolation": {
|
|
||||||
"network": "loopback-only-netns",
|
|
||||||
"process_identity": "missioncore",
|
|
||||||
"artifact_policy": "d-only",
|
|
||||||
},
|
|
||||||
"authority": {
|
|
||||||
"scope": "virtual-only",
|
|
||||||
"actuator_authority": False,
|
|
||||||
"direct_actuator_setpoints_allowed": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _command_acceptance() -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"schema_version": "missioncore.command-acceptance/v2",
|
|
||||||
"run_id": "s1c-command-test",
|
|
||||||
"command_id": "cmd-001",
|
|
||||||
"sequence": 1,
|
|
||||||
"issued_at_sim_ns": 1_000_000,
|
|
||||||
"valid_until_sim_ns": 251_000_000,
|
|
||||||
"speed_mps": 1.0,
|
|
||||||
"steering_normalized": -0.25,
|
|
||||||
"authority_scope": "virtual-only",
|
|
||||||
"delivery": {
|
|
||||||
"provider_id": "unreal-direct-control",
|
|
||||||
"control_profile": "rover-speed-steering/v1",
|
|
||||||
"accepted": True,
|
|
||||||
"controller_ready": True,
|
|
||||||
"ttl_expired_count": 0,
|
|
||||||
"diagnostics": {"armed": True, "offboard": True},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _vehicle_state() -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"schema_version": "missioncore.vehicle-state/v1",
|
|
||||||
"run_id": "s1c-command-test",
|
|
||||||
"sequence": 1,
|
|
||||||
"observed_at_utc": "2026-07-25T10:00:00Z",
|
|
||||||
"host_monotonic_ns": 1_000_000,
|
|
||||||
"sim_time_ns": 2_000_000,
|
|
||||||
"frame_id": "map_enu",
|
|
||||||
"child_frame_id": "base_link_flu",
|
|
||||||
"pose": {
|
|
||||||
"position_m": {"x": 1.0, "y": 2.0, "z": 0.0},
|
|
||||||
"orientation_xyzw": {"x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0},
|
|
||||||
},
|
|
||||||
"source": {
|
|
||||||
"provider": "unreal-native",
|
|
||||||
"topic": "missioncore/vehicle-state",
|
|
||||||
"signal": "ground-truth",
|
|
||||||
"quality": "diagnostic",
|
|
||||||
},
|
|
||||||
"safety": {
|
|
||||||
"scope": "virtual-only",
|
|
||||||
"actuator_authority": False,
|
|
||||||
"navigation_or_safety_accepted": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _serve_once(
|
|
||||||
socket_path: Path,
|
|
||||||
result: dict[str, Any],
|
|
||||||
*,
|
|
||||||
mutate_response: bool = False,
|
|
||||||
) -> tuple[threading.Thread, list[dict[str, Any]]]:
|
|
||||||
requests: list[dict[str, Any]] = []
|
|
||||||
ready = threading.Event()
|
|
||||||
|
|
||||||
def serve() -> None:
|
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
|
|
||||||
server.bind(str(socket_path))
|
|
||||||
server.listen(1)
|
|
||||||
ready.set()
|
|
||||||
connection, _ = server.accept()
|
|
||||||
with connection:
|
|
||||||
request = json.loads(connection.makefile("rb").readline())
|
|
||||||
requests.append(request)
|
|
||||||
response = {
|
|
||||||
"schema_version": RESPONSE_SCHEMA,
|
|
||||||
"request_id": "wrong" if mutate_response else request["request_id"],
|
|
||||||
"ok": True,
|
|
||||||
"result": result,
|
|
||||||
"error": None,
|
|
||||||
}
|
|
||||||
connection.sendall(
|
|
||||||
json.dumps(response, separators=(",", ":")).encode("utf-8") + b"\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
thread = threading.Thread(target=serve, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
assert ready.wait(timeout=2)
|
|
||||||
return thread, requests
|
|
||||||
|
|
||||||
|
|
||||||
def test_unix_worker_gateway_uses_exact_bounded_private_protocol() -> None:
|
|
||||||
socket_path = Path(f"/tmp/mc-{uuid4().hex}.sock")
|
|
||||||
thread, requests = _serve_once(socket_path, _status())
|
|
||||||
gateway = UnixSocketWorkerGateway(socket_path)
|
|
||||||
|
|
||||||
try:
|
|
||||||
status = gateway.status()
|
|
||||||
thread.join(timeout=2)
|
|
||||||
|
|
||||||
assert status["worker_id"] == "mission-gpu-s1"
|
|
||||||
assert requests == [
|
|
||||||
{
|
|
||||||
"schema_version": REQUEST_SCHEMA,
|
|
||||||
"request_id": requests[0]["request_id"],
|
|
||||||
"operation": "status",
|
|
||||||
"payload": {},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
assert len(requests[0]["request_id"]) == 32
|
|
||||||
finally:
|
|
||||||
socket_path.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_unix_worker_gateway_rejects_response_identity_drift() -> None:
|
|
||||||
socket_path = Path(f"/tmp/mc-{uuid4().hex}.sock")
|
|
||||||
thread, _ = _serve_once(socket_path, _status(), mutate_response=True)
|
|
||||||
gateway = UnixSocketWorkerGateway(socket_path)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with pytest.raises(SimulationWorkerGatewayError, match="identity"):
|
|
||||||
gateway.status()
|
|
||||||
thread.join(timeout=2)
|
|
||||||
finally:
|
|
||||||
socket_path.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_unix_worker_gateway_reports_missing_worker_without_path_disclosure(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
gateway = UnixSocketWorkerGateway(tmp_path / "missing.sock")
|
|
||||||
|
|
||||||
with pytest.raises(SimulationWorkerUnavailableError) as failure:
|
|
||||||
gateway.status()
|
|
||||||
assert str(tmp_path) not in str(failure.value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_unix_worker_gateway_sends_bounded_virtual_rover_command() -> None:
|
|
||||||
socket_path = Path(f"/tmp/mc-{uuid4().hex}.sock")
|
|
||||||
thread, requests = _serve_once(socket_path, _command_acceptance())
|
|
||||||
gateway = UnixSocketWorkerGateway(socket_path)
|
|
||||||
|
|
||||||
try:
|
|
||||||
accepted = gateway.command(
|
|
||||||
run_id="s1c-command-test",
|
|
||||||
speed_mps=1.0,
|
|
||||||
steering_normalized=-0.25,
|
|
||||||
idempotency_key="command-001",
|
|
||||||
)
|
|
||||||
thread.join(timeout=2)
|
|
||||||
|
|
||||||
assert accepted["delivery"]["provider_id"] == "unreal-direct-control"
|
|
||||||
assert accepted["delivery"]["controller_ready"] is True
|
|
||||||
assert requests[0]["operation"] == "command"
|
|
||||||
assert requests[0]["payload"] == {
|
|
||||||
"run_id": "s1c-command-test",
|
|
||||||
"speed_mps": 1.0,
|
|
||||||
"steering_normalized": -0.25,
|
|
||||||
"idempotency_key": "command-001",
|
|
||||||
}
|
|
||||||
finally:
|
|
||||||
socket_path.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_unix_worker_gateway_accepts_provider_neutral_diagnostic_state() -> None:
|
|
||||||
socket_path = Path(f"/tmp/mc-{uuid4().hex}.sock")
|
|
||||||
thread, _ = _serve_once(socket_path, _vehicle_state())
|
|
||||||
gateway = UnixSocketWorkerGateway(socket_path)
|
|
||||||
|
|
||||||
try:
|
|
||||||
state = gateway.live()
|
|
||||||
thread.join(timeout=2)
|
|
||||||
|
|
||||||
assert state["source"]["provider"] == "unreal-native"
|
|
||||||
assert state["frame_id"] == "map_enu"
|
|
||||||
finally:
|
|
||||||
socket_path.unlink(missing_ok=True)
|
|
||||||
Loading…
Reference in New Issue