feat(simulation): add provider-neutral worker profile
This commit is contained in:
parent
5abd07d2bf
commit
37c24b5fa8
13
README.md
13
README.md
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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; S1A–S1D lifecycle/browser/Ackermann command slices PASS on the accepted Gazebo/PX4 regression profile. S1E now makes provider interchangeability executable through a strict provider profile, generic live-state source and generic command acceptance. Gazebo remains the frozen low-cost regression tier; ADR 0017 gates a measured native Unreal/CARLA/Project AirSim U0 selection before any production bridge. Differential Rover, calibrated closed-loop speed, canonical PX4/ROS 2 telemetry, pause/step/reset, heartbeat/offboard/link-loss cases, navigation/safety acceptance, shared-deployment auth/RBAC and real actuator authority remain absent |
|
||||
|
||||
USB project copying remains optional ground truth rather than a blocker for the
|
||||
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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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 | 2–4 days | Product thesis, SRS, ADR and canonical contracts accepted |
|
||||
| S0 | 3–5 days | Target compatibility, D-only runtime, exact pins, clock/process/resource evidence |
|
||||
| S1 | 7–12 days | Stock Ackermann and Differential lifecycle/control/failsafe evidence |
|
||||
| U0 | 2–4 days | Native Unreal/CARLA/Project AirSim selection matrix with measured worker fit |
|
||||
| U1 | 5–10 days | Selected Unreal candidate proves canonical clock/state/command lifecycle |
|
||||
| S2 | 15–25 days | LiDAR/odometry/Nav2 baseline, evaluator and obstacle scenarios |
|
||||
| S2B | 5–8 days | Representative 30-world BARN pilot |
|
||||
| S3 | 20–35 days | K1-like virtual sensors and Mission Core perception closed loop |
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",)),
|
||||
),
|
||||
)
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue