Compare commits
No commits in common. "feat/simulation-polygon-s0" and "main" have entirely different histories.
feat/simul
...
main
32
README.md
32
README.md
|
|
@ -121,34 +121,6 @@ performance, direct worker transport and bounded live fan-out remain open. See
|
|||
[ADR 0014](docs/adr/0014-bounded-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
|
||||
|
||||
The browser application is the universal Mission Core Control Station rather than
|
||||
|
|
@ -247,10 +219,6 @@ standalone release install. The frontend consumes sibling `file:` packages from
|
|||
revision or content hash. Publishing/vendoring those packages or enforcing an
|
||||
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
|
||||
inside the Mission Core shell. It can open a compatible RRD over same-origin
|
||||
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.
|
||||
Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
|
||||
|
||||
## Current checkpoint — 2026-07-24
|
||||
## Current checkpoint — 2026-07-20
|
||||
|
||||
| Stage | Result |
|
||||
| --- | --- |
|
||||
|
|
@ -24,7 +24,6 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
|
|||
| Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process |
|
||||
| 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 |
|
||||
| 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
|
||||
now-verified network path. Owner-operated LixelGO traffic verifies the MQTT
|
||||
|
|
@ -122,37 +121,6 @@ control is plugin-owned. A plugin-commanded acquisition sends one canonical STOP
|
|||
and seals locally after protocol-reported standby; an operator-manual acquisition
|
||||
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
|
||||
|
||||
Deliverables:
|
||||
|
|
|
|||
|
|
@ -1,563 +0,0 @@
|
|||
# Mission Core Polygon: product definition and system requirements
|
||||
|
||||
Status: canonical working SRS, 2026-07-24.
|
||||
|
||||
Ops source of truth:
|
||||
|
||||
- [MISSIONCOR-38](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-38) is the
|
||||
product epic and original simulation-branch scope.
|
||||
- [MISSIONCOR-39](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-39) is the
|
||||
canonical, changeable architecture plan.
|
||||
- [MISSIONCOR-40](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-40) is the
|
||||
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.
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
# Lab 006: SIM S0 worker bootstrap and stock-rover smoke
|
||||
|
||||
Date: 2026-07-24 (Europe/Moscow).
|
||||
|
||||
## Scope
|
||||
|
||||
The reviewed Windows 11 AI worker received a dedicated Docker-free
|
||||
`MissionCore-Sim` WSL2 Ubuntu 24.04 distribution physically stored at
|
||||
`D:\NDC_MISSIONCORE\simulation\wsl\MissionCore-Sim`. Source, build, cache,
|
||||
runtime, artifacts and datasets are all under the same D-only root. Existing
|
||||
Docker Desktop co-tenants were inventoried but not stopped, reconfigured or
|
||||
used.
|
||||
|
||||
This lab grants no real or simulated actuator authority. Every smoke run kept
|
||||
`actuator_authority=false`; it observed the stock rover while disarmed.
|
||||
|
||||
## Redacted worker inventory
|
||||
|
||||
- Windows 11 Pro, build 26200;
|
||||
- WSL2 kernel `6.18.33.1-microsoft-standard-WSL2`;
|
||||
- Ubuntu 24.04.4 LTS, x86_64;
|
||||
- 13th Gen Intel Core i9-13900KF, 32 WSL-visible logical CPUs;
|
||||
- 50,427,760,640 WSL-visible RAM bytes and 12,884,901,888 swap bytes;
|
||||
- NVIDIA GeForce RTX 4090, driver 610.47, 24,564 MiB VRAM;
|
||||
- D capacity 2,000,381,014,016 bytes;
|
||||
- D free after bootstrap and builds: 392,758,685,696 bytes.
|
||||
|
||||
The remaining D capacity is above the 300 GiB S0 start threshold and 200 GiB
|
||||
stop threshold. This does not waive continuous disk monitoring.
|
||||
|
||||
## Installed candidate generation
|
||||
|
||||
- ROS 2 Jazzy `ros-base=0.11.0-1noble.20260616.084325`;
|
||||
- `ros-gz=1.0.22-1noble.20260616.074726`;
|
||||
- Gazebo Harmonic `1.0.0-1~noble`, Gazebo Sim `8.14.0-1~noble`,
|
||||
SDFormat `14.9.0-1~noble`;
|
||||
- PX4 Autopilot v1.17.0 commit
|
||||
`d6f12ad1c4f70ad3230afd7d86e971421e02fef4`;
|
||||
- `px4_msgs` v1.17.0 commit
|
||||
`86d8239e962f6939e05c3737784f60c02fa884db`;
|
||||
- PX4 Gazebo models commit
|
||||
`b6127f4ec20de867e215fb5f78ae88b80f371909`;
|
||||
- Micro XRCE-DDS Agent v2.4.3 commit
|
||||
`73622810d984349b80bbac0ef55fc0b694d62222`;
|
||||
- Nav2 `navigation2=1.3.12-1noble.20260615.181551` and
|
||||
`nav2-bringup=1.3.12-1noble.20260616.082701`.
|
||||
|
||||
PX4 SITL and `px4_msgs` were built successfully from the pinned D-only source.
|
||||
The Micro XRCE-DDS Agent was built from source and installed under the D-only
|
||||
runtime root. Nav2 is installed but has not yet passed an S2 navigation case.
|
||||
|
||||
## Observed smoke result
|
||||
|
||||
Two consecutive canonical runs and one preliminary resource run passed:
|
||||
|
||||
- `20260724T134400Z-stock-rover`;
|
||||
- `20260724T134600Z-stock-rover-repeat`;
|
||||
- `20260724T135000Z-stock-rover-baseline`.
|
||||
|
||||
The final smoke-script generation also passed as
|
||||
`20260724T135800Z-stock-rover-final` and added a runtime listener inventory plus
|
||||
the point resource snapshot.
|
||||
|
||||
Each run started PX4 v1.17.0 with Gazebo world `rover` and model
|
||||
`rover_ackermann_0`, established a Micro XRCE-DDS session, observed
|
||||
`/fmu/out/vehicle_status_v1`, observed Gazebo `/clock`, requested PX4 shutdown
|
||||
and found no owned runtime residue. The captured vehicle state was disarmed,
|
||||
`failsafe=false`, with pre-flight checks passing.
|
||||
|
||||
Each provider set ran inside a new Linux network namespace containing only
|
||||
`127.0.0.1` and `::1`. The Agent's UDP `0.0.0.0:8888` bind was therefore
|
||||
effective only inside that loopback boundary. No PX4, Gazebo or Agent service
|
||||
was exposed to the LAN.
|
||||
|
||||
The two canonical artifact directories are 68 KiB each. The preliminary
|
||||
resource run completed in 38.75 seconds. One point sample observed approximately
|
||||
202 MiB RSS for headless Gazebo, 19 MiB for PX4 and 17 MiB for the Agent. The
|
||||
worker GPU was already occupied by unrelated co-tenants; no S0 GPU allocation
|
||||
was attributable in this headless run. These point observations are not the
|
||||
accepted 1× or 2× resource baselines.
|
||||
|
||||
## Diagnostics retained
|
||||
|
||||
Earlier bootstrap attempts remain outside Git under the D-only artifact root:
|
||||
|
||||
- one check raced the asynchronous DDS connection even though the session
|
||||
subsequently established;
|
||||
- one used an unsupported ROS 2 Jazzy long option;
|
||||
- one used ROS domain 42 while PX4 v1.17.0 published on domain 0.
|
||||
|
||||
The smoke contract now waits for the Agent session plus the PX4
|
||||
`vehicle_status_v1` writer, uses Jazzy `topic list -t`, and relies on the
|
||||
loopback-only namespace to isolate domain 0.
|
||||
|
||||
An initial malformed interactive log repeatedly captured PX4 terminal repaint
|
||||
output and reached approximately 1.1 GiB. That generated diagnostic artifact
|
||||
was removed, then the case was rerun non-interactively; the valid standalone
|
||||
PX4 log was approximately 8 KiB.
|
||||
|
||||
## Target doctor
|
||||
|
||||
The exact candidate profile was executed through the read-only doctor inside
|
||||
`MissionCore-Sim`. Profile SHA-256:
|
||||
`70bf0df44c85dd57eb3cedb98a474a46ba498c6ffc60ce0d8a06829e28587df1`.
|
||||
|
||||
Target identity, authority, clock, port/process registries, all seven D-only
|
||||
mutable roots, the disk threshold and all eight required executables passed.
|
||||
Version acceptance and the nine-item digest-bound evidence set remain
|
||||
incomplete, so the doctor correctly returned `INCOMPLETE`.
|
||||
|
||||
## Verdict
|
||||
|
||||
`INCOMPLETE`.
|
||||
|
||||
Worker compatibility, D-only placement, exact candidate pins, PX4 build,
|
||||
stock-rover launch, DDS telemetry, authoritative clock observation and repeated
|
||||
clean lifecycle are factually proven. S0 still requires:
|
||||
|
||||
- pause/resume and deterministic single-step evidence;
|
||||
- 1× speed/real-time-factor acceptance;
|
||||
- isolated 2× resource/coexistence baseline;
|
||||
- a complete digest-bound evidence manifest;
|
||||
- final `GO`, `INCOMPLETE` or `BLOCKED` decision.
|
||||
|
||||
This lab does not accept Nav2 behavior, navigation safety, real-time guarantees,
|
||||
or real actuator control.
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
# Lab 007: SIM S0 acceptance
|
||||
|
||||
Date: 2026-07-24 (Europe/Moscow).
|
||||
|
||||
## Scope
|
||||
|
||||
This lab closes the infrastructure qualification gate for Mission Core
|
||||
Polygon. It validates the exact D-only worker profile, stock PX4 Ackermann
|
||||
runtime, ROS 2 telemetry, Gazebo time control, 1×/2× resource behavior,
|
||||
deterministic shutdown and digest-bound evidence admission.
|
||||
|
||||
It does not accept S1 orchestration, rover command behavior, Nav2 navigation,
|
||||
safety behavior, HIL, physical shadow or real actuator control.
|
||||
`actuator_authority=false` throughout.
|
||||
|
||||
## Accepted profile
|
||||
|
||||
- profile ID: `ai-worker-px4-gazebo-v1`;
|
||||
- profile SHA-256:
|
||||
`aeecf5005301db230e1340b85215cbbd862b72974a22b8c43767c28be8453e2f`;
|
||||
- evidence tooling commit:
|
||||
`2548bb57403411ce9c8374151e3bfa34ac0866ee`;
|
||||
- target: dedicated Docker-free `MissionCore-Sim` WSL2 Ubuntu 24.04 on D;
|
||||
- all seven component pins: resolved and `accepted`;
|
||||
- all seven mutable roots: present under `/mnt/d/NDC_MISSIONCORE`;
|
||||
- disk at final doctor: 365.55 GiB free, above the 300 GiB start and
|
||||
200 GiB stop thresholds.
|
||||
|
||||
Exact ROS 2, Gazebo, PX4, `px4_msgs`, PX4 Gazebo model, Micro XRCE-DDS Agent and
|
||||
Nav2 versions remain recorded in the qualification profile and Lab 006.
|
||||
|
||||
## Accepted runs
|
||||
|
||||
The exact accepted-profile generation produced two immutable runs:
|
||||
|
||||
- `20260724T155000Z-accepted-1x`;
|
||||
- `20260724T160000Z-accepted-2x`.
|
||||
|
||||
Both runs passed:
|
||||
|
||||
- stock world `rover`, model `rover_ackermann_0`;
|
||||
- PX4 v1.17.0 and Micro XRCE-DDS session;
|
||||
- ROS 2 `/fmu/out/vehicle_status_v1`;
|
||||
- authoritative Gazebo `/clock`;
|
||||
- loopback-only network namespace;
|
||||
- pause, single-step and declared speed behavior;
|
||||
- structured resource baseline;
|
||||
- clean shutdown with no owned process residue.
|
||||
|
||||
The vehicle stayed disarmed. No actuator command was issued.
|
||||
|
||||
## Time acceptance
|
||||
|
||||
| Case | Pause advance | Single step | Measured RTF | Accepted range |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| 1× | 0 ns | +2,000,000 ns / +1 iteration | 0.981913 | 0.8–1.2 |
|
||||
| 2× | 0 ns | +2,000,000 ns / +1 iteration | 1.967258 | 1.6–2.4 |
|
||||
|
||||
Gazebo `/clock` is the closed-loop simulation authority. The zero pause
|
||||
advance and exact 2 ms single-step are observed deltas, not inferred settings.
|
||||
|
||||
## Resource acceptance
|
||||
|
||||
| Metric | 1× | 2× | Contract |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Samples at 500 ms | 15 | 15 | at least 6 |
|
||||
| Peak owned RSS | 407,629,824 B | 408,756,224 B | at most 2 GiB |
|
||||
| Peak owned CPU | 101.8% | 169.2% | at most 800% |
|
||||
| Minimum available WSL RAM | 28,437,897,216 B | 28,662,071,296 B | at least 8 GiB |
|
||||
| Minimum D free | 392,613,560,320 B | 392,568,233,984 B | at least 200 GiB |
|
||||
| Maximum swap used | 0 B | 0 B | observed |
|
||||
|
||||
Global GPU context peaked at 13,579 MiB used and 52% utilization in both cases.
|
||||
Those values include unrelated co-tenants and are intentionally not attributed
|
||||
to headless S0.
|
||||
|
||||
## Evidence admission
|
||||
|
||||
The private D-only evidence generation contains nine redacted JSON artifacts
|
||||
and one manifest, approximately 100 KiB total:
|
||||
|
||||
1. worker inventory;
|
||||
2. D-only physical placement;
|
||||
3. PX4 stock-rover launch;
|
||||
4. ROS 2 telemetry;
|
||||
5. authoritative simulation clock;
|
||||
6. pause/step/speed;
|
||||
7. clean stop with no orphans;
|
||||
8. 1× resource baseline;
|
||||
9. 2× resource baseline.
|
||||
|
||||
Every manifest entry is `pass`, names a confined regular file and binds its
|
||||
SHA-256. The manifest binds the accepted profile SHA-256. Failed diagnostic
|
||||
generations were not reused.
|
||||
|
||||
## Target doctor
|
||||
|
||||
The read-only doctor returned:
|
||||
|
||||
```text
|
||||
verdict=go
|
||||
target_host_match=true
|
||||
profile-contract=pass
|
||||
authority-contract=pass
|
||||
clock-contract=pass
|
||||
port-registry=pass
|
||||
process-registry=pass
|
||||
target-host=pass
|
||||
d-only-storage=pass
|
||||
disk-budget=pass
|
||||
version-lock=pass
|
||||
required-tools=pass
|
||||
factual-evidence=pass
|
||||
```
|
||||
|
||||
The persisted redacted `doctor.json` SHA-256 is
|
||||
`6a2571bd7af0e85b1345201068ccd4bffa215f84a8a9f62a4357d5dbfa236c6c`.
|
||||
|
||||
## Verdict
|
||||
|
||||
`GO` for SIM S0 infrastructure qualification.
|
||||
|
||||
The next permitted product phase is SIM S1: server-owned start/pause/resume/
|
||||
step/reset/stop, immutable `QualificationRun`, canonical Ackermann and
|
||||
Differential rover commands, TTL/heartbeat/watchdog, stop-on-loss and captured
|
||||
PX4 failsafe behavior. No real-control permission is created by this verdict.
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
# SIM S0 AI worker runbook
|
||||
|
||||
This runbook is for MISSIONCOR-40. It does not authorize system installation,
|
||||
real vehicle control or a `GO` verdict by itself.
|
||||
|
||||
## Safety and placement
|
||||
|
||||
- Target: reviewed Windows 11 worker with the dedicated `MissionCore-Sim` WSL2
|
||||
Ubuntu 24.04 distribution physically installed on D.
|
||||
- All mutable bytes must be under `D:\NDC_MISSIONCORE`.
|
||||
- Do not place a WSL distribution, source, build, cache, log, bag, ULog,
|
||||
Gazebo artifact or dataset on C.
|
||||
- Do not use or reconfigure the existing Docker Desktop runtime in S0. Its
|
||||
backend is C-backed and serves unrelated co-tenants.
|
||||
- Do not expose S0 services beyond loopback.
|
||||
- Run providers inside the ephemeral loopback-only network namespace created
|
||||
by `simulation/s0/worker-smoke.sh`. Micro XRCE-DDS Agent binds UDP wildcard
|
||||
inside that namespace because v2.4.3 has no address-selection option.
|
||||
- Do not download full external datasets.
|
||||
- Keep `actuator_authority=false`.
|
||||
|
||||
## Repository preflight
|
||||
|
||||
From the repository-local Python 3.12 environment:
|
||||
|
||||
```bash
|
||||
uv sync --frozen --group dev
|
||||
uv run missioncore-sim s0 doctor --json
|
||||
```
|
||||
|
||||
Before target evidence exists, the expected verdict is `INCOMPLETE`.
|
||||
|
||||
## Stock-rover smoke
|
||||
|
||||
Copy the reviewed script to the D-only worker source root, then invoke it as
|
||||
root inside `MissionCore-Sim` with a unique immutable run ID:
|
||||
|
||||
```bash
|
||||
simulation/s0/worker-smoke.sh <UTC-run-id>-stock-rover <1|2>
|
||||
```
|
||||
|
||||
The script:
|
||||
|
||||
- creates a new network namespace containing only loopback;
|
||||
- starts Micro XRCE-DDS Agent, PX4 SITL and the stock Gazebo Ackermann world;
|
||||
- verifies the DDS session, `/fmu/out/vehicle_status_v1` and Gazebo `/clock`;
|
||||
- proves pause freeze and exactly one 2 ms physics step;
|
||||
- measures the declared 1× or 2× real-time factor over four seconds;
|
||||
- samples owned CPU/RSS, available RAM, swap, D free space and global co-tenant
|
||||
GPU load at 500 ms intervals;
|
||||
- records Gazebo service/topic inventory and runtime listeners;
|
||||
- sends the PX4 `shutdown` command and rejects owned-process residue.
|
||||
|
||||
Run both factors as separate immutable cases. Do not reuse a run ID. A failed
|
||||
run remains diagnostic evidence; rerun with a new ID after correction.
|
||||
|
||||
## Required target inventory
|
||||
|
||||
Record a redacted artifact for each of:
|
||||
|
||||
1. Windows release, WSL version, Ubuntu version and architecture.
|
||||
2. GPU/driver, CPU, RAM and VRAM.
|
||||
3. Physical location of the dedicated WSL distribution and confirmation that
|
||||
the Docker-free S0 profile is active.
|
||||
4. D total/free bytes before installation.
|
||||
5. Existing Docker images, WSL distributions and simulation-related processes.
|
||||
6. Existing listeners and the candidate port registry.
|
||||
7. Existing Frigate/Ollama/Triton resource use and whether S0 is coexistence or
|
||||
exclusive-profile testing.
|
||||
|
||||
Do not record credentials, tokens or private network secrets.
|
||||
|
||||
## Candidate stack
|
||||
|
||||
Use `simulation/s0/qualification-profile.yaml` as the input. Resolve and record:
|
||||
|
||||
- exact ROS 2 Jazzy package versions;
|
||||
- exact Gazebo Harmonic package versions;
|
||||
- PX4 v1.17.0 commit;
|
||||
- matching `px4_msgs` commit;
|
||||
- Micro XRCE-DDS Agent commit/version;
|
||||
- Nav2 Jazzy commit/package versions;
|
||||
- PX4 Gazebo model commit;
|
||||
- licenses and image digests where containers are used.
|
||||
|
||||
Change every component to `accepted` only after target build/runtime evidence
|
||||
exists. Updating a pin changes the profile SHA-256 and invalidates evidence for
|
||||
the prior generation.
|
||||
|
||||
## Runtime cases
|
||||
|
||||
The S0 factual cases are:
|
||||
|
||||
1. Launch one stock rover headless.
|
||||
2. Observe PX4 health and ROS 2 telemetry.
|
||||
3. Prove Gazebo `/clock` is authoritative.
|
||||
4. Prove pause freezes simulation time.
|
||||
5. Prove one single-step advances the declared amount.
|
||||
6. Prove 1× and 2× speed behavior and measure real-time factor.
|
||||
7. Stop through the intended lifecycle.
|
||||
8. Prove no owned process remains.
|
||||
9. Repeat from a clean start.
|
||||
|
||||
QGroundControl is diagnostic only and cannot become the Mission Core command
|
||||
owner.
|
||||
|
||||
## Evidence manifest
|
||||
|
||||
Create a private/redacted evidence directory outside normal Git. Its manifest:
|
||||
|
||||
```yaml
|
||||
schema_version: missioncore.simulation-s0-evidence/v1
|
||||
profile_sha256: <sha256 of qualification-profile.yaml>
|
||||
checks:
|
||||
- id: worker-inventory
|
||||
status: pass
|
||||
artifact: worker-inventory.redacted.json
|
||||
sha256: <artifact sha256>
|
||||
note: Redacted host and resource inventory.
|
||||
```
|
||||
|
||||
The manifest must contain exactly all IDs from `required_evidence`. Artifact
|
||||
paths are relative, confined regular files. The doctor rejects missing,
|
||||
additional, changed or cross-generation evidence.
|
||||
|
||||
Build the redacted manifest only from two passing runs made with the exact
|
||||
accepted profile generation:
|
||||
|
||||
```bash
|
||||
python3 simulation/s0/build_evidence_manifest.py \
|
||||
--profile /mnt/d/NDC_MISSIONCORE/simulation/source/qualification-profile.yaml \
|
||||
--run-1x /mnt/d/NDC_MISSIONCORE/simulation/artifacts/s0/<accepted-1x-run> \
|
||||
--run-2x /mnt/d/NDC_MISSIONCORE/simulation/artifacts/s0/<accepted-2x-run> \
|
||||
--output-dir /mnt/d/NDC_MISSIONCORE/simulation/artifacts/s0/evidence/<generation> \
|
||||
--d-root /mnt/d/NDC_MISSIONCORE \
|
||||
--source-root /mnt/d/NDC_MISSIONCORE/simulation/source \
|
||||
--repository-commit <full-commit-sha> \
|
||||
--wsl-windows-path 'D:\NDC_MISSIONCORE\simulation\wsl\MissionCore-Sim'
|
||||
```
|
||||
|
||||
The generator refuses an existing output directory, non-passing runs,
|
||||
candidate pins, commit drift, incomplete Nav2/ROS discovery or any input/output
|
||||
outside the reviewed D root.
|
||||
|
||||
Validate without writing:
|
||||
|
||||
```bash
|
||||
uv run missioncore-sim s0 doctor \
|
||||
--profile simulation/s0/qualification-profile.yaml \
|
||||
--evidence /path/to/redacted/evidence.yaml \
|
||||
--json
|
||||
```
|
||||
|
||||
## Verdict rules
|
||||
|
||||
- `GO`: target host matches, every pin is accepted, D-only and disk thresholds
|
||||
pass, required tools exist and all digest-bound evidence validates.
|
||||
- `INCOMPLETE`: work remains or the doctor is running on a development host.
|
||||
- `BLOCKED`: contract, storage, tools or evidence fail validation.
|
||||
|
||||
Publish the factual result to MISSIONCOR-40 with exact repository commit,
|
||||
profile SHA-256, files touched and validation performed.
|
||||
|
|
@ -30,7 +30,6 @@ missioncore-plugin-sdk = { path = "packages/plugin-sdk", editable = true }
|
|||
|
||||
[project.scripts]
|
||||
k1link = "k1link.device_plugins.xgrids_k1.cli:app"
|
||||
missioncore-sim = "k1link.simulation.cli:app"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
|
|
|
|||
|
|
@ -1,493 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Build a confined, digest-bound SIM S0 evidence pack from accepted worker runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
EVIDENCE_SCHEMA = "missioncore.simulation-s0-evidence/v1"
|
||||
|
||||
|
||||
class EvidenceError(RuntimeError):
|
||||
"""Evidence inputs are incomplete, inconsistent, or outside the reviewed boundary."""
|
||||
|
||||
|
||||
def _run(command: list[str]) -> str:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise EvidenceError(
|
||||
f"command failed ({result.returncode}): {' '.join(command)}; "
|
||||
f"stderr={result.stderr.strip()!r}"
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict[str, Any]:
|
||||
loaded = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(loaded, dict):
|
||||
raise EvidenceError(f"expected a JSON object: {path}")
|
||||
return loaded
|
||||
|
||||
|
||||
def _load_yaml(path: Path) -> dict[str, Any]:
|
||||
documents = [
|
||||
document
|
||||
for document in yaml.safe_load_all(path.read_text(encoding="utf-8"))
|
||||
if document is not None
|
||||
]
|
||||
if len(documents) != 1 or not isinstance(documents[0], dict):
|
||||
raise EvidenceError(f"expected a YAML mapping: {path}")
|
||||
return documents[0]
|
||||
|
||||
|
||||
def _result_env(path: Path) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
key, separator, value = line.partition("=")
|
||||
if not separator or not key:
|
||||
raise EvidenceError(f"invalid result line in {path}: {line!r}")
|
||||
result[key] = value
|
||||
required_passes = {
|
||||
"px4_stock_rover",
|
||||
"uxrce_dds",
|
||||
"ros2_vehicle_status",
|
||||
"gazebo_clock",
|
||||
"pause_step_speed",
|
||||
"resource_baseline",
|
||||
"clean_lifecycle",
|
||||
}
|
||||
failed = sorted(key for key in required_passes if result.get(key) != "pass")
|
||||
if failed:
|
||||
raise EvidenceError(f"run did not pass required checks: {', '.join(failed)}")
|
||||
if result.get("actuator_authority") != "false":
|
||||
raise EvidenceError("run widened actuator authority")
|
||||
return result
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _package_version(package: str) -> str:
|
||||
return _run(["dpkg-query", "-W", "-f=${Version}", package])
|
||||
|
||||
|
||||
def _git_commit(path: Path) -> str:
|
||||
commit = _run(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
f"safe.directory={path}",
|
||||
"-C",
|
||||
str(path),
|
||||
"rev-parse",
|
||||
"HEAD",
|
||||
]
|
||||
)
|
||||
if re.fullmatch(r"[a-f0-9]{40}", commit) is None:
|
||||
raise EvidenceError(f"invalid Git commit at {path}: {commit!r}")
|
||||
return commit
|
||||
|
||||
|
||||
def _memory_inventory() -> dict[str, int]:
|
||||
values: dict[str, int] = {}
|
||||
for line in Path("/proc/meminfo").read_text(encoding="utf-8").splitlines():
|
||||
key, raw = line.split(":", maxsplit=1)
|
||||
values[key] = int(raw.strip().split()[0]) * 1024
|
||||
return {
|
||||
"total_bytes": values["MemTotal"],
|
||||
"available_bytes": values["MemAvailable"],
|
||||
"swap_total_bytes": values["SwapTotal"],
|
||||
"swap_free_bytes": values["SwapFree"],
|
||||
}
|
||||
|
||||
|
||||
def _gpu_inventory() -> dict[str, str | int] | None:
|
||||
if shutil.which("nvidia-smi") is None:
|
||||
return None
|
||||
output = _run(
|
||||
[
|
||||
"nvidia-smi",
|
||||
"--query-gpu=name,driver_version,memory.total",
|
||||
"--format=csv,noheader,nounits",
|
||||
]
|
||||
)
|
||||
name, driver, memory_mib = (part.strip() for part in output.splitlines()[0].split(","))
|
||||
return {
|
||||
"name": name,
|
||||
"driver": driver,
|
||||
"memory_total_mib": int(memory_mib),
|
||||
}
|
||||
|
||||
|
||||
def _cpu_model() -> str:
|
||||
for line in Path("/proc/cpuinfo").read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("model name"):
|
||||
return line.split(":", maxsplit=1)[1].strip()
|
||||
raise EvidenceError("CPU model was not available")
|
||||
|
||||
|
||||
def _component_inventory(profile: dict[str, Any], source_root: Path) -> dict[str, Any]:
|
||||
components = profile.get("components")
|
||||
if not isinstance(components, list) or not components:
|
||||
raise EvidenceError("profile components are missing")
|
||||
if any(
|
||||
not isinstance(component, dict) or component.get("qualification_state") != "accepted"
|
||||
for component in components
|
||||
):
|
||||
raise EvidenceError("all profile components must be accepted before evidence admission")
|
||||
|
||||
packages = {
|
||||
"ros-jazzy-ros-base": _package_version("ros-jazzy-ros-base"),
|
||||
"ros-jazzy-ros-gz": _package_version("ros-jazzy-ros-gz"),
|
||||
"gz-harmonic": _package_version("gz-harmonic"),
|
||||
"gz-sim8-cli": _package_version("gz-sim8-cli"),
|
||||
"libsdformat14": _package_version("libsdformat14"),
|
||||
"ros-jazzy-navigation2": _package_version("ros-jazzy-navigation2"),
|
||||
"ros-jazzy-nav2-bringup": _package_version("ros-jazzy-nav2-bringup"),
|
||||
}
|
||||
commits = {
|
||||
"px4-autopilot": _git_commit(source_root / "PX4-Autopilot"),
|
||||
"px4-msgs": _git_commit(source_root / "px4_msgs"),
|
||||
"px4-gazebo-models": _git_commit(
|
||||
source_root / "PX4-Autopilot" / "Tools" / "simulation" / "gz"
|
||||
),
|
||||
"micro-xrce-dds-agent": _git_commit(source_root / "Micro-XRCE-DDS-Agent"),
|
||||
}
|
||||
declared = {
|
||||
str(component["id"]): {
|
||||
"resolved_version": component.get("resolved_version"),
|
||||
"resolved_commit": component.get("resolved_commit"),
|
||||
"license": component.get("license"),
|
||||
"qualification_state": component.get("qualification_state"),
|
||||
}
|
||||
for component in components
|
||||
}
|
||||
for identifier, commit in commits.items():
|
||||
if declared[identifier]["resolved_commit"] != commit:
|
||||
raise EvidenceError(f"worker commit drift for {identifier}")
|
||||
|
||||
nav2_prefix = _run(["ros2", "pkg", "prefix", "nav2_bringup"])
|
||||
px4_msgs_prefix = _run(["ros2", "pkg", "prefix", "px4_msgs"])
|
||||
nav2_executables = _run(["ros2", "pkg", "executables", "nav2_controller"]).splitlines()
|
||||
if not nav2_executables:
|
||||
raise EvidenceError("Nav2 controller executable is unavailable")
|
||||
return {
|
||||
"declared": declared,
|
||||
"installed_packages": packages,
|
||||
"observed_commits": commits,
|
||||
"runtime_discovery": {
|
||||
"nav2_bringup_prefix": nav2_prefix,
|
||||
"nav2_controller_executables": nav2_executables,
|
||||
"px4_msgs_prefix": px4_msgs_prefix,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _world_model_from_log(path: Path) -> dict[str, str]:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
world_model = re.search(r"world:\s*([^,\s]+),\s*model:\s*([^\s]+)", text)
|
||||
commit = re.search(r"PX4 git-hash:\s*([a-f0-9]{40})", text)
|
||||
version = re.search(r"PX4 version:\s*(.+)", text)
|
||||
if world_model is None or commit is None or version is None:
|
||||
raise EvidenceError(f"PX4 identity is incomplete in {path}")
|
||||
return {
|
||||
"world": world_model.group(1),
|
||||
"model": world_model.group(2),
|
||||
"px4_commit": commit.group(1),
|
||||
"px4_version": version.group(1).strip(),
|
||||
}
|
||||
|
||||
|
||||
def _ensure_confined(path: Path, root: Path, *, label: str) -> Path:
|
||||
resolved = path.expanduser().resolve(strict=True)
|
||||
if not resolved.is_relative_to(root):
|
||||
raise EvidenceError(f"{label} escaped reviewed root {root}: {resolved}")
|
||||
return resolved
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--profile", type=Path, required=True)
|
||||
parser.add_argument("--run-1x", type=Path, required=True)
|
||||
parser.add_argument("--run-2x", type=Path, required=True)
|
||||
parser.add_argument("--output-dir", type=Path, required=True)
|
||||
parser.add_argument("--d-root", type=Path, required=True)
|
||||
parser.add_argument("--source-root", type=Path, required=True)
|
||||
parser.add_argument("--repository-commit", required=True)
|
||||
parser.add_argument("--wsl-windows-path", required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _arguments()
|
||||
d_root = args.d_root.expanduser().resolve(strict=True)
|
||||
profile_path = _ensure_confined(args.profile, d_root, label="profile")
|
||||
run_1x = _ensure_confined(args.run_1x, d_root, label="1x run")
|
||||
run_2x = _ensure_confined(args.run_2x, d_root, label="2x run")
|
||||
source_root = _ensure_confined(args.source_root, d_root, label="source root")
|
||||
output_dir = args.output_dir.expanduser().resolve(strict=False)
|
||||
if not output_dir.is_relative_to(d_root):
|
||||
raise EvidenceError("evidence output escaped the reviewed D-only root")
|
||||
if output_dir.exists():
|
||||
raise EvidenceError(f"evidence output already exists: {output_dir}")
|
||||
if re.fullmatch(r"[a-f0-9]{40}", args.repository_commit) is None:
|
||||
raise EvidenceError("repository commit must be a full Git SHA")
|
||||
|
||||
profile = _load_yaml(profile_path)
|
||||
profile_sha256 = _sha256(profile_path)
|
||||
result_1x = _result_env(run_1x / "result.env")
|
||||
result_2x = _result_env(run_2x / "result.env")
|
||||
if result_1x.get("speed_factor") != "1" or result_2x.get("speed_factor") != "2":
|
||||
raise EvidenceError("run speed factors are not the declared 1x/2x pair")
|
||||
time_1x = _load_json(run_1x / "time-control.json")
|
||||
time_2x = _load_json(run_2x / "time-control.json")
|
||||
resource_1x = _load_json(run_1x / "resource-baseline.json")
|
||||
resource_2x = _load_json(run_2x / "resource-baseline.json")
|
||||
if any(
|
||||
report.get("verdict") != "pass" for report in (time_1x, time_2x, resource_1x, resource_2x)
|
||||
):
|
||||
raise EvidenceError("time or resource report is not a pass")
|
||||
|
||||
output_dir.mkdir(parents=True)
|
||||
disk = shutil.disk_usage(d_root)
|
||||
target_host = profile["target_host"]
|
||||
worker_inventory = {
|
||||
"redacted": True,
|
||||
"repository_commit": args.repository_commit,
|
||||
"profile_sha256": profile_sha256,
|
||||
"target": {
|
||||
"system": platform.system(),
|
||||
"kernel_release": platform.release(),
|
||||
"architecture": platform.machine(),
|
||||
"wsl_distribution": os.environ.get("WSL_DISTRO_NAME"),
|
||||
"linux_pretty_name": _run(
|
||||
["bash", "-lc", ". /etc/os-release && printf '%s' \"$PRETTY_NAME\""]
|
||||
),
|
||||
},
|
||||
"cpu": {
|
||||
"model": _cpu_model(),
|
||||
"logical_processors": os.cpu_count(),
|
||||
},
|
||||
"memory": _memory_inventory(),
|
||||
"gpu": _gpu_inventory(),
|
||||
"disk": {
|
||||
"total_bytes": disk.total,
|
||||
"free_bytes": disk.free,
|
||||
},
|
||||
"components": _component_inventory(profile, source_root),
|
||||
}
|
||||
if worker_inventory["target"]["wsl_distribution"] != target_host["wsl_distribution"]:
|
||||
raise EvidenceError("worker distribution does not match the profile")
|
||||
|
||||
mutable_paths = profile["storage"]["mutable_paths"]
|
||||
placement: list[dict[str, Any]] = []
|
||||
for item in mutable_paths:
|
||||
path = Path(str(item["wsl_path"])).resolve(strict=True)
|
||||
if not path.is_relative_to(d_root):
|
||||
raise EvidenceError(f"mutable path escaped D-only root: {path}")
|
||||
placement.append(
|
||||
{
|
||||
"id": item["id"],
|
||||
"windows_path": item["windows_path"],
|
||||
"wsl_path": str(path),
|
||||
"exists": path.is_dir(),
|
||||
}
|
||||
)
|
||||
d_only = {
|
||||
"redacted": True,
|
||||
"profile_sha256": profile_sha256,
|
||||
"wsl_distribution_windows_path": args.wsl_windows_path,
|
||||
"wsl_mount_root": str(d_root),
|
||||
"container_runtime": target_host["container_runtime"],
|
||||
"existing_docker_desktop_used_or_modified": False,
|
||||
"mutable_paths": placement,
|
||||
"disk": {
|
||||
"total_bytes": disk.total,
|
||||
"free_bytes": disk.free,
|
||||
"minimum_free_gib": profile["storage"]["minimum_free_gib"],
|
||||
"stop_below_gib": profile["storage"]["stop_below_gib"],
|
||||
},
|
||||
}
|
||||
|
||||
px4_launch = {
|
||||
"repository_commit": args.repository_commit,
|
||||
"runs": [
|
||||
{
|
||||
"run_id": result_1x["run_id"],
|
||||
"speed_factor": 1,
|
||||
"identity": _world_model_from_log(run_1x / "px4-rover.log"),
|
||||
"result": result_1x,
|
||||
},
|
||||
{
|
||||
"run_id": result_2x["run_id"],
|
||||
"speed_factor": 2,
|
||||
"identity": _world_model_from_log(run_2x / "px4-rover.log"),
|
||||
"result": result_2x,
|
||||
},
|
||||
],
|
||||
}
|
||||
telemetry = {
|
||||
"runs": [
|
||||
{
|
||||
"run_id": result["run_id"],
|
||||
"vehicle_status": _load_yaml(run / "vehicle-status.yaml"),
|
||||
"vehicle_status_topic_present": (
|
||||
"/fmu/out/vehicle_status_v1"
|
||||
in (run / "ros-topics.txt").read_text(encoding="utf-8")
|
||||
),
|
||||
}
|
||||
for result, run in ((result_1x, run_1x), (result_2x, run_2x))
|
||||
]
|
||||
}
|
||||
authoritative_clock = {
|
||||
"authority": profile["clock"],
|
||||
"runs": [
|
||||
{
|
||||
"run_id": result["run_id"],
|
||||
"clock_sample": _load_yaml(run / "clock.yaml"),
|
||||
"pause_advance_ns": report["pause"]["observed_advance_ns"],
|
||||
}
|
||||
for result, run, report in (
|
||||
(result_1x, run_1x, time_1x),
|
||||
(result_2x, run_2x, time_2x),
|
||||
)
|
||||
],
|
||||
}
|
||||
pause_step_speed = {
|
||||
"contract": profile["runtime_acceptance"],
|
||||
"runs": [
|
||||
{
|
||||
"run_id": result_1x["run_id"],
|
||||
"speed_factor": 1,
|
||||
"pause": time_1x["pause"],
|
||||
"single_step": time_1x["single_step"],
|
||||
"speed": time_1x["speed"],
|
||||
"verdict": time_1x["verdict"],
|
||||
},
|
||||
{
|
||||
"run_id": result_2x["run_id"],
|
||||
"speed_factor": 2,
|
||||
"pause": time_2x["pause"],
|
||||
"single_step": time_2x["single_step"],
|
||||
"speed": time_2x["speed"],
|
||||
"verdict": time_2x["verdict"],
|
||||
},
|
||||
],
|
||||
}
|
||||
clean_stop = {
|
||||
"runs": [
|
||||
{
|
||||
"run_id": result_1x["run_id"],
|
||||
"clean_lifecycle": result_1x["clean_lifecycle"],
|
||||
},
|
||||
{
|
||||
"run_id": result_2x["run_id"],
|
||||
"clean_lifecycle": result_2x["clean_lifecycle"],
|
||||
},
|
||||
],
|
||||
"owned_process_residue": False,
|
||||
}
|
||||
|
||||
artifacts: dict[str, tuple[str, dict[str, Any]]] = {
|
||||
"worker-inventory": (
|
||||
"worker-inventory.redacted.json",
|
||||
worker_inventory,
|
||||
),
|
||||
"d-only-physical-placement": (
|
||||
"d-only-physical-placement.redacted.json",
|
||||
d_only,
|
||||
),
|
||||
"px4-stock-rover-launch": (
|
||||
"px4-stock-rover-launch.redacted.json",
|
||||
px4_launch,
|
||||
),
|
||||
"ros2-telemetry": (
|
||||
"ros2-telemetry.redacted.json",
|
||||
telemetry,
|
||||
),
|
||||
"authoritative-simulation-clock": (
|
||||
"authoritative-simulation-clock.redacted.json",
|
||||
authoritative_clock,
|
||||
),
|
||||
"pause-step-speed": (
|
||||
"pause-step-speed.redacted.json",
|
||||
pause_step_speed,
|
||||
),
|
||||
"clean-stop-no-orphans": (
|
||||
"clean-stop-no-orphans.redacted.json",
|
||||
clean_stop,
|
||||
),
|
||||
"resource-baseline-1x": (
|
||||
"resource-baseline-1x.redacted.json",
|
||||
resource_1x,
|
||||
),
|
||||
"resource-baseline-2x": (
|
||||
"resource-baseline-2x.redacted.json",
|
||||
resource_2x,
|
||||
),
|
||||
}
|
||||
required = profile.get("required_evidence")
|
||||
if not isinstance(required, list) or set(required) != set(artifacts):
|
||||
raise EvidenceError("profile required_evidence does not match the generator")
|
||||
|
||||
manifest_checks: list[dict[str, str]] = []
|
||||
for identifier in required:
|
||||
filename, payload = artifacts[str(identifier)]
|
||||
artifact_path = output_dir / filename
|
||||
_write_json(artifact_path, payload)
|
||||
manifest_checks.append(
|
||||
{
|
||||
"id": str(identifier),
|
||||
"status": "pass",
|
||||
"artifact": filename,
|
||||
"sha256": _sha256(artifact_path),
|
||||
"note": f"Redacted factual worker evidence for {identifier}.",
|
||||
}
|
||||
)
|
||||
manifest = {
|
||||
"schema_version": EVIDENCE_SCHEMA,
|
||||
"profile_sha256": profile_sha256,
|
||||
"checks": manifest_checks,
|
||||
}
|
||||
manifest_path = output_dir / "evidence.yaml"
|
||||
manifest_path.write_text(
|
||||
yaml.safe_dump(manifest, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"evidence_manifest": str(manifest_path),
|
||||
"profile_sha256": profile_sha256,
|
||||
"checks": len(manifest_checks),
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,487 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Qualify Gazebo pause, single-step, speed and resource behavior on SIM S0."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
TIME_SCHEMA = "missioncore.simulation-time-control/v1"
|
||||
RESOURCE_SCHEMA = "missioncore.simulation-resource-baseline/v1"
|
||||
MIB = 1024**2
|
||||
GIB = 1024**3
|
||||
|
||||
|
||||
class ProbeError(RuntimeError):
|
||||
"""A runtime observation could not satisfy the reviewed S0 contract."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorldStats:
|
||||
sim_time_ns: int
|
||||
real_time_ns: int
|
||||
iterations: int
|
||||
paused: bool
|
||||
real_time_factor: float
|
||||
step_size_ns: int
|
||||
|
||||
|
||||
def _run(command: list[str], *, timeout: float) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ProbeError(
|
||||
f"command failed ({result.returncode}): {' '.join(command)}; "
|
||||
f"stderr={result.stderr.strip()!r}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _time_field(message: str, field: str) -> int:
|
||||
match = re.search(rf"\b{re.escape(field)}\s*\{{(?P<body>.*?)\}}", message, re.DOTALL)
|
||||
if match is None:
|
||||
raise ProbeError(f"Gazebo stats omitted {field}")
|
||||
body = match.group("body")
|
||||
seconds = re.search(r"\bsec:\s*(-?\d+)", body)
|
||||
nanoseconds = re.search(r"\bnsec:\s*(-?\d+)", body)
|
||||
sec_value = int(seconds.group(1)) if seconds is not None else 0
|
||||
nsec_value = int(nanoseconds.group(1)) if nanoseconds is not None else 0
|
||||
return sec_value * 1_000_000_000 + nsec_value
|
||||
|
||||
|
||||
def _scalar_field(
|
||||
message: str,
|
||||
field: str,
|
||||
converter: type[int] | type[float],
|
||||
*,
|
||||
default: int | float | None = None,
|
||||
) -> int | float:
|
||||
match = re.search(rf"\b{re.escape(field)}:\s*([^\s]+)", message)
|
||||
if match is None:
|
||||
if default is None:
|
||||
raise ProbeError(f"Gazebo stats omitted {field}")
|
||||
return default
|
||||
return converter(match.group(1))
|
||||
|
||||
|
||||
def _stats_snapshot() -> WorldStats:
|
||||
result = _run(
|
||||
["gz", "topic", "--echo", "--topic", "/stats", "-n", "1"],
|
||||
timeout=8,
|
||||
)
|
||||
paused_match = re.search(r"\bpaused:\s*(true|false)", result.stdout)
|
||||
paused = paused_match is not None and paused_match.group(1) == "true"
|
||||
return WorldStats(
|
||||
sim_time_ns=_time_field(result.stdout, "sim_time"),
|
||||
real_time_ns=_time_field(result.stdout, "real_time"),
|
||||
iterations=int(_scalar_field(result.stdout, "iterations", int)),
|
||||
paused=paused,
|
||||
real_time_factor=float(
|
||||
_scalar_field(result.stdout, "real_time_factor", float, default=0.0)
|
||||
),
|
||||
step_size_ns=_time_field(result.stdout, "step_size"),
|
||||
)
|
||||
|
||||
|
||||
def _control(world: str, request: str) -> str:
|
||||
result = _run(
|
||||
[
|
||||
"gz",
|
||||
"service",
|
||||
"-s",
|
||||
f"/world/{world}/control",
|
||||
"--reqtype",
|
||||
"gz.msgs.WorldControl",
|
||||
"--reptype",
|
||||
"gz.msgs.Boolean",
|
||||
"--timeout",
|
||||
"5000",
|
||||
"--req",
|
||||
request,
|
||||
],
|
||||
timeout=8,
|
||||
)
|
||||
if "data: true" not in result.stdout:
|
||||
raise ProbeError(
|
||||
f"Gazebo rejected world control request {request!r}: {result.stdout.strip()!r}"
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _wait_for_pause_state(expected: bool, *, timeout: float = 6) -> WorldStats:
|
||||
deadline = time.monotonic() + timeout
|
||||
observed: WorldStats | None = None
|
||||
while time.monotonic() < deadline:
|
||||
observed = _stats_snapshot()
|
||||
if observed.paused is expected:
|
||||
return observed
|
||||
raise ProbeError(f"Gazebo pause state did not become {expected}; last={observed!r}")
|
||||
|
||||
|
||||
def _wait_for_iteration(minimum: int, *, timeout: float = 6) -> WorldStats:
|
||||
deadline = time.monotonic() + timeout
|
||||
observed: WorldStats | None = None
|
||||
while time.monotonic() < deadline:
|
||||
observed = _stats_snapshot()
|
||||
if observed.iterations >= minimum:
|
||||
return observed
|
||||
raise ProbeError(f"Gazebo iteration did not reach {minimum}; last={observed!r}")
|
||||
|
||||
|
||||
def _meminfo() -> dict[str, int]:
|
||||
values: dict[str, int] = {}
|
||||
for line in Path("/proc/meminfo").read_text(encoding="utf-8").splitlines():
|
||||
key, raw = line.split(":", maxsplit=1)
|
||||
amount = int(raw.strip().split()[0])
|
||||
values[key] = amount * 1024
|
||||
return values
|
||||
|
||||
|
||||
def _owned_processes(
|
||||
*,
|
||||
pids: set[int],
|
||||
process_groups: set[int],
|
||||
) -> list[dict[str, int | float | str]]:
|
||||
result = _run(
|
||||
[
|
||||
"ps",
|
||||
"-eo",
|
||||
"pid=,ppid=,pgid=,rss=,pcpu=,comm=",
|
||||
],
|
||||
timeout=5,
|
||||
)
|
||||
processes: list[dict[str, int | float | str]] = []
|
||||
for line in result.stdout.splitlines():
|
||||
fields = line.split(maxsplit=5)
|
||||
if len(fields) != 6:
|
||||
continue
|
||||
pid, ppid, pgid, rss_kib, cpu_percent, command = fields
|
||||
pid_value = int(pid)
|
||||
pgid_value = int(pgid)
|
||||
if pid_value not in pids and pgid_value not in process_groups:
|
||||
continue
|
||||
processes.append(
|
||||
{
|
||||
"pid": pid_value,
|
||||
"ppid": int(ppid),
|
||||
"pgid": pgid_value,
|
||||
"rss_bytes": int(rss_kib) * 1024,
|
||||
"cpu_percent": float(cpu_percent),
|
||||
"command": command,
|
||||
}
|
||||
)
|
||||
return processes
|
||||
|
||||
|
||||
def _gpu_sample() -> dict[str, int] | None:
|
||||
if shutil.which("nvidia-smi") is None:
|
||||
return None
|
||||
result = _run(
|
||||
[
|
||||
"nvidia-smi",
|
||||
"--query-gpu=memory.used,utilization.gpu",
|
||||
"--format=csv,noheader,nounits",
|
||||
],
|
||||
timeout=5,
|
||||
)
|
||||
first_line = result.stdout.strip().splitlines()[0]
|
||||
memory_mib, utilization_percent = (int(value.strip()) for value in first_line.split(","))
|
||||
return {
|
||||
"global_memory_used_mib": memory_mib,
|
||||
"global_utilization_percent": utilization_percent,
|
||||
}
|
||||
|
||||
|
||||
class ResourceSampler:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
owned_pids: set[int],
|
||||
owned_process_groups: set[int],
|
||||
storage_root: Path,
|
||||
interval_seconds: float,
|
||||
) -> None:
|
||||
self._owned_pids = owned_pids
|
||||
self._owned_process_groups = owned_process_groups
|
||||
self._storage_root = storage_root
|
||||
self._interval_seconds = interval_seconds
|
||||
self._stop = threading.Event()
|
||||
self._thread = threading.Thread(target=self._run, name="s0-resource-sampler")
|
||||
self.samples: list[dict[str, Any]] = []
|
||||
self.error: str | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
self._thread.join(timeout=max(5.0, self._interval_seconds * 4))
|
||||
if self._thread.is_alive():
|
||||
raise ProbeError("resource sampler did not stop")
|
||||
if self.error is not None:
|
||||
raise ProbeError(f"resource sampler failed: {self.error}")
|
||||
|
||||
def _run(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
processes = _owned_processes(
|
||||
pids=self._owned_pids,
|
||||
process_groups=self._owned_process_groups,
|
||||
)
|
||||
memory = _meminfo()
|
||||
disk = shutil.disk_usage(self._storage_root)
|
||||
self.samples.append(
|
||||
{
|
||||
"monotonic_ns": time.monotonic_ns(),
|
||||
"owned_rss_bytes": sum(int(process["rss_bytes"]) for process in processes),
|
||||
"owned_cpu_percent": sum(
|
||||
float(process["cpu_percent"]) for process in processes
|
||||
),
|
||||
"memory_available_bytes": memory["MemAvailable"],
|
||||
"swap_used_bytes": memory["SwapTotal"] - memory["SwapFree"],
|
||||
"disk_free_bytes": disk.free,
|
||||
"gpu": _gpu_sample(),
|
||||
"processes": processes,
|
||||
}
|
||||
)
|
||||
if self._stop.wait(self._interval_seconds):
|
||||
break
|
||||
except Exception as error: # noqa: BLE001 - preserve factual sampler failure
|
||||
self.error = f"{type(error).__name__}: {error}"
|
||||
|
||||
|
||||
def _load_contract(profile_path: Path, speed_factor: int) -> dict[str, int | str]:
|
||||
profile = yaml.safe_load(profile_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(profile, dict):
|
||||
raise ProbeError("qualification profile must be a mapping")
|
||||
runtime = profile.get("runtime_acceptance")
|
||||
storage = profile.get("storage")
|
||||
if not isinstance(runtime, dict) or not isinstance(storage, dict):
|
||||
raise ProbeError("qualification profile omitted runtime_acceptance or storage")
|
||||
factors = runtime.get("speed_factors")
|
||||
if not isinstance(factors, list) or speed_factor not in factors:
|
||||
raise ProbeError(f"speed factor {speed_factor} is not declared by the profile")
|
||||
return {
|
||||
"world": str(runtime["world"]),
|
||||
"model": str(runtime["model"]),
|
||||
"physics_step_ns": int(runtime["physics_step_ns"]),
|
||||
"pause_max_advance_ns": int(runtime["pause_max_advance_ns"]),
|
||||
"rtf_tolerance_percent": int(runtime["rtf_tolerance_percent"]),
|
||||
"measurement_window_seconds": int(runtime["measurement_window_seconds"]),
|
||||
"resource_sample_interval_milliseconds": int(
|
||||
runtime["resource_sample_interval_milliseconds"]
|
||||
),
|
||||
"max_owned_rss_mib": int(runtime["max_owned_rss_mib"]),
|
||||
"max_owned_cpu_percent": int(runtime["max_owned_cpu_percent"]),
|
||||
"minimum_available_memory_gib": int(runtime["minimum_available_memory_gib"]),
|
||||
"stop_below_gib": int(storage["stop_below_gib"]),
|
||||
}
|
||||
|
||||
|
||||
def _resource_report(
|
||||
*,
|
||||
samples: list[dict[str, Any]],
|
||||
contract: dict[str, int | str],
|
||||
speed_factor: int,
|
||||
) -> dict[str, Any]:
|
||||
if not samples:
|
||||
raise ProbeError("resource sampler produced no samples")
|
||||
peak_rss = max(int(sample["owned_rss_bytes"]) for sample in samples)
|
||||
peak_cpu = max(float(sample["owned_cpu_percent"]) for sample in samples)
|
||||
minimum_memory = min(int(sample["memory_available_bytes"]) for sample in samples)
|
||||
minimum_disk = min(int(sample["disk_free_bytes"]) for sample in samples)
|
||||
maximum_swap = max(int(sample["swap_used_bytes"]) for sample in samples)
|
||||
gpu_samples = [sample["gpu"] for sample in samples if sample["gpu"] is not None]
|
||||
checks = {
|
||||
"sample_count": len(samples) >= 6,
|
||||
"owned_rss": peak_rss <= int(contract["max_owned_rss_mib"]) * MIB,
|
||||
"owned_cpu": peak_cpu <= int(contract["max_owned_cpu_percent"]),
|
||||
"available_memory": (minimum_memory >= int(contract["minimum_available_memory_gib"]) * GIB),
|
||||
"disk_floor": minimum_disk >= int(contract["stop_below_gib"]) * GIB,
|
||||
}
|
||||
summary: dict[str, Any] = {
|
||||
"sample_count": len(samples),
|
||||
"peak_owned_rss_bytes": peak_rss,
|
||||
"peak_owned_cpu_percent": peak_cpu,
|
||||
"minimum_memory_available_bytes": minimum_memory,
|
||||
"minimum_disk_free_bytes": minimum_disk,
|
||||
"maximum_swap_used_bytes": maximum_swap,
|
||||
"gpu_metrics_are_global_and_include_co_tenants": True,
|
||||
}
|
||||
if gpu_samples:
|
||||
summary["peak_global_gpu_memory_used_mib"] = max(
|
||||
int(sample["global_memory_used_mib"]) for sample in gpu_samples
|
||||
)
|
||||
summary["peak_global_gpu_utilization_percent"] = max(
|
||||
int(sample["global_utilization_percent"]) for sample in gpu_samples
|
||||
)
|
||||
return {
|
||||
"schema_version": RESOURCE_SCHEMA,
|
||||
"speed_factor": speed_factor,
|
||||
"actuator_authority": False,
|
||||
"checks": checks,
|
||||
"summary": summary,
|
||||
"samples": samples,
|
||||
"verdict": "pass" if all(checks.values()) else "fail",
|
||||
}
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--profile", type=Path, required=True)
|
||||
parser.add_argument("--speed-factor", type=int, required=True)
|
||||
parser.add_argument("--time-output", type=Path, required=True)
|
||||
parser.add_argument("--resource-output", type=Path, required=True)
|
||||
parser.add_argument("--storage-root", type=Path, required=True)
|
||||
parser.add_argument("--owned-pid", action="append", type=int, default=[])
|
||||
parser.add_argument("--owned-pgid", action="append", type=int, default=[])
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _arguments()
|
||||
contract = _load_contract(args.profile, args.speed_factor)
|
||||
sampler = ResourceSampler(
|
||||
owned_pids=set(args.owned_pid),
|
||||
owned_process_groups=set(args.owned_pgid),
|
||||
storage_root=args.storage_root,
|
||||
interval_seconds=int(contract["resource_sample_interval_milliseconds"]) / 1000,
|
||||
)
|
||||
time_report: dict[str, Any] = {
|
||||
"schema_version": TIME_SCHEMA,
|
||||
"speed_factor": args.speed_factor,
|
||||
"world": contract["world"],
|
||||
"model": contract["model"],
|
||||
"actuator_authority": False,
|
||||
"contract": contract,
|
||||
}
|
||||
exit_code = 1
|
||||
sampler.start()
|
||||
try:
|
||||
world = str(contract["world"])
|
||||
_control(world, "pause: true")
|
||||
paused_start = _wait_for_pause_state(True)
|
||||
time.sleep(1.0)
|
||||
paused_end = _stats_snapshot()
|
||||
pause_delta = paused_end.sim_time_ns - paused_start.sim_time_ns
|
||||
pause_pass = paused_end.paused and 0 <= pause_delta <= int(contract["pause_max_advance_ns"])
|
||||
|
||||
step_before = paused_end
|
||||
_control(world, "pause: true, multi_step: 1")
|
||||
step_after = _wait_for_iteration(step_before.iterations + 1)
|
||||
step_delta = step_after.sim_time_ns - step_before.sim_time_ns
|
||||
iteration_delta = step_after.iterations - step_before.iterations
|
||||
step_pass = (
|
||||
step_after.paused
|
||||
and step_delta == int(contract["physics_step_ns"])
|
||||
and iteration_delta == 1
|
||||
)
|
||||
|
||||
_control(world, "pause: false")
|
||||
_wait_for_pause_state(False)
|
||||
time.sleep(0.75)
|
||||
speed_start = _stats_snapshot()
|
||||
time.sleep(int(contract["measurement_window_seconds"]))
|
||||
speed_end = _stats_snapshot()
|
||||
sim_delta = speed_end.sim_time_ns - speed_start.sim_time_ns
|
||||
real_delta = speed_end.real_time_ns - speed_start.real_time_ns
|
||||
if real_delta <= 0:
|
||||
raise ProbeError("Gazebo real-time delta was not positive")
|
||||
measured_rtf = sim_delta / real_delta
|
||||
tolerance = int(contract["rtf_tolerance_percent"]) / 100
|
||||
lower = args.speed_factor * (1 - tolerance)
|
||||
upper = args.speed_factor * (1 + tolerance)
|
||||
speed_pass = lower <= measured_rtf <= upper
|
||||
|
||||
time_report.update(
|
||||
{
|
||||
"pause": {
|
||||
"before": asdict(paused_start),
|
||||
"after": asdict(paused_end),
|
||||
"observed_advance_ns": pause_delta,
|
||||
"pass": pause_pass,
|
||||
},
|
||||
"single_step": {
|
||||
"before": asdict(step_before),
|
||||
"after": asdict(step_after),
|
||||
"observed_advance_ns": step_delta,
|
||||
"observed_iteration_delta": iteration_delta,
|
||||
"pass": step_pass,
|
||||
},
|
||||
"speed": {
|
||||
"before": asdict(speed_start),
|
||||
"after": asdict(speed_end),
|
||||
"simulation_advance_ns": sim_delta,
|
||||
"real_advance_ns": real_delta,
|
||||
"measured_rtf": measured_rtf,
|
||||
"accepted_range": [lower, upper],
|
||||
"pass": speed_pass,
|
||||
},
|
||||
}
|
||||
)
|
||||
time_report["verdict"] = "pass" if pause_pass and step_pass and speed_pass else "fail"
|
||||
except Exception as error: # noqa: BLE001 - persist the factual probe failure
|
||||
time_report["verdict"] = "fail"
|
||||
time_report["error"] = f"{type(error).__name__}: {error}"
|
||||
finally:
|
||||
try:
|
||||
_control(str(contract["world"]), "pause: false")
|
||||
except Exception as resume_error: # noqa: BLE001 - report cleanup failure
|
||||
time_report["resume_error"] = f"{type(resume_error).__name__}: {resume_error}"
|
||||
time_report["verdict"] = "fail"
|
||||
try:
|
||||
sampler.stop()
|
||||
resource_report = _resource_report(
|
||||
samples=sampler.samples,
|
||||
contract=contract,
|
||||
speed_factor=args.speed_factor,
|
||||
)
|
||||
except Exception as resource_error: # noqa: BLE001
|
||||
resource_report = {
|
||||
"schema_version": RESOURCE_SCHEMA,
|
||||
"speed_factor": args.speed_factor,
|
||||
"actuator_authority": False,
|
||||
"verdict": "fail",
|
||||
"error": f"{type(resource_error).__name__}: {resource_error}",
|
||||
"samples": sampler.samples,
|
||||
}
|
||||
_write_json(args.time_output, time_report)
|
||||
_write_json(args.resource_output, resource_report)
|
||||
|
||||
if time_report["verdict"] == "pass" and resource_report["verdict"] == "pass":
|
||||
exit_code = 0
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"time_verdict": time_report["verdict"],
|
||||
"resource_verdict": resource_report["verdict"],
|
||||
"speed_factor": args.speed_factor,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return exit_code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
schema_version: missioncore.simulation-s0-profile/v1
|
||||
profile_id: ai-worker-px4-gazebo-v1
|
||||
|
||||
target_host:
|
||||
windows_release: windows-11
|
||||
virtualization: wsl2
|
||||
wsl_distribution: MissionCore-Sim
|
||||
linux_distribution: ubuntu-24.04
|
||||
architecture: x86_64
|
||||
container_runtime: none
|
||||
|
||||
storage:
|
||||
windows_root: 'D:\NDC_MISSIONCORE'
|
||||
wsl_root: /mnt/d/NDC_MISSIONCORE
|
||||
minimum_free_gib: 300
|
||||
stop_below_gib: 200
|
||||
forbid_windows_system_drive: true
|
||||
mutable_paths:
|
||||
- id: wsl
|
||||
windows_path: 'D:\NDC_MISSIONCORE\simulation\wsl'
|
||||
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/wsl
|
||||
- id: source
|
||||
windows_path: 'D:\NDC_MISSIONCORE\simulation\source'
|
||||
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/source
|
||||
- id: build
|
||||
windows_path: 'D:\NDC_MISSIONCORE\simulation\build'
|
||||
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/build
|
||||
- id: cache
|
||||
windows_path: 'D:\NDC_MISSIONCORE\simulation\cache'
|
||||
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/cache
|
||||
- id: runtime
|
||||
windows_path: 'D:\NDC_MISSIONCORE\simulation\runtime'
|
||||
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/runtime
|
||||
- id: artifacts
|
||||
windows_path: 'D:\NDC_MISSIONCORE\simulation\artifacts'
|
||||
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/artifacts
|
||||
- id: datasets
|
||||
windows_path: 'D:\NDC_MISSIONCORE\simulation\datasets'
|
||||
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/datasets
|
||||
|
||||
clock:
|
||||
authority: gazebo:/clock
|
||||
ros_use_sim_time: true
|
||||
px4_uxrce_dds_sync_enabled: false
|
||||
canonical_unit: nanoseconds
|
||||
utc_role: provenance-only
|
||||
|
||||
authority:
|
||||
simulation_or_shadow_only: true
|
||||
actuator_authority: false
|
||||
navigation_or_safety_accepted: false
|
||||
direct_actuator_setpoints_allowed: false
|
||||
allowed_setpoint_profiles:
|
||||
- rover-speed-steering/v1
|
||||
- rover-speed-yaw-rate/v1
|
||||
|
||||
ros:
|
||||
domain_id: 0
|
||||
namespace: /missioncore/polygon/s0
|
||||
|
||||
runtime_acceptance:
|
||||
world: rover
|
||||
model: rover_ackermann_0
|
||||
physics_step_ns: 2000000
|
||||
pause_max_advance_ns: 0
|
||||
speed_factors:
|
||||
- 1
|
||||
- 2
|
||||
rtf_tolerance_percent: 20
|
||||
measurement_window_seconds: 4
|
||||
resource_sample_interval_milliseconds: 500
|
||||
max_owned_rss_mib: 2048
|
||||
max_owned_cpu_percent: 800
|
||||
minimum_available_memory_gib: 8
|
||||
|
||||
components:
|
||||
- id: ros2
|
||||
source_url: https://docs.ros.org/en/jazzy/
|
||||
requested_ref: jazzy
|
||||
resolved_version: ros-base=0.11.0-1noble.20260616.084325;ros-gz=1.0.22-1noble.20260616.074726
|
||||
resolved_commit:
|
||||
license: mixed-apache-2.0-bsd
|
||||
qualification_state: accepted
|
||||
- id: gazebo
|
||||
source_url: https://gazebosim.org/docs/harmonic/
|
||||
requested_ref: harmonic
|
||||
resolved_version: gz-harmonic=1.0.0-1~noble;gz-sim=8.14.0-1~noble;sdformat=14.9.0-1~noble
|
||||
resolved_commit:
|
||||
license: apache-2.0
|
||||
qualification_state: accepted
|
||||
- id: px4-autopilot
|
||||
source_url: https://github.com/PX4/PX4-Autopilot
|
||||
requested_ref: v1.17.0
|
||||
resolved_version: v1.17.0
|
||||
resolved_commit: d6f12ad1c4f70ad3230afd7d86e971421e02fef4
|
||||
license: bsd-3-clause
|
||||
qualification_state: accepted
|
||||
- id: px4-msgs
|
||||
source_url: https://github.com/PX4/px4_msgs
|
||||
requested_ref: v1.17.0
|
||||
resolved_version: v1.17.0
|
||||
resolved_commit: 86d8239e962f6939e05c3737784f60c02fa884db
|
||||
license: bsd-3-clause
|
||||
qualification_state: accepted
|
||||
- id: px4-gazebo-models
|
||||
source_url: https://github.com/PX4/PX4-gazebo-models
|
||||
requested_ref: PX4-Autopilot/v1.17.0-submodule
|
||||
resolved_version: px4-v1.17.0-submodule
|
||||
resolved_commit: b6127f4ec20de867e215fb5f78ae88b80f371909
|
||||
license: bsd-3-clause
|
||||
qualification_state: accepted
|
||||
- id: micro-xrce-dds-agent
|
||||
source_url: https://github.com/eProsima/Micro-XRCE-DDS-Agent
|
||||
requested_ref: v2.4.3
|
||||
resolved_version: v2.4.3
|
||||
resolved_commit: 73622810d984349b80bbac0ef55fc0b694d62222
|
||||
license: apache-2.0
|
||||
qualification_state: accepted
|
||||
- id: nav2
|
||||
source_url: https://github.com/ros-navigation/navigation2
|
||||
requested_ref: jazzy@2026-07-24
|
||||
resolved_version: navigation2=1.3.12-1noble.20260615.181551;nav2-bringup=1.3.12-1noble.20260616.082701
|
||||
resolved_commit: c92ea2fd9008f50c0ec8447610800214b2a0dafb
|
||||
license: apache-2.0
|
||||
qualification_state: accepted
|
||||
|
||||
ports:
|
||||
- id: micro-xrce-dds
|
||||
host_scope: loopback-only-netns
|
||||
protocol: udp
|
||||
bind: 0.0.0.0
|
||||
port: 8888
|
||||
owner: micro-xrce-dds-agent
|
||||
- id: px4-mavlink-onboard
|
||||
host_scope: loopback-only-netns
|
||||
protocol: udp
|
||||
bind: 0.0.0.0
|
||||
port: 14580
|
||||
owner: px4-sitl
|
||||
- id: px4-mavlink-normal
|
||||
host_scope: loopback-only-netns
|
||||
protocol: udp
|
||||
bind: 0.0.0.0
|
||||
port: 18570
|
||||
owner: px4-sitl
|
||||
- id: simulation-orchestrator-api
|
||||
host_scope: loopback-only-netns
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
port: 18080
|
||||
owner: simulation-orchestrator
|
||||
- id: simulation-telemetry
|
||||
host_scope: loopback-only-netns
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
port: 18081
|
||||
owner: simulation-orchestrator
|
||||
|
||||
processes:
|
||||
- id: simulation-orchestrator
|
||||
parent:
|
||||
shutdown_order: 60
|
||||
- id: gazebo-server
|
||||
parent: simulation-orchestrator
|
||||
shutdown_order: 50
|
||||
- id: px4-sitl
|
||||
parent: simulation-orchestrator
|
||||
shutdown_order: 40
|
||||
- id: micro-xrce-dds-agent
|
||||
parent: simulation-orchestrator
|
||||
shutdown_order: 30
|
||||
- id: ros2-bridge
|
||||
parent: simulation-orchestrator
|
||||
shutdown_order: 20
|
||||
- id: nav2
|
||||
parent: simulation-orchestrator
|
||||
shutdown_order: 10
|
||||
|
||||
required_tools:
|
||||
- git
|
||||
- gz
|
||||
- make
|
||||
- cmake
|
||||
- ninja
|
||||
- colcon
|
||||
- ros2
|
||||
- MicroXRCEAgent
|
||||
|
||||
required_evidence:
|
||||
- worker-inventory
|
||||
- d-only-physical-placement
|
||||
- px4-stock-rover-launch
|
||||
- ros2-telemetry
|
||||
- authoritative-simulation-clock
|
||||
- pause-step-speed
|
||||
- clean-stop-no-orphans
|
||||
- resource-baseline-1x
|
||||
- resource-baseline-2x
|
||||
|
|
@ -1,296 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run the SIM S0 stock-rover smoke case inside a loopback-only network
|
||||
# namespace. The script writes factual runtime artifacts to D and never grants
|
||||
# actuator authority.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly EXPECTED_DISTRO="MissionCore-Sim"
|
||||
readonly D_ROOT="${MISSIONCORE_SIM_D_ROOT:-/mnt/d/NDC_MISSIONCORE/simulation}"
|
||||
readonly PX4_ROOT="${MISSIONCORE_SIM_PX4_ROOT:-${D_ROOT}/source/PX4-Autopilot}"
|
||||
readonly AGENT_PREFIX="${MISSIONCORE_SIM_AGENT_PREFIX:-${D_ROOT}/runtime/micro-xrce-dds-agent/2.4.3}"
|
||||
readonly PX4_MSGS_SETUP="${MISSIONCORE_SIM_PX4_MSGS_SETUP:-${D_ROOT}/build/ros2/px4_msgs/install/setup.bash}"
|
||||
readonly ARTIFACT_ROOT="${MISSIONCORE_SIM_ARTIFACT_ROOT:-${D_ROOT}/artifacts/s0}"
|
||||
readonly PROFILE_PATH="${MISSIONCORE_SIM_PROFILE_PATH:-${D_ROOT}/source/qualification-profile.yaml}"
|
||||
readonly TIME_PROBE="${MISSIONCORE_SIM_TIME_PROBE:-${D_ROOT}/source/gazebo_time_control.py}"
|
||||
readonly RUN_ID="${1:-$(date -u +%Y%m%dT%H%M%SZ)}"
|
||||
readonly SPEED_FACTOR="${2:-1}"
|
||||
readonly RUN_DIR="${ARTIFACT_ROOT}/${RUN_ID}"
|
||||
|
||||
if [[ "${SPEED_FACTOR}" != "1" && "${SPEED_FACTOR}" != "2" ]]; then
|
||||
echo "error: speed factor must be one of the reviewed values: 1 or 2" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ "${MISSIONCORE_S0_NETNS:-0}" != "1" ]]; then
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "error: initial worker-smoke invocation must run as root to create the network namespace" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "${WSL_DISTRO_NAME:-}" != "${EXPECTED_DISTRO}" ]]; then
|
||||
echo "error: expected WSL distro ${EXPECTED_DISTRO}, observed ${WSL_DISTRO_NAME:-unset}" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exec unshare --net -- bash -c '
|
||||
set -e
|
||||
ip link set lo up
|
||||
exec runuser -u missioncore -- env MISSIONCORE_S0_NETNS=1 "$@"
|
||||
' bash "$0" "$@"
|
||||
fi
|
||||
|
||||
if [[ "${WSL_DISTRO_NAME:-}" != "${EXPECTED_DISTRO}" ]]; then
|
||||
echo "error: smoke case escaped the reviewed WSL distribution" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
for required_path in "${PX4_ROOT}" "${AGENT_PREFIX}" "${PX4_MSGS_SETUP}" "${ARTIFACT_ROOT}"; do
|
||||
if [[ "${required_path}" != /mnt/d/NDC_MISSIONCORE/* ]]; then
|
||||
echo "error: mutable or executable path escaped D-only root: ${required_path}" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
readonly AGENT_BIN="${AGENT_PREFIX}/bin/MicroXRCEAgent"
|
||||
readonly AGENT_LOG="${RUN_DIR}/micro-xrce-dds-agent.log"
|
||||
readonly PX4_LOG="${RUN_DIR}/px4-rover.log"
|
||||
readonly BRIDGE_LOG="${RUN_DIR}/ros-gz-clock-bridge.log"
|
||||
readonly TOPICS_LOG="${RUN_DIR}/ros-topics.txt"
|
||||
readonly VEHICLE_STATUS_LOG="${RUN_DIR}/vehicle-status.yaml"
|
||||
readonly CLOCK_LOG="${RUN_DIR}/clock.yaml"
|
||||
readonly NETWORK_LOG="${RUN_DIR}/network.txt"
|
||||
readonly RESOURCE_LOG="${RUN_DIR}/resource-snapshot.txt"
|
||||
readonly GZ_SERVICES_LOG="${RUN_DIR}/gazebo-services.txt"
|
||||
readonly GZ_TOPICS_LOG="${RUN_DIR}/gazebo-topics.txt"
|
||||
readonly TIME_CONTROL_LOG="${RUN_DIR}/time-control.json"
|
||||
readonly RESOURCE_BASELINE_LOG="${RUN_DIR}/resource-baseline.json"
|
||||
readonly TIME_PROBE_STDOUT="${RUN_DIR}/time-probe.stdout"
|
||||
readonly RESULT_LOG="${RUN_DIR}/result.env"
|
||||
|
||||
for required_file in \
|
||||
"${PX4_ROOT}/build/px4_sitl_default/bin/px4" \
|
||||
"${PX4_ROOT}/Tools/simulation/gz/worlds/rover.sdf" \
|
||||
"${AGENT_BIN}" \
|
||||
"/opt/ros/jazzy/setup.bash" \
|
||||
"${PX4_MSGS_SETUP}" \
|
||||
"${PROFILE_PATH}" \
|
||||
"${TIME_PROBE}"; do
|
||||
if [[ ! -e "${required_file}" ]]; then
|
||||
echo "error: required S0 runtime input is missing: ${required_file}" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
mkdir -p "${RUN_DIR}"
|
||||
|
||||
agent_pid=""
|
||||
px4_pgid=""
|
||||
bridge_pgid=""
|
||||
|
||||
stop_process_group() {
|
||||
local pgid="${1:-}"
|
||||
if [[ -z "${pgid}" ]]; then
|
||||
return
|
||||
fi
|
||||
if kill -0 -- "-${pgid}" 2>/dev/null; then
|
||||
kill -INT -- "-${pgid}" 2>/dev/null || true
|
||||
for _ in {1..20}; do
|
||||
if ! kill -0 -- "-${pgid}" 2>/dev/null; then
|
||||
return
|
||||
fi
|
||||
sleep 0.25
|
||||
done
|
||||
kill -TERM -- "-${pgid}" 2>/dev/null || true
|
||||
sleep 1
|
||||
kill -KILL -- "-${pgid}" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
set +e
|
||||
exec 3>&-
|
||||
stop_process_group "${bridge_pgid}"
|
||||
stop_process_group "${px4_pgid}"
|
||||
if [[ -n "${agent_pid}" ]] && kill -0 "${agent_pid}" 2>/dev/null; then
|
||||
kill -TERM "${agent_pid}" 2>/dev/null
|
||||
wait "${agent_pid}" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
wait_for_pattern() {
|
||||
local file="$1"
|
||||
local pattern="$2"
|
||||
local attempts="$3"
|
||||
for ((attempt = 0; attempt < attempts; attempt += 1)); do
|
||||
if grep -Fq "${pattern}" "${file}" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
env \
|
||||
LD_LIBRARY_PATH="${AGENT_PREFIX}/lib" \
|
||||
"${AGENT_BIN}" udp4 -p 8888 -v 4 >"${AGENT_LOG}" 2>&1 &
|
||||
agent_pid="$!"
|
||||
sleep 1
|
||||
if ! kill -0 "${agent_pid}" 2>/dev/null; then
|
||||
echo "error: Micro XRCE-DDS Agent exited during startup" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "network_namespace=loopback-only"
|
||||
ip -brief address
|
||||
ss -lunp
|
||||
} >"${NETWORK_LOG}"
|
||||
|
||||
coproc PX4_PROCESS {
|
||||
exec setsid env HEADLESS=1 PX4_SIM_SPEED_FACTOR="${SPEED_FACTOR}" make -C "${PX4_ROOT}" \
|
||||
px4_sitl gz_rover_ackermann >"${PX4_LOG}" 2>&1
|
||||
}
|
||||
px4_pgid="${PX4_PROCESS_PID}"
|
||||
exec 3>&"${PX4_PROCESS[1]}"
|
||||
|
||||
if ! wait_for_pattern "${PX4_LOG}" "Startup script returned successfully" 120; then
|
||||
echo "error: PX4 stock rover did not reach startup completion" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 3
|
||||
printf '%s\n' \
|
||||
"ver all" \
|
||||
"commander status" >&3
|
||||
|
||||
if ! wait_for_pattern "${AGENT_LOG}" "session established" 60 \
|
||||
|| ! wait_for_pattern \
|
||||
"${PX4_LOG}" \
|
||||
"successfully created rt/fmu/out/vehicle_status_v1 data writer" \
|
||||
60; then
|
||||
echo "error: PX4 uXRCE-DDS client did not connect to the loopback agent" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "uxrce_dds_client status" >&3
|
||||
if ! wait_for_pattern "${PX4_LOG}" "Running, connected" 20; then
|
||||
echo "error: PX4 did not report the established uXRCE-DDS session" >&2
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo
|
||||
echo "runtime_listeners=loopback-only-netns"
|
||||
ss -lunp
|
||||
} >>"${NETWORK_LOG}"
|
||||
gz service -l >"${GZ_SERVICES_LOG}"
|
||||
gz topic -l >"${GZ_TOPICS_LOG}"
|
||||
|
||||
set +u
|
||||
# shellcheck source=/dev/null
|
||||
source /opt/ros/jazzy/setup.bash
|
||||
# shellcheck source=/dev/null
|
||||
source "${PX4_MSGS_SETUP}"
|
||||
set -u
|
||||
export ROS_DOMAIN_ID=0
|
||||
export ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST
|
||||
unset ROS_LOCALHOST_ONLY
|
||||
|
||||
setsid ros2 run ros_gz_bridge parameter_bridge \
|
||||
'/clock@rosgraph_msgs/msg/Clock[gz.msgs.Clock' >"${BRIDGE_LOG}" 2>&1 &
|
||||
bridge_pgid="$!"
|
||||
|
||||
timeout 20s ros2 topic list -t >"${TOPICS_LOG}"
|
||||
vehicle_status_topic="/fmu/out/vehicle_status"
|
||||
if grep -Fq "/fmu/out/vehicle_status_v1" "${TOPICS_LOG}"; then
|
||||
vehicle_status_topic="/fmu/out/vehicle_status_v1"
|
||||
fi
|
||||
timeout 20s ros2 topic echo --once \
|
||||
"${vehicle_status_topic}" px4_msgs/msg/VehicleStatus >"${VEHICLE_STATUS_LOG}"
|
||||
timeout 20s ros2 topic echo --once \
|
||||
/clock rosgraph_msgs/msg/Clock >"${CLOCK_LOG}"
|
||||
|
||||
python3 "${TIME_PROBE}" \
|
||||
--profile "${PROFILE_PATH}" \
|
||||
--speed-factor "${SPEED_FACTOR}" \
|
||||
--time-output "${TIME_CONTROL_LOG}" \
|
||||
--resource-output "${RESOURCE_BASELINE_LOG}" \
|
||||
--storage-root "${D_ROOT}" \
|
||||
--owned-pid "${agent_pid}" \
|
||||
--owned-pgid "${px4_pgid}" \
|
||||
--owned-pgid "${bridge_pgid}" >"${TIME_PROBE_STDOUT}"
|
||||
|
||||
{
|
||||
echo "sample=non-acceptance-point-snapshot"
|
||||
free -b
|
||||
ps -g "${px4_pgid}" -o pid,ppid,pgid,rss,vsz,pcpu,comm,args
|
||||
ps -p "${agent_pid}" -o pid,ppid,pgid,rss,vsz,pcpu,comm,args
|
||||
ps -g "${bridge_pgid}" -o pid,ppid,pgid,rss,vsz,pcpu,comm,args
|
||||
if command -v nvidia-smi >/dev/null; then
|
||||
nvidia-smi \
|
||||
--query-gpu=memory.used,utilization.gpu \
|
||||
--format=csv,noheader,nounits
|
||||
fi
|
||||
} >"${RESOURCE_LOG}"
|
||||
|
||||
printf '%s\n' "shutdown" >&3
|
||||
if ! wait_for_pattern "${PX4_LOG}" "Exiting NOW." 20; then
|
||||
echo "error: PX4 did not acknowledge the shutdown command" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup
|
||||
trap - EXIT INT TERM
|
||||
|
||||
px4_status="fail"
|
||||
dds_status="fail"
|
||||
vehicle_status="fail"
|
||||
clock_status="fail"
|
||||
time_control_status="fail"
|
||||
resource_baseline_status="fail"
|
||||
lifecycle_status="fail"
|
||||
|
||||
grep -Fq "world: rover, model: rover_ackermann_0" "${PX4_LOG}" && px4_status="pass"
|
||||
if grep -Fq "session established" "${AGENT_LOG}" \
|
||||
&& grep -Fq \
|
||||
"successfully created rt/fmu/out/vehicle_status_v1 data writer" \
|
||||
"${PX4_LOG}"; then
|
||||
dds_status="pass"
|
||||
fi
|
||||
grep -Fq "arming_state:" "${VEHICLE_STATUS_LOG}" && vehicle_status="pass"
|
||||
grep -Fq "clock:" "${CLOCK_LOG}" && clock_status="pass"
|
||||
grep -Fq '"verdict": "pass"' "${TIME_CONTROL_LOG}" && time_control_status="pass"
|
||||
grep -Fq '"verdict": "pass"' "${RESOURCE_BASELINE_LOG}" \
|
||||
&& resource_baseline_status="pass"
|
||||
if ! pgrep -f "${PX4_ROOT}/build/px4_sitl_default/bin/px4" >/dev/null \
|
||||
&& ! pgrep -f "gz sim.*rover.sdf" >/dev/null \
|
||||
&& ! pgrep -f "${AGENT_BIN}" >/dev/null; then
|
||||
lifecycle_status="pass"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "run_id=${RUN_ID}"
|
||||
echo "network_scope=loopback-only-netns"
|
||||
echo "actuator_authority=false"
|
||||
echo "speed_factor=${SPEED_FACTOR}"
|
||||
echo "px4_stock_rover=${px4_status}"
|
||||
echo "uxrce_dds=${dds_status}"
|
||||
echo "ros2_vehicle_status=${vehicle_status}"
|
||||
echo "gazebo_clock=${clock_status}"
|
||||
echo "pause_step_speed=${time_control_status}"
|
||||
echo "resource_baseline=${resource_baseline_status}"
|
||||
echo "clean_lifecycle=${lifecycle_status}"
|
||||
} >"${RESULT_LOG}"
|
||||
|
||||
if [[ "${px4_status}" != "pass" \
|
||||
|| "${dds_status}" != "pass" \
|
||||
|| "${vehicle_status}" != "pass" \
|
||||
|| "${clock_status}" != "pass" \
|
||||
|| "${time_control_status}" != "pass" \
|
||||
|| "${resource_baseline_status}" != "pass" \
|
||||
|| "${lifecycle_status}" != "pass" ]]; then
|
||||
echo "error: one or more S0 smoke checks failed; inspect ${RUN_DIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "SIM S0 smoke PASS: ${RUN_DIR}"
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
"""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",
|
||||
]
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from k1link.simulation import S0ProfileError, run_s0_doctor
|
||||
|
||||
app = typer.Typer(
|
||||
name="missioncore-sim",
|
||||
help="Fail-closed qualification tooling for the Mission Core Polygon.",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
s0_app = typer.Typer(
|
||||
help="SIM S0 compatibility and infrastructure qualification.",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
app.add_typer(s0_app, name="s0")
|
||||
console = Console()
|
||||
|
||||
|
||||
def _default_profile() -> Path:
|
||||
return Path(__file__).resolve().parents[3] / "simulation" / "s0" / "qualification-profile.yaml"
|
||||
|
||||
|
||||
DEFAULT_PROFILE = _default_profile()
|
||||
|
||||
|
||||
@s0_app.command("doctor")
|
||||
def s0_doctor(
|
||||
profile: Annotated[
|
||||
Path,
|
||||
typer.Option(
|
||||
"--profile",
|
||||
help="Strict SIM S0 qualification profile.",
|
||||
exists=True,
|
||||
dir_okay=False,
|
||||
readable=True,
|
||||
),
|
||||
] = DEFAULT_PROFILE,
|
||||
evidence: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
"--evidence",
|
||||
help="Optional digest-bound target-worker evidence manifest.",
|
||||
exists=True,
|
||||
dir_okay=False,
|
||||
readable=True,
|
||||
),
|
||||
] = None,
|
||||
json_output: Annotated[
|
||||
bool,
|
||||
typer.Option("--json", help="Emit the complete report as JSON."),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Inspect contracts and local target readiness without changing the host."""
|
||||
|
||||
try:
|
||||
report = run_s0_doctor(profile, evidence_path=evidence)
|
||||
except (OSError, S0ProfileError) as exc:
|
||||
if json_output:
|
||||
console.print_json(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": "missioncore.simulation-s0-doctor/v1",
|
||||
"verdict": "blocked",
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
console.print(f"[red]SIM S0 profile rejected:[/red] {exc}")
|
||||
raise typer.Exit(code=2) from exc
|
||||
|
||||
payload = report.to_dict()
|
||||
if json_output:
|
||||
console.print_json(json.dumps(payload))
|
||||
return
|
||||
|
||||
table = Table(title=f"SIM S0 doctor — {report.profile_id}")
|
||||
table.add_column("Check")
|
||||
table.add_column("Status")
|
||||
table.add_column("Detail")
|
||||
colors = {"pass": "green", "fail": "red", "unknown": "yellow"}
|
||||
for check in report.checks:
|
||||
status = check.status.value
|
||||
styled_status = f"[{colors[status]}]{status}[/{colors[status]}]"
|
||||
table.add_row(check.identifier, styled_status, check.detail)
|
||||
console.print(table)
|
||||
console.print(f"Verdict: [bold]{report.verdict.value.upper()}[/bold]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,268 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from k1link.simulation import (
|
||||
CheckStatus,
|
||||
DoctorVerdict,
|
||||
S0ProfileError,
|
||||
load_s0_profile,
|
||||
run_s0_doctor,
|
||||
)
|
||||
from k1link.simulation.cli import app
|
||||
|
||||
PROFILE = Path(__file__).resolve().parents[1] / "simulation" / "s0" / "qualification-profile.yaml"
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _profile_document() -> dict[str, object]:
|
||||
loaded = yaml.safe_load(PROFILE.read_text(encoding="utf-8"))
|
||||
assert isinstance(loaded, dict)
|
||||
return loaded
|
||||
|
||||
|
||||
def _write_profile(tmp_path: Path, document: dict[str, object]) -> Path:
|
||||
path = tmp_path / "profile.yaml"
|
||||
path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_canonical_s0_profile_is_strict_and_safe() -> None:
|
||||
profile = load_s0_profile(PROFILE)
|
||||
|
||||
assert profile.profile_id == "ai-worker-px4-gazebo-v1"
|
||||
assert profile.target_host.wsl_distribution == "MissionCore-Sim"
|
||||
assert profile.target_host.container_runtime == "none"
|
||||
assert profile.storage.windows_root.drive.upper() == "D:"
|
||||
assert profile.storage.wsl_root.as_posix() == "/mnt/d/NDC_MISSIONCORE"
|
||||
assert profile.clock.authority == "gazebo:/clock"
|
||||
assert profile.clock.ros_use_sim_time is True
|
||||
assert profile.clock.px4_uxrce_dds_sync_enabled is False
|
||||
assert profile.authority.simulation_or_shadow_only is True
|
||||
assert profile.authority.actuator_authority is False
|
||||
assert profile.authority.navigation_or_safety_accepted is False
|
||||
assert profile.authority.direct_actuator_setpoints_allowed is False
|
||||
assert profile.ros.domain_id == 0
|
||||
assert profile.runtime_acceptance.world == "rover"
|
||||
assert profile.runtime_acceptance.model == "rover_ackermann_0"
|
||||
assert profile.runtime_acceptance.physics_step_ns == 2_000_000
|
||||
assert profile.runtime_acceptance.pause_max_advance_ns == 0
|
||||
assert profile.runtime_acceptance.speed_factors == (1, 2)
|
||||
assert profile.runtime_acceptance.rtf_tolerance_percent == 20
|
||||
assert "docker" not in profile.required_tools
|
||||
assert {item.identifier for item in profile.components} >= {
|
||||
"px4-autopilot",
|
||||
"px4-msgs",
|
||||
"gazebo",
|
||||
"nav2",
|
||||
}
|
||||
components = {item.identifier: item for item in profile.components}
|
||||
assert components["px4-gazebo-models"].resolved_commit == (
|
||||
"b6127f4ec20de867e215fb5f78ae88b80f371909"
|
||||
)
|
||||
assert {component.qualification_state for component in components.values()} == {"accepted"}
|
||||
micro_xrce = next(item for item in profile.ports if item.identifier == "micro-xrce-dds")
|
||||
assert micro_xrce.host_scope == "loopback-only-netns"
|
||||
assert micro_xrce.bind == "0.0.0.0"
|
||||
assert {item.identifier for item in profile.processes} == {
|
||||
"simulation-orchestrator",
|
||||
"gazebo-server",
|
||||
"px4-sitl",
|
||||
"micro-xrce-dds-agent",
|
||||
"ros2-bridge",
|
||||
"nav2",
|
||||
}
|
||||
|
||||
|
||||
def test_profile_rejects_real_actuator_authority(tmp_path: Path) -> None:
|
||||
document = _profile_document()
|
||||
authority = document["authority"]
|
||||
assert isinstance(authority, dict)
|
||||
authority["actuator_authority"] = True
|
||||
|
||||
with pytest.raises(S0ProfileError, match="cannot grant real"):
|
||||
load_s0_profile(_write_profile(tmp_path, document))
|
||||
|
||||
|
||||
def test_profile_rejects_mutable_c_drive_path(tmp_path: Path) -> None:
|
||||
document = _profile_document()
|
||||
storage = document["storage"]
|
||||
assert isinstance(storage, dict)
|
||||
mutable = storage["mutable_paths"]
|
||||
assert isinstance(mutable, list)
|
||||
first = mutable[0]
|
||||
assert isinstance(first, dict)
|
||||
first["windows_path"] = r"C:\NDC_MISSIONCORE\wsl"
|
||||
|
||||
with pytest.raises(S0ProfileError, match="escapes D-only root"):
|
||||
load_s0_profile(_write_profile(tmp_path, document))
|
||||
|
||||
|
||||
def test_profile_rejects_duplicate_scoped_port(tmp_path: Path) -> None:
|
||||
document = _profile_document()
|
||||
ports = document["ports"]
|
||||
assert isinstance(ports, list)
|
||||
duplicate = dict(ports[0])
|
||||
duplicate["id"] = "duplicate-port"
|
||||
duplicate["owner"] = "simulation-orchestrator"
|
||||
ports.append(duplicate)
|
||||
|
||||
with pytest.raises(S0ProfileError, match="duplicates a scoped binding"):
|
||||
load_s0_profile(_write_profile(tmp_path, document))
|
||||
|
||||
|
||||
def test_profile_rejects_wildcard_bind_outside_loopback_namespace(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
document = _profile_document()
|
||||
ports = document["ports"]
|
||||
assert isinstance(ports, list)
|
||||
first = ports[0]
|
||||
assert isinstance(first, dict)
|
||||
first["host_scope"] = "worker-wsl"
|
||||
|
||||
with pytest.raises(S0ProfileError, match="wildcard bind requires loopback-only-netns"):
|
||||
load_s0_profile(_write_profile(tmp_path, document))
|
||||
|
||||
|
||||
def test_profile_rejects_runtime_speed_contract_drift(tmp_path: Path) -> None:
|
||||
document = _profile_document()
|
||||
runtime = document["runtime_acceptance"]
|
||||
assert isinstance(runtime, dict)
|
||||
runtime["speed_factors"] = [1, 4]
|
||||
|
||||
with pytest.raises(S0ProfileError, match="exactly the 1x and 2x"):
|
||||
load_s0_profile(_write_profile(tmp_path, document))
|
||||
|
||||
|
||||
def test_doctor_never_claims_go_without_target_evidence(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"k1link.simulation.s0._target_host_match",
|
||||
lambda target: (False, "test host is not target"),
|
||||
)
|
||||
|
||||
report = run_s0_doctor(PROFILE)
|
||||
|
||||
assert report.verdict is DoctorVerdict.INCOMPLETE
|
||||
assert report.target_host_match is False
|
||||
assert any(
|
||||
check.identifier == "factual-evidence" and check.status is CheckStatus.UNKNOWN
|
||||
for check in report.checks
|
||||
)
|
||||
payload = report.to_dict()
|
||||
assert payload["authority"] == {
|
||||
"simulation_or_shadow_only": True,
|
||||
"actuator_authority": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"direct_actuator_setpoints_allowed": False,
|
||||
}
|
||||
|
||||
|
||||
def test_doctor_validates_digest_bound_evidence(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"k1link.simulation.s0._target_host_match",
|
||||
lambda target: (False, "test host is not target"),
|
||||
)
|
||||
profile = load_s0_profile(PROFILE)
|
||||
checks: list[dict[str, object]] = []
|
||||
for identifier in profile.required_evidence:
|
||||
artifact = tmp_path / f"{identifier}.txt"
|
||||
artifact.write_text(f"evidence for {identifier}\n", encoding="utf-8")
|
||||
checks.append(
|
||||
{
|
||||
"id": identifier,
|
||||
"status": "pass",
|
||||
"artifact": artifact.name,
|
||||
"sha256": hashlib.sha256(artifact.read_bytes()).hexdigest(),
|
||||
"note": "synthetic unit-test evidence",
|
||||
}
|
||||
)
|
||||
evidence = tmp_path / "evidence.yaml"
|
||||
evidence.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"schema_version": "missioncore.simulation-s0-evidence/v1",
|
||||
"profile_sha256": hashlib.sha256(PROFILE.read_bytes()).hexdigest(),
|
||||
"checks": checks,
|
||||
},
|
||||
sort_keys=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
report = run_s0_doctor(PROFILE, evidence_path=evidence)
|
||||
|
||||
assert report.verdict is DoctorVerdict.INCOMPLETE
|
||||
assert any(
|
||||
check.identifier == "factual-evidence" and check.status is CheckStatus.PASS
|
||||
for check in report.checks
|
||||
)
|
||||
|
||||
|
||||
def test_doctor_rejects_changed_evidence_artifact(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"k1link.simulation.s0._target_host_match",
|
||||
lambda target: (False, "test host is not target"),
|
||||
)
|
||||
profile = load_s0_profile(PROFILE)
|
||||
checks: list[dict[str, object]] = []
|
||||
artifacts: list[Path] = []
|
||||
for identifier in profile.required_evidence:
|
||||
artifact = tmp_path / f"{identifier}.txt"
|
||||
artifact.write_text("original\n", encoding="utf-8")
|
||||
artifacts.append(artifact)
|
||||
checks.append(
|
||||
{
|
||||
"id": identifier,
|
||||
"status": "pass",
|
||||
"artifact": artifact.name,
|
||||
"sha256": hashlib.sha256(artifact.read_bytes()).hexdigest(),
|
||||
"note": "synthetic unit-test evidence",
|
||||
}
|
||||
)
|
||||
evidence = tmp_path / "evidence.yaml"
|
||||
evidence.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"schema_version": "missioncore.simulation-s0-evidence/v1",
|
||||
"profile_sha256": hashlib.sha256(PROFILE.read_bytes()).hexdigest(),
|
||||
"checks": checks,
|
||||
},
|
||||
sort_keys=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
artifacts[0].write_text("changed\n", encoding="utf-8")
|
||||
|
||||
report = run_s0_doctor(PROFILE, evidence_path=evidence)
|
||||
|
||||
assert report.verdict is DoctorVerdict.BLOCKED
|
||||
assert any(
|
||||
check.identifier == "factual-evidence"
|
||||
and check.status is CheckStatus.FAIL
|
||||
and "digest changed" in check.detail
|
||||
for check in report.checks
|
||||
)
|
||||
|
||||
|
||||
def test_simulation_doctor_cli_emits_json_without_side_effects() -> None:
|
||||
result = runner.invoke(app, ["s0", "doctor", "--profile", str(PROFILE), "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["schema_version"] == "missioncore.simulation-s0-doctor/v1"
|
||||
assert payload["profile_id"] == "ai-worker-px4-gazebo-v1"
|
||||
assert payload["verdict"] in {"incomplete", "blocked"}
|
||||
assert payload["authority"]["actuator_authority"] is False
|
||||
Loading…
Reference in New Issue