Compare commits
6 Commits
main
...
feat/simul
| Author | SHA1 | Date |
|---|---|---|
|
|
a19053dfd4 | |
|
|
2548bb5740 | |
|
|
fc5cbdff8a | |
|
|
c9b1009259 | |
|
|
6290fcb2ce | |
|
|
81761067e9 |
32
README.md
32
README.md
|
|
@ -121,6 +121,34 @@ 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
This accepts simulation infrastructure, not navigation behavior, safety
|
||||||
|
behavior, S1 command authority or real actuator control.
|
||||||
|
|
||||||
|
See the [Polygon product/SRS](docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md),
|
||||||
|
[ADR 0015](docs/adr/0015-simulation-polygon-qualification-boundary.md) and the
|
||||||
|
[SIM S0 worker runbook](docs/runbooks/SIM_S0_AI_WORKER.md).
|
||||||
|
|
||||||
## 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
|
||||||
|
|
@ -219,6 +247,10 @@ 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 planned as a seventh section, but UI delivery is gated on the
|
||||||
|
accepted clock/frame/authority contracts and a proven S1 backend lifecycle with
|
||||||
|
persisted run history. It is not present in the current shell.
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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-20
|
## Current checkpoint — 2026-07-24
|
||||||
|
|
||||||
| Stage | Result |
|
| Stage | Result |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|
@ -24,6 +24,7 @@ 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 — dedicated Docker-free D-only worker, exact accepted pins, stock Ackermann PX4/ROS 2/Gazebo telemetry, authoritative clock, pause/2 ms step, 1×/2× RTF/resource baselines, repeated clean lifecycle and all nine digest-bound evidence claims passed target doctor. S1 orchestrator/control, navigation/safety acceptance 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
|
||||||
|
|
@ -121,6 +122,37 @@ 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).
|
||||||
|
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 and must add server-owned lifecycle, canonical rover commands,
|
||||||
|
TTL/watchdog/failsafe evidence and immutable qualification runs.
|
||||||
|
|
||||||
## Stage 0 — repository and host baseline
|
## Stage 0 — repository and host baseline
|
||||||
|
|
||||||
Deliverables:
|
Deliverables:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,563 @@
|
||||||
|
# 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
|
||||||
|
active SIM S0 implementation and qualification gate.
|
||||||
|
|
||||||
|
This document and ADR 0015 are the repository truth. A material change to run
|
||||||
|
kinds, authority, clocks, frames, source-of-record, provider boundaries 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`;
|
||||||
|
- `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;
|
||||||
|
- implement a top-level UI before the backend S1 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:
|
||||||
|
|
||||||
|
- Gazebo owns simulated world state, sensors, contacts and physics.
|
||||||
|
- PX4 SITL owns rover control loops, constraints, offboard lifecycle and
|
||||||
|
autopilot failsafe.
|
||||||
|
- ROS 2 transports typed telemetry and commands.
|
||||||
|
- 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
|
||||||
|
|
|
||||||
|
+---- Gazebo adapter ---- Gazebo world/physics/sensors
|
||||||
|
+---- PX4 adapter ------- PX4 SITL rover controller/failsafe
|
||||||
|
+---- ROS 2 adapter ----- Micro XRCE-DDS and canonical topics
|
||||||
|
+---- planner adapter --- Nav2 or Mission Core planner
|
||||||
|
+---- viewer adapter ---- canonical state/derived report
|
||||||
|
```
|
||||||
|
|
||||||
|
Gazebo and PX4 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.
|
||||||
|
|
||||||
|
## 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`:
|
||||||
|
|
||||||
|
- Gazebo `/clock` is authoritative.
|
||||||
|
- 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 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 |
|
||||||
|
| 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. Nav2 adapter and truth baseline.
|
||||||
|
7. Evaluator, report and BARN pilot.
|
||||||
|
8. Virtual K1 and perception.
|
||||||
|
9. Replay/shadow datasets.
|
||||||
|
10. RAVNOVES00 twin.
|
||||||
|
11. 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.
|
||||||
|
|
||||||
|
### 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 planned as the seventh top-level bounded context:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Center | Fleet | Observation | Polygon | Missions | Data | System
|
||||||
|
```
|
||||||
|
|
||||||
|
Top-level UI implementation begins only after:
|
||||||
|
|
||||||
|
1. this SRS and the clock/frame/authority contracts are accepted;
|
||||||
|
2. S1 backend lifecycle is proven;
|
||||||
|
3. run history is persisted independently of a browser session.
|
||||||
|
|
||||||
|
The future UI may present Scenarios, Run, Control, History, Compare and Report.
|
||||||
|
It cannot own provider processes, bypass authority checks or write directly to
|
||||||
|
PX4.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
# ADR 0015: Simulation Polygon as a qualification boundary
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted for architecture; SIM S0 qualification returned `GO` on 2026-07-24.
|
||||||
|
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. Gazebo `/clock` is authoritative in closed-loop simulation. ROS 2 uses
|
||||||
|
simulated time and PX4 uXRCE-DDS time synchronization is disabled for this
|
||||||
|
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. A top-level Polygon UI is gated on accepted contracts and a proven S1
|
||||||
|
backend lifecycle with persisted run history.
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -30,6 +30,7 @@ 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 = [
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,493 @@
|
||||||
|
#!/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())
|
||||||
|
|
@ -0,0 +1,487 @@
|
||||||
|
#!/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())
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,296 @@
|
||||||
|
#!/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}"
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""Mission Core qualification and simulation boundaries."""
|
||||||
|
|
||||||
|
from k1link.simulation.s0 import (
|
||||||
|
CheckStatus,
|
||||||
|
DoctorVerdict,
|
||||||
|
RuntimeAcceptance,
|
||||||
|
S0DoctorReport,
|
||||||
|
S0Profile,
|
||||||
|
S0ProfileError,
|
||||||
|
load_s0_profile,
|
||||||
|
run_s0_doctor,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CheckStatus",
|
||||||
|
"DoctorVerdict",
|
||||||
|
"RuntimeAcceptance",
|
||||||
|
"S0DoctorReport",
|
||||||
|
"S0Profile",
|
||||||
|
"S0ProfileError",
|
||||||
|
"load_s0_profile",
|
||||||
|
"run_s0_doctor",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
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()
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,268 @@
|
||||||
|
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
|
||||||
Loading…
Reference in New Issue