fix(lab): enforce canonical replay runtime
This commit is contained in:
@@ -106,6 +106,36 @@ export interface VegetationMixedRouteReview {
|
||||
cases: readonly VegetationMixedRouteCase[];
|
||||
}
|
||||
|
||||
export interface VegetationRouteTgsAnchor {
|
||||
sourceSequence: number;
|
||||
slot: number;
|
||||
currentPointsXyzM: readonly (readonly [number, number, number])[];
|
||||
costmap: {
|
||||
cellSizeM: 0.45;
|
||||
centersXyM: readonly (readonly [number, number])[];
|
||||
stateCodes: readonly number[];
|
||||
zBoundsM: readonly (readonly [number | null, number | null])[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface CanonicalRecordedLabSpatialFrame {
|
||||
targetTimeNs: number;
|
||||
sourceTimeNs: number;
|
||||
poseTimeNs: number;
|
||||
trajectoryTimeNs: number;
|
||||
sourcePointCount: number;
|
||||
bodyFrame: {
|
||||
originMapXyzM: readonly [number, number, number];
|
||||
basisMapFromBody: readonly [
|
||||
readonly [number, number, number],
|
||||
readonly [number, number, number],
|
||||
readonly [number, number, number],
|
||||
];
|
||||
};
|
||||
sourcePointsBodyXyzM: readonly (readonly [number, number, number])[];
|
||||
localSlamBodyXyzM: readonly (readonly [number, number, number])[];
|
||||
}
|
||||
|
||||
export interface VegetationFullRouteLayer {
|
||||
name: string;
|
||||
resultId: string;
|
||||
@@ -942,6 +972,172 @@ export function vegetationFullRouteMaskUrl(
|
||||
return `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/route-masks/${layer}/${sequence}`;
|
||||
}
|
||||
|
||||
export async function fetchVegetationRouteTgsAnchor(
|
||||
resultId: string,
|
||||
sourceSequence: number,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<VegetationRouteTgsAnchor> {
|
||||
if (!RESULT_ID.test(resultId) || !Number.isInteger(sourceSequence) || sourceSequence < 1) {
|
||||
throw new VegetationShadowContractError("Vegetation TGS anchor identity недопустима.");
|
||||
}
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}`
|
||||
+ `/route-tgs-anchor/${sourceSequence}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new VegetationShadowContractError(`Vegetation TGS anchor недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
const payload = objectValue(await response.json(), "vegetation.route_tgs_anchor");
|
||||
exact(
|
||||
payload.schema_version,
|
||||
"missioncore.lab-v1-route-tgs-anchor/v1",
|
||||
"vegetation.route_tgs_anchor.schema_version",
|
||||
);
|
||||
exact(payload.source_sequence, sourceSequence, "vegetation.route_tgs_anchor.source_sequence");
|
||||
const pointValue = (value: unknown, label: string): readonly number[] => {
|
||||
const point = arrayValue(value, label).map((item, index) => numberValue(item, `${label}[${index}]`));
|
||||
if (point.length !== 2 && point.length !== 3) {
|
||||
throw new VegetationShadowContractError(`${label}: размер изменён.`);
|
||||
}
|
||||
return point;
|
||||
};
|
||||
const points = arrayValue(payload.current_points_xyz_m, "vegetation.route_tgs_anchor.points")
|
||||
.map((value, index) => pointValue(value, `vegetation.route_tgs_anchor.points[${index}]`));
|
||||
const costmap = objectValue(payload.costmap, "vegetation.route_tgs_anchor.costmap");
|
||||
exact(costmap.cell_size_m, 0.45, "vegetation.route_tgs_anchor.costmap.cell_size_m");
|
||||
const centers = arrayValue(costmap.centers_xy_m, "vegetation.route_tgs_anchor.costmap.centers")
|
||||
.map((value, index) => pointValue(value, `vegetation.route_tgs_anchor.costmap.centers[${index}]`));
|
||||
const stateCodes = arrayValue(costmap.state_codes, "vegetation.route_tgs_anchor.costmap.states")
|
||||
.map((value, index) => integerValue(value, `vegetation.route_tgs_anchor.costmap.states[${index}]`));
|
||||
const zBounds = arrayValue(costmap.z_bounds_m, "vegetation.route_tgs_anchor.costmap.z_bounds")
|
||||
.map((value, index) => {
|
||||
const row = arrayValue(value, `vegetation.route_tgs_anchor.costmap.z_bounds[${index}]`);
|
||||
if (row.length !== 2 || row.some((item) => item !== null && (typeof item !== "number" || !Number.isFinite(item)))) {
|
||||
throw new VegetationShadowContractError("vegetation.route_tgs_anchor.costmap.z_bounds: контракт изменён.");
|
||||
}
|
||||
return row as readonly [number | null, number | null];
|
||||
});
|
||||
if (
|
||||
centers.length !== 2244
|
||||
|| stateCodes.length !== 2244
|
||||
|| zBounds.length !== 2244
|
||||
|| stateCodes.some((value) => value > 3)
|
||||
|| points.some((point) => point.length !== 3)
|
||||
|| centers.some((point) => point.length !== 2)
|
||||
) {
|
||||
throw new VegetationShadowContractError("Vegetation TGS anchor shape изменён.");
|
||||
}
|
||||
return {
|
||||
sourceSequence,
|
||||
slot: integerValue(payload.slot, "vegetation.route_tgs_anchor.slot"),
|
||||
currentPointsXyzM: points.map((point) => [point[0]!, point[1]!, point[2]!] as const),
|
||||
costmap: {
|
||||
cellSizeM: 0.45,
|
||||
centersXyM: centers.map((point) => [point[0]!, point[1]!] as const),
|
||||
stateCodes,
|
||||
zBoundsM: zBounds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchCanonicalRecordedLabSpatialFrame(
|
||||
sessionId: string,
|
||||
generationSha256: string,
|
||||
targetTimeNs: number,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<CanonicalRecordedLabSpatialFrame> {
|
||||
if (
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(sessionId)
|
||||
|| !SHA256.test(generationSha256)
|
||||
|| !Number.isSafeInteger(targetTimeNs)
|
||||
|| targetTimeNs < 0
|
||||
) {
|
||||
throw new VegetationShadowContractError("Canonical LAB spatial identity недопустима.");
|
||||
}
|
||||
const query = new URLSearchParams({
|
||||
generation: generationSha256,
|
||||
time_ns: String(targetTimeNs),
|
||||
});
|
||||
const response = await fetcher(
|
||||
`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}`
|
||||
+ `/canonical-lab/spatial-frame?${query.toString()}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new VegetationShadowContractError(
|
||||
`Canonical LAB spatial frame недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const payload = objectValue(await response.json(), "canonical_lab.spatial_frame");
|
||||
exact(
|
||||
payload.schema_version,
|
||||
"missioncore.canonical-recorded-lab-spatial-frame/v1",
|
||||
"canonical_lab.spatial_frame.schema_version",
|
||||
);
|
||||
exact(payload.target_time_ns, targetTimeNs, "canonical_lab.spatial_frame.target_time_ns");
|
||||
const pointList = (value: unknown, label: string) => arrayValue(value, label).map(
|
||||
(entry, index) => {
|
||||
const point = arrayValue(entry, `${label}[${index}]`).map(
|
||||
(channel, channelIndex) => numberValue(channel, `${label}[${index}][${channelIndex}]`),
|
||||
);
|
||||
if (point.length !== 3) {
|
||||
throw new VegetationShadowContractError(`${label}[${index}]: размер изменён.`);
|
||||
}
|
||||
return [point[0]!, point[1]!, point[2]!] as const;
|
||||
},
|
||||
);
|
||||
const sourcePoints = pointList(
|
||||
payload.source_points_body_xyz_m,
|
||||
"canonical_lab.spatial_frame.source_points",
|
||||
);
|
||||
const localSlam = pointList(
|
||||
payload.local_slam_body_xyz_m,
|
||||
"canonical_lab.spatial_frame.local_slam",
|
||||
);
|
||||
const sourcePointCount = integerValue(
|
||||
payload.source_point_count,
|
||||
"canonical_lab.spatial_frame.source_point_count",
|
||||
);
|
||||
if (sourcePointCount !== sourcePoints.length || sourcePointCount > 100_000 || localSlam.length > 10_000) {
|
||||
throw new VegetationShadowContractError("Canonical LAB spatial accounting изменён.");
|
||||
}
|
||||
const bodyFrame = objectValue(payload.body_frame, "canonical_lab.spatial_frame.body_frame");
|
||||
const origin = pointList(
|
||||
[bodyFrame.origin_map_xyz_m],
|
||||
"canonical_lab.spatial_frame.body_frame.origin",
|
||||
)[0]!;
|
||||
const basisRows = pointList(
|
||||
bodyFrame.basis_map_from_body,
|
||||
"canonical_lab.spatial_frame.body_frame.basis",
|
||||
);
|
||||
if (basisRows.length !== 3) {
|
||||
throw new VegetationShadowContractError("Canonical LAB spatial basis изменён.");
|
||||
}
|
||||
return {
|
||||
targetTimeNs,
|
||||
sourceTimeNs: integerValue(payload.source_time_ns, "canonical_lab.spatial_frame.source_time_ns"),
|
||||
poseTimeNs: integerValue(payload.pose_time_ns, "canonical_lab.spatial_frame.pose_time_ns"),
|
||||
trajectoryTimeNs: integerValue(
|
||||
payload.trajectory_time_ns,
|
||||
"canonical_lab.spatial_frame.trajectory_time_ns",
|
||||
),
|
||||
sourcePointCount,
|
||||
bodyFrame: {
|
||||
originMapXyzM: origin,
|
||||
basisMapFromBody: [basisRows[0]!, basisRows[1]!, basisRows[2]!],
|
||||
},
|
||||
sourcePointsBodyXyzM: sourcePoints,
|
||||
localSlamBodyXyzM: localSlam,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchVegetationShadowResult(
|
||||
resultId: string,
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user