feat(polygon): qualify GOOSE ground providers
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
export interface QualificationMetricAggregate {
|
||||
micro: {
|
||||
groundIou: number;
|
||||
naturalGroundRecall: number;
|
||||
obstacleNonGroundRecall: number;
|
||||
};
|
||||
latencyMs: {
|
||||
p50: number;
|
||||
p95: number;
|
||||
maximum: number;
|
||||
};
|
||||
assignedFraction: number;
|
||||
sourceCoverage: number;
|
||||
frameCount: number;
|
||||
}
|
||||
|
||||
export interface QualificationCheck {
|
||||
checkId: string;
|
||||
observed: number;
|
||||
operator: ">=" | "<=";
|
||||
threshold: number;
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
export interface QualificationWorstFrame {
|
||||
frameId: string;
|
||||
currentGroundIou: number;
|
||||
patchworkGroundIou: number;
|
||||
groundIouDelta: number;
|
||||
patchworkNaturalGroundRecall: number;
|
||||
}
|
||||
|
||||
export interface GroundQualification {
|
||||
runId: string;
|
||||
identitySha256: string;
|
||||
sourceId: string;
|
||||
split: string;
|
||||
frameCount: number;
|
||||
current: QualificationMetricAggregate;
|
||||
patchwork: QualificationMetricAggregate;
|
||||
degradations: Record<string, QualificationMetricAggregate>;
|
||||
checks: QualificationCheck[];
|
||||
worstFrames: QualificationWorstFrame[];
|
||||
decision: {
|
||||
status: "shadow-candidate" | "qualification-rejected";
|
||||
passed: boolean;
|
||||
promotedToNavigationOrSafety: false;
|
||||
reason: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GroundFailurePreview {
|
||||
runId: string;
|
||||
frameId: string;
|
||||
sourcePointCount: number;
|
||||
pointCount: number;
|
||||
pointsXyzM: Array<[number, number, number]>;
|
||||
groundTruthGround: number[];
|
||||
evaluated: number[];
|
||||
currentGround: number[];
|
||||
patchworkGround: number[];
|
||||
currentDisagreement: number[];
|
||||
patchworkDisagreement: number[];
|
||||
}
|
||||
|
||||
export class GroundQualificationContractError extends Error {}
|
||||
|
||||
export class GroundQualificationApiError extends Error {
|
||||
constructor(message: string, readonly status: number | null = null) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
type QualificationFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
function record(value: unknown, field: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new GroundQualificationContractError(`${field} должен быть объектом.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, field: string, maximum: number): unknown[] {
|
||||
if (!Array.isArray(value) || value.length > maximum) {
|
||||
throw new GroundQualificationContractError(`${field} должен быть ограниченным массивом.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(value: unknown, field: string, maximum = 256): string {
|
||||
if (typeof value !== "string" || !value.trim() || value.length > maximum) {
|
||||
throw new GroundQualificationContractError(`${field} должен быть непустой строкой.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function id(value: unknown, field: string): string {
|
||||
const result = text(value, field, 128);
|
||||
if (!SAFE_ID.test(result)) {
|
||||
throw new GroundQualificationContractError(`${field} содержит недопустимый идентификатор.`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function number(value: unknown, field: string, minimum = 0): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) {
|
||||
throw new GroundQualificationContractError(`${field} должен быть конечным числом.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function fraction(value: unknown, field: string): number {
|
||||
const result = number(value, field);
|
||||
if (result > 1) {
|
||||
throw new GroundQualificationContractError(`${field} должен быть долей 0..1.`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, field: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new GroundQualificationContractError(`${field} должен быть boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function aggregate(value: unknown, field: string): QualificationMetricAggregate {
|
||||
const source = record(value, field);
|
||||
const micro = record(source.micro, `${field}.micro`);
|
||||
const latency = record(source.latency_ms, `${field}.latency_ms`);
|
||||
return {
|
||||
micro: {
|
||||
groundIou: fraction(micro.ground_iou, `${field}.micro.ground_iou`),
|
||||
naturalGroundRecall: fraction(
|
||||
micro.natural_ground_recall,
|
||||
`${field}.micro.natural_ground_recall`,
|
||||
),
|
||||
obstacleNonGroundRecall: fraction(
|
||||
micro.obstacle_non_ground_recall,
|
||||
`${field}.micro.obstacle_non_ground_recall`,
|
||||
),
|
||||
},
|
||||
latencyMs: {
|
||||
p50: number(latency.p50, `${field}.latency_ms.p50`),
|
||||
p95: number(latency.p95, `${field}.latency_ms.p95`),
|
||||
maximum: number(latency.maximum, `${field}.latency_ms.maximum`),
|
||||
},
|
||||
assignedFraction: fraction(source.assigned_fraction, `${field}.assigned_fraction`),
|
||||
sourceCoverage: fraction(source.source_coverage, `${field}.source_coverage`),
|
||||
frameCount: number(source.frame_count, `${field}.frame_count`, 1),
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeGroundQualification(payload: unknown): GroundQualification {
|
||||
const source = record(payload, "qualification");
|
||||
if (
|
||||
source.schema_version !== "missioncore.polygon-ground-qualification/v1"
|
||||
|| source.access !== "read-only"
|
||||
) {
|
||||
throw new GroundQualificationContractError("Неизвестная схема квалификации.");
|
||||
}
|
||||
const runId = id(source.run_id, "run_id");
|
||||
const identitySha256 = text(source.identity_sha256, "identity_sha256", 64);
|
||||
if (!SHA256.test(identitySha256)) {
|
||||
throw new GroundQualificationContractError("identity_sha256 должен содержать SHA-256.");
|
||||
}
|
||||
const aggregates = record(source.aggregates, "aggregates");
|
||||
const degradationSource = record(source.degradations, "degradations");
|
||||
const degradations: Record<string, QualificationMetricAggregate> = {};
|
||||
Object.entries(degradationSource).forEach(([profileId, value]) => {
|
||||
if (!SAFE_ID.test(profileId) || Object.keys(degradations).length >= 32) {
|
||||
throw new GroundQualificationContractError("Профили деградации имеют неверный контракт.");
|
||||
}
|
||||
degradations[profileId] = aggregate(value, `degradations.${profileId}`);
|
||||
});
|
||||
const checks = array(source.checks, "checks", 128).map((value, index) => {
|
||||
const check = record(value, `checks[${index}]`);
|
||||
const rawOperator = text(check.operator, `checks[${index}].operator`, 2);
|
||||
if (rawOperator !== ">=" && rawOperator !== "<=") {
|
||||
throw new GroundQualificationContractError("Проверка содержит неизвестный оператор.");
|
||||
}
|
||||
const operator: QualificationCheck["operator"] = rawOperator;
|
||||
return {
|
||||
checkId: id(check.check_id, `checks[${index}].check_id`),
|
||||
observed: number(check.observed, `checks[${index}].observed`, -Infinity),
|
||||
operator,
|
||||
threshold: number(check.threshold, `checks[${index}].threshold`, -Infinity),
|
||||
passed: boolean(check.passed, `checks[${index}].passed`),
|
||||
};
|
||||
});
|
||||
const worstFrames = array(source.worst_frames, "worst_frames", 20).map(
|
||||
(value, index) => {
|
||||
const frame = record(value, `worst_frames[${index}]`);
|
||||
return {
|
||||
frameId: id(frame.frame_id, `worst_frames[${index}].frame_id`),
|
||||
currentGroundIou: fraction(
|
||||
frame.current_ground_iou,
|
||||
`worst_frames[${index}].current_ground_iou`,
|
||||
),
|
||||
patchworkGroundIou: fraction(
|
||||
frame.patchwork_ground_iou,
|
||||
`worst_frames[${index}].patchwork_ground_iou`,
|
||||
),
|
||||
groundIouDelta: number(
|
||||
frame.ground_iou_delta,
|
||||
`worst_frames[${index}].ground_iou_delta`,
|
||||
-1,
|
||||
),
|
||||
patchworkNaturalGroundRecall: fraction(
|
||||
frame.patchwork_natural_ground_recall,
|
||||
`worst_frames[${index}].patchwork_natural_ground_recall`,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
const decision = record(source.decision, "decision");
|
||||
const status = text(decision.status, "decision.status", 64);
|
||||
if (status !== "shadow-candidate" && status !== "qualification-rejected") {
|
||||
throw new GroundQualificationContractError("Решение квалификации неизвестно.");
|
||||
}
|
||||
if (decision.promoted_to_navigation_or_safety !== false) {
|
||||
throw new GroundQualificationContractError(
|
||||
"Квалификация не может принимать навигацию или safety.",
|
||||
);
|
||||
}
|
||||
const frameCount = number(source.frame_count, "frame_count", 1);
|
||||
const current = aggregate(aggregates.current, "aggregates.current");
|
||||
const patchwork = aggregate(aggregates.patchworkpp, "aggregates.patchworkpp");
|
||||
if (current.frameCount !== frameCount || patchwork.frameCount !== frameCount) {
|
||||
throw new GroundQualificationContractError("Агрегаты не совпадают с числом кадров.");
|
||||
}
|
||||
return {
|
||||
runId,
|
||||
identitySha256,
|
||||
sourceId: text(source.source_id, "source_id"),
|
||||
split: id(source.split, "split"),
|
||||
frameCount,
|
||||
current,
|
||||
patchwork,
|
||||
degradations,
|
||||
checks,
|
||||
worstFrames,
|
||||
decision: {
|
||||
status,
|
||||
passed: boolean(decision.passed, "decision.passed"),
|
||||
promotedToNavigationOrSafety: false,
|
||||
reason: text(decision.reason, "decision.reason", 512),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mask(value: unknown, field: string, expected: number): number[] {
|
||||
const values = array(value, field, 50_000).map((item, index) => {
|
||||
if (item !== 0 && item !== 1) {
|
||||
throw new GroundQualificationContractError(`${field}[${index}] должен быть 0 или 1.`);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (values.length !== expected) {
|
||||
throw new GroundQualificationContractError(`${field} не совпадает с point_count.`);
|
||||
}
|
||||
return values as number[];
|
||||
}
|
||||
|
||||
export function decodeGroundFailurePreview(payload: unknown): GroundFailurePreview {
|
||||
const source = record(payload, "failure preview");
|
||||
if (
|
||||
source.schema_version !== "missioncore.polygon-ground-failure-preview/v1"
|
||||
|| source.access !== "read-only"
|
||||
) {
|
||||
throw new GroundQualificationContractError("Неизвестная схема preview.");
|
||||
}
|
||||
const pointCount = number(source.point_count, "point_count", 1);
|
||||
const pointsXyzM = array(source.points_xyz_m, "points_xyz_m", 50_000).map(
|
||||
(value, index): [number, number, number] => {
|
||||
const tuple = array(value, `points_xyz_m[${index}]`, 3);
|
||||
if (tuple.length !== 3) {
|
||||
throw new GroundQualificationContractError("Точка должна содержать XYZ.");
|
||||
}
|
||||
return [
|
||||
number(tuple[0], `points_xyz_m[${index}].x`, -Infinity),
|
||||
number(tuple[1], `points_xyz_m[${index}].y`, -Infinity),
|
||||
number(tuple[2], `points_xyz_m[${index}].z`, -Infinity),
|
||||
];
|
||||
},
|
||||
);
|
||||
if (pointsXyzM.length !== pointCount) {
|
||||
throw new GroundQualificationContractError("points_xyz_m не совпадает с point_count.");
|
||||
}
|
||||
return {
|
||||
runId: id(source.run_id, "run_id"),
|
||||
frameId: id(source.frame_id, "frame_id"),
|
||||
sourcePointCount: number(source.source_point_count, "source_point_count", pointCount),
|
||||
pointCount,
|
||||
pointsXyzM,
|
||||
groundTruthGround: mask(source.ground_truth_ground, "ground_truth_ground", pointCount),
|
||||
evaluated: mask(source.evaluated, "evaluated", pointCount),
|
||||
currentGround: mask(source.current_ground, "current_ground", pointCount),
|
||||
patchworkGround: mask(source.patchwork_ground, "patchwork_ground", pointCount),
|
||||
currentDisagreement: mask(
|
||||
source.current_disagreement,
|
||||
"current_disagreement",
|
||||
pointCount,
|
||||
),
|
||||
patchworkDisagreement: mask(
|
||||
source.patchwork_disagreement,
|
||||
"patchwork_disagreement",
|
||||
pointCount,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function requestJson(
|
||||
url: string,
|
||||
signal: AbortSignal | undefined,
|
||||
fetcher: QualificationFetch,
|
||||
): Promise<unknown> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(url, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new GroundQualificationApiError("Не удалось загрузить квалификацию.");
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new GroundQualificationContractError("Polygon API вернул повреждённый JSON.");
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = typeof body === "object" && body !== null && "detail" in body
|
||||
? String((body as { detail: unknown }).detail)
|
||||
: `Polygon API HTTP ${response.status}.`;
|
||||
throw new GroundQualificationApiError(detail, response.status);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function fetchGroundQualification(
|
||||
runId: string,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: { signal?: AbortSignal; fetcher?: QualificationFetch } = {},
|
||||
): Promise<GroundQualification | null> {
|
||||
if (!SAFE_ID.test(runId)) {
|
||||
throw new GroundQualificationContractError("Недопустимый run_id.");
|
||||
}
|
||||
try {
|
||||
return decodeGroundQualification(
|
||||
await requestJson(
|
||||
`/api/v1/polygon/runs/${encodeURIComponent(runId)}/qualification`,
|
||||
signal,
|
||||
fetcher,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof GroundQualificationApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGroundFailurePreview(
|
||||
runId: string,
|
||||
frameId: string,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: { signal?: AbortSignal; fetcher?: QualificationFetch } = {},
|
||||
): Promise<GroundFailurePreview> {
|
||||
if (!SAFE_ID.test(runId) || !SAFE_ID.test(frameId)) {
|
||||
throw new GroundQualificationContractError("Недопустимый идентификатор preview.");
|
||||
}
|
||||
return decodeGroundFailurePreview(
|
||||
await requestJson(
|
||||
`/api/v1/polygon/runs/${encodeURIComponent(runId)}/qualification/failures/`
|
||||
+ encodeURIComponent(frameId),
|
||||
signal,
|
||||
fetcher,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -97,6 +97,15 @@
|
||||
}
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.polygon-qualification__grid,
|
||||
.polygon-qualification__failures {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.polygon-qualification__providers dl {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.feature-group {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.9rem;
|
||||
@@ -174,6 +183,19 @@
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.polygon-qualification__heading,
|
||||
.polygon-qualification__viewer > header {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.polygon-qualification__providers {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.polygon-qualification__degradations p {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.dataset-purpose ol {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
@@ -775,6 +775,246 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.polygon-qualification {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
border-radius: 1.2rem;
|
||||
background: var(--station-panel);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.polygon-qualification__heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.polygon-qualification__heading h3,
|
||||
.polygon-qualification__heading p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.polygon-qualification__heading h3 {
|
||||
margin-top: 0.35rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.polygon-qualification__heading p {
|
||||
max-width: 46rem;
|
||||
margin-top: 0.45rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.polygon-qualification__providers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.polygon-qualification__providers article,
|
||||
.polygon-qualification__checks,
|
||||
.polygon-qualification__degradations,
|
||||
.polygon-qualification__failures > article {
|
||||
border-radius: 0.9rem;
|
||||
background: rgb(255 255 255 / 0.03);
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.polygon-qualification__providers article > strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.polygon-qualification__providers dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.6rem;
|
||||
margin: 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.polygon-qualification__providers dl > div {
|
||||
display: grid;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.polygon-qualification__providers dt,
|
||||
.polygon-qualification__providers dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.polygon-qualification__providers dt {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.55rem;
|
||||
}
|
||||
|
||||
.polygon-qualification__providers dd {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.polygon-qualification__grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.polygon-qualification__checks > div,
|
||||
.polygon-qualification__degradations > div {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
.polygon-qualification__checks p,
|
||||
.polygon-qualification__degradations p {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
border-radius: 0.65rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 0.48rem 0.55rem;
|
||||
}
|
||||
|
||||
.polygon-qualification__checks p {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.polygon-qualification__checks i {
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.polygon-qualification__checks p[data-passed="true"] i {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.polygon-qualification__checks span,
|
||||
.polygon-qualification__checks code,
|
||||
.polygon-qualification__degradations strong,
|
||||
.polygon-qualification__degradations span {
|
||||
overflow: hidden;
|
||||
font-size: 0.55rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.polygon-qualification__checks span,
|
||||
.polygon-qualification__degradations strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.polygon-qualification__checks code,
|
||||
.polygon-qualification__degradations span {
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.polygon-qualification__degradations p {
|
||||
grid-template-columns: minmax(7rem, 1fr) repeat(3, auto);
|
||||
}
|
||||
|
||||
.polygon-qualification__failures {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 0.34fr) minmax(0, 1.66fr);
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.polygon-qualification__failures > article:first-child > div {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
.polygon-qualification__failures button {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.22rem;
|
||||
border: 0;
|
||||
border-radius: 0.65rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 0.55rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.polygon-qualification__failures button:hover,
|
||||
.polygon-qualification__failures button:focus-visible,
|
||||
.polygon-qualification__failures button[data-active="true"] {
|
||||
outline: 0;
|
||||
background: rgb(255 255 255 / 0.075);
|
||||
}
|
||||
|
||||
.polygon-qualification__failures button span,
|
||||
.polygon-qualification__failures button code {
|
||||
overflow: hidden;
|
||||
font-size: 0.54rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.polygon-qualification__failures button code {
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.polygon-qualification__viewer {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.polygon-qualification__viewer > header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.polygon-qualification__viewer > header > div:first-child {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.polygon-qualification__viewer > header strong {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.62rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.polygon-qualification__viewer .lidar-ground-scene {
|
||||
min-height: 30rem;
|
||||
}
|
||||
|
||||
.polygon-qualification__viewer-empty {
|
||||
display: grid;
|
||||
min-height: 30rem;
|
||||
place-items: center;
|
||||
border-radius: 0.85rem;
|
||||
background: #06070a;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.polygon-qualification__error {
|
||||
border-radius: 0.9rem;
|
||||
background: rgb(var(--nodedc-danger-rgb) / 0.08);
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0.8rem 1rem;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.capability-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
|
||||
@@ -275,10 +275,11 @@ export function DatasetGatewayWorkspace() {
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
disabled
|
||||
title="Прогон станет доступен после импорта датасета"
|
||||
variant="secondary"
|
||||
title="Открыть read-only архив квалификационных прогонов"
|
||||
onClick={() => window.location.assign("/?workspace=polygon-run")}
|
||||
>
|
||||
Создать прогон
|
||||
Открыть прогоны
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -5,6 +5,12 @@ import {
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
fetchGroundFailurePreview,
|
||||
fetchGroundQualification,
|
||||
type GroundFailurePreview,
|
||||
type GroundQualification,
|
||||
} from "../core/polygon/groundQualification";
|
||||
import {
|
||||
fetchPolygonRunCatalog,
|
||||
fetchPolygonRunDetail,
|
||||
@@ -13,6 +19,10 @@ import {
|
||||
type PolygonRunRoute,
|
||||
type PolygonRunState,
|
||||
} from "../core/polygon/runArchive";
|
||||
import {
|
||||
LidarGroundPointCloud,
|
||||
type LidarGroundViewMode,
|
||||
} from "./LidarGroundPointCloud";
|
||||
import { PolygonLivePanel } from "./PolygonLivePanel";
|
||||
|
||||
interface PolygonRunWorkspaceProps {
|
||||
@@ -62,6 +72,17 @@ function errorMessage(error: unknown): string {
|
||||
return "Не удалось прочитать доказательства прогона.";
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatMilliseconds(value: number): string {
|
||||
return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} мс`;
|
||||
}
|
||||
|
||||
export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||
const [catalog, setCatalog] = useState<PolygonRunCatalog | null>(null);
|
||||
const [detail, setDetail] = useState<PolygonRunDetail | null>(null);
|
||||
@@ -69,6 +90,12 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||
const [loading, setLoading] = useState(route.error === null);
|
||||
const [error, setError] = useState<string | null>(route.error);
|
||||
const [reloadGeneration, setReloadGeneration] = useState(0);
|
||||
const [qualification, setQualification] = useState<GroundQualification | null>(null);
|
||||
const [qualificationError, setQualificationError] = useState<string | null>(null);
|
||||
const [failurePreview, setFailurePreview] = useState<GroundFailurePreview | null>(null);
|
||||
const [selectedFailureFrameId, setSelectedFailureFrameId] = useState<string | null>(null);
|
||||
const [failureMode, setFailureMode] =
|
||||
useState<LidarGroundViewMode>("candidate-disagreement");
|
||||
|
||||
useEffect(() => {
|
||||
if (route.error) {
|
||||
@@ -106,10 +133,71 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||
return () => controller.abort();
|
||||
}, [reloadGeneration, route.error, route.runId, selectedRunId]);
|
||||
|
||||
useEffect(() => {
|
||||
const runId = detail?.run.runId;
|
||||
if (!runId) {
|
||||
setQualification(null);
|
||||
setQualificationError(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setQualification(null);
|
||||
setQualificationError(null);
|
||||
setFailurePreview(null);
|
||||
setSelectedFailureFrameId(null);
|
||||
void fetchGroundQualification(runId, { signal: controller.signal })
|
||||
.then((result) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setQualification(result);
|
||||
setSelectedFailureFrameId(result?.worstFrames[0]?.frameId ?? null);
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (!controller.signal.aborted) setQualificationError(errorMessage(loadError));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [detail?.run.runId]);
|
||||
|
||||
useEffect(() => {
|
||||
const runId = qualification?.runId;
|
||||
if (!runId || !selectedFailureFrameId) {
|
||||
setFailurePreview(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setFailurePreview(null);
|
||||
void fetchGroundFailurePreview(runId, selectedFailureFrameId, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((result) => {
|
||||
if (!controller.signal.aborted) setFailurePreview(result);
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (!controller.signal.aborted) setQualificationError(errorMessage(loadError));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [qualification?.runId, selectedFailureFrameId]);
|
||||
|
||||
const visibleEvents = useMemo(
|
||||
() => detail ? [...detail.events].reverse() : [],
|
||||
[detail],
|
||||
);
|
||||
const failureFrame = useMemo(() => {
|
||||
if (!failurePreview) return null;
|
||||
return {
|
||||
pointCount: failurePreview.pointCount,
|
||||
pointsXyzM: failurePreview.pointsXyzM,
|
||||
intensity0To255: null,
|
||||
masks: {
|
||||
currentGround: failurePreview.currentGround,
|
||||
currentAssigned: failurePreview.evaluated,
|
||||
candidateGround: failurePreview.patchworkGround,
|
||||
candidateAssigned: failurePreview.evaluated,
|
||||
disagreement: failurePreview.currentDisagreement,
|
||||
candidateDisagreement: failurePreview.patchworkDisagreement,
|
||||
groundTruthGround: failurePreview.groundTruthGround,
|
||||
},
|
||||
};
|
||||
}, [failurePreview]);
|
||||
|
||||
if (loading && !detail) {
|
||||
return (
|
||||
@@ -199,6 +287,133 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{qualification ? (
|
||||
<section className="polygon-qualification" aria-label="Квалификация ground provider">
|
||||
<header className="polygon-qualification__heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">GOOSE VALIDATION · {qualification.frameCount} КАДРОВ</span>
|
||||
<h3>Current против Patchwork++</h3>
|
||||
<p>
|
||||
Публичная разметка проверяет переносимость ground pipeline. Результат остаётся
|
||||
shadow-only и не даёт права на навигацию или safety.
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone={qualification.decision.passed ? "success" : "danger"}>
|
||||
{qualification.decision.passed ? "Shadow candidate" : "Gate не пройден"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
<div className="polygon-qualification__providers">
|
||||
{([
|
||||
["Current", qualification.current],
|
||||
["Patchwork++", qualification.patchwork],
|
||||
] as const).map(([label, aggregate]) => (
|
||||
<article key={label}>
|
||||
<strong>{label}</strong>
|
||||
<dl>
|
||||
<div><dt>Ground IoU</dt><dd>{formatPercent(aggregate.micro.groundIou)}</dd></div>
|
||||
<div><dt>Natural ground</dt><dd>{formatPercent(aggregate.micro.naturalGroundRecall)}</dd></div>
|
||||
<div><dt>Obstacle recall</dt><dd>{formatPercent(aggregate.micro.obstacleNonGroundRecall)}</dd></div>
|
||||
<div><dt>Latency p95</dt><dd>{formatMilliseconds(aggregate.latencyMs.p95)}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="polygon-qualification__grid">
|
||||
<article className="polygon-qualification__checks">
|
||||
<span className="section-eyebrow">ЗАРАНЕЕ ЗАФИКСИРОВАННЫЕ GATES</span>
|
||||
<div>
|
||||
{qualification.checks.map((check) => (
|
||||
<p key={check.checkId} data-passed={check.passed ? "true" : "false"}>
|
||||
<i aria-hidden="true" />
|
||||
<span>{check.checkId}</span>
|
||||
<code>
|
||||
{check.observed.toLocaleString("ru-RU", { maximumFractionDigits: 3 })}
|
||||
{" "}{check.operator}{" "}
|
||||
{check.threshold.toLocaleString("ru-RU", { maximumFractionDigits: 3 })}
|
||||
</code>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="polygon-qualification__degradations">
|
||||
<span className="section-eyebrow">ДЕТЕРМИНИРОВАННЫЕ ДЕГРАДАЦИИ</span>
|
||||
<div>
|
||||
{Object.entries(qualification.degradations).map(([profileId, aggregate]) => (
|
||||
<p key={profileId}>
|
||||
<strong>{profileId}</strong>
|
||||
<span>IoU {formatPercent(aggregate.micro.groundIou)}</span>
|
||||
<span>Natural {formatPercent(aggregate.micro.naturalGroundRecall)}</span>
|
||||
<span>p95 {formatMilliseconds(aggregate.latencyMs.p95)}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="polygon-qualification__failures">
|
||||
<article>
|
||||
<span className="section-eyebrow">ХУДШИЕ КАДРЫ</span>
|
||||
<div>
|
||||
{qualification.worstFrames.slice(0, 5).map((frame) => (
|
||||
<button
|
||||
type="button"
|
||||
key={frame.frameId}
|
||||
data-active={frame.frameId === selectedFailureFrameId ? "true" : undefined}
|
||||
onClick={() => setSelectedFailureFrameId(frame.frameId)}
|
||||
>
|
||||
<span>{frame.frameId}</span>
|
||||
<code>
|
||||
PW {formatPercent(frame.patchworkGroundIou)} ·
|
||||
Δ {formatPercent(frame.groundIouDelta)}
|
||||
</code>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
<article className="polygon-qualification__viewer">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">РАЗБОР ОШИБКИ</span>
|
||||
<strong>{failurePreview?.frameId ?? "Загружаем кадр"}</strong>
|
||||
</div>
|
||||
<div className="lidar-ground-modes" aria-label="Режим отображения ошибки">
|
||||
{([
|
||||
["ground-truth", "Ground truth"],
|
||||
["current", "Current"],
|
||||
["candidate", "Patchwork++"],
|
||||
["disagreement", "Ошибка Current"],
|
||||
["candidate-disagreement", "Ошибка PW++"],
|
||||
] as const).map(([mode, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
key={mode}
|
||||
data-active={failureMode === mode ? "true" : undefined}
|
||||
onClick={() => setFailureMode(mode)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
{failureFrame ? (
|
||||
<LidarGroundPointCloud frame={failureFrame} mode={failureMode} />
|
||||
) : (
|
||||
<div className="polygon-qualification__viewer-empty">
|
||||
Читаем bounded preview из sealed-артефакта прогона…
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
) : qualificationError ? (
|
||||
<div className="polygon-qualification__error">
|
||||
Квалификационный отчёт недоступен: {qualificationError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="polygon-run-layout">
|
||||
<GlassSurface className="polygon-run-catalog" padding="lg">
|
||||
<header className="polygon-run-panel-heading">
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let decodeGroundQualification;
|
||||
let decodeGroundFailurePreview;
|
||||
let fetchGroundQualification;
|
||||
let GroundQualificationContractError;
|
||||
|
||||
const aggregate = {
|
||||
micro: {
|
||||
ground_iou: 0.75,
|
||||
natural_ground_recall: 0.8,
|
||||
obstacle_non_ground_recall: 0.92,
|
||||
},
|
||||
latency_ms: { p50: 12, p95: 18, maximum: 25 },
|
||||
assigned_fraction: 1,
|
||||
source_coverage: 1,
|
||||
frame_count: 961,
|
||||
};
|
||||
|
||||
const qualification = {
|
||||
schema_version: "missioncore.polygon-ground-qualification/v1",
|
||||
access: "read-only",
|
||||
run_id: "goose-ground-abc",
|
||||
identity_sha256: "a".repeat(64),
|
||||
source_id: "goose-3d/v2025-08-22",
|
||||
split: "validation",
|
||||
frame_count: 961,
|
||||
aggregates: {
|
||||
current: { ...aggregate, micro: { ...aggregate.micro, ground_iou: 0.5 } },
|
||||
patchworkpp: aggregate,
|
||||
},
|
||||
degradations: { "range-20m": { ...aggregate, source_coverage: 0.6 } },
|
||||
checks: [{
|
||||
check_id: "ground-iou-gain",
|
||||
observed: 0.25,
|
||||
operator: ">=",
|
||||
threshold: 0.07,
|
||||
passed: true,
|
||||
}],
|
||||
worst_frames: [{
|
||||
frame_id: "2022-07-22_flight__0071_0001",
|
||||
current_ground_iou: 0.6,
|
||||
patchwork_ground_iou: 0.55,
|
||||
ground_iou_delta: -0.05,
|
||||
patchwork_natural_ground_recall: 0.7,
|
||||
}],
|
||||
decision: {
|
||||
status: "shadow-candidate",
|
||||
passed: true,
|
||||
promoted_to_navigation_or_safety: false,
|
||||
reason: "all gates passed",
|
||||
},
|
||||
safety: {
|
||||
qualification_only: true,
|
||||
actuator_authority: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
},
|
||||
};
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
decodeGroundQualification,
|
||||
decodeGroundFailurePreview,
|
||||
fetchGroundQualification,
|
||||
GroundQualificationContractError,
|
||||
} = await server.ssrLoadModule("/src/core/polygon/groundQualification.ts"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("decodes a bounded shadow-only qualification", () => {
|
||||
const decoded = decodeGroundQualification(qualification);
|
||||
assert.equal(decoded.frameCount, 961);
|
||||
assert.equal(decoded.patchwork.micro.groundIou, 0.75);
|
||||
assert.equal(decoded.degradations["range-20m"].sourceCoverage, 0.6);
|
||||
assert.equal(decoded.decision.promotedToNavigationOrSafety, false);
|
||||
});
|
||||
|
||||
test("rejects navigation or safety promotion", () => {
|
||||
assert.throws(
|
||||
() => decodeGroundQualification({
|
||||
...qualification,
|
||||
decision: {
|
||||
...qualification.decision,
|
||||
promoted_to_navigation_or_safety: true,
|
||||
},
|
||||
}),
|
||||
GroundQualificationContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("decodes point-aligned failure preview", () => {
|
||||
const decoded = decodeGroundFailurePreview({
|
||||
schema_version: "missioncore.polygon-ground-failure-preview/v1",
|
||||
access: "read-only",
|
||||
run_id: "goose-ground-abc",
|
||||
source_id: "goose-3d/v2025-08-22",
|
||||
frame_id: "2022-07-22_flight__0071_0001",
|
||||
source_point_count: 2,
|
||||
point_count: 2,
|
||||
sampling: "deterministic-even-index",
|
||||
points_xyz_m: [[0, 0, 0], [1, 1, 1]],
|
||||
ground_truth_ground: [1, 0],
|
||||
evaluated: [1, 1],
|
||||
current_ground: [0, 0],
|
||||
patchwork_ground: [1, 0],
|
||||
current_disagreement: [1, 0],
|
||||
patchwork_disagreement: [0, 0],
|
||||
safety: { navigation_or_safety_accepted: false },
|
||||
});
|
||||
assert.equal(decoded.pointCount, 2);
|
||||
assert.deepEqual(decoded.patchworkGround, [1, 0]);
|
||||
});
|
||||
|
||||
test("treats missing qualification as a generic run", async () => {
|
||||
const result = await fetchGroundQualification("goose-ground-abc", {
|
||||
fetcher: async () => new Response(
|
||||
JSON.stringify({ detail: "not found" }),
|
||||
{ status: 404, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
});
|
||||
assert.equal(result, null);
|
||||
});
|
||||
Reference in New Issue
Block a user