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