feat(simulation): add provider-neutral worker profile

This commit is contained in:
DCCONSTRUCTIONS 2026-07-25 00:29:00 +03:00
parent 5abd07d2bf
commit 37c24b5fa8
21 changed files with 1275 additions and 121 deletions

View File

@ -129,6 +129,15 @@ lifecycle, authority, canonical contracts, provenance and reports; Gazebo, PX4
SITL, ROS 2, Nav2 and viewers remain replaceable providers. Simulation, replay,
digital twin, HIL and physical shadow keep distinct causal run kinds.
The provider boundary is now executable through
`missioncore.simulation-provider-profile/v1`: worker status declares provider
roles/capabilities, clock, ENU/FLU frames and supported control profiles.
Gazebo/PX4 stays the accepted regression profile. Unreal is evaluated as a
separate high-fidelity provider family; native Unreal, CARLA and Project AirSim
must pass ADR 0017 U0 before a production adapter is selected. Unreal runs on a
worker, never inside React; the existing browser scene, lifecycle, evidence and
safety contracts are retained.
SIM S0 is accepted on the reviewed D-only worker. Its strict profile fixes
loopback-only process isolation, exact provider pins, a 2 ms physics step,
1×/2× RTF/resource budgets and disabled real/direct actuator authority. Two
@ -243,7 +252,9 @@ endpoint or direct provider command.
See the [Polygon product/SRS](docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md),
[ADR 0015](docs/adr/0015-simulation-polygon-qualification-boundary.md),
[ADR 0016](docs/adr/0016-distributed-product-edge-and-worker-topology.md) and the
[ADR 0016](docs/adr/0016-distributed-product-edge-and-worker-topology.md),
[ADR 0017](docs/adr/0017-provider-neutral-simulation-and-unreal-evaluation.md)
and the
[SIM S0 worker runbook](docs/runbooks/SIM_S0_AI_WORKER.md). The live-worker
launch and acceptance procedure is in
[SIM S1C live-worker runbook](docs/runbooks/SIM_S1C_LIVE_WORKER.md).

View File

@ -1,12 +1,44 @@
import type { PolygonRunState } from "./runArchive";
export type PolygonProviderRole =
| "world"
| "physics"
| "state"
| "controller"
| "transport"
| "sensor"
| "traffic";
export interface PolygonProviderDescriptor {
providerId: string;
roles: PolygonProviderRole[];
capabilities: string[];
}
export interface PolygonProviderProfile {
profileId: string;
providers: PolygonProviderDescriptor[];
clock: {
providerId: string;
domain: string;
unit: "nanoseconds";
mode: "simulation";
};
controlProfiles: string[];
canonicalFrames: {
world: "map_enu";
body: "base_link_flu";
};
}
export interface PolygonWorkerStatus {
workerId: string;
available: boolean;
controlAvailable: boolean;
activeRunId: string | null;
runState: PolygonRunState | null;
providerIds: string[];
activeProviderIds: string[];
providerProfile: PolygonProviderProfile | null;
isolation: {
network: string;
processIdentity: string;
@ -22,6 +54,10 @@ export interface PolygonVehicleState {
simTimeNs: number;
position: { x: number; y: number; z: number };
orientation: { x: number; y: number; z: number; w: number };
sourceProvider: string;
sourceTopic: string;
sourceSignal: "ground-truth";
sourceQuality: "diagnostic";
}
export interface PolygonCommandAcceptance {
@ -32,9 +68,12 @@ export interface PolygonCommandAcceptance {
validUntilSimNs: number;
speedMps: number;
steeringNormalized: number;
armed: boolean;
offboard: boolean;
deliveryProvider: string;
controlProfile: string;
accepted: true;
controllerReady: boolean;
ttlExpiredCount: number;
diagnostics: Record<string, boolean>;
}
export class PolygonWorkerContractError extends Error {}
@ -51,6 +90,17 @@ type PolygonWorkerFetch = (
) => Promise<Response>;
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const SAFE_CAPABILITY = /^[a-z0-9][a-z0-9._/-]{0,127}$/;
const SAFE_CLOCK_DOMAIN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
const PROVIDER_ROLES = new Set<PolygonProviderRole>([
"world",
"physics",
"state",
"controller",
"transport",
"sensor",
"traffic",
]);
const RUN_STATES = new Set<PolygonRunState>([
"admitted",
"starting",
@ -70,10 +120,22 @@ const STATUS_KEYS = new Set([
"control_available",
"active_run_id",
"run_state",
"provider_ids",
"active_provider_ids",
"provider_profile",
"isolation",
"authority",
]);
const PROVIDER_PROFILE_KEYS = new Set([
"schema_version",
"profile_id",
"providers",
"clock",
"control_profiles",
"canonical_frames",
]);
const PROVIDER_DESCRIPTOR_KEYS = new Set(["provider_id", "roles", "capabilities"]);
const CLOCK_KEYS = new Set(["provider_id", "domain", "unit", "mode"]);
const CANONICAL_FRAME_KEYS = new Set(["world", "body"]);
const ISOLATION_KEYS = new Set(["network", "process_identity", "artifact_policy"]);
const AUTHORITY_KEYS = new Set([
"scope",
@ -115,11 +177,12 @@ const COMMAND_KEYS = new Set([
"delivery",
]);
const COMMAND_DELIVERY_KEYS = new Set([
"provider",
"mode",
"armed",
"offboard",
"provider_id",
"control_profile",
"accepted",
"controller_ready",
"ttl_expired_count",
"diagnostics",
]);
function isRecord(value: unknown): value is Record<string, unknown> {
@ -184,11 +247,130 @@ function finiteValue(value: unknown, label: string): number {
return value;
}
function uniqueStrings(
value: unknown,
label: string,
validator: (item: unknown, itemLabel: string) => string,
): string[] {
if (!Array.isArray(value) || value.length === 0 || value.length > 32) {
throw new PolygonWorkerContractError(`${label} должен быть ограниченным массивом.`);
}
const result = value.map((item, index) => validator(item, `${label}[${index}]`));
if (new Set(result).size !== result.length) {
throw new PolygonWorkerContractError(`${label} содержит повторения.`);
}
return result;
}
function decodeProviderProfile(payload: unknown): PolygonProviderProfile {
const value = record(payload, "provider_profile");
exactKeys(value, PROVIDER_PROFILE_KEYS, "provider_profile");
if (value.schema_version !== "missioncore.simulation-provider-profile/v1") {
throw new PolygonWorkerContractError("provider_profile имеет неизвестную схему.");
}
if (!Array.isArray(value.providers) || value.providers.length === 0 || value.providers.length > 32) {
throw new PolygonWorkerContractError("provider_profile.providers имеет недопустимый размер.");
}
const providers = value.providers.map((item, index): PolygonProviderDescriptor => {
const provider = record(item, `providers[${index}]`);
exactKeys(provider, PROVIDER_DESCRIPTOR_KEYS, `providers[${index}]`);
const roles = uniqueStrings(
provider.roles,
`providers[${index}].roles`,
(role, label) => {
const result = stringValue(role, label, 32);
if (!PROVIDER_ROLES.has(result as PolygonProviderRole)) {
throw new PolygonWorkerContractError(`${label} содержит неизвестную роль.`);
}
return result;
},
) as PolygonProviderRole[];
const capabilities = uniqueStrings(
provider.capabilities,
`providers[${index}].capabilities`,
(capability, label) => {
const result = stringValue(capability, label, 128);
if (!SAFE_CAPABILITY.test(result)) {
throw new PolygonWorkerContractError(`${label} содержит небезопасную возможность.`);
}
return result;
},
);
return {
providerId: safeId(provider.provider_id, `providers[${index}].provider_id`),
roles,
capabilities,
};
});
if (new Set(providers.map((provider) => provider.providerId)).size !== providers.length) {
throw new PolygonWorkerContractError("provider_profile содержит повторяющиеся provider_id.");
}
const clockValue = record(value.clock, "provider_profile.clock");
exactKeys(clockValue, CLOCK_KEYS, "provider_profile.clock");
const clockProviderId = safeId(clockValue.provider_id, "clock.provider_id");
const clockDomain = stringValue(clockValue.domain, "clock.domain", 128);
if (
!SAFE_CLOCK_DOMAIN.test(clockDomain) ||
clockValue.unit !== "nanoseconds" ||
clockValue.mode !== "simulation"
) {
throw new PolygonWorkerContractError("provider_profile.clock нарушает v1 контракт.");
}
const clockProvider = providers.find((provider) => provider.providerId === clockProviderId);
if (!clockProvider?.capabilities.includes("clock.simulation")) {
throw new PolygonWorkerContractError("Источник simulation clock не объявлен провайдером.");
}
if (!providers.some((provider) =>
provider.roles.includes("state") &&
provider.capabilities.includes("state.vehicle-pose"))) {
throw new PolygonWorkerContractError("provider_profile не предоставляет VehicleState.");
}
const controlProfiles = uniqueStrings(
value.control_profiles,
"provider_profile.control_profiles",
(profile, label) => {
const result = stringValue(profile, label, 64);
if (!["rover-speed-steering/v1", "rover-speed-yaw-rate/v1"].includes(result)) {
throw new PolygonWorkerContractError(`${label} содержит неизвестный профиль.`);
}
return result;
},
);
const controllerCapabilities = new Set(
providers
.filter((provider) => provider.roles.includes("controller"))
.flatMap((provider) => provider.capabilities),
);
if (controlProfiles.some((profile) => !controllerCapabilities.has(`command.${profile}`))) {
throw new PolygonWorkerContractError("Контроллер не поддерживает заявленный профиль команд.");
}
const frames = record(value.canonical_frames, "canonical_frames");
exactKeys(frames, CANONICAL_FRAME_KEYS, "canonical_frames");
if (frames.world !== "map_enu" || frames.body !== "base_link_flu") {
throw new PolygonWorkerContractError("provider_profile не нормализован в ENU/FLU.");
}
return {
profileId: safeId(value.profile_id, "provider_profile.profile_id"),
providers,
clock: {
providerId: clockProviderId,
domain: clockDomain,
unit: "nanoseconds",
mode: "simulation",
},
controlProfiles,
canonicalFrames: {
world: "map_enu",
body: "base_link_flu",
},
};
}
export function decodePolygonWorkerStatus(payload: unknown): PolygonWorkerStatus {
const value = record(payload, "Статус Simulation Worker");
exactKeys(value, STATUS_KEYS, "Статус Simulation Worker");
if (
value.schema_version !== "missioncore.simulation-worker-status/v1" ||
value.schema_version !== "missioncore.simulation-worker-status/v2" ||
value.transport !== "unix" ||
value.mode !== "simulation"
) {
@ -208,11 +390,22 @@ export function decodePolygonWorkerStatus(payload: unknown): PolygonWorkerStatus
) {
throw new PolygonWorkerContractError("Simulation Worker вышел за virtual-only границу.");
}
if (!Array.isArray(value.provider_ids) || value.provider_ids.length > 32) {
throw new PolygonWorkerContractError("provider_ids должен быть ограниченным массивом.");
if (!Array.isArray(value.active_provider_ids) || value.active_provider_ids.length > 32) {
throw new PolygonWorkerContractError("active_provider_ids должен быть ограниченным массивом.");
}
const activeProviderIds = value.active_provider_ids.map((item, index) =>
safeId(item, `active_provider_ids[${index}]`));
if (new Set(activeProviderIds).size !== activeProviderIds.length) {
throw new PolygonWorkerContractError("active_provider_ids содержит повторения.");
}
const available = booleanValue(value.available, "available");
const controlAvailable = booleanValue(value.control_available, "control_available");
const providerProfile = value.provider_profile === null
? null
: decodeProviderProfile(value.provider_profile);
if ((available && providerProfile === null) || (!available && controlAvailable)) {
throw new PolygonWorkerContractError("Доступность worker и provider_profile противоречат.");
}
const providerIds = value.provider_ids.map((item, index) =>
safeId(item, `provider_ids[${index}]`));
const activeRunId = value.active_run_id === null
? null
: safeId(value.active_run_id, "active_run_id");
@ -227,11 +420,12 @@ export function decodePolygonWorkerStatus(payload: unknown): PolygonWorkerStatus
}
return {
workerId: safeId(value.worker_id, "worker_id"),
available: booleanValue(value.available, "available"),
controlAvailable: booleanValue(value.control_available, "control_available"),
available,
controlAvailable,
activeRunId,
runState: runStateValue as PolygonRunState | null,
providerIds,
activeProviderIds,
providerProfile,
isolation: {
network: stringValue(isolation.network, "isolation.network", 64),
processIdentity: safeId(isolation.process_identity, "isolation.process_identity"),
@ -259,11 +453,10 @@ export function decodePolygonVehicleState(payload: unknown): PolygonVehicleState
const source = record(value.source, "source");
exactKeys(source, SOURCE_KEYS, "source");
if (
source.provider !== "gazebo" ||
source.signal !== "ground-truth" ||
source.quality !== "diagnostic"
) {
throw new PolygonWorkerContractError("Live-сигнал не маркирован как Gazebo diagnostic.");
throw new PolygonWorkerContractError("Live-сигнал не маркирован как diagnostic ground truth.");
}
const safety = record(value.safety, "safety");
exactKeys(safety, SAFETY_KEYS, "safety");
@ -295,6 +488,10 @@ export function decodePolygonVehicleState(payload: unknown): PolygonVehicleState
z: finiteValue(orientation.z, "orientation.z"),
w: finiteValue(orientation.w, "orientation.w"),
},
sourceProvider: safeId(source.provider, "source.provider"),
sourceTopic: stringValue(source.topic, "source.topic", 512),
sourceSignal: "ground-truth",
sourceQuality: "diagnostic",
};
}
@ -302,19 +499,26 @@ export function decodePolygonCommandAcceptance(payload: unknown): PolygonCommand
const value = record(payload, "Подтверждение команды");
exactKeys(value, COMMAND_KEYS, "Подтверждение команды");
if (
value.schema_version !== "missioncore.command-acceptance/v1" ||
value.schema_version !== "missioncore.command-acceptance/v2" ||
value.authority_scope !== "virtual-only"
) {
throw new PolygonWorkerContractError("Команда вышла за virtual-only контракт.");
}
const delivery = record(value.delivery, "delivery");
exactKeys(delivery, COMMAND_DELIVERY_KEYS, "delivery");
if (
delivery.provider !== "px4-ros2-offboard" ||
delivery.mode !== "speed-steering"
) {
throw new PolygonWorkerContractError("Команда подтверждена неизвестным PX4 adapter.");
if (delivery.control_profile !== "rover-speed-steering/v1" || delivery.accepted !== true) {
throw new PolygonWorkerContractError("Команда подтверждена несовместимым adapter.");
}
const diagnosticsValue = record(delivery.diagnostics, "delivery.diagnostics");
if (Object.keys(diagnosticsValue).length > 16) {
throw new PolygonWorkerContractError("delivery.diagnostics превышает допустимый размер.");
}
const diagnostics = Object.fromEntries(
Object.entries(diagnosticsValue).map(([key, item]) => [
safeId(key, "delivery.diagnostics key"),
booleanValue(item, `delivery.diagnostics.${key}`),
]),
);
const sequence = integerValue(value.sequence, "sequence");
const issuedAtSimNs = integerValue(value.issued_at_sim_ns, "issued_at_sim_ns");
const validUntilSimNs = integerValue(value.valid_until_sim_ns, "valid_until_sim_ns");
@ -342,12 +546,15 @@ export function decodePolygonCommandAcceptance(payload: unknown): PolygonCommand
validUntilSimNs,
speedMps,
steeringNormalized,
armed: booleanValue(delivery.armed, "delivery.armed"),
offboard: booleanValue(delivery.offboard, "delivery.offboard"),
deliveryProvider: safeId(delivery.provider_id, "delivery.provider_id"),
controlProfile: "rover-speed-steering/v1",
accepted: true,
controllerReady: booleanValue(delivery.controller_ready, "delivery.controller_ready"),
ttlExpiredCount: integerValue(
delivery.ttl_expired_count,
"delivery.ttl_expired_count",
),
diagnostics,
};
}

View File

@ -136,6 +136,12 @@ export function PolygonLivePanel() {
}
}, [worker?.activeRunId]);
const controllerProvider = worker?.providerProfile?.providers.find((provider) =>
provider.roles.includes("controller"));
const providerStack = worker?.providerProfile?.providers
.map((provider) => provider.providerId)
.join(" · ");
const stopMotion = async () => {
const runId = worker?.activeRunId;
setControlIntent(null);
@ -184,7 +190,10 @@ export function PolygonLivePanel() {
<div>
<span className="section-eyebrow">ПОЛИГОН / LIVE</span>
<h2>Stock Ackermann Rover</h2>
<p>PX4/Gazebo исполняются на отдельном worker; браузер показывает канонический срез.</p>
<p>
Провайдеры симуляции исполняются на отдельном worker; браузер показывает
канонический срез ENU/FLU.
</p>
</div>
<div>
<StatusBadge
@ -198,7 +207,7 @@ export function PolygonLivePanel() {
disabled={!worker?.controlAvailable || actionBusy}
onClick={() => void runAction()}
>
{actionBusy ? "Ожидаем PX4/Gazebo…" : runActive ? "Остановить" : "Запустить"}
{actionBusy ? "Ожидаем провайдеры…" : runActive ? "Остановить" : "Запустить"}
</Button>
</div>
</header>
@ -221,9 +230,11 @@ export function PolygonLivePanel() {
<div className="polygon-live-telemetry">
<div className="polygon-rover-controls">
<div className="polygon-rover-controls__heading">
<span>Управление PX4</span>
<StatusBadge tone={commandAcceptance?.offboard ? "success" : "neutral"}>
{commandAcceptance?.offboard ? "Armed · Offboard" : "Ожидает прогона"}
<span>
Управление {controllerProvider?.providerId ?? "машиной"}
</span>
<StatusBadge tone={commandAcceptance?.controllerReady ? "success" : "neutral"}>
{commandAcceptance?.controllerReady ? "Controller ready" : "Ожидает прогона"}
</StatusBadge>
</div>
<div className="polygon-rover-controls__grid">
@ -270,8 +281,16 @@ export function PolygonLivePanel() {
</strong>
</div>
<div>
<span>Провайдеры</span>
<strong>{worker?.providerIds.join(" · ") || "—"}</strong>
<span>Провайдерный стек</span>
<strong>{providerStack || "—"}</strong>
</div>
<div>
<span>Активные процессы</span>
<strong>{worker?.activeProviderIds.join(" · ") || "—"}</strong>
</div>
<div>
<span>Источник live-состояния</span>
<strong>{live ? `${live.sourceProvider} · ${live.sourceSignal}` : "—"}</strong>
</div>
<div>
<span>Последняя команда</span>
@ -284,8 +303,9 @@ export function PolygonLivePanel() {
<div className="polygon-live-boundary">
<StatusBadge tone="success">Virtual only</StatusBadge>
<p>
Команды проходят PX4 ROS 2 Offboard только в SITL. Физического actuator
authority нет; live-поза остаётся диагностической.
Команды проходят только через заявленный controller provider
{controllerProvider ? ` ${controllerProvider.providerId}` : ""}. Физического
actuator authority нет; live-поза остаётся диагностической.
</p>
</div>
{error && <p className="polygon-live-error">{error}</p>}

View File

@ -175,7 +175,7 @@ function jsonResponse(payload, status = 200) {
function workerStatus(overrides = {}) {
return {
schema_version: "missioncore.simulation-worker-status/v1",
schema_version: "missioncore.simulation-worker-status/v2",
worker_id: "mission-gpu-s1",
transport: "unix",
mode: "simulation",
@ -183,7 +183,44 @@ function workerStatus(overrides = {}) {
control_available: true,
active_run_id: "s1c-6cb1495-20260724t180000z-aabbcc",
run_state: "running",
provider_ids: ["micro-xrce-dds-agent", "px4-gazebo-stock-rover"],
active_provider_ids: ["micro-xrce-dds-agent", "px4-gazebo-stock-rover"],
provider_profile: {
schema_version: "missioncore.simulation-provider-profile/v1",
profile_id: "stock-rover-gazebo-px4-s1d",
providers: [
{
provider_id: "gazebo",
roles: ["world", "physics", "state", "sensor"],
capabilities: [
"clock.simulation",
"state.vehicle-pose",
"truth.ground-truth",
"sensor.virtual",
],
},
{
provider_id: "px4-ros2-offboard",
roles: ["controller"],
capabilities: ["command.rover-speed-steering/v1"],
},
{
provider_id: "micro-xrce-dds-agent",
roles: ["transport"],
capabilities: ["transport.ros2"],
},
],
clock: {
provider_id: "gazebo",
domain: "gazebo:/clock",
unit: "nanoseconds",
mode: "simulation",
},
control_profiles: ["rover-speed-steering/v1"],
canonical_frames: {
world: "map_enu",
body: "base_link_flu",
},
},
isolation: {
network: "loopback-only-netns",
process_identity: "missioncore",
@ -229,7 +266,7 @@ function vehicleState(overrides = {}) {
function commandAcceptance(overrides = {}) {
return {
schema_version: "missioncore.command-acceptance/v1",
schema_version: "missioncore.command-acceptance/v2",
run_id: "s1c-6cb1495-20260724t180000z-aabbcc",
command_id: "cmd-aabbcc",
sequence: 1,
@ -239,11 +276,15 @@ function commandAcceptance(overrides = {}) {
steering_normalized: -0.55,
authority_scope: "virtual-only",
delivery: {
provider: "px4-ros2-offboard",
mode: "speed-steering",
armed: true,
offboard: true,
provider_id: "px4-ros2-offboard",
control_profile: "rover-speed-steering/v1",
accepted: true,
controller_ready: true,
ttl_expired_count: 0,
diagnostics: {
armed: true,
offboard: true,
},
},
...overrides,
};
@ -353,7 +394,8 @@ test("Polygon Live decodes only D-only virtual diagnostic state", () => {
assert.equal(status.activeRunId, live.runId);
assert.equal(status.isolation.artifactPolicy, "d-only");
assert.deepEqual(live.position, { x: 1.25, y: -0.5, z: 0.18 });
assert.equal(command.offboard, true);
assert.equal(command.controllerReady, true);
assert.equal(command.deliveryProvider, "px4-ros2-offboard");
assert.equal(command.steeringNormalized, -0.55);
assert.throws(
() => decodePolygonWorkerStatus(workerStatus({
@ -381,12 +423,67 @@ test("Polygon Live decodes only D-only virtual diagnostic state", () => {
);
});
test("Polygon Live accepts an Unreal provider behind the canonical contract", () => {
const unrealStatus = workerStatus({
provider_profile: {
...workerStatus().provider_profile,
profile_id: "unreal-native-rover-v1",
providers: [
{
provider_id: "unreal-native",
roles: ["world", "physics", "state", "sensor"],
capabilities: [
"clock.simulation",
"state.vehicle-pose",
"truth.ground-truth",
"sensor.virtual",
],
},
{
provider_id: "unreal-direct-control",
roles: ["controller"],
capabilities: ["command.rover-speed-steering/v1"],
},
],
clock: {
provider_id: "unreal-native",
domain: "unreal:fixed-step",
unit: "nanoseconds",
mode: "simulation",
},
},
});
const status = decodePolygonWorkerStatus(unrealStatus);
const live = decodePolygonVehicleState(vehicleState({
source: {
provider: "unreal-native",
topic: "missioncore/vehicle-state",
signal: "ground-truth",
quality: "diagnostic",
},
}));
const command = decodePolygonCommandAcceptance(commandAcceptance({
delivery: {
provider_id: "unreal-direct-control",
control_profile: "rover-speed-steering/v1",
accepted: true,
controller_ready: true,
ttl_expired_count: 0,
diagnostics: { fixed_step: true },
},
}));
assert.equal(status.providerProfile.clock.domain, "unreal:fixed-step");
assert.equal(live.sourceProvider, "unreal-native");
assert.equal(command.deliveryProvider, "unreal-direct-control");
});
test("Polygon Live uses same-origin gateway and idempotent lifecycle requests", async () => {
const calls = [];
const stopped = workerStatus({
active_run_id: null,
run_state: null,
provider_ids: [],
active_provider_ids: [],
});
const fetcher = async (url, init) => {
calls.push({ url: String(url), init });

View File

@ -24,7 +24,7 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
| Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process |
| 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; S1A complete; S1B lifecycle PASS; S1C registered live worker PASS; UI-2 browser surface PASS; S1D Ackermann command/motion slice PASS — exact code generation `49b0f47` passed 40 focused tests on the immutable D-only generation. The capability-gated `Полигон` root owns one procedural 3D Ackermann Rover workspace with full azimuth/vertical orbit, zoom, pan, follow/reset and live diagnostic ENU state. Cold start completed in one request after 32.08 s. Run `s1c-49b0f47-20260724t195259z-62edd2` proved 3.74 m straight motion, a curved turn, reverse, 44.6 ms sampled command admission, 250 ms TTL expiry and clean zero-residue stop through PX4 rover-level Offboard throttle/steering setpoints. Differential Rover, calibrated closed-loop speed, canonical PX4/ROS 2 telemetry, pause/step/reset, heartbeat/offboard/link-loss cases, navigation/safety acceptance, shared-deployment auth/RBAC and real actuator authority remain absent |
| Simulation Polygon | SIM S0 GO; S1AS1D lifecycle/browser/Ackermann command slices PASS on the accepted Gazebo/PX4 regression profile. S1E now makes provider interchangeability executable through a strict provider profile, generic live-state source and generic command acceptance. Gazebo remains the frozen low-cost regression tier; ADR 0017 gates a measured native Unreal/CARLA/Project AirSim U0 selection before any production bridge. Differential Rover, calibrated closed-loop speed, canonical PX4/ROS 2 telemetry, pause/step/reset, heartbeat/offboard/link-loss cases, navigation/safety acceptance, shared-deployment auth/RBAC and real actuator authority remain absent |
USB project copying remains optional ground truth rather than a blocker for the
now-verified network path. Owner-operated LixelGO traffic verifies the MQTT
@ -128,8 +128,9 @@ The Polygon branch follows
[`docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md`](12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md)
and
[`ADR 0015`](adr/0015-simulation-polygon-qualification-boundary.md). Component
placement and field/offline topology follow
[`ADR 0016`](adr/0016-distributed-product-edge-and-worker-topology.md).
placement follows [`ADR 0016`](adr/0016-distributed-product-edge-and-worker-topology.md);
provider neutrality and the Unreal evaluation follow
[`ADR 0017`](adr/0017-provider-neutral-simulation-and-unreal-evaluation.md).
It does not reorder or weaken the K1 physical-evidence gates in this document.
Its first gate, SIM S0, is accepted:

View File

@ -13,7 +13,7 @@ Ops source of truth:
- [MISSIONCOR-41](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-41) is the
active S1 lifecycle/authority implementation and qualification gate.
This document, ADR 0015 and ADR 0016 are the repository truth. A material change
This document, ADR 0015, ADR 0016 and ADR 0017 are the repository truth. A material change
to run kinds, authority, clocks, frames, source-of-record, provider boundaries,
component placement or phase gates must update the ADR/SRS and MISSIONCOR-39
together.
@ -103,6 +103,15 @@ Polygon is now a parallel product branch. As of this document:
- this is not a calibrated closed-loop speed controller acceptance and does
not close Differential Rover, canonical PX4/ROS 2 telemetry,
heartbeat/offboard/link-loss, failsafe, navigation or safety checkers;
- the next repository slice introduces
`missioncore.simulation-provider-profile/v1`, worker status v2 and command
acceptance v2. Runtime roles/capabilities, authoritative simulation clock,
canonical frames and control profiles are no longer inferred from hardcoded
Gazebo/PX4 names;
- the stock Gazebo/PX4 profile remains the accepted regression baseline.
Unreal is a candidate high-fidelity provider family; native Unreal, CARLA and
Project AirSim require the bounded U0 comparison from ADR 0017 before one is
selected;
- `actuator_authority=false`;
- `navigation_or_safety_accepted=false`.
@ -166,10 +175,13 @@ Mission Core owns:
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.
- a world/physics/state provider owns simulated world state, sensors, contacts,
truth and the declared simulation clock; the accepted regression provider is
Gazebo, while Unreal candidates remain unaccepted;
- a controller provider owns rover control loops, constraints, command
lifecycle and controller failsafe; the accepted stock controller is PX4 SITL;
- a transport provider carries typed telemetry and commands; the stock profile
uses ROS 2 and Micro XRCE-DDS;
- Nav2 supplies the first mature planning/costmap/collision-checking baseline.
- Rerun or another viewer presents derived evidence and never becomes a queue,
orchestrator or source-of-record.
@ -201,14 +213,15 @@ Simulation Orchestrator
+---- 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
+---- provider profile -- roles/capabilities/clock/frames
+---- world adapter ----- Gazebo baseline or admitted Unreal profile
+---- control adapter --- PX4 SITL or another admitted controller
+---- transport adapter - ROS 2/XRCE or another admitted transport
+---- planner adapter --- Nav2 or Mission Core planner
+---- viewer adapter ---- canonical state/derived report
```
Gazebo and PX4 never run inside the web process. The browser never owns process
Native simulation and controller providers never run inside the web process. The browser never owns process
lifecycle or command authority. Losing the browser cannot terminate the only
copy of run state and cannot bypass the server-side authority gate.
@ -388,7 +401,10 @@ last. An orphan process is an S0/S1 failure.
For `simulation_closed_loop`:
- Gazebo `/clock` is authoritative.
- the admitted provider profile declares exactly one authoritative simulation
clock and a provider with `clock.simulation`;
- the stock regression profile uses Gazebo `/clock`; an Unreal adapter must
publish a distinct fixed-step clock domain;
- ROS 2 consumers use `use_sim_time=true`.
- PX4 uXRCE-DDS time synchronization is disabled when Gazebo time is used.
- Canonical timestamps are integer nanoseconds and include their clock domain.
@ -496,7 +512,7 @@ incompatible.
## 14. Upstream baseline
The S0 accepted line is:
The S0 accepted regression line is:
- Windows 11 AI worker;
- dedicated `MissionCore-Sim` WSL2 Ubuntu 24.04 distribution physically on D;
@ -558,6 +574,8 @@ context and are not misattributed to headless S0.
| P0 | 24 days | Product thesis, SRS, ADR and canonical contracts accepted |
| S0 | 35 days | Target compatibility, D-only runtime, exact pins, clock/process/resource evidence |
| S1 | 712 days | Stock Ackermann and Differential lifecycle/control/failsafe evidence |
| U0 | 24 days | Native Unreal/CARLA/Project AirSim selection matrix with measured worker fit |
| U1 | 510 days | Selected Unreal candidate proves canonical clock/state/command lifecycle |
| S2 | 1525 days | LiDAR/odometry/Nav2 baseline, evaluator and obstacle scenarios |
| S2B | 58 days | Representative 30-world BARN pilot |
| S3 | 2035 days | K1-like virtual sensors and Mission Core perception closed loop |
@ -572,12 +590,14 @@ Implementation cards follow this order:
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.
6. Provider-neutral profile/status/command boundary.
7. Complete the U0 Unreal candidate comparison; U1 may run in parallel.
8. Nav2 adapter and truth baseline on the frozen Gazebo profile.
9. Evaluator, report and BARN pilot.
10. Virtual K1 and perception; promote an Unreal provider only after U1.
11. Replay/shadow datasets.
12. RAVNOVES00 twin and Unreal physical/sensor world if U2 is accepted.
13. Trike, HIL and safety gate.
## 17. SIM S0 acceptance
@ -761,6 +781,27 @@ Differential Rover, deceleration/emergency-stop profiles, accepted PX4/ROS 2
telemetry, reset/repeatability, heartbeat/offboard/link-loss, full failsafe and
navigation/safety cases.
#### S1E provider-neutral boundary status
The next repository increment makes provider interchangeability executable
rather than aspirational:
- `SimulationProviderProfile` strictly declares logical provider roles,
capabilities, authoritative simulation clock, canonical ENU/FLU frames and
supported rover control profiles;
- worker status v2 separates the configured provider stack from currently
active process IDs;
- `VehicleState` retains the v1 canonical frame/safety contract but accepts any
safe named diagnostic ground-truth provider;
- command acceptance v2 reports a generic controller provider, canonical
control profile, readiness and bounded boolean diagnostics;
- the stock Gazebo/PX4/XRCE profile satisfies the new contract;
- backend and browser tests admit an Unreal-shaped profile without adding
Unreal-specific branches.
This is contract acceptance only. It does not claim that Unreal is installed,
selected, running or qualified. ADR 0017 U0 is the next decision gate.
### S2
- Gazebo LiDAR/odometry feed the ROS 2/Nav2 baseline.
@ -846,6 +887,10 @@ history. It does not claim command authority. Scenarios, pause/step/reset,
canonical rover commands, Compare and Report remain planned. UI-2 cannot own
provider processes, bypass authority checks or write directly to PX4.
The browser scene remains a canonical-state product view, not the physics
engine. A future Unreal worker supplies the same state/evidence contracts; it
is not embedded into React and its native editor/window remains diagnostic.
## 21. Change control
A material change requires:

View File

@ -35,9 +35,10 @@ reports easy to misuse.
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.
6. Each admitted closed-loop provider profile declares exactly one
authoritative simulation clock. The accepted stock profile uses Gazebo
`/clock`; ROS 2 uses simulated time and PX4 uXRCE-DDS time synchronization
is disabled for that profile.
7. Mission Core uses ENU/FLU. PX4 uses NED/FRD. Exactly one adapter boundary
converts frames and requires golden tests.
8. The first command boundary permits only rover speed+steering and
@ -67,6 +68,10 @@ reports easy to misuse.
[ADR 0016](0016-distributed-product-edge-and-worker-topology.md). Native
Gazebo GUI is worker-local diagnostics; the operator product remains the
browser Control Station over canonical state.
18. Provider roles, capabilities, clock and canonical frames follow
[ADR 0017](0017-provider-neutral-simulation-and-unreal-evaluation.md).
Gazebo remains the regression baseline; Unreal is an unevaluated
high-fidelity provider family, not a replacement product architecture.
## Consequences

View File

@ -0,0 +1,179 @@
# ADR 0017: Provider-neutral simulation and Unreal evaluation
## Status
Accepted for architecture on 2026-07-25. The provider contract is implemented
on the Polygon branch. Gazebo remains the accepted regression baseline. No
Unreal integration, Unreal runtime profile or choice between native Unreal,
CARLA and Project AirSim is accepted by this decision.
## Context
The first Polygon vertical proved that Mission Core can own a virtual Ackermann
run, display canonical ENU/FLU state in React, persist evidence and deliver
bounded virtual-only rover commands through PX4. That proof used one concrete
stack: Gazebo Harmonic, PX4 SITL, ROS 2 and Micro XRCE-DDS.
Treating that stack as the product architecture would make later work expensive:
- a realistic vehicle, road surface, terrain and sensor model may be easier to
build and inspect in Unreal;
- reconstructed Gaussian scenes are primarily visual evidence and do not by
themselves supply collision, friction, road or navigation geometry;
- native Unreal, CARLA and Project AirSim have different vehicle, sensor,
controller, platform and lifecycle assumptions;
- a browser cannot and should not execute the full physics/autopilot stack;
- replacing Gazebo must not replace Mission Core run identity, authority,
evidence, UI or evaluator contracts.
The choice is therefore not “Gazebo or Unreal for the whole product.” It is
which worker-side provider profile is appropriate for each qualification case.
## Decision
1. Mission Core remains simulation-provider neutral. A worker publishes an
exact `missioncore.simulation-provider-profile/v1` before it may be used by
the browser or gateway.
2. A provider profile declares logical providers, roles, capabilities,
authoritative simulation clock, supported canonical control profiles and
the fixed `map_enu`/`base_link_flu` boundary.
3. Provider roles are `world`, `physics`, `state`, `controller`, `transport`,
`sensor` and `traffic`. One process may implement several roles; several
processes may compose one profile.
4. Component pins and provider roles remain separate:
component pins prove exact software provenance, while provider descriptors
explain runtime responsibility and interoperability.
5. Worker status v2 exposes the configured provider profile separately from
currently active process IDs. `VehicleState` names its source provider.
Command acceptance v2 names the delivering controller provider and canonical
control profile.
6. The browser consumes canonical state and evidence. Unreal, Gazebo, PX4,
ROS 2 and their native windows remain outside React and run on registered
workers.
7. Gazebo/PX4 remains the frozen, comparatively cheap deterministic regression
profile until another profile passes the same lifecycle, clock, command,
safety and evidence gates.
8. Unreal is evaluated as a high-fidelity provider family. Native Unreal,
CARLA and Project AirSim are separate candidates and are not interchangeable
labels:
- native Unreal maximizes project control but requires Mission Core-owned
vehicle, sensor, clock and bridge work;
- CARLA is evaluated when road/traffic simulation and its supported vehicle
model fit the rover case;
- Project AirSim is evaluated only against its current supported vehicle,
PX4, ROS 2, platform and maintenance constraints.
9. A Gaussian scene may provide appearance and sensor-rendering context. Every
qualification world still needs separately versioned physical collision
meshes, road/drivable surfaces, materials/friction, semantic regions,
spawn/goal definitions and evaluator truth.
10. Unreal does not block S1 completion or the S2 Gazebo/Nav2 baseline. It
becomes most valuable in S3 virtual-sensor/perception work and S5
high-fidelity/digital-twin work. An earlier bounded adapter spike is allowed
because it reduces architectural risk.
11. PX4 remains a replaceable controller provider. An Unreal profile may use
PX4 through an admitted external-simulator boundary or may use a different
virtual controller, but each choice creates a distinct profile and cannot
inherit the other profile's evidence.
12. No real actuator authority is introduced. All current profiles remain
`virtual-only`.
## Fidelity ladder
| Level | Purpose | Initial provider |
| --- | --- | --- |
| L0 contract tests | Fast lifecycle, schema and safety regression | In-process fakes |
| L1 dynamics regression | Cheap repeated control/navigation qualification | Gazebo + PX4 SITL |
| L2 sensor/scene fidelity | Camera/LiDAR/perception and difficult visual worlds | Unreal candidate |
| L3 hardware/control integration | Controller-in-loop and HIL | Separate gated profile |
| L4 physical evidence | Shadow and later controlled field qualification | Vehicle Edge Agent |
A higher level does not replace lower-level regression. Evidence is comparable
only when scenario, provider profile, metric profile and reproducibility tier
allow it.
## Unreal evaluation gates
### U0 — selection spike
For native Unreal, CARLA and Project AirSim, record:
- supported rover/Ackermann dynamics and controllable wheel/suspension model;
- fixed-step clock, pause/step/reset and headless execution;
- external command/state bridge and PX4 compatibility;
- camera, depth, LiDAR, IMU, GNSS and ground-truth availability;
- collision/contact and semantic truth;
- Linux/Windows worker placement, GPU/VRAM/RAM and startup time;
- automation, packaging, licensing, upstream activity and version pinning;
- import path for existing Unreal assets and Gaussian reconstructions.
Exit: one candidate or native implementation is selected for a disposable U1
adapter. “Looks better” is not an exit criterion.
### U1 — canonical adapter proof
- publish one provider profile and authoritative fixed-step clock;
- launch/reset/stop through the Simulation Orchestrator;
- map a stock Ackermann pose to `VehicleState`;
- accept `rover-speed-steering/v1` under the existing TTL/watchdog boundary;
- prove straight, turn, reverse, command expiry and clean zero-residue stop;
- retain exact world, provider, host and resource evidence on D.
Exit: the existing React panel and run archive work without Unreal-specific
branches in Mission Core.
### U2 — physical and sensor world
- add reviewed collision/drivable geometry alongside visual assets/Gaussians;
- pin vehicle mass, inertia, wheelbase, steering limits, suspension, friction
and sensor extrinsics;
- publish evaluator-only truth separately from planner/perception inputs;
- validate camera/LiDAR timing, noise and frame conversion.
Exit: sensor/perception experiments are repeatable and do not consume hidden
ground truth.
### U3 — comparative qualification
- run equivalent scenarios through the Gazebo and Unreal profiles;
- report control, collision, latency, resource and reproducibility differences;
- declare which conclusions are portable across providers and which are not.
Exit: Unreal supplies measurable product value beyond rendering quality.
## Immediate work policy
Continue work that remains valuable across providers:
- provider-neutral orchestration and capability admission;
- run, event, command, artifact and report contracts;
- TTL, heartbeat, watchdog, failsafe and authority gates;
- canonical frames, clocks, telemetry and evaluator metrics;
- scenario/profile registry and browser presentation of canonical state;
- Gazebo regression cases needed to validate those boundaries.
Defer until U0 selects a direction:
- a polished custom Gazebo vehicle;
- large Gazebo world/model libraries;
- simulator-specific UI controls;
- a production Unreal bridge;
- Gaussian collision or road generation assumptions;
- provider-specific perception contracts.
## Consequences
- Existing Polygon work is retained as the control plane and regression tier.
- Unreal experimentation can proceed independently without forking the product
model or exposing a native engine to the browser.
- The first Unreal work is a measured adapter spike, not a platform migration.
- More explicit provider metadata and compatibility testing is required.
- Two physics providers increase qualification cost, but allow fast regression
and high-fidelity experiments to coexist.
## References
- [PX4 simulator MAVLink API](https://docs.px4.io/main/en/simulation/)
- [CARLA documentation](https://carla.readthedocs.io/)
- [Project AirSim documentation](https://iamaisim.github.io/ProjectAirSim/)
- [ASAM OpenDRIVE](https://www.asam.net/standards/detail/opendrive/)

View File

@ -48,14 +48,62 @@ Expected status through the backend gateway:
```json
{
"schema_version": "missioncore.simulation-worker-status/v1",
"schema_version": "missioncore.simulation-worker-status/v2",
"worker_id": "mission-gpu-s1",
"transport": "unix",
"mode": "simulation",
"available": true,
"control_available": true,
"active_run_id": null,
"run_state": null
"run_state": null,
"active_provider_ids": [],
"provider_profile": {
"schema_version": "missioncore.simulation-provider-profile/v1",
"profile_id": "stock-rover-gazebo-px4-s1d",
"providers": [
{
"provider_id": "gazebo",
"roles": ["world", "physics", "state", "sensor"],
"capabilities": [
"clock.simulation",
"state.vehicle-pose",
"truth.ground-truth",
"sensor.virtual"
]
},
{
"provider_id": "px4-ros2-offboard",
"roles": ["controller"],
"capabilities": ["command.rover-speed-steering/v1"]
},
{
"provider_id": "micro-xrce-dds-agent",
"roles": ["transport"],
"capabilities": ["transport.ros2"]
}
],
"clock": {
"provider_id": "gazebo",
"domain": "gazebo:/clock",
"unit": "nanoseconds",
"mode": "simulation"
},
"control_profiles": ["rover-speed-steering/v1"],
"canonical_frames": {
"world": "map_enu",
"body": "base_link_flu"
}
},
"isolation": {
"network": "loopback-only-netns",
"process_identity": "missioncore",
"artifact_policy": "d-only"
},
"authority": {
"scope": "virtual-only",
"actuator_authority": false,
"direct_actuator_setpoints_allowed": false
}
}
```

View File

@ -31,6 +31,14 @@ from k1link.simulation.process_supervisor import (
ProcessStopResult,
ProcessSupervisorError,
)
from k1link.simulation.provider_contract import (
PROVIDER_PROFILE_SCHEMA,
ProviderRole,
SimulationClockDescriptor,
SimulationProviderContractError,
SimulationProviderDescriptor,
SimulationProviderProfile,
)
from k1link.simulation.run_store import (
QualificationRunConflictError,
QualificationRunIntegrityError,
@ -83,7 +91,9 @@ __all__ = [
"ProcessSpec",
"ProcessStopResult",
"ProcessSupervisorError",
"PROVIDER_PROFILE_SCHEMA",
"ProviderPin",
"ProviderRole",
"QualificationArtifact",
"QualificationEvent",
"QualificationRun",
@ -105,8 +115,12 @@ __all__ = [
"StockRoverProfileError",
"StockRoverTargetPaths",
"SimulationContractError",
"SimulationClockDescriptor",
"SimulationApplicationService",
"SimulationOrchestratorError",
"SimulationProviderContractError",
"SimulationProviderDescriptor",
"SimulationProviderProfile",
"SimulationWorkerPort",
"SimulationWorldControl",
"WorkerAdmission",

View File

@ -11,6 +11,7 @@ from typing import Any, Final
IDENTIFIER_PATTERN: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
SHA256_PATTERN: Final = re.compile(r"^[a-f0-9]{64}$")
GIT_REVISION_PATTERN: Final = re.compile(r"^[a-f0-9]{7,64}$")
CLOCK_DOMAIN_PATTERN: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$")
class SimulationContractError(ValueError):
@ -243,11 +244,8 @@ class QualificationRun:
_sha256(self.host_profile_sha256, "host profile digest")
if self.seed < 0:
raise SimulationContractError("run seed must not be negative")
if self.clock_domain != "gazebo:/clock" and self.kind in {
RunKind.SIMULATION_CLOSED_LOOP,
RunKind.DIGITAL_TWIN_CLOSED_LOOP,
}:
raise SimulationContractError("closed-loop simulation requires Gazebo /clock")
if not CLOCK_DOMAIN_PATTERN.fullmatch(self.clock_domain):
raise SimulationContractError("clock domain is not a safe identifier")
_utc_timestamp(self.created_at_utc, "created timestamp")
if self.started_at_utc is not None:
_utc_timestamp(self.started_at_utc, "started timestamp")

View File

@ -0,0 +1,264 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import StrEnum
from typing import Any, Final
from k1link.simulation.contracts import ControlProfile
PROVIDER_PROFILE_SCHEMA: Final = "missioncore.simulation-provider-profile/v1"
IDENTIFIER_PATTERN: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
CAPABILITY_PATTERN: Final = re.compile(r"^[a-z0-9][a-z0-9._/-]{0,127}$")
CLOCK_DOMAIN_PATTERN: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$")
COMMAND_CAPABILITY_BY_PROFILE: Final = {
ControlProfile.ROVER_SPEED_STEERING_V1: "command.rover-speed-steering/v1",
ControlProfile.ROVER_SPEED_YAW_RATE_V1: "command.rover-speed-yaw-rate/v1",
}
class SimulationProviderContractError(ValueError):
"""A simulation provider profile violates the admitted v1 contract."""
class ProviderRole(StrEnum):
WORLD = "world"
PHYSICS = "physics"
STATE = "state"
CONTROLLER = "controller"
TRANSPORT = "transport"
SENSOR = "sensor"
TRAFFIC = "traffic"
@dataclass(frozen=True, slots=True)
class SimulationProviderDescriptor:
provider_id: str
roles: tuple[ProviderRole, ...]
capabilities: tuple[str, ...]
def __post_init__(self) -> None:
_identifier(self.provider_id, "provider id")
if not self.roles:
raise SimulationProviderContractError("provider roles must not be empty")
if any(not isinstance(role, ProviderRole) for role in self.roles):
raise SimulationProviderContractError("provider role is unknown")
if len(self.roles) != len(set(self.roles)):
raise SimulationProviderContractError("provider roles must be unique")
if not self.capabilities:
raise SimulationProviderContractError("provider capabilities must not be empty")
if len(self.capabilities) != len(set(self.capabilities)):
raise SimulationProviderContractError("provider capabilities must be unique")
for capability in self.capabilities:
_capability(capability)
def to_dict(self) -> dict[str, object]:
return {
"provider_id": self.provider_id,
"roles": [role.value for role in self.roles],
"capabilities": list(self.capabilities),
}
@classmethod
def from_dict(cls, value: object) -> SimulationProviderDescriptor:
document = _object(value, "provider descriptor")
_exact_keys(document, {"provider_id", "roles", "capabilities"}, "provider descriptor")
roles = _array(document, "roles")
capabilities = _array(document, "capabilities")
try:
parsed_roles = tuple(
ProviderRole(_string_value(role, "provider role")) for role in roles
)
except ValueError as exc:
raise SimulationProviderContractError("provider role is unknown") from exc
return cls(
provider_id=_string(document, "provider_id"),
roles=parsed_roles,
capabilities=tuple(
_string_value(capability, "provider capability") for capability in capabilities
),
)
@dataclass(frozen=True, slots=True)
class SimulationClockDescriptor:
provider_id: str
domain: str
unit: str = "nanoseconds"
mode: str = "simulation"
def __post_init__(self) -> None:
_identifier(self.provider_id, "clock provider id")
if not CLOCK_DOMAIN_PATTERN.fullmatch(self.domain):
raise SimulationProviderContractError("clock domain is not safe")
if self.unit != "nanoseconds" or self.mode != "simulation":
raise SimulationProviderContractError(
"v1 provider clocks must use simulation nanoseconds"
)
def to_dict(self) -> dict[str, object]:
return {
"provider_id": self.provider_id,
"domain": self.domain,
"unit": self.unit,
"mode": self.mode,
}
@classmethod
def from_dict(cls, value: object) -> SimulationClockDescriptor:
document = _object(value, "simulation clock")
_exact_keys(
document,
{"provider_id", "domain", "unit", "mode"},
"simulation clock",
)
return cls(
provider_id=_string(document, "provider_id"),
domain=_string(document, "domain"),
unit=_string(document, "unit"),
mode=_string(document, "mode"),
)
@dataclass(frozen=True, slots=True)
class SimulationProviderProfile:
profile_id: str
providers: tuple[SimulationProviderDescriptor, ...]
clock: SimulationClockDescriptor
control_profiles: tuple[ControlProfile, ...]
world_frame: str = "map_enu"
body_frame: str = "base_link_flu"
def __post_init__(self) -> None:
_identifier(self.profile_id, "provider profile id")
if not self.providers:
raise SimulationProviderContractError("provider profile must declare providers")
provider_ids = [provider.provider_id for provider in self.providers]
if len(provider_ids) != len(set(provider_ids)):
raise SimulationProviderContractError("provider ids must be unique")
providers_by_id = {provider.provider_id: provider for provider in self.providers}
clock_provider = providers_by_id.get(self.clock.provider_id)
if clock_provider is None or "clock.simulation" not in clock_provider.capabilities:
raise SimulationProviderContractError("clock provider must declare clock.simulation")
if not any(
ProviderRole.STATE in provider.roles and "state.vehicle-pose" in provider.capabilities
for provider in self.providers
):
raise SimulationProviderContractError(
"provider profile must expose canonical vehicle pose"
)
if not self.control_profiles:
raise SimulationProviderContractError("control profiles must not be empty")
if any(not isinstance(profile, ControlProfile) for profile in self.control_profiles):
raise SimulationProviderContractError("control profile is unknown")
if len(self.control_profiles) != len(set(self.control_profiles)):
raise SimulationProviderContractError("control profiles must be unique")
controller_capabilities = {
capability
for provider in self.providers
if ProviderRole.CONTROLLER in provider.roles
for capability in provider.capabilities
}
if any(
COMMAND_CAPABILITY_BY_PROFILE[profile] not in controller_capabilities
for profile in self.control_profiles
):
raise SimulationProviderContractError(
"controller providers do not satisfy the declared control profiles"
)
if self.world_frame != "map_enu" or self.body_frame != "base_link_flu":
raise SimulationProviderContractError(
"v1 provider profiles must expose map_enu and base_link_flu"
)
def to_dict(self) -> dict[str, object]:
return {
"schema_version": PROVIDER_PROFILE_SCHEMA,
"profile_id": self.profile_id,
"providers": [provider.to_dict() for provider in self.providers],
"clock": self.clock.to_dict(),
"control_profiles": [profile.value for profile in self.control_profiles],
"canonical_frames": {
"world": self.world_frame,
"body": self.body_frame,
},
}
@classmethod
def from_dict(cls, value: object) -> SimulationProviderProfile:
document = _object(value, "simulation provider profile")
_exact_keys(
document,
{
"schema_version",
"profile_id",
"providers",
"clock",
"control_profiles",
"canonical_frames",
},
"simulation provider profile",
)
if document.get("schema_version") != PROVIDER_PROFILE_SCHEMA:
raise SimulationProviderContractError("provider profile schema is incompatible")
providers = _array(document, "providers")
control_profiles = _array(document, "control_profiles")
frames = _object(document.get("canonical_frames"), "canonical frames")
_exact_keys(frames, {"world", "body"}, "canonical frames")
try:
parsed_control_profiles = tuple(
ControlProfile(_string_value(profile, "control profile"))
for profile in control_profiles
)
except ValueError as exc:
raise SimulationProviderContractError("control profile is unknown") from exc
return cls(
profile_id=_string(document, "profile_id"),
providers=tuple(
SimulationProviderDescriptor.from_dict(provider) for provider in providers
),
clock=SimulationClockDescriptor.from_dict(document.get("clock")),
control_profiles=parsed_control_profiles,
world_frame=_string(frames, "world"),
body_frame=_string(frames, "body"),
)
def _identifier(value: str, label: str) -> str:
if not IDENTIFIER_PATTERN.fullmatch(value):
raise SimulationProviderContractError(f"{label} is not a safe identifier")
return value
def _capability(value: str) -> str:
if not CAPABILITY_PATTERN.fullmatch(value):
raise SimulationProviderContractError("provider capability is not safe")
return value
def _object(value: object, label: str) -> dict[str, Any]:
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
raise SimulationProviderContractError(f"{label} must be an object")
return value
def _array(document: dict[str, Any], key: str) -> list[object]:
value = document.get(key)
if not isinstance(value, list):
raise SimulationProviderContractError(f"{key} must be an array")
return value
def _string(document: dict[str, Any], key: str) -> str:
return _string_value(document.get(key), key)
def _string_value(value: object, label: str) -> str:
if not isinstance(value, str) or not value:
raise SimulationProviderContractError(f"{label} must be a nonempty string")
return value
def _exact_keys(value: dict[str, Any], expected: set[str], label: str) -> None:
if set(value) != expected:
raise SimulationProviderContractError(f"{label} keys do not match v1")

View File

@ -7,10 +7,54 @@ from typing import Final
import yaml
from k1link.simulation.contracts import ControlProfile
from k1link.simulation.process_supervisor import ProcessSpec
from k1link.simulation.provider_contract import (
ProviderRole,
SimulationClockDescriptor,
SimulationProviderDescriptor,
SimulationProviderProfile,
)
LIFECYCLE_PROFILE_SCHEMA: Final = "missioncore.stock-rover-lifecycle/v1"
EXPECTED_D_ROOT: Final = Path("/mnt/d/NDC_MISSIONCORE/simulation")
GAZEBO_STATE_PROVIDER_ID: Final = "gazebo"
PX4_COMMAND_PROVIDER_ID: Final = "px4-ros2-offboard"
STOCK_ROVER_PROVIDER_PROFILE: Final = SimulationProviderProfile(
profile_id="stock-rover-gazebo-px4-s1d",
providers=(
SimulationProviderDescriptor(
provider_id=GAZEBO_STATE_PROVIDER_ID,
roles=(
ProviderRole.WORLD,
ProviderRole.PHYSICS,
ProviderRole.STATE,
ProviderRole.SENSOR,
),
capabilities=(
"clock.simulation",
"state.vehicle-pose",
"truth.ground-truth",
"sensor.virtual",
),
),
SimulationProviderDescriptor(
provider_id=PX4_COMMAND_PROVIDER_ID,
roles=(ProviderRole.CONTROLLER,),
capabilities=("command.rover-speed-steering/v1",),
),
SimulationProviderDescriptor(
provider_id="micro-xrce-dds-agent",
roles=(ProviderRole.TRANSPORT,),
capabilities=("transport.ros2",),
),
),
clock=SimulationClockDescriptor(
provider_id=GAZEBO_STATE_PROVIDER_ID,
domain="gazebo:/clock",
),
control_profiles=(ControlProfile.ROVER_SPEED_STEERING_V1,),
)
class StockRoverProfileError(ValueError):

View File

@ -39,6 +39,9 @@ from k1link.simulation.run_store import (
)
from k1link.simulation.s0 import ComponentPin
from k1link.simulation.stock_rover import (
GAZEBO_STATE_PROVIDER_ID,
PX4_COMMAND_PROVIDER_ID,
STOCK_ROVER_PROVIDER_PROFILE,
StockRoverTargetPaths,
load_stock_rover_lifecycle_profile,
stock_rover_process_environment,
@ -173,7 +176,7 @@ class GazeboPoseCollector:
},
},
"source": {
"provider": "gazebo",
"provider": GAZEBO_STATE_PROVIDER_ID,
"topic": POSE_TOPIC,
"signal": "ground-truth",
"quality": "diagnostic",
@ -371,12 +374,14 @@ class SimulationWorkerAgent:
def status(self) -> dict[str, Any]:
active = self._active_run()
provider_ids: list[str] = []
active_provider_ids: list[str] = []
if active is not None and active.state in {RunState.RUNNING, RunState.PAUSED}:
try:
provider_ids = [record.process_id for record in self.supervisor.snapshot()]
active_provider_ids = [
record.process_id for record in self.supervisor.snapshot()
]
except Exception:
provider_ids = []
active_provider_ids = []
return {
"schema_version": STATUS_SCHEMA,
"worker_id": "mission-gpu-s1",
@ -386,7 +391,8 @@ class SimulationWorkerAgent:
"control_available": True,
"active_run_id": active.run_id if active else None,
"run_state": active.state.value if active else None,
"provider_ids": provider_ids,
"active_provider_ids": active_provider_ids,
"provider_profile": STOCK_ROVER_PROVIDER_PROFILE.to_dict(),
"isolation": {
"network": "loopback-only-netns",
"process_identity": "missioncore",
@ -552,7 +558,7 @@ class SimulationWorkerAgent:
sim_time_ns=issued_at_sim_ns,
payload={
"command_id": command.command_id,
"provider": "px4-ros2-offboard",
"provider": PX4_COMMAND_PROVIDER_ID,
"detail": type(exc).__name__,
},
expected_revision=current.revision,
@ -632,7 +638,7 @@ class SimulationWorkerAgent:
command_ttl_max_ns=250_000_000,
heartbeat_timeout_monotonic_ns=500_000_000,
),
clock_domain="gazebo:/clock",
clock_domain=STOCK_ROVER_PROVIDER_PROFILE.clock.domain,
created_at_utc=_utc_now(),
)
@ -810,11 +816,15 @@ def _command_acceptance(
"steering_normalized": command.steering_normalized,
"authority_scope": command.authority_scope.value,
"delivery": {
"provider": "px4-ros2-offboard",
"mode": "speed-steering",
"armed": snapshot.armed,
"offboard": snapshot.offboard,
"provider_id": PX4_COMMAND_PROVIDER_ID,
"control_profile": command.profile.value,
"accepted": True,
"controller_ready": snapshot.armed and snapshot.offboard,
"ttl_expired_count": snapshot.ttl_expired_count,
"diagnostics": {
"armed": snapshot.armed,
"offboard": snapshot.offboard,
},
},
}

View File

@ -2,19 +2,27 @@ from __future__ import annotations
import json
import math
import re
import socket
from collections.abc import Mapping
from pathlib import Path
from typing import Any, Final, Protocol
from uuid import uuid4
from k1link.simulation.contracts import ControlProfile
from k1link.simulation.provider_contract import (
SimulationProviderContractError,
SimulationProviderProfile,
)
REQUEST_SCHEMA: Final = "missioncore.simulation-worker-request/v1"
RESPONSE_SCHEMA: Final = "missioncore.simulation-worker-response/v1"
STATUS_SCHEMA: Final = "missioncore.simulation-worker-status/v1"
STATUS_SCHEMA: Final = "missioncore.simulation-worker-status/v2"
VEHICLE_STATE_SCHEMA: Final = "missioncore.vehicle-state/v1"
COMMAND_ACCEPTANCE_SCHEMA: Final = "missioncore.command-acceptance/v1"
COMMAND_ACCEPTANCE_SCHEMA: Final = "missioncore.command-acceptance/v2"
MAX_MESSAGE_BYTES: Final = 64 * 1024
OPERATIONS: Final = frozenset({"status", "live", "start", "command", "stop"})
SAFE_ID_PATTERN: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
class SimulationWorkerGatewayError(RuntimeError):
@ -237,24 +245,35 @@ def _validate_status(value: Mapping[str, Any]) -> None:
"control_available",
"active_run_id",
"run_state",
"provider_ids",
"active_provider_ids",
"provider_profile",
"isolation",
"authority",
}
if set(value) != expected or value.get("schema_version") != STATUS_SCHEMA:
raise SimulationWorkerGatewayError("worker status does not match v1")
raise SimulationWorkerGatewayError("worker status does not match v2")
active_provider_ids = value.get("active_provider_ids")
if (
not isinstance(value.get("worker_id"), str)
or value.get("transport") != "unix"
or value.get("mode") != "simulation"
or value.get("available") is not True
or not isinstance(value.get("control_available"), bool)
or not isinstance(value.get("provider_ids"), list)
or any(not isinstance(item, str) for item in value["provider_ids"])
or not isinstance(active_provider_ids, list)
or len(active_provider_ids) > 32
or any(
not isinstance(item, str) or not SAFE_ID_PATTERN.fullmatch(item)
for item in active_provider_ids
)
or len(active_provider_ids) != len(set(active_provider_ids))
or not isinstance(value.get("isolation"), dict)
or not isinstance(value.get("authority"), dict)
):
raise SimulationWorkerGatewayError("worker status contains invalid values")
try:
SimulationProviderProfile.from_dict(value.get("provider_profile"))
except SimulationProviderContractError as exc:
raise SimulationWorkerGatewayError("worker provider profile is invalid") from exc
if value.get("active_run_id") is not None and not isinstance(value["active_run_id"], str):
raise SimulationWorkerGatewayError("worker active run id is invalid")
if value.get("run_state") is not None and not isinstance(value["run_state"], str):
@ -290,6 +309,18 @@ def _validate_vehicle_state(value: Mapping[str, Any]) -> None:
or not isinstance(value.get("safety"), dict)
):
raise SimulationWorkerGatewayError("vehicle state contains invalid values")
source = value["source"]
if (
set(source) != {"provider", "topic", "signal", "quality"}
or not isinstance(source.get("provider"), str)
or not SAFE_ID_PATTERN.fullmatch(source["provider"])
or not isinstance(source.get("topic"), str)
or not 1 <= len(source["topic"]) <= 512
or "\x00" in source["topic"]
or source.get("signal") != "ground-truth"
or source.get("quality") != "diagnostic"
):
raise SimulationWorkerGatewayError("vehicle state source is invalid")
def _validate_command_acceptance(value: Mapping[str, Any]) -> None:
@ -306,7 +337,7 @@ def _validate_command_acceptance(value: Mapping[str, Any]) -> None:
"delivery",
}
if set(value) != expected or value.get("schema_version") != COMMAND_ACCEPTANCE_SCHEMA:
raise SimulationWorkerGatewayError("command acceptance does not match v1")
raise SimulationWorkerGatewayError("command acceptance does not match v2")
delivery = value.get("delivery")
numeric_fields = (
"sequence",
@ -328,17 +359,35 @@ def _validate_command_acceptance(value: Mapping[str, Any]) -> None:
or not isinstance(delivery, dict)
or set(delivery)
!= {
"provider",
"mode",
"armed",
"offboard",
"provider_id",
"control_profile",
"accepted",
"controller_ready",
"ttl_expired_count",
"diagnostics",
}
or delivery.get("provider") != "px4-ros2-offboard"
or delivery.get("mode") != "speed-steering"
or not isinstance(delivery.get("armed"), bool)
or not isinstance(delivery.get("offboard"), bool)
or not isinstance(delivery.get("provider_id"), str)
or not SAFE_ID_PATTERN.fullmatch(delivery["provider_id"])
or delivery.get("control_profile")
!= ControlProfile.ROVER_SPEED_STEERING_V1.value
or delivery.get("accepted") is not True
or not isinstance(delivery.get("controller_ready"), bool)
or isinstance(delivery.get("ttl_expired_count"), bool)
or not isinstance(delivery.get("ttl_expired_count"), int)
or delivery["ttl_expired_count"] < 0
or not _valid_boolean_diagnostics(delivery.get("diagnostics"))
):
raise SimulationWorkerGatewayError("command acceptance contains invalid values")
def _valid_boolean_diagnostics(value: object) -> bool:
return (
isinstance(value, dict)
and len(value) <= 16
and all(
isinstance(key, str)
and SAFE_ID_PATTERN.fullmatch(key) is not None
and isinstance(item, bool)
for key, item in value.items()
)
)

View File

@ -377,7 +377,8 @@ def _unavailable_worker_status() -> dict[str, Any]:
"control_available": False,
"active_run_id": None,
"run_state": None,
"provider_ids": [],
"active_provider_ids": [],
"provider_profile": None,
"isolation": {
"network": "unavailable",
"process_identity": "missioncore",

View File

@ -18,6 +18,7 @@ from k1link.simulation import (
RunKind,
RunState,
)
from k1link.simulation.stock_rover import STOCK_ROVER_PROVIDER_PROFILE
from k1link.web.polygon_api import (
AckermannCommandRequest,
StartStockRoverRequest,
@ -256,7 +257,7 @@ class _FakeWorkerGateway:
self.calls.append(("command", idempotency_key))
assert run_id == self.active_run_id
return {
"schema_version": "missioncore.command-acceptance/v1",
"schema_version": "missioncore.command-acceptance/v2",
"run_id": run_id,
"command_id": "cmd-test",
"sequence": 1,
@ -266,17 +267,18 @@ class _FakeWorkerGateway:
"steering_normalized": steering_normalized,
"authority_scope": "virtual-only",
"delivery": {
"provider": "px4-ros2-offboard",
"mode": "speed-steering",
"armed": True,
"offboard": True,
"provider_id": "px4-ros2-offboard",
"control_profile": "rover-speed-steering/v1",
"accepted": True,
"controller_ready": True,
"ttl_expired_count": 0,
"diagnostics": {"armed": True, "offboard": True},
},
}
def _status(self) -> dict[str, Any]:
return {
"schema_version": "missioncore.simulation-worker-status/v1",
"schema_version": "missioncore.simulation-worker-status/v2",
"worker_id": "mission-gpu-s1",
"transport": "unix",
"mode": "simulation",
@ -284,7 +286,10 @@ class _FakeWorkerGateway:
"control_available": True,
"active_run_id": self.active_run_id,
"run_state": "running" if self.active_run_id else None,
"provider_ids": ["px4-gazebo-stock-rover"] if self.active_run_id else [],
"active_provider_ids": (
["px4-gazebo-stock-rover"] if self.active_run_id else []
),
"provider_profile": STOCK_ROVER_PROVIDER_PROFILE.to_dict(),
"isolation": {
"network": "loopback-only-netns",
"process_identity": "missioncore",
@ -340,7 +345,7 @@ def test_polygon_worker_api_fails_closed_then_proxies_virtual_only_lifecycle() -
idempotency_key="command-001",
)
assert command["authority_scope"] == "virtual-only"
assert command["delivery"]["offboard"] is True
assert command["delivery"]["controller_ready"] is True
stopped = _endpoint(enabled, "/api/v1/polygon/worker/runs/{run_id}/stop", "POST")(
run_id=running["active_run_id"],
idempotency_key="stop-001",
@ -366,7 +371,8 @@ def test_polygon_worker_status_is_explicitly_unavailable_when_not_registered() -
worker_provider=lambda: None,
)
status = _endpoint(router, "/api/v1/polygon/worker", "GET")()
assert status["schema_version"] == "missioncore.simulation-worker-status/v1"
assert status["schema_version"] == "missioncore.simulation-worker-status/v2"
assert status["available"] is False
assert status["control_available"] is False
assert status["provider_profile"] is None
assert status["authority"]["scope"] == "virtual-only"

View File

@ -0,0 +1,99 @@
from __future__ import annotations
from dataclasses import replace
import pytest
from k1link.simulation.contracts import ControlProfile
from k1link.simulation.provider_contract import (
ProviderRole,
SimulationClockDescriptor,
SimulationProviderContractError,
SimulationProviderDescriptor,
SimulationProviderProfile,
)
from k1link.simulation.stock_rover import STOCK_ROVER_PROVIDER_PROFILE
def _unreal_profile() -> SimulationProviderProfile:
return SimulationProviderProfile(
profile_id="unreal-native-rover-v1",
providers=(
SimulationProviderDescriptor(
provider_id="unreal-native",
roles=(
ProviderRole.WORLD,
ProviderRole.PHYSICS,
ProviderRole.STATE,
ProviderRole.SENSOR,
),
capabilities=(
"clock.simulation",
"state.vehicle-pose",
"truth.ground-truth",
"sensor.virtual",
),
),
SimulationProviderDescriptor(
provider_id="unreal-direct-control",
roles=(ProviderRole.CONTROLLER,),
capabilities=("command.rover-speed-steering/v1",),
),
),
clock=SimulationClockDescriptor(
provider_id="unreal-native",
domain="unreal:fixed-step",
),
control_profiles=(ControlProfile.ROVER_SPEED_STEERING_V1,),
)
def test_stock_rover_provider_profile_round_trips_exactly() -> None:
restored = SimulationProviderProfile.from_dict(STOCK_ROVER_PROVIDER_PROFILE.to_dict())
assert restored == STOCK_ROVER_PROVIDER_PROFILE
assert restored.clock.domain == "gazebo:/clock"
assert any(ProviderRole.CONTROLLER in provider.roles for provider in restored.providers)
def test_provider_contract_accepts_unreal_without_changing_canonical_frames() -> None:
profile = SimulationProviderProfile.from_dict(_unreal_profile().to_dict())
assert profile.clock.domain == "unreal:fixed-step"
assert profile.world_frame == "map_enu"
assert profile.body_frame == "base_link_flu"
def test_provider_contract_rejects_missing_clock_capability() -> None:
profile = _unreal_profile()
world = profile.providers[0]
with pytest.raises(SimulationProviderContractError, match="clock.simulation"):
replace(
profile,
providers=(
replace(
world,
capabilities=tuple(
capability
for capability in world.capabilities
if capability != "clock.simulation"
),
),
profile.providers[1],
),
)
def test_provider_contract_rejects_undeclared_command_capability() -> None:
profile = _unreal_profile()
controller = profile.providers[1]
with pytest.raises(SimulationProviderContractError, match="control profiles"):
replace(
profile,
providers=(
profile.providers[0],
replace(controller, capabilities=("transport.custom",)),
),
)

View File

@ -89,6 +89,14 @@ def _start(store: QualificationRunStore, run_id: str = "run-s1-001") -> Qualific
)
def test_closed_loop_run_accepts_provider_neutral_simulation_clock() -> None:
run = replace(_run(), clock_domain="unreal:fixed-step")
assert run.clock_domain == "unreal:fixed-step"
with pytest.raises(SimulationContractError, match="clock domain"):
replace(run, clock_domain="../../host-clock")
def test_run_manifest_is_immutable_and_runtime_replays_from_events(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path / "runs")
created = store.create(_run())

View File

@ -144,8 +144,9 @@ def test_worker_agent_journals_and_idempotently_delivers_virtual_command(
assert accepted == repeated
assert accepted["authority_scope"] == "virtual-only"
assert accepted["delivery"]["provider"] == "px4-ros2-offboard"
assert accepted["delivery"]["armed"] is True
assert accepted["delivery"]["provider_id"] == "px4-ros2-offboard"
assert accepted["delivery"]["controller_ready"] is True
assert accepted["delivery"]["diagnostics"]["armed"] is True
commands = agent.store.list_commands("s1c-command-test")
assert len(commands) == 1
assert commands[0].valid_until_sim_ns - commands[0].issued_at_sim_ns == 250_000_000

View File

@ -9,6 +9,7 @@ from uuid import uuid4
import pytest
from k1link.simulation.stock_rover import STOCK_ROVER_PROVIDER_PROFILE
from k1link.simulation.worker_gateway import (
REQUEST_SCHEMA,
RESPONSE_SCHEMA,
@ -20,7 +21,7 @@ from k1link.simulation.worker_gateway import (
def _status() -> dict[str, Any]:
return {
"schema_version": "missioncore.simulation-worker-status/v1",
"schema_version": "missioncore.simulation-worker-status/v2",
"worker_id": "mission-gpu-s1",
"transport": "unix",
"mode": "simulation",
@ -28,7 +29,8 @@ def _status() -> dict[str, Any]:
"control_available": True,
"active_run_id": None,
"run_state": None,
"provider_ids": [],
"active_provider_ids": [],
"provider_profile": STOCK_ROVER_PROVIDER_PROFILE.to_dict(),
"isolation": {
"network": "loopback-only-netns",
"process_identity": "missioncore",
@ -44,7 +46,7 @@ def _status() -> dict[str, Any]:
def _command_acceptance() -> dict[str, Any]:
return {
"schema_version": "missioncore.command-acceptance/v1",
"schema_version": "missioncore.command-acceptance/v2",
"run_id": "s1c-command-test",
"command_id": "cmd-001",
"sequence": 1,
@ -54,11 +56,40 @@ def _command_acceptance() -> dict[str, Any]:
"steering_normalized": -0.25,
"authority_scope": "virtual-only",
"delivery": {
"provider": "px4-ros2-offboard",
"mode": "speed-steering",
"armed": True,
"offboard": True,
"provider_id": "unreal-direct-control",
"control_profile": "rover-speed-steering/v1",
"accepted": True,
"controller_ready": True,
"ttl_expired_count": 0,
"diagnostics": {"armed": True, "offboard": True},
},
}
def _vehicle_state() -> dict[str, Any]:
return {
"schema_version": "missioncore.vehicle-state/v1",
"run_id": "s1c-command-test",
"sequence": 1,
"observed_at_utc": "2026-07-25T10:00:00Z",
"host_monotonic_ns": 1_000_000,
"sim_time_ns": 2_000_000,
"frame_id": "map_enu",
"child_frame_id": "base_link_flu",
"pose": {
"position_m": {"x": 1.0, "y": 2.0, "z": 0.0},
"orientation_xyzw": {"x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0},
},
"source": {
"provider": "unreal-native",
"topic": "missioncore/vehicle-state",
"signal": "ground-truth",
"quality": "diagnostic",
},
"safety": {
"scope": "virtual-only",
"actuator_authority": False,
"navigation_or_safety_accepted": False,
},
}
@ -158,7 +189,8 @@ def test_unix_worker_gateway_sends_bounded_virtual_rover_command() -> None:
)
thread.join(timeout=2)
assert accepted["delivery"]["offboard"] is True
assert accepted["delivery"]["provider_id"] == "unreal-direct-control"
assert accepted["delivery"]["controller_ready"] is True
assert requests[0]["operation"] == "command"
assert requests[0]["payload"] == {
"run_id": "s1c-command-test",
@ -168,3 +200,18 @@ def test_unix_worker_gateway_sends_bounded_virtual_rover_command() -> None:
}
finally:
socket_path.unlink(missing_ok=True)
def test_unix_worker_gateway_accepts_provider_neutral_diagnostic_state() -> None:
socket_path = Path(f"/tmp/mc-{uuid4().hex}.sock")
thread, _ = _serve_once(socket_path, _vehicle_state())
gateway = UnixSocketWorkerGateway(socket_path)
try:
state = gateway.live()
thread.join(timeout=2)
assert state["source"]["provider"] == "unreal-native"
assert state["frame_id"] == "map_enu"
finally:
socket_path.unlink(missing_ok=True)