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,
|
SITL, ROS 2, Nav2 and viewers remain replaceable providers. Simulation, replay,
|
||||||
digital twin, HIL and physical shadow keep distinct causal run kinds.
|
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
|
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,
|
loopback-only process isolation, exact provider pins, a 2 ms physics step,
|
||||||
1×/2× RTF/resource budgets and disabled real/direct actuator authority. Two
|
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),
|
See the [Polygon product/SRS](docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md),
|
||||||
[ADR 0015](docs/adr/0015-simulation-polygon-qualification-boundary.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
|
[SIM S0 worker runbook](docs/runbooks/SIM_S0_AI_WORKER.md). The live-worker
|
||||||
launch and acceptance procedure is in
|
launch and acceptance procedure is in
|
||||||
[SIM S1C live-worker runbook](docs/runbooks/SIM_S1C_LIVE_WORKER.md).
|
[SIM S1C live-worker runbook](docs/runbooks/SIM_S1C_LIVE_WORKER.md).
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,44 @@
|
||||||
import type { PolygonRunState } from "./runArchive";
|
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 {
|
export interface PolygonWorkerStatus {
|
||||||
workerId: string;
|
workerId: string;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
controlAvailable: boolean;
|
controlAvailable: boolean;
|
||||||
activeRunId: string | null;
|
activeRunId: string | null;
|
||||||
runState: PolygonRunState | null;
|
runState: PolygonRunState | null;
|
||||||
providerIds: string[];
|
activeProviderIds: string[];
|
||||||
|
providerProfile: PolygonProviderProfile | null;
|
||||||
isolation: {
|
isolation: {
|
||||||
network: string;
|
network: string;
|
||||||
processIdentity: string;
|
processIdentity: string;
|
||||||
|
|
@ -22,6 +54,10 @@ export interface PolygonVehicleState {
|
||||||
simTimeNs: number;
|
simTimeNs: number;
|
||||||
position: { x: number; y: number; z: number };
|
position: { x: number; y: number; z: number };
|
||||||
orientation: { x: number; y: number; z: number; w: number };
|
orientation: { x: number; y: number; z: number; w: number };
|
||||||
|
sourceProvider: string;
|
||||||
|
sourceTopic: string;
|
||||||
|
sourceSignal: "ground-truth";
|
||||||
|
sourceQuality: "diagnostic";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolygonCommandAcceptance {
|
export interface PolygonCommandAcceptance {
|
||||||
|
|
@ -32,9 +68,12 @@ export interface PolygonCommandAcceptance {
|
||||||
validUntilSimNs: number;
|
validUntilSimNs: number;
|
||||||
speedMps: number;
|
speedMps: number;
|
||||||
steeringNormalized: number;
|
steeringNormalized: number;
|
||||||
armed: boolean;
|
deliveryProvider: string;
|
||||||
offboard: boolean;
|
controlProfile: string;
|
||||||
|
accepted: true;
|
||||||
|
controllerReady: boolean;
|
||||||
ttlExpiredCount: number;
|
ttlExpiredCount: number;
|
||||||
|
diagnostics: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PolygonWorkerContractError extends Error {}
|
export class PolygonWorkerContractError extends Error {}
|
||||||
|
|
@ -51,6 +90,17 @@ type PolygonWorkerFetch = (
|
||||||
) => Promise<Response>;
|
) => Promise<Response>;
|
||||||
|
|
||||||
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
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>([
|
const RUN_STATES = new Set<PolygonRunState>([
|
||||||
"admitted",
|
"admitted",
|
||||||
"starting",
|
"starting",
|
||||||
|
|
@ -70,10 +120,22 @@ const STATUS_KEYS = new Set([
|
||||||
"control_available",
|
"control_available",
|
||||||
"active_run_id",
|
"active_run_id",
|
||||||
"run_state",
|
"run_state",
|
||||||
"provider_ids",
|
"active_provider_ids",
|
||||||
|
"provider_profile",
|
||||||
"isolation",
|
"isolation",
|
||||||
"authority",
|
"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 ISOLATION_KEYS = new Set(["network", "process_identity", "artifact_policy"]);
|
||||||
const AUTHORITY_KEYS = new Set([
|
const AUTHORITY_KEYS = new Set([
|
||||||
"scope",
|
"scope",
|
||||||
|
|
@ -115,11 +177,12 @@ const COMMAND_KEYS = new Set([
|
||||||
"delivery",
|
"delivery",
|
||||||
]);
|
]);
|
||||||
const COMMAND_DELIVERY_KEYS = new Set([
|
const COMMAND_DELIVERY_KEYS = new Set([
|
||||||
"provider",
|
"provider_id",
|
||||||
"mode",
|
"control_profile",
|
||||||
"armed",
|
"accepted",
|
||||||
"offboard",
|
"controller_ready",
|
||||||
"ttl_expired_count",
|
"ttl_expired_count",
|
||||||
|
"diagnostics",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
|
@ -184,11 +247,130 @@ function finiteValue(value: unknown, label: string): number {
|
||||||
return value;
|
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 {
|
export function decodePolygonWorkerStatus(payload: unknown): PolygonWorkerStatus {
|
||||||
const value = record(payload, "Статус Simulation Worker");
|
const value = record(payload, "Статус Simulation Worker");
|
||||||
exactKeys(value, STATUS_KEYS, "Статус Simulation Worker");
|
exactKeys(value, STATUS_KEYS, "Статус Simulation Worker");
|
||||||
if (
|
if (
|
||||||
value.schema_version !== "missioncore.simulation-worker-status/v1" ||
|
value.schema_version !== "missioncore.simulation-worker-status/v2" ||
|
||||||
value.transport !== "unix" ||
|
value.transport !== "unix" ||
|
||||||
value.mode !== "simulation"
|
value.mode !== "simulation"
|
||||||
) {
|
) {
|
||||||
|
|
@ -208,11 +390,22 @@ export function decodePolygonWorkerStatus(payload: unknown): PolygonWorkerStatus
|
||||||
) {
|
) {
|
||||||
throw new PolygonWorkerContractError("Simulation Worker вышел за virtual-only границу.");
|
throw new PolygonWorkerContractError("Simulation Worker вышел за virtual-only границу.");
|
||||||
}
|
}
|
||||||
if (!Array.isArray(value.provider_ids) || value.provider_ids.length > 32) {
|
if (!Array.isArray(value.active_provider_ids) || value.active_provider_ids.length > 32) {
|
||||||
throw new PolygonWorkerContractError("provider_ids должен быть ограниченным массивом.");
|
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
|
const activeRunId = value.active_run_id === null
|
||||||
? null
|
? null
|
||||||
: safeId(value.active_run_id, "active_run_id");
|
: safeId(value.active_run_id, "active_run_id");
|
||||||
|
|
@ -227,11 +420,12 @@ export function decodePolygonWorkerStatus(payload: unknown): PolygonWorkerStatus
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
workerId: safeId(value.worker_id, "worker_id"),
|
workerId: safeId(value.worker_id, "worker_id"),
|
||||||
available: booleanValue(value.available, "available"),
|
available,
|
||||||
controlAvailable: booleanValue(value.control_available, "control_available"),
|
controlAvailable,
|
||||||
activeRunId,
|
activeRunId,
|
||||||
runState: runStateValue as PolygonRunState | null,
|
runState: runStateValue as PolygonRunState | null,
|
||||||
providerIds,
|
activeProviderIds,
|
||||||
|
providerProfile,
|
||||||
isolation: {
|
isolation: {
|
||||||
network: stringValue(isolation.network, "isolation.network", 64),
|
network: stringValue(isolation.network, "isolation.network", 64),
|
||||||
processIdentity: safeId(isolation.process_identity, "isolation.process_identity"),
|
processIdentity: safeId(isolation.process_identity, "isolation.process_identity"),
|
||||||
|
|
@ -259,11 +453,10 @@ export function decodePolygonVehicleState(payload: unknown): PolygonVehicleState
|
||||||
const source = record(value.source, "source");
|
const source = record(value.source, "source");
|
||||||
exactKeys(source, SOURCE_KEYS, "source");
|
exactKeys(source, SOURCE_KEYS, "source");
|
||||||
if (
|
if (
|
||||||
source.provider !== "gazebo" ||
|
|
||||||
source.signal !== "ground-truth" ||
|
source.signal !== "ground-truth" ||
|
||||||
source.quality !== "diagnostic"
|
source.quality !== "diagnostic"
|
||||||
) {
|
) {
|
||||||
throw new PolygonWorkerContractError("Live-сигнал не маркирован как Gazebo diagnostic.");
|
throw new PolygonWorkerContractError("Live-сигнал не маркирован как diagnostic ground truth.");
|
||||||
}
|
}
|
||||||
const safety = record(value.safety, "safety");
|
const safety = record(value.safety, "safety");
|
||||||
exactKeys(safety, SAFETY_KEYS, "safety");
|
exactKeys(safety, SAFETY_KEYS, "safety");
|
||||||
|
|
@ -295,6 +488,10 @@ export function decodePolygonVehicleState(payload: unknown): PolygonVehicleState
|
||||||
z: finiteValue(orientation.z, "orientation.z"),
|
z: finiteValue(orientation.z, "orientation.z"),
|
||||||
w: finiteValue(orientation.w, "orientation.w"),
|
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, "Подтверждение команды");
|
const value = record(payload, "Подтверждение команды");
|
||||||
exactKeys(value, COMMAND_KEYS, "Подтверждение команды");
|
exactKeys(value, COMMAND_KEYS, "Подтверждение команды");
|
||||||
if (
|
if (
|
||||||
value.schema_version !== "missioncore.command-acceptance/v1" ||
|
value.schema_version !== "missioncore.command-acceptance/v2" ||
|
||||||
value.authority_scope !== "virtual-only"
|
value.authority_scope !== "virtual-only"
|
||||||
) {
|
) {
|
||||||
throw new PolygonWorkerContractError("Команда вышла за virtual-only контракт.");
|
throw new PolygonWorkerContractError("Команда вышла за virtual-only контракт.");
|
||||||
}
|
}
|
||||||
const delivery = record(value.delivery, "delivery");
|
const delivery = record(value.delivery, "delivery");
|
||||||
exactKeys(delivery, COMMAND_DELIVERY_KEYS, "delivery");
|
exactKeys(delivery, COMMAND_DELIVERY_KEYS, "delivery");
|
||||||
if (
|
if (delivery.control_profile !== "rover-speed-steering/v1" || delivery.accepted !== true) {
|
||||||
delivery.provider !== "px4-ros2-offboard" ||
|
throw new PolygonWorkerContractError("Команда подтверждена несовместимым adapter.");
|
||||||
delivery.mode !== "speed-steering"
|
|
||||||
) {
|
|
||||||
throw new PolygonWorkerContractError("Команда подтверждена неизвестным PX4 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 sequence = integerValue(value.sequence, "sequence");
|
||||||
const issuedAtSimNs = integerValue(value.issued_at_sim_ns, "issued_at_sim_ns");
|
const issuedAtSimNs = integerValue(value.issued_at_sim_ns, "issued_at_sim_ns");
|
||||||
const validUntilSimNs = integerValue(value.valid_until_sim_ns, "valid_until_sim_ns");
|
const validUntilSimNs = integerValue(value.valid_until_sim_ns, "valid_until_sim_ns");
|
||||||
|
|
@ -342,12 +546,15 @@ export function decodePolygonCommandAcceptance(payload: unknown): PolygonCommand
|
||||||
validUntilSimNs,
|
validUntilSimNs,
|
||||||
speedMps,
|
speedMps,
|
||||||
steeringNormalized,
|
steeringNormalized,
|
||||||
armed: booleanValue(delivery.armed, "delivery.armed"),
|
deliveryProvider: safeId(delivery.provider_id, "delivery.provider_id"),
|
||||||
offboard: booleanValue(delivery.offboard, "delivery.offboard"),
|
controlProfile: "rover-speed-steering/v1",
|
||||||
|
accepted: true,
|
||||||
|
controllerReady: booleanValue(delivery.controller_ready, "delivery.controller_ready"),
|
||||||
ttlExpiredCount: integerValue(
|
ttlExpiredCount: integerValue(
|
||||||
delivery.ttl_expired_count,
|
delivery.ttl_expired_count,
|
||||||
"delivery.ttl_expired_count",
|
"delivery.ttl_expired_count",
|
||||||
),
|
),
|
||||||
|
diagnostics,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,12 @@ export function PolygonLivePanel() {
|
||||||
}
|
}
|
||||||
}, [worker?.activeRunId]);
|
}, [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 stopMotion = async () => {
|
||||||
const runId = worker?.activeRunId;
|
const runId = worker?.activeRunId;
|
||||||
setControlIntent(null);
|
setControlIntent(null);
|
||||||
|
|
@ -184,7 +190,10 @@ export function PolygonLivePanel() {
|
||||||
<div>
|
<div>
|
||||||
<span className="section-eyebrow">ПОЛИГОН / LIVE</span>
|
<span className="section-eyebrow">ПОЛИГОН / LIVE</span>
|
||||||
<h2>Stock Ackermann Rover</h2>
|
<h2>Stock Ackermann Rover</h2>
|
||||||
<p>PX4/Gazebo исполняются на отдельном worker; браузер показывает канонический срез.</p>
|
<p>
|
||||||
|
Провайдеры симуляции исполняются на отдельном worker; браузер показывает
|
||||||
|
канонический срез ENU/FLU.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
|
|
@ -198,7 +207,7 @@ export function PolygonLivePanel() {
|
||||||
disabled={!worker?.controlAvailable || actionBusy}
|
disabled={!worker?.controlAvailable || actionBusy}
|
||||||
onClick={() => void runAction()}
|
onClick={() => void runAction()}
|
||||||
>
|
>
|
||||||
{actionBusy ? "Ожидаем PX4/Gazebo…" : runActive ? "Остановить" : "Запустить"}
|
{actionBusy ? "Ожидаем провайдеры…" : runActive ? "Остановить" : "Запустить"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -221,9 +230,11 @@ export function PolygonLivePanel() {
|
||||||
<div className="polygon-live-telemetry">
|
<div className="polygon-live-telemetry">
|
||||||
<div className="polygon-rover-controls">
|
<div className="polygon-rover-controls">
|
||||||
<div className="polygon-rover-controls__heading">
|
<div className="polygon-rover-controls__heading">
|
||||||
<span>Управление PX4</span>
|
<span>
|
||||||
<StatusBadge tone={commandAcceptance?.offboard ? "success" : "neutral"}>
|
Управление {controllerProvider?.providerId ?? "машиной"}
|
||||||
{commandAcceptance?.offboard ? "Armed · Offboard" : "Ожидает прогона"}
|
</span>
|
||||||
|
<StatusBadge tone={commandAcceptance?.controllerReady ? "success" : "neutral"}>
|
||||||
|
{commandAcceptance?.controllerReady ? "Controller ready" : "Ожидает прогона"}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
<div className="polygon-rover-controls__grid">
|
<div className="polygon-rover-controls__grid">
|
||||||
|
|
@ -270,8 +281,16 @@ export function PolygonLivePanel() {
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Провайдеры</span>
|
<span>Провайдерный стек</span>
|
||||||
<strong>{worker?.providerIds.join(" · ") || "—"}</strong>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<span>Последняя команда</span>
|
<span>Последняя команда</span>
|
||||||
|
|
@ -284,8 +303,9 @@ export function PolygonLivePanel() {
|
||||||
<div className="polygon-live-boundary">
|
<div className="polygon-live-boundary">
|
||||||
<StatusBadge tone="success">Virtual only</StatusBadge>
|
<StatusBadge tone="success">Virtual only</StatusBadge>
|
||||||
<p>
|
<p>
|
||||||
Команды проходят PX4 ROS 2 Offboard только в SITL. Физического actuator
|
Команды проходят только через заявленный controller provider
|
||||||
authority нет; live-поза остаётся диагностической.
|
{controllerProvider ? ` ${controllerProvider.providerId}` : ""}. Физического
|
||||||
|
actuator authority нет; live-поза остаётся диагностической.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="polygon-live-error">{error}</p>}
|
{error && <p className="polygon-live-error">{error}</p>}
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,7 @@ function jsonResponse(payload, status = 200) {
|
||||||
|
|
||||||
function workerStatus(overrides = {}) {
|
function workerStatus(overrides = {}) {
|
||||||
return {
|
return {
|
||||||
schema_version: "missioncore.simulation-worker-status/v1",
|
schema_version: "missioncore.simulation-worker-status/v2",
|
||||||
worker_id: "mission-gpu-s1",
|
worker_id: "mission-gpu-s1",
|
||||||
transport: "unix",
|
transport: "unix",
|
||||||
mode: "simulation",
|
mode: "simulation",
|
||||||
|
|
@ -183,7 +183,44 @@ function workerStatus(overrides = {}) {
|
||||||
control_available: true,
|
control_available: true,
|
||||||
active_run_id: "s1c-6cb1495-20260724t180000z-aabbcc",
|
active_run_id: "s1c-6cb1495-20260724t180000z-aabbcc",
|
||||||
run_state: "running",
|
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: {
|
isolation: {
|
||||||
network: "loopback-only-netns",
|
network: "loopback-only-netns",
|
||||||
process_identity: "missioncore",
|
process_identity: "missioncore",
|
||||||
|
|
@ -229,7 +266,7 @@ function vehicleState(overrides = {}) {
|
||||||
|
|
||||||
function commandAcceptance(overrides = {}) {
|
function commandAcceptance(overrides = {}) {
|
||||||
return {
|
return {
|
||||||
schema_version: "missioncore.command-acceptance/v1",
|
schema_version: "missioncore.command-acceptance/v2",
|
||||||
run_id: "s1c-6cb1495-20260724t180000z-aabbcc",
|
run_id: "s1c-6cb1495-20260724t180000z-aabbcc",
|
||||||
command_id: "cmd-aabbcc",
|
command_id: "cmd-aabbcc",
|
||||||
sequence: 1,
|
sequence: 1,
|
||||||
|
|
@ -239,11 +276,15 @@ function commandAcceptance(overrides = {}) {
|
||||||
steering_normalized: -0.55,
|
steering_normalized: -0.55,
|
||||||
authority_scope: "virtual-only",
|
authority_scope: "virtual-only",
|
||||||
delivery: {
|
delivery: {
|
||||||
provider: "px4-ros2-offboard",
|
provider_id: "px4-ros2-offboard",
|
||||||
mode: "speed-steering",
|
control_profile: "rover-speed-steering/v1",
|
||||||
|
accepted: true,
|
||||||
|
controller_ready: true,
|
||||||
|
ttl_expired_count: 0,
|
||||||
|
diagnostics: {
|
||||||
armed: true,
|
armed: true,
|
||||||
offboard: true,
|
offboard: true,
|
||||||
ttl_expired_count: 0,
|
},
|
||||||
},
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|
@ -353,7 +394,8 @@ test("Polygon Live decodes only D-only virtual diagnostic state", () => {
|
||||||
assert.equal(status.activeRunId, live.runId);
|
assert.equal(status.activeRunId, live.runId);
|
||||||
assert.equal(status.isolation.artifactPolicy, "d-only");
|
assert.equal(status.isolation.artifactPolicy, "d-only");
|
||||||
assert.deepEqual(live.position, { x: 1.25, y: -0.5, z: 0.18 });
|
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.equal(command.steeringNormalized, -0.55);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => decodePolygonWorkerStatus(workerStatus({
|
() => 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 () => {
|
test("Polygon Live uses same-origin gateway and idempotent lifecycle requests", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const stopped = workerStatus({
|
const stopped = workerStatus({
|
||||||
active_run_id: null,
|
active_run_id: null,
|
||||||
run_state: null,
|
run_state: null,
|
||||||
provider_ids: [],
|
active_provider_ids: [],
|
||||||
});
|
});
|
||||||
const fetcher = async (url, init) => {
|
const fetcher = async (url, init) => {
|
||||||
calls.push({ url: String(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 |
|
| Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process |
|
||||||
| K1 application control | GO (physical staged cycle) — after fixing the PCAP-proven `sint64` time field, one explicit UI launch completed all 14 canonical operations on one control session, reached live `SCANNING + project + init_ready`, displayed real points, then one explicit STOP returned K1 to unbound `READY`. No retry or fallback command was sent. Native-project reuse through LixelGO/USB remains an independent verification |
|
| K1 application control | GO (physical staged cycle) — after fixing the PCAP-proven `sint64` time field, one explicit UI launch completed all 14 canonical operations on one control session, reached live `SCANNING + project + init_ready`, displayed real points, then one explicit STOP returned K1 to unbound `READY`. No retry or fallback command was sent. Native-project reuse through LixelGO/USB remains an independent verification |
|
||||||
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
|
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
|
||||||
| Simulation Polygon | SIM S0 GO; S1A 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
|
USB project copying remains optional ground truth rather than a blocker for the
|
||||||
now-verified network path. Owner-operated LixelGO traffic verifies the MQTT
|
now-verified network path. Owner-operated LixelGO traffic verifies the MQTT
|
||||||
|
|
@ -128,8 +128,9 @@ The Polygon branch follows
|
||||||
[`docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md`](12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md)
|
[`docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md`](12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md)
|
||||||
and
|
and
|
||||||
[`ADR 0015`](adr/0015-simulation-polygon-qualification-boundary.md). Component
|
[`ADR 0015`](adr/0015-simulation-polygon-qualification-boundary.md). Component
|
||||||
placement and field/offline topology follow
|
placement follows [`ADR 0016`](adr/0016-distributed-product-edge-and-worker-topology.md);
|
||||||
[`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.
|
It does not reorder or weaken the K1 physical-evidence gates in this document.
|
||||||
Its first gate, SIM S0, is accepted:
|
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
|
- [MISSIONCOR-41](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-41) is the
|
||||||
active S1 lifecycle/authority implementation and qualification gate.
|
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,
|
to run kinds, authority, clocks, frames, source-of-record, provider boundaries,
|
||||||
component placement or phase gates must update the ADR/SRS and MISSIONCOR-39
|
component placement or phase gates must update the ADR/SRS and MISSIONCOR-39
|
||||||
together.
|
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
|
- this is not a calibrated closed-loop speed controller acceptance and does
|
||||||
not close Differential Rover, canonical PX4/ROS 2 telemetry,
|
not close Differential Rover, canonical PX4/ROS 2 telemetry,
|
||||||
heartbeat/offboard/link-loss, failsafe, navigation or safety checkers;
|
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`;
|
- `actuator_authority=false`;
|
||||||
- `navigation_or_safety_accepted=false`.
|
- `navigation_or_safety_accepted=false`.
|
||||||
|
|
||||||
|
|
@ -166,10 +175,13 @@ Mission Core owns:
|
||||||
|
|
||||||
Providers remain replaceable:
|
Providers remain replaceable:
|
||||||
|
|
||||||
- Gazebo owns simulated world state, sensors, contacts and physics.
|
- a world/physics/state provider owns simulated world state, sensors, contacts,
|
||||||
- PX4 SITL owns rover control loops, constraints, offboard lifecycle and
|
truth and the declared simulation clock; the accepted regression provider is
|
||||||
autopilot failsafe.
|
Gazebo, while Unreal candidates remain unaccepted;
|
||||||
- ROS 2 transports typed telemetry and commands.
|
- 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.
|
- Nav2 supplies the first mature planning/costmap/collision-checking baseline.
|
||||||
- Rerun or another viewer presents derived evidence and never becomes a queue,
|
- Rerun or another viewer presents derived evidence and never becomes a queue,
|
||||||
orchestrator or source-of-record.
|
orchestrator or source-of-record.
|
||||||
|
|
@ -201,14 +213,15 @@ Simulation Orchestrator
|
||||||
+---- clock, namespace, port and process ownership
|
+---- clock, namespace, port and process ownership
|
||||||
+---- artifact/provenance store and evaluator
|
+---- artifact/provenance store and evaluator
|
||||||
|
|
|
|
||||||
+---- Gazebo adapter ---- Gazebo world/physics/sensors
|
+---- provider profile -- roles/capabilities/clock/frames
|
||||||
+---- PX4 adapter ------- PX4 SITL rover controller/failsafe
|
+---- world adapter ----- Gazebo baseline or admitted Unreal profile
|
||||||
+---- ROS 2 adapter ----- Micro XRCE-DDS and canonical topics
|
+---- control adapter --- PX4 SITL or another admitted controller
|
||||||
|
+---- transport adapter - ROS 2/XRCE or another admitted transport
|
||||||
+---- planner adapter --- Nav2 or Mission Core planner
|
+---- planner adapter --- Nav2 or Mission Core planner
|
||||||
+---- viewer adapter ---- canonical state/derived report
|
+---- 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
|
lifecycle or command authority. Losing the browser cannot terminate the only
|
||||||
copy of run state and cannot bypass the server-side authority gate.
|
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`:
|
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`.
|
- ROS 2 consumers use `use_sim_time=true`.
|
||||||
- PX4 uXRCE-DDS time synchronization is disabled when Gazebo time is used.
|
- PX4 uXRCE-DDS time synchronization is disabled when Gazebo time is used.
|
||||||
- Canonical timestamps are integer nanoseconds and include their clock domain.
|
- Canonical timestamps are integer nanoseconds and include their clock domain.
|
||||||
|
|
@ -496,7 +512,7 @@ incompatible.
|
||||||
|
|
||||||
## 14. Upstream baseline
|
## 14. Upstream baseline
|
||||||
|
|
||||||
The S0 accepted line is:
|
The S0 accepted regression line is:
|
||||||
|
|
||||||
- Windows 11 AI worker;
|
- Windows 11 AI worker;
|
||||||
- dedicated `MissionCore-Sim` WSL2 Ubuntu 24.04 distribution physically on D;
|
- 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| S2 | 15–25 days | LiDAR/odometry/Nav2 baseline, evaluator and obstacle scenarios |
|
||||||
| S2B | 5–8 days | Representative 30-world BARN pilot |
|
| S2B | 5–8 days | Representative 30-world BARN pilot |
|
||||||
| S3 | 20–35 days | K1-like virtual sensors and Mission Core perception closed loop |
|
| 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.
|
3. Clock, frame and authority contracts.
|
||||||
4. Orchestrator lifecycle and artifact store.
|
4. Orchestrator lifecycle and artifact store.
|
||||||
5. PX4 stock rover control and failsafe.
|
5. PX4 stock rover control and failsafe.
|
||||||
6. Nav2 adapter and truth baseline.
|
6. Provider-neutral profile/status/command boundary.
|
||||||
7. Evaluator, report and BARN pilot.
|
7. Complete the U0 Unreal candidate comparison; U1 may run in parallel.
|
||||||
8. Virtual K1 and perception.
|
8. Nav2 adapter and truth baseline on the frozen Gazebo profile.
|
||||||
9. Replay/shadow datasets.
|
9. Evaluator, report and BARN pilot.
|
||||||
10. RAVNOVES00 twin.
|
10. Virtual K1 and perception; promote an Unreal provider only after U1.
|
||||||
11. Trike, HIL and safety gate.
|
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
|
## 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
|
telemetry, reset/repeatability, heartbeat/offboard/link-loss, full failsafe and
|
||||||
navigation/safety cases.
|
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
|
### S2
|
||||||
|
|
||||||
- Gazebo LiDAR/odometry feed the ROS 2/Nav2 baseline.
|
- 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
|
canonical rover commands, Compare and Report remain planned. UI-2 cannot own
|
||||||
provider processes, bypass authority checks or write directly to PX4.
|
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
|
## 21. Change control
|
||||||
|
|
||||||
A material change requires:
|
A material change requires:
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,10 @@ reports easy to misuse.
|
||||||
5. Simulation Orchestrator is the sole process-lifecycle owner. Gazebo and PX4
|
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
|
do not run inside the Mission Core web process, and the browser has no direct
|
||||||
PX4 channel.
|
PX4 channel.
|
||||||
6. Gazebo `/clock` is authoritative in closed-loop simulation. ROS 2 uses
|
6. Each admitted closed-loop provider profile declares exactly one
|
||||||
simulated time and PX4 uXRCE-DDS time synchronization is disabled for this
|
authoritative simulation clock. The accepted stock profile uses Gazebo
|
||||||
profile.
|
`/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
|
7. Mission Core uses ENU/FLU. PX4 uses NED/FRD. Exactly one adapter boundary
|
||||||
converts frames and requires golden tests.
|
converts frames and requires golden tests.
|
||||||
8. The first command boundary permits only rover speed+steering and
|
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
|
[ADR 0016](0016-distributed-product-edge-and-worker-topology.md). Native
|
||||||
Gazebo GUI is worker-local diagnostics; the operator product remains the
|
Gazebo GUI is worker-local diagnostics; the operator product remains the
|
||||||
browser Control Station over canonical state.
|
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
|
## 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
|
```json
|
||||||
{
|
{
|
||||||
"schema_version": "missioncore.simulation-worker-status/v1",
|
"schema_version": "missioncore.simulation-worker-status/v2",
|
||||||
"worker_id": "mission-gpu-s1",
|
"worker_id": "mission-gpu-s1",
|
||||||
"transport": "unix",
|
"transport": "unix",
|
||||||
"mode": "simulation",
|
"mode": "simulation",
|
||||||
"available": true,
|
"available": true,
|
||||||
"control_available": true,
|
"control_available": true,
|
||||||
"active_run_id": null,
|
"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,
|
ProcessStopResult,
|
||||||
ProcessSupervisorError,
|
ProcessSupervisorError,
|
||||||
)
|
)
|
||||||
|
from k1link.simulation.provider_contract import (
|
||||||
|
PROVIDER_PROFILE_SCHEMA,
|
||||||
|
ProviderRole,
|
||||||
|
SimulationClockDescriptor,
|
||||||
|
SimulationProviderContractError,
|
||||||
|
SimulationProviderDescriptor,
|
||||||
|
SimulationProviderProfile,
|
||||||
|
)
|
||||||
from k1link.simulation.run_store import (
|
from k1link.simulation.run_store import (
|
||||||
QualificationRunConflictError,
|
QualificationRunConflictError,
|
||||||
QualificationRunIntegrityError,
|
QualificationRunIntegrityError,
|
||||||
|
|
@ -83,7 +91,9 @@ __all__ = [
|
||||||
"ProcessSpec",
|
"ProcessSpec",
|
||||||
"ProcessStopResult",
|
"ProcessStopResult",
|
||||||
"ProcessSupervisorError",
|
"ProcessSupervisorError",
|
||||||
|
"PROVIDER_PROFILE_SCHEMA",
|
||||||
"ProviderPin",
|
"ProviderPin",
|
||||||
|
"ProviderRole",
|
||||||
"QualificationArtifact",
|
"QualificationArtifact",
|
||||||
"QualificationEvent",
|
"QualificationEvent",
|
||||||
"QualificationRun",
|
"QualificationRun",
|
||||||
|
|
@ -105,8 +115,12 @@ __all__ = [
|
||||||
"StockRoverProfileError",
|
"StockRoverProfileError",
|
||||||
"StockRoverTargetPaths",
|
"StockRoverTargetPaths",
|
||||||
"SimulationContractError",
|
"SimulationContractError",
|
||||||
|
"SimulationClockDescriptor",
|
||||||
"SimulationApplicationService",
|
"SimulationApplicationService",
|
||||||
"SimulationOrchestratorError",
|
"SimulationOrchestratorError",
|
||||||
|
"SimulationProviderContractError",
|
||||||
|
"SimulationProviderDescriptor",
|
||||||
|
"SimulationProviderProfile",
|
||||||
"SimulationWorkerPort",
|
"SimulationWorkerPort",
|
||||||
"SimulationWorldControl",
|
"SimulationWorldControl",
|
||||||
"WorkerAdmission",
|
"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}$")
|
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}$")
|
SHA256_PATTERN: Final = re.compile(r"^[a-f0-9]{64}$")
|
||||||
GIT_REVISION_PATTERN: Final = re.compile(r"^[a-f0-9]{7,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):
|
class SimulationContractError(ValueError):
|
||||||
|
|
@ -243,11 +244,8 @@ class QualificationRun:
|
||||||
_sha256(self.host_profile_sha256, "host profile digest")
|
_sha256(self.host_profile_sha256, "host profile digest")
|
||||||
if self.seed < 0:
|
if self.seed < 0:
|
||||||
raise SimulationContractError("run seed must not be negative")
|
raise SimulationContractError("run seed must not be negative")
|
||||||
if self.clock_domain != "gazebo:/clock" and self.kind in {
|
if not CLOCK_DOMAIN_PATTERN.fullmatch(self.clock_domain):
|
||||||
RunKind.SIMULATION_CLOSED_LOOP,
|
raise SimulationContractError("clock domain is not a safe identifier")
|
||||||
RunKind.DIGITAL_TWIN_CLOSED_LOOP,
|
|
||||||
}:
|
|
||||||
raise SimulationContractError("closed-loop simulation requires Gazebo /clock")
|
|
||||||
_utc_timestamp(self.created_at_utc, "created timestamp")
|
_utc_timestamp(self.created_at_utc, "created timestamp")
|
||||||
if self.started_at_utc is not None:
|
if self.started_at_utc is not None:
|
||||||
_utc_timestamp(self.started_at_utc, "started timestamp")
|
_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
|
import yaml
|
||||||
|
|
||||||
|
from k1link.simulation.contracts import ControlProfile
|
||||||
from k1link.simulation.process_supervisor import ProcessSpec
|
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"
|
LIFECYCLE_PROFILE_SCHEMA: Final = "missioncore.stock-rover-lifecycle/v1"
|
||||||
EXPECTED_D_ROOT: Final = Path("/mnt/d/NDC_MISSIONCORE/simulation")
|
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):
|
class StockRoverProfileError(ValueError):
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ from k1link.simulation.run_store import (
|
||||||
)
|
)
|
||||||
from k1link.simulation.s0 import ComponentPin
|
from k1link.simulation.s0 import ComponentPin
|
||||||
from k1link.simulation.stock_rover import (
|
from k1link.simulation.stock_rover import (
|
||||||
|
GAZEBO_STATE_PROVIDER_ID,
|
||||||
|
PX4_COMMAND_PROVIDER_ID,
|
||||||
|
STOCK_ROVER_PROVIDER_PROFILE,
|
||||||
StockRoverTargetPaths,
|
StockRoverTargetPaths,
|
||||||
load_stock_rover_lifecycle_profile,
|
load_stock_rover_lifecycle_profile,
|
||||||
stock_rover_process_environment,
|
stock_rover_process_environment,
|
||||||
|
|
@ -173,7 +176,7 @@ class GazeboPoseCollector:
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"provider": "gazebo",
|
"provider": GAZEBO_STATE_PROVIDER_ID,
|
||||||
"topic": POSE_TOPIC,
|
"topic": POSE_TOPIC,
|
||||||
"signal": "ground-truth",
|
"signal": "ground-truth",
|
||||||
"quality": "diagnostic",
|
"quality": "diagnostic",
|
||||||
|
|
@ -371,12 +374,14 @@ class SimulationWorkerAgent:
|
||||||
|
|
||||||
def status(self) -> dict[str, Any]:
|
def status(self) -> dict[str, Any]:
|
||||||
active = self._active_run()
|
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}:
|
if active is not None and active.state in {RunState.RUNNING, RunState.PAUSED}:
|
||||||
try:
|
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:
|
except Exception:
|
||||||
provider_ids = []
|
active_provider_ids = []
|
||||||
return {
|
return {
|
||||||
"schema_version": STATUS_SCHEMA,
|
"schema_version": STATUS_SCHEMA,
|
||||||
"worker_id": "mission-gpu-s1",
|
"worker_id": "mission-gpu-s1",
|
||||||
|
|
@ -386,7 +391,8 @@ class SimulationWorkerAgent:
|
||||||
"control_available": True,
|
"control_available": True,
|
||||||
"active_run_id": active.run_id if active else None,
|
"active_run_id": active.run_id if active else None,
|
||||||
"run_state": active.state.value 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": {
|
"isolation": {
|
||||||
"network": "loopback-only-netns",
|
"network": "loopback-only-netns",
|
||||||
"process_identity": "missioncore",
|
"process_identity": "missioncore",
|
||||||
|
|
@ -552,7 +558,7 @@ class SimulationWorkerAgent:
|
||||||
sim_time_ns=issued_at_sim_ns,
|
sim_time_ns=issued_at_sim_ns,
|
||||||
payload={
|
payload={
|
||||||
"command_id": command.command_id,
|
"command_id": command.command_id,
|
||||||
"provider": "px4-ros2-offboard",
|
"provider": PX4_COMMAND_PROVIDER_ID,
|
||||||
"detail": type(exc).__name__,
|
"detail": type(exc).__name__,
|
||||||
},
|
},
|
||||||
expected_revision=current.revision,
|
expected_revision=current.revision,
|
||||||
|
|
@ -632,7 +638,7 @@ class SimulationWorkerAgent:
|
||||||
command_ttl_max_ns=250_000_000,
|
command_ttl_max_ns=250_000_000,
|
||||||
heartbeat_timeout_monotonic_ns=500_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(),
|
created_at_utc=_utc_now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -810,11 +816,15 @@ def _command_acceptance(
|
||||||
"steering_normalized": command.steering_normalized,
|
"steering_normalized": command.steering_normalized,
|
||||||
"authority_scope": command.authority_scope.value,
|
"authority_scope": command.authority_scope.value,
|
||||||
"delivery": {
|
"delivery": {
|
||||||
"provider": "px4-ros2-offboard",
|
"provider_id": PX4_COMMAND_PROVIDER_ID,
|
||||||
"mode": "speed-steering",
|
"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,
|
"armed": snapshot.armed,
|
||||||
"offboard": snapshot.offboard,
|
"offboard": snapshot.offboard,
|
||||||
"ttl_expired_count": snapshot.ttl_expired_count,
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,27 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
|
import re
|
||||||
import socket
|
import socket
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Final, Protocol
|
from typing import Any, Final, Protocol
|
||||||
from uuid import uuid4
|
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"
|
REQUEST_SCHEMA: Final = "missioncore.simulation-worker-request/v1"
|
||||||
RESPONSE_SCHEMA: Final = "missioncore.simulation-worker-response/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"
|
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
|
MAX_MESSAGE_BYTES: Final = 64 * 1024
|
||||||
OPERATIONS: Final = frozenset({"status", "live", "start", "command", "stop"})
|
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):
|
class SimulationWorkerGatewayError(RuntimeError):
|
||||||
|
|
@ -237,24 +245,35 @@ def _validate_status(value: Mapping[str, Any]) -> None:
|
||||||
"control_available",
|
"control_available",
|
||||||
"active_run_id",
|
"active_run_id",
|
||||||
"run_state",
|
"run_state",
|
||||||
"provider_ids",
|
"active_provider_ids",
|
||||||
|
"provider_profile",
|
||||||
"isolation",
|
"isolation",
|
||||||
"authority",
|
"authority",
|
||||||
}
|
}
|
||||||
if set(value) != expected or value.get("schema_version") != STATUS_SCHEMA:
|
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 (
|
if (
|
||||||
not isinstance(value.get("worker_id"), str)
|
not isinstance(value.get("worker_id"), str)
|
||||||
or value.get("transport") != "unix"
|
or value.get("transport") != "unix"
|
||||||
or value.get("mode") != "simulation"
|
or value.get("mode") != "simulation"
|
||||||
or value.get("available") is not True
|
or value.get("available") is not True
|
||||||
or not isinstance(value.get("control_available"), bool)
|
or not isinstance(value.get("control_available"), bool)
|
||||||
or not isinstance(value.get("provider_ids"), list)
|
or not isinstance(active_provider_ids, list)
|
||||||
or any(not isinstance(item, str) for item in value["provider_ids"])
|
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("isolation"), dict)
|
||||||
or not isinstance(value.get("authority"), dict)
|
or not isinstance(value.get("authority"), dict)
|
||||||
):
|
):
|
||||||
raise SimulationWorkerGatewayError("worker status contains invalid values")
|
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):
|
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")
|
raise SimulationWorkerGatewayError("worker active run id is invalid")
|
||||||
if value.get("run_state") is not None and not isinstance(value["run_state"], str):
|
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)
|
or not isinstance(value.get("safety"), dict)
|
||||||
):
|
):
|
||||||
raise SimulationWorkerGatewayError("vehicle state contains invalid values")
|
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:
|
def _validate_command_acceptance(value: Mapping[str, Any]) -> None:
|
||||||
|
|
@ -306,7 +337,7 @@ def _validate_command_acceptance(value: Mapping[str, Any]) -> None:
|
||||||
"delivery",
|
"delivery",
|
||||||
}
|
}
|
||||||
if set(value) != expected or value.get("schema_version") != COMMAND_ACCEPTANCE_SCHEMA:
|
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")
|
delivery = value.get("delivery")
|
||||||
numeric_fields = (
|
numeric_fields = (
|
||||||
"sequence",
|
"sequence",
|
||||||
|
|
@ -328,17 +359,35 @@ def _validate_command_acceptance(value: Mapping[str, Any]) -> None:
|
||||||
or not isinstance(delivery, dict)
|
or not isinstance(delivery, dict)
|
||||||
or set(delivery)
|
or set(delivery)
|
||||||
!= {
|
!= {
|
||||||
"provider",
|
"provider_id",
|
||||||
"mode",
|
"control_profile",
|
||||||
"armed",
|
"accepted",
|
||||||
"offboard",
|
"controller_ready",
|
||||||
"ttl_expired_count",
|
"ttl_expired_count",
|
||||||
|
"diagnostics",
|
||||||
}
|
}
|
||||||
or delivery.get("provider") != "px4-ros2-offboard"
|
or not isinstance(delivery.get("provider_id"), str)
|
||||||
or delivery.get("mode") != "speed-steering"
|
or not SAFE_ID_PATTERN.fullmatch(delivery["provider_id"])
|
||||||
or not isinstance(delivery.get("armed"), bool)
|
or delivery.get("control_profile")
|
||||||
or not isinstance(delivery.get("offboard"), bool)
|
!= 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 isinstance(delivery.get("ttl_expired_count"), bool)
|
||||||
or not isinstance(delivery.get("ttl_expired_count"), int)
|
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")
|
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,
|
"control_available": False,
|
||||||
"active_run_id": None,
|
"active_run_id": None,
|
||||||
"run_state": None,
|
"run_state": None,
|
||||||
"provider_ids": [],
|
"active_provider_ids": [],
|
||||||
|
"provider_profile": None,
|
||||||
"isolation": {
|
"isolation": {
|
||||||
"network": "unavailable",
|
"network": "unavailable",
|
||||||
"process_identity": "missioncore",
|
"process_identity": "missioncore",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from k1link.simulation import (
|
||||||
RunKind,
|
RunKind,
|
||||||
RunState,
|
RunState,
|
||||||
)
|
)
|
||||||
|
from k1link.simulation.stock_rover import STOCK_ROVER_PROVIDER_PROFILE
|
||||||
from k1link.web.polygon_api import (
|
from k1link.web.polygon_api import (
|
||||||
AckermannCommandRequest,
|
AckermannCommandRequest,
|
||||||
StartStockRoverRequest,
|
StartStockRoverRequest,
|
||||||
|
|
@ -256,7 +257,7 @@ class _FakeWorkerGateway:
|
||||||
self.calls.append(("command", idempotency_key))
|
self.calls.append(("command", idempotency_key))
|
||||||
assert run_id == self.active_run_id
|
assert run_id == self.active_run_id
|
||||||
return {
|
return {
|
||||||
"schema_version": "missioncore.command-acceptance/v1",
|
"schema_version": "missioncore.command-acceptance/v2",
|
||||||
"run_id": run_id,
|
"run_id": run_id,
|
||||||
"command_id": "cmd-test",
|
"command_id": "cmd-test",
|
||||||
"sequence": 1,
|
"sequence": 1,
|
||||||
|
|
@ -266,17 +267,18 @@ class _FakeWorkerGateway:
|
||||||
"steering_normalized": steering_normalized,
|
"steering_normalized": steering_normalized,
|
||||||
"authority_scope": "virtual-only",
|
"authority_scope": "virtual-only",
|
||||||
"delivery": {
|
"delivery": {
|
||||||
"provider": "px4-ros2-offboard",
|
"provider_id": "px4-ros2-offboard",
|
||||||
"mode": "speed-steering",
|
"control_profile": "rover-speed-steering/v1",
|
||||||
"armed": True,
|
"accepted": True,
|
||||||
"offboard": True,
|
"controller_ready": True,
|
||||||
"ttl_expired_count": 0,
|
"ttl_expired_count": 0,
|
||||||
|
"diagnostics": {"armed": True, "offboard": True},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def _status(self) -> dict[str, Any]:
|
def _status(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"schema_version": "missioncore.simulation-worker-status/v1",
|
"schema_version": "missioncore.simulation-worker-status/v2",
|
||||||
"worker_id": "mission-gpu-s1",
|
"worker_id": "mission-gpu-s1",
|
||||||
"transport": "unix",
|
"transport": "unix",
|
||||||
"mode": "simulation",
|
"mode": "simulation",
|
||||||
|
|
@ -284,7 +286,10 @@ class _FakeWorkerGateway:
|
||||||
"control_available": True,
|
"control_available": True,
|
||||||
"active_run_id": self.active_run_id,
|
"active_run_id": self.active_run_id,
|
||||||
"run_state": "running" if self.active_run_id else None,
|
"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": {
|
"isolation": {
|
||||||
"network": "loopback-only-netns",
|
"network": "loopback-only-netns",
|
||||||
"process_identity": "missioncore",
|
"process_identity": "missioncore",
|
||||||
|
|
@ -340,7 +345,7 @@ def test_polygon_worker_api_fails_closed_then_proxies_virtual_only_lifecycle() -
|
||||||
idempotency_key="command-001",
|
idempotency_key="command-001",
|
||||||
)
|
)
|
||||||
assert command["authority_scope"] == "virtual-only"
|
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")(
|
stopped = _endpoint(enabled, "/api/v1/polygon/worker/runs/{run_id}/stop", "POST")(
|
||||||
run_id=running["active_run_id"],
|
run_id=running["active_run_id"],
|
||||||
idempotency_key="stop-001",
|
idempotency_key="stop-001",
|
||||||
|
|
@ -366,7 +371,8 @@ def test_polygon_worker_status_is_explicitly_unavailable_when_not_registered() -
|
||||||
worker_provider=lambda: None,
|
worker_provider=lambda: None,
|
||||||
)
|
)
|
||||||
status = _endpoint(router, "/api/v1/polygon/worker", "GET")()
|
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["available"] is False
|
||||||
assert status["control_available"] is False
|
assert status["control_available"] is False
|
||||||
|
assert status["provider_profile"] is None
|
||||||
assert status["authority"]["scope"] == "virtual-only"
|
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:
|
def test_run_manifest_is_immutable_and_runtime_replays_from_events(tmp_path: Path) -> None:
|
||||||
store = QualificationRunStore(tmp_path / "runs")
|
store = QualificationRunStore(tmp_path / "runs")
|
||||||
created = store.create(_run())
|
created = store.create(_run())
|
||||||
|
|
|
||||||
|
|
@ -144,8 +144,9 @@ def test_worker_agent_journals_and_idempotently_delivers_virtual_command(
|
||||||
|
|
||||||
assert accepted == repeated
|
assert accepted == repeated
|
||||||
assert accepted["authority_scope"] == "virtual-only"
|
assert accepted["authority_scope"] == "virtual-only"
|
||||||
assert accepted["delivery"]["provider"] == "px4-ros2-offboard"
|
assert accepted["delivery"]["provider_id"] == "px4-ros2-offboard"
|
||||||
assert accepted["delivery"]["armed"] is True
|
assert accepted["delivery"]["controller_ready"] is True
|
||||||
|
assert accepted["delivery"]["diagnostics"]["armed"] is True
|
||||||
commands = agent.store.list_commands("s1c-command-test")
|
commands = agent.store.list_commands("s1c-command-test")
|
||||||
assert len(commands) == 1
|
assert len(commands) == 1
|
||||||
assert commands[0].valid_until_sim_ns - commands[0].issued_at_sim_ns == 250_000_000
|
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
|
import pytest
|
||||||
|
|
||||||
|
from k1link.simulation.stock_rover import STOCK_ROVER_PROVIDER_PROFILE
|
||||||
from k1link.simulation.worker_gateway import (
|
from k1link.simulation.worker_gateway import (
|
||||||
REQUEST_SCHEMA,
|
REQUEST_SCHEMA,
|
||||||
RESPONSE_SCHEMA,
|
RESPONSE_SCHEMA,
|
||||||
|
|
@ -20,7 +21,7 @@ from k1link.simulation.worker_gateway import (
|
||||||
|
|
||||||
def _status() -> dict[str, Any]:
|
def _status() -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"schema_version": "missioncore.simulation-worker-status/v1",
|
"schema_version": "missioncore.simulation-worker-status/v2",
|
||||||
"worker_id": "mission-gpu-s1",
|
"worker_id": "mission-gpu-s1",
|
||||||
"transport": "unix",
|
"transport": "unix",
|
||||||
"mode": "simulation",
|
"mode": "simulation",
|
||||||
|
|
@ -28,7 +29,8 @@ def _status() -> dict[str, Any]:
|
||||||
"control_available": True,
|
"control_available": True,
|
||||||
"active_run_id": None,
|
"active_run_id": None,
|
||||||
"run_state": None,
|
"run_state": None,
|
||||||
"provider_ids": [],
|
"active_provider_ids": [],
|
||||||
|
"provider_profile": STOCK_ROVER_PROVIDER_PROFILE.to_dict(),
|
||||||
"isolation": {
|
"isolation": {
|
||||||
"network": "loopback-only-netns",
|
"network": "loopback-only-netns",
|
||||||
"process_identity": "missioncore",
|
"process_identity": "missioncore",
|
||||||
|
|
@ -44,7 +46,7 @@ def _status() -> dict[str, Any]:
|
||||||
|
|
||||||
def _command_acceptance() -> dict[str, Any]:
|
def _command_acceptance() -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"schema_version": "missioncore.command-acceptance/v1",
|
"schema_version": "missioncore.command-acceptance/v2",
|
||||||
"run_id": "s1c-command-test",
|
"run_id": "s1c-command-test",
|
||||||
"command_id": "cmd-001",
|
"command_id": "cmd-001",
|
||||||
"sequence": 1,
|
"sequence": 1,
|
||||||
|
|
@ -54,11 +56,40 @@ def _command_acceptance() -> dict[str, Any]:
|
||||||
"steering_normalized": -0.25,
|
"steering_normalized": -0.25,
|
||||||
"authority_scope": "virtual-only",
|
"authority_scope": "virtual-only",
|
||||||
"delivery": {
|
"delivery": {
|
||||||
"provider": "px4-ros2-offboard",
|
"provider_id": "unreal-direct-control",
|
||||||
"mode": "speed-steering",
|
"control_profile": "rover-speed-steering/v1",
|
||||||
"armed": True,
|
"accepted": True,
|
||||||
"offboard": True,
|
"controller_ready": True,
|
||||||
"ttl_expired_count": 0,
|
"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)
|
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]["operation"] == "command"
|
||||||
assert requests[0]["payload"] == {
|
assert requests[0]["payload"] == {
|
||||||
"run_id": "s1c-command-test",
|
"run_id": "s1c-command-test",
|
||||||
|
|
@ -168,3 +200,18 @@ def test_unix_worker_gateway_sends_bounded_virtual_rover_command() -> None:
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
socket_path.unlink(missing_ok=True)
|
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