|
|
|
@@ -0,0 +1,349 @@
|
|
|
|
|
import type { LaboratoryFetch } from "./advancedResults";
|
|
|
|
|
|
|
|
|
|
const RESULT_ID = /^m49-tgs-fail-closed-[a-f0-9]{64}$/;
|
|
|
|
|
|
|
|
|
|
export type M49TgsProfile = "current_increment" | "causal_rolling_1s";
|
|
|
|
|
export type M49TgsStateCode = 0 | 1 | 2 | 3;
|
|
|
|
|
export type M49TgsPointStateCode = 1 | 2 | 3;
|
|
|
|
|
|
|
|
|
|
export interface M49TgsAnchorSummary {
|
|
|
|
|
anchorFrameIndex: number;
|
|
|
|
|
slot: number;
|
|
|
|
|
pointCount: number;
|
|
|
|
|
groundPointCount: number;
|
|
|
|
|
nongroundPointCount: number;
|
|
|
|
|
rejectedPointCount: number;
|
|
|
|
|
groundCellCount: number;
|
|
|
|
|
nongroundCellCount: number;
|
|
|
|
|
rejectedCellCount: number;
|
|
|
|
|
unobservedCellCount: number;
|
|
|
|
|
allPointsAccounted: true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface M49TgsFailClosedResult {
|
|
|
|
|
resultId: string;
|
|
|
|
|
createdAtUtc: string;
|
|
|
|
|
source: {
|
|
|
|
|
sourceId: "RAVNOVES00";
|
|
|
|
|
sourceSessionId: "20260720T065719Z_viewer_live";
|
|
|
|
|
sourcePackSha256: string;
|
|
|
|
|
linkedVisualResultId: string;
|
|
|
|
|
anchorFrameIndices: readonly number[];
|
|
|
|
|
};
|
|
|
|
|
configuration: {
|
|
|
|
|
profileId: string;
|
|
|
|
|
configSha256: string;
|
|
|
|
|
coordinateFrame: "map-gravity-local";
|
|
|
|
|
primaryProfile: "causal_rolling_1s";
|
|
|
|
|
cellSizeM: number;
|
|
|
|
|
radiusM: number;
|
|
|
|
|
};
|
|
|
|
|
execution: {
|
|
|
|
|
worker: "Worker 006";
|
|
|
|
|
device: "cpu";
|
|
|
|
|
gpuUsed: false;
|
|
|
|
|
wrapperElapsedSeconds: number;
|
|
|
|
|
};
|
|
|
|
|
metrics: {
|
|
|
|
|
anchorCount: number;
|
|
|
|
|
anchorProfileCount: number;
|
|
|
|
|
allEligiblePointsAccounted: true;
|
|
|
|
|
costmapCellCount: number;
|
|
|
|
|
processWallCurrentP50Ms: number;
|
|
|
|
|
processWallCurrentMaxMs: number;
|
|
|
|
|
processWallRollingP50Ms: number;
|
|
|
|
|
processWallRollingMaxMs: number;
|
|
|
|
|
processMaxRssKib: number;
|
|
|
|
|
primary: readonly M49TgsAnchorSummary[];
|
|
|
|
|
};
|
|
|
|
|
acceptance: {
|
|
|
|
|
representationComplete: true;
|
|
|
|
|
allPointsAccounted: true;
|
|
|
|
|
aosAbsent: true;
|
|
|
|
|
gpuAbsent: true;
|
|
|
|
|
visualQualityAccepted: false;
|
|
|
|
|
traversabilityAccepted: false;
|
|
|
|
|
};
|
|
|
|
|
decision: {
|
|
|
|
|
state: "visual-review-required";
|
|
|
|
|
candidateRetained: true;
|
|
|
|
|
nextAction: string;
|
|
|
|
|
};
|
|
|
|
|
limitations: readonly string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface M49TgsAnchorSpatial {
|
|
|
|
|
resultId: string;
|
|
|
|
|
anchorFrameIndex: number;
|
|
|
|
|
sourceSequence: number;
|
|
|
|
|
profile: M49TgsProfile;
|
|
|
|
|
coordinateFrame: "map-gravity-local";
|
|
|
|
|
pointsXyzM: readonly (readonly [number, number, number])[];
|
|
|
|
|
pointStates: readonly M49TgsPointStateCode[];
|
|
|
|
|
costmap: {
|
|
|
|
|
cellSizeM: number;
|
|
|
|
|
radiusM: number;
|
|
|
|
|
centersXyM: readonly (readonly [number, number])[];
|
|
|
|
|
states: readonly M49TgsStateCode[];
|
|
|
|
|
zBoundsM: readonly (readonly [number | null, number | null])[];
|
|
|
|
|
};
|
|
|
|
|
allPointsAccounted: true;
|
|
|
|
|
aosUsed: false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class M49TgsContractError extends Error {}
|
|
|
|
|
|
|
|
|
|
function objectValue(value: unknown, label: string): Record<string, unknown> {
|
|
|
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
|
|
|
throw new M49TgsContractError(`${label}: ожидался объект.`);
|
|
|
|
|
}
|
|
|
|
|
return value as Record<string, unknown>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function text(value: unknown, label: string): string {
|
|
|
|
|
if (typeof value !== "string" || !value.trim()) {
|
|
|
|
|
throw new M49TgsContractError(`${label}: ожидалась строка.`);
|
|
|
|
|
}
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function exact<T extends string | boolean>(value: unknown, expected: T, label: string): T {
|
|
|
|
|
if (value !== expected) throw new M49TgsContractError(`${label}: нарушен контракт.`);
|
|
|
|
|
return expected;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function numberValue(value: unknown, label: string): number {
|
|
|
|
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
|
|
|
throw new M49TgsContractError(`${label}: ожидалось число.`);
|
|
|
|
|
}
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function integer(value: unknown, label: string): number {
|
|
|
|
|
const parsed = numberValue(value, label);
|
|
|
|
|
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
|
|
|
throw new M49TgsContractError(`${label}: ожидалось целое число.`);
|
|
|
|
|
}
|
|
|
|
|
return parsed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sha256(value: unknown, label: string): string {
|
|
|
|
|
const parsed = text(value, label);
|
|
|
|
|
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
|
|
|
|
throw new M49TgsContractError(`${label}: неверный SHA-256.`);
|
|
|
|
|
}
|
|
|
|
|
return parsed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resultId(value: unknown): string {
|
|
|
|
|
const parsed = text(value, "M49 result id");
|
|
|
|
|
if (!RESULT_ID.test(parsed)) throw new M49TgsContractError("M49 identity недопустима.");
|
|
|
|
|
return parsed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function numbers(value: unknown, size: number, label: string): number[] {
|
|
|
|
|
if (!Array.isArray(value) || value.length !== size) {
|
|
|
|
|
throw new M49TgsContractError(`${label}: неверная размерность.`);
|
|
|
|
|
}
|
|
|
|
|
return value.map((item) => numberValue(item, label));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function anchor(value: unknown, label: string): M49TgsAnchorSummary {
|
|
|
|
|
const row = objectValue(value, label);
|
|
|
|
|
exact(row.profile_id, "causal_rolling_1s", `${label}.profile`);
|
|
|
|
|
exact(row.all_points_accounted, true, `${label}.accounting`);
|
|
|
|
|
const parsed: M49TgsAnchorSummary = {
|
|
|
|
|
anchorFrameIndex: integer(row.anchor_frame_index, `${label}.anchor`),
|
|
|
|
|
slot: integer(row.slot, `${label}.slot`),
|
|
|
|
|
pointCount: integer(row.point_count, `${label}.points`),
|
|
|
|
|
groundPointCount: integer(row.ground_point_count, `${label}.ground points`),
|
|
|
|
|
nongroundPointCount: integer(row.nonground_point_count, `${label}.nonground points`),
|
|
|
|
|
rejectedPointCount: integer(row.rejected_point_count, `${label}.rejected points`),
|
|
|
|
|
groundCellCount: integer(row.ground_cell_count, `${label}.ground cells`),
|
|
|
|
|
nongroundCellCount: integer(row.nonground_cell_count, `${label}.nonground cells`),
|
|
|
|
|
rejectedCellCount: integer(row.rejected_cell_count, `${label}.rejected cells`),
|
|
|
|
|
unobservedCellCount: integer(row.unobserved_cell_count, `${label}.unobserved cells`),
|
|
|
|
|
allPointsAccounted: true,
|
|
|
|
|
};
|
|
|
|
|
if (
|
|
|
|
|
parsed.groundPointCount + parsed.nongroundPointCount + parsed.rejectedPointCount
|
|
|
|
|
!== parsed.pointCount
|
|
|
|
|
|| parsed.groundCellCount + parsed.nongroundCellCount
|
|
|
|
|
+ parsed.rejectedCellCount + parsed.unobservedCellCount !== 2244
|
|
|
|
|
) {
|
|
|
|
|
throw new M49TgsContractError(`${label}: fail-closed accounting нарушен.`);
|
|
|
|
|
}
|
|
|
|
|
return parsed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function fetchM49TgsFailClosedResult(
|
|
|
|
|
id: string,
|
|
|
|
|
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
|
|
|
|
): Promise<M49TgsFailClosedResult> {
|
|
|
|
|
if (!RESULT_ID.test(id)) throw new M49TgsContractError("M49 identity недопустима.");
|
|
|
|
|
const response = await fetcher(
|
|
|
|
|
`/api/v1/laboratory/m49/tgs-fail-closed/${encodeURIComponent(id)}`,
|
|
|
|
|
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
|
|
|
|
);
|
|
|
|
|
if (!response.ok) throw new M49TgsContractError(`M49 TGS недоступен: HTTP ${response.status}.`);
|
|
|
|
|
const payload = objectValue(await response.json(), "M49 TGS");
|
|
|
|
|
exact(payload.schema_version, "missioncore.m49-tgs-fail-closed-view/v1", "M49 schema");
|
|
|
|
|
exact(payload.result_id, id, "M49 result");
|
|
|
|
|
exact(payload.ground_truth, false, "M49 ground truth");
|
|
|
|
|
exact(payload.access, "read-only", "M49 access");
|
|
|
|
|
const source = objectValue(payload.source, "M49 source");
|
|
|
|
|
const configuration = objectValue(payload.configuration, "M49 configuration");
|
|
|
|
|
const execution = objectValue(payload.execution, "M49 execution");
|
|
|
|
|
const metrics = objectValue(payload.metrics, "M49 metrics");
|
|
|
|
|
const acceptance = objectValue(payload.acceptance, "M49 acceptance");
|
|
|
|
|
const decision = objectValue(payload.decision, "M49 decision");
|
|
|
|
|
if (!Array.isArray(source.anchor_frame_indices) || !Array.isArray(metrics.primary)) {
|
|
|
|
|
throw new M49TgsContractError("M49 anchors: ожидался массив.");
|
|
|
|
|
}
|
|
|
|
|
if (!Array.isArray(payload.limitations)) {
|
|
|
|
|
throw new M49TgsContractError("M49 limitations: ожидался массив.");
|
|
|
|
|
}
|
|
|
|
|
const anchorFrameIndices = source.anchor_frame_indices.map(
|
|
|
|
|
(item, index) => integer(item, `M49 anchor ${index}`),
|
|
|
|
|
);
|
|
|
|
|
const primary = metrics.primary.map((item, index) => anchor(item, `M49 primary ${index}`));
|
|
|
|
|
if (
|
|
|
|
|
anchorFrameIndices.length !== 10
|
|
|
|
|
|| new Set(anchorFrameIndices).size !== 10
|
|
|
|
|
|| primary.length !== 10
|
|
|
|
|
|| integer(metrics.anchor_count, "M49 anchor count") !== 10
|
|
|
|
|
|| integer(metrics.anchor_profile_count, "M49 profile count") !== 20
|
|
|
|
|
|| integer(metrics.costmap_cell_count, "M49 costmap cells") !== 2244
|
|
|
|
|
|| primary.some((item, index) => item.anchorFrameIndex !== anchorFrameIndices[index])
|
|
|
|
|
) {
|
|
|
|
|
throw new M49TgsContractError("M49 anchor set или costmap contract нарушен.");
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
resultId: resultId(payload.result_id),
|
|
|
|
|
createdAtUtc: text(payload.created_at_utc, "M49 created"),
|
|
|
|
|
source: {
|
|
|
|
|
sourceId: exact(source.source_id, "RAVNOVES00", "M49 source id"),
|
|
|
|
|
sourceSessionId: exact(
|
|
|
|
|
source.source_session_id,
|
|
|
|
|
"20260720T065719Z_viewer_live",
|
|
|
|
|
"M49 source session",
|
|
|
|
|
),
|
|
|
|
|
sourcePackSha256: sha256(source.source_pack_sha256, "M49 source pack"),
|
|
|
|
|
linkedVisualResultId: text(source.linked_visual_result_id, "M49 visual result"),
|
|
|
|
|
anchorFrameIndices,
|
|
|
|
|
},
|
|
|
|
|
configuration: {
|
|
|
|
|
profileId: text(configuration.profile_id, "M49 profile"),
|
|
|
|
|
configSha256: sha256(configuration.config_sha256, "M49 config"),
|
|
|
|
|
coordinateFrame: exact(configuration.coordinate_frame, "map-gravity-local", "M49 frame"),
|
|
|
|
|
primaryProfile: exact(configuration.primary_profile, "causal_rolling_1s", "M49 primary"),
|
|
|
|
|
cellSizeM: numberValue(configuration.cell_size_m, "M49 cell size"),
|
|
|
|
|
radiusM: numberValue(configuration.radius_m, "M49 radius"),
|
|
|
|
|
},
|
|
|
|
|
execution: {
|
|
|
|
|
worker: exact(execution.worker, "Worker 006", "M49 worker"),
|
|
|
|
|
device: exact(execution.device, "cpu", "M49 device"),
|
|
|
|
|
gpuUsed: exact(execution.gpu_used, false, "M49 GPU"),
|
|
|
|
|
wrapperElapsedSeconds: numberValue(execution.wrapper_elapsed_seconds, "M49 wall"),
|
|
|
|
|
},
|
|
|
|
|
metrics: {
|
|
|
|
|
anchorCount: 10,
|
|
|
|
|
anchorProfileCount: 20,
|
|
|
|
|
allEligiblePointsAccounted: exact(metrics.all_eligible_points_accounted, true, "M49 accounting"),
|
|
|
|
|
costmapCellCount: 2244,
|
|
|
|
|
processWallCurrentP50Ms: numberValue(metrics.process_wall_current_p50_ms, "M49 current p50"),
|
|
|
|
|
processWallCurrentMaxMs: numberValue(metrics.process_wall_current_max_ms, "M49 current max"),
|
|
|
|
|
processWallRollingP50Ms: numberValue(metrics.process_wall_rolling_p50_ms, "M49 rolling p50"),
|
|
|
|
|
processWallRollingMaxMs: numberValue(metrics.process_wall_rolling_max_ms, "M49 rolling max"),
|
|
|
|
|
processMaxRssKib: integer(metrics.process_max_rss_kib, "M49 RSS"),
|
|
|
|
|
primary,
|
|
|
|
|
},
|
|
|
|
|
acceptance: {
|
|
|
|
|
representationComplete: exact(acceptance.representation_complete, true, "M49 representation"),
|
|
|
|
|
allPointsAccounted: exact(acceptance.all_points_accounted, true, "M49 points"),
|
|
|
|
|
aosAbsent: exact(acceptance.aos_absent, true, "M49 AOS"),
|
|
|
|
|
gpuAbsent: exact(acceptance.gpu_absent, true, "M49 GPU absent"),
|
|
|
|
|
visualQualityAccepted: exact(acceptance.visual_quality_accepted, false, "M49 visual quality"),
|
|
|
|
|
traversabilityAccepted: exact(acceptance.traversability_accepted, false, "M49 traversability"),
|
|
|
|
|
},
|
|
|
|
|
decision: {
|
|
|
|
|
state: exact(decision.state, "visual-review-required", "M49 decision"),
|
|
|
|
|
candidateRetained: exact(decision.candidate_retained, true, "M49 retained"),
|
|
|
|
|
nextAction: text(decision.next_action, "M49 next action"),
|
|
|
|
|
},
|
|
|
|
|
limitations: payload.limitations.map((item, index) => text(item, `M49 limitation ${index}`)),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function fetchM49TgsAnchorSpatial(
|
|
|
|
|
id: string,
|
|
|
|
|
anchorFrameIndex: number,
|
|
|
|
|
profile: M49TgsProfile = "causal_rolling_1s",
|
|
|
|
|
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
|
|
|
|
): Promise<M49TgsAnchorSpatial> {
|
|
|
|
|
if (!RESULT_ID.test(id) || !Number.isSafeInteger(anchorFrameIndex) || anchorFrameIndex < 0) {
|
|
|
|
|
throw new M49TgsContractError("M49 anchor identity недопустима.");
|
|
|
|
|
}
|
|
|
|
|
const response = await fetcher(
|
|
|
|
|
`/api/v1/laboratory/m49/tgs-fail-closed/${encodeURIComponent(id)}/anchors/${anchorFrameIndex}/spatial?profile=${encodeURIComponent(profile)}`,
|
|
|
|
|
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
|
|
|
|
);
|
|
|
|
|
if (!response.ok) throw new M49TgsContractError(`M49 anchor недоступен: HTTP ${response.status}.`);
|
|
|
|
|
const payload = objectValue(await response.json(), "M49 anchor");
|
|
|
|
|
exact(payload.schema_version, "missioncore.m49-tgs-anchor-spatial/v1", "M49 anchor schema");
|
|
|
|
|
exact(payload.result_id, id, "M49 anchor result");
|
|
|
|
|
exact(payload.coordinate_frame, "map-gravity-local", "M49 anchor frame");
|
|
|
|
|
exact(payload.all_points_accounted, true, "M49 anchor accounting");
|
|
|
|
|
exact(payload.aos_used, false, "M49 anchor AOS");
|
|
|
|
|
if (!Array.isArray(payload.points_xyz_m) || !Array.isArray(payload.point_states)) {
|
|
|
|
|
throw new M49TgsContractError("M49 points: ожидался массив.");
|
|
|
|
|
}
|
|
|
|
|
const costmap = objectValue(payload.costmap, "M49 costmap");
|
|
|
|
|
if (!Array.isArray(costmap.centers_xy_m) || !Array.isArray(costmap.states) || !Array.isArray(costmap.z_bounds_m)) {
|
|
|
|
|
throw new M49TgsContractError("M49 costmap arrays: нарушен контракт.");
|
|
|
|
|
}
|
|
|
|
|
const points = payload.points_xyz_m.map((item, index) => numbers(item, 3, `M49 point ${index}`) as [number, number, number]);
|
|
|
|
|
const pointStates = payload.point_states.map((item, index) => {
|
|
|
|
|
const state = integer(item, `M49 point state ${index}`);
|
|
|
|
|
if (state !== 1 && state !== 2 && state !== 3) throw new M49TgsContractError("M49 point state неизвестен.");
|
|
|
|
|
return state;
|
|
|
|
|
});
|
|
|
|
|
if (points.length !== pointStates.length) throw new M49TgsContractError("M49 point accounting нарушен.");
|
|
|
|
|
const centers = costmap.centers_xy_m.map((item, index) => numbers(item, 2, `M49 cell ${index}`) as [number, number]);
|
|
|
|
|
const states = costmap.states.map((item, index) => {
|
|
|
|
|
const state = integer(item, `M49 cell state ${index}`);
|
|
|
|
|
if (state !== 0 && state !== 1 && state !== 2 && state !== 3) throw new M49TgsContractError("M49 cell state неизвестен.");
|
|
|
|
|
return state;
|
|
|
|
|
});
|
|
|
|
|
const zBounds = costmap.z_bounds_m.map((item, index) => {
|
|
|
|
|
if (!Array.isArray(item) || item.length !== 2) throw new M49TgsContractError(`M49 z ${index}: размерность.`);
|
|
|
|
|
return item.map((value) => value === null ? null : numberValue(value, `M49 z ${index}`)) as [number | null, number | null];
|
|
|
|
|
});
|
|
|
|
|
if (
|
|
|
|
|
centers.length !== 2244
|
|
|
|
|
|| centers.length !== states.length
|
|
|
|
|
|| centers.length !== zBounds.length
|
|
|
|
|
|| integer(payload.anchor_frame_index, "M49 anchor frame") !== anchorFrameIndex
|
|
|
|
|
|| integer(payload.source_sequence, "M49 source sequence") !== anchorFrameIndex
|
|
|
|
|
) {
|
|
|
|
|
throw new M49TgsContractError("M49 costmap accounting нарушен.");
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
resultId: id,
|
|
|
|
|
anchorFrameIndex,
|
|
|
|
|
sourceSequence: anchorFrameIndex,
|
|
|
|
|
profile: exact(payload.profile, profile, "M49 profile"),
|
|
|
|
|
coordinateFrame: "map-gravity-local",
|
|
|
|
|
pointsXyzM: points,
|
|
|
|
|
pointStates,
|
|
|
|
|
costmap: {
|
|
|
|
|
cellSizeM: numberValue(costmap.cell_size_m, "M49 cell size"),
|
|
|
|
|
radiusM: numberValue(costmap.radius_m, "M49 radius"),
|
|
|
|
|
centersXyM: centers,
|
|
|
|
|
states,
|
|
|
|
|
zBoundsM: zBounds,
|
|
|
|
|
},
|
|
|
|
|
allPointsAccounted: true,
|
|
|
|
|
aosUsed: false,
|
|
|
|
|
};
|
|
|
|
|
}
|