Compare commits
231
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34ed8a0f5f | ||
|
|
64860be4ae | ||
|
|
4ac07f7671 | ||
|
|
bcda5d001a | ||
|
|
cbbaea9148 | ||
|
|
3d3c0eced2 | ||
|
|
d50d3bb3d3 | ||
|
|
51bb1369eb | ||
|
|
a3fc13ad63 | ||
|
|
c041a569f6 | ||
|
|
5ebc9fc27c | ||
|
|
99eafaf02d | ||
|
|
03830cd15e | ||
|
|
6e7428a116 | ||
|
|
3e5aedd74d | ||
|
|
687c050c28 | ||
|
|
4e00e804f3 | ||
|
|
456141a3f3 | ||
|
|
7d911b4b1f | ||
|
|
71c8b043c9 | ||
|
|
0752e5c6bf | ||
|
|
1001a31638 | ||
|
|
85035fa07b | ||
|
|
eaad9deda1 | ||
|
|
7217244886 | ||
|
|
c7843a3c7e | ||
|
|
a3138f3d71 | ||
|
|
d01be34cac | ||
|
|
ac4a00c67d | ||
|
|
aee60f12b9 | ||
|
|
321cde70e8 | ||
|
|
986ea69610 | ||
|
|
72a2f37c03 | ||
|
|
4a24275ddc | ||
|
|
17a07355cf | ||
|
|
cd8a1045c4 | ||
|
|
3235ba7b1c | ||
|
|
9e6b5b403a | ||
|
|
9ef115d3b4 | ||
|
|
606cbbfae2 | ||
|
|
0ca7316a24 | ||
|
|
aff331082f | ||
|
|
52da9b75b7 | ||
|
|
be50144ca8 | ||
|
|
81ae4425cb | ||
|
|
8eaa3ab497 | ||
|
|
b7a51e26e6 | ||
|
|
b8fb4ebcba | ||
|
|
e38f2fb1da | ||
|
|
9df9f58ab8 | ||
|
|
aacc6dc43b | ||
|
|
13ed096f80 | ||
|
|
31313c3c96 | ||
|
|
7aa3ce55c0 | ||
|
|
c70ad345ea | ||
|
|
de19229895 | ||
|
|
b0d0bc8d7f | ||
|
|
6b810952a4 | ||
|
|
998775c3af | ||
|
|
438e0ca263 | ||
|
|
3ecbe3fb21 | ||
|
|
b1f9957317 | ||
|
|
8bd632509e | ||
|
|
8e161a197f | ||
|
|
7756310bdf | ||
|
|
b38cd51338 | ||
|
|
40794f13aa | ||
|
|
720487379c | ||
|
|
c9242480c2 | ||
|
|
95ac235122 | ||
|
|
a680debfaf | ||
|
|
029b486c67 | ||
|
|
ae1e41f7fe | ||
|
|
8ef215e98f | ||
|
|
78a3dc2162 | ||
|
|
1b3e0b3406 | ||
|
|
6d0abbc569 | ||
|
|
de12e96297 | ||
|
|
4c763bd8aa | ||
|
|
67bd96868f | ||
|
|
7a72a206f1 | ||
|
|
12c8a2d74c | ||
|
|
3982256f08 | ||
|
|
c1b0f6f8a3 | ||
|
|
55b7821a5c | ||
|
|
b44c4b3265 | ||
|
|
a2cb50d1ad | ||
|
|
8fe6184c52 | ||
|
|
a9a5ca5a26 | ||
|
|
29fee5c0ac | ||
|
|
dc6f9b66da | ||
|
|
dee6d57133 | ||
|
|
8c91d40e2e | ||
|
|
81d7ad1a77 | ||
|
|
e4e5bf6ea1 | ||
|
|
fdcaf57ae7 | ||
|
|
e9bbfb9a41 | ||
|
|
7d1a70d8e0 | ||
|
|
e56fa0c074 | ||
|
|
2ab9e45548 | ||
|
|
091ab2671e | ||
|
|
cc6838ed24 | ||
|
|
8e4891d608 | ||
|
|
2678295ca7 | ||
|
|
0c3855eac5 | ||
|
|
b91d3b913e | ||
|
|
5a06194962 | ||
|
|
f2a4dfe863 | ||
|
|
8e3710b493 | ||
|
|
66f8d7a1fa | ||
|
|
d95a13059c | ||
|
|
6180c3b8b7 | ||
|
|
32c5d0dda5 | ||
|
|
611853d3af | ||
|
|
9db39347bd | ||
|
|
1e1b7fe43d | ||
|
|
82a44478fb | ||
|
|
8b1f109b09 | ||
|
|
c455301764 | ||
|
|
636db089ae | ||
|
|
d8ccc9345a | ||
|
|
1eba7c82ba | ||
|
|
2a97cf28c0 | ||
|
|
37b8930527 | ||
|
|
79eb4b46f7 | ||
|
|
783aa444ac | ||
|
|
bfd95c7fee | ||
|
|
d729abab31 | ||
|
|
1f20e0d7d9 | ||
|
|
6e5124bd9c | ||
|
|
c018f2b074 | ||
|
|
e35f81ac34 | ||
|
|
6db5ac05ef | ||
|
|
a2fcaa7b39 | ||
|
|
65a72d40a8 | ||
|
|
d2964a0ddc | ||
|
|
a63d2fe15b | ||
|
|
2ffbb51a7e | ||
|
|
4a65b52f6d | ||
|
|
1c0181297d | ||
|
|
1f4c960cc4 | ||
|
|
b8a008b50e | ||
|
|
9fc002d86f | ||
|
|
667db00b52 | ||
|
|
67e12a47a4 | ||
|
|
438196cd6d | ||
|
|
fa20079c9e | ||
|
|
b894945344 | ||
|
|
621084fcd6 | ||
|
|
95c6540691 | ||
|
|
2c016b117d | ||
|
|
fa2d66ab0e | ||
|
|
9bbd5845b7 | ||
|
|
32257fc2b4 | ||
|
|
10c306f162 | ||
|
|
091d560ac8 | ||
|
|
001d597a89 | ||
|
|
a44d7627fd | ||
|
|
2ffaf94052 | ||
|
|
f50405d907 | ||
|
|
4db00b53f7 | ||
|
|
e9ff29297b | ||
|
|
360ec39ff3 | ||
|
|
dc55ff16c9 | ||
|
|
453d760be4 | ||
|
|
42041b37cd | ||
|
|
05413e3f5c | ||
|
|
677dbdb776 | ||
|
|
f791b63890 | ||
|
|
36ea7ea3d8 | ||
|
|
a1e2cb523f | ||
|
|
e6d5411bdd | ||
|
|
4c83e8a4e7 | ||
|
|
796b9306f9 | ||
|
|
c6c48fbc38 | ||
|
|
3449b2bc3e | ||
|
|
04a658b218 | ||
|
|
cfe17e49bb | ||
|
|
d8b7ed4526 | ||
|
|
21c7a5507c | ||
|
|
15821a1c98 | ||
|
|
41e347d20a | ||
|
|
8446eb4492 | ||
|
|
f2f044080f | ||
|
|
054feec0d2 | ||
|
|
13c35ff446 | ||
|
|
e97573e221 | ||
|
|
2e5c4ba1c5 | ||
|
|
b037076506 | ||
|
|
cea63f9a1e | ||
|
|
d6decbe05c | ||
|
|
60ba64004b | ||
|
|
951b40c870 | ||
|
|
881e97312b | ||
|
|
c01712c30d | ||
|
|
f57d64bf52 | ||
|
|
3333e9ac0f | ||
|
|
2dfb34ef21 | ||
|
|
75a3e669d9 | ||
|
|
e4fdd9fa20 | ||
|
|
3f549f91f1 | ||
|
|
37c24b5fa8 | ||
|
|
5abd07d2bf | ||
|
|
bc61331604 | ||
|
|
49b0f47fea | ||
|
|
0fb2782f2d | ||
|
|
6ad1803aaa | ||
|
|
8ace0cd102 | ||
|
|
b9ce5f53a0 | ||
|
|
8eecb8ed69 | ||
|
|
a0e9597cb5 | ||
|
|
97a3775acf | ||
|
|
f63ed36913 | ||
|
|
101707d54c | ||
|
|
53fd1e3bfc | ||
|
|
60e79160c9 | ||
|
|
17a0e7a7f5 | ||
|
|
ee7d1dd26c | ||
|
|
6cc40282e0 | ||
|
|
1995dfdf68 | ||
|
|
b7ccca3481 | ||
|
|
359f0d1d39 | ||
|
|
b790f29d59 | ||
|
|
a78c8c83f5 | ||
|
|
6cb14954d4 | ||
|
|
b4e3b3b735 | ||
|
|
01ec12263a | ||
|
|
e1809ac9e1 | ||
|
|
630d1ae819 | ||
|
|
12fba7126d | ||
|
|
fc8c81dbc4 |
@@ -0,0 +1,260 @@
|
||||
---
|
||||
name: mission-core-product-ui
|
||||
description: Design and enforce Mission Core product surfaces, information architecture, product UI, and laboratory presentation. Use for every new or relocated workspace, product root, navigation category, panel, viewer, Control Station surface, LAB catalog/summary, visualization control, fullscreen behavior, product-facing status, or UI refactor in NODEDC_MISSION_CORE, including work that could introduce a new component, page pattern, or task-specific composition.
|
||||
---
|
||||
|
||||
# Mission Core product UI
|
||||
|
||||
## Required context
|
||||
|
||||
Before changing UI:
|
||||
|
||||
1. Read the repository `AGENTS.md`.
|
||||
2. Read `docs/17_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md`.
|
||||
3. Read `docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md`.
|
||||
4. For a new, relocated, or materially changed non-LAB surface, read
|
||||
`docs/19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md`.
|
||||
5. For LAB work, also read `docs/15_LABORATORY_RUN_CANON.md`.
|
||||
6. Read the sibling Design Guideline:
|
||||
- `registry/registry.json`
|
||||
- `registry/components.json`
|
||||
- `registry/icons.json`
|
||||
- `docs/COMPONENTS.md`
|
||||
- `docs/GOVERNANCE.md`
|
||||
- the documentation for the affected page pattern or component.
|
||||
|
||||
Do not start implementation before resolving the required controls and icons in
|
||||
the registries.
|
||||
|
||||
## Canonical local endpoint
|
||||
|
||||
- Keep the integrated Mission Core Control Station and API exclusively on
|
||||
`http://127.0.0.1:8000`.
|
||||
- Treat the canonical `8000` process as durable operator state, not as a
|
||||
disposable QA process. Finishing a test, build, browser check, Codex turn, or
|
||||
implementation increment never authorizes stopping it.
|
||||
- At the start of every Control Station task, inspect `8000`. If it is not
|
||||
serving Mission Core, start the canonical integrated service before UI work.
|
||||
At handoff, verify it again and leave it running.
|
||||
- A rebuild may replace the process on `8000`, but the replacement must be the
|
||||
same canonical service and must be listening successfully before handoff.
|
||||
Never leave the operator with a stopped Control Station after a restart.
|
||||
- Never start a second integrated backend on an alternate port to bypass a
|
||||
stale or occupied `8000`. In particular, TCP `8765` is a legacy Foxglove
|
||||
regression port and is not an operator path.
|
||||
- Before browser QA or product handoff, inspect listeners on `8000` and `8765`.
|
||||
If the Mission Core process on `8000` is stale, restart that exact canonical
|
||||
service instead of launching another instance.
|
||||
- Use `5173` only for an explicitly requested Vite development session and
|
||||
`4173` only for an explicitly requested production preview. Both must proxy
|
||||
the API to `8000`; neither changes the canonical integrated endpoint.
|
||||
- Finish with exactly one integrated Mission Core backend on `8000` and no
|
||||
Mission Core backend on `8765`. Open or hand off `8000`, never an alternate
|
||||
backend URL.
|
||||
|
||||
## Local workstation resource gate
|
||||
|
||||
- The local operator machine is a 14-inch 2023 MacBook Pro with only 18 GB of
|
||||
physical memory. Its memory, swap, CPU, GPU, and thermal headroom are part of
|
||||
the product-test safety boundary.
|
||||
- Run memory-intensive work sequentially. Never overlap a full frontend test
|
||||
pass, production build, Docker/Compose build or startup, synthetic replay,
|
||||
and browser visual QA.
|
||||
- Before starting a memory-intensive operation, inspect host memory pressure and
|
||||
the active Docker/process workload. If memory pressure is elevated or swap is
|
||||
growing, stop temporary work and recover headroom before continuing.
|
||||
- Prefer focused tests and an existing valid build. Run the full suite and
|
||||
production build only when they are required acceptance gates, one at a time.
|
||||
- Do not start Docker Desktop, Compose stacks, duplicate application servers,
|
||||
duplicate browser automation sessions, or load/stress tests for convenience.
|
||||
Bounded load work belongs on Worker 006, not on the Mac.
|
||||
- After each test, build, replay, or visual-QA stage, stop temporary watchers,
|
||||
publishers, viewers, workers, and containers before starting the next heavy
|
||||
stage. Preserve only the canonical integrated service on `8000` and other
|
||||
explicitly required durable operator services.
|
||||
- Do not change Docker Desktop CPU or memory limits without explicit product
|
||||
owner approval. A configured Docker VM ceiling is not safe available host
|
||||
memory.
|
||||
|
||||
## Component gate
|
||||
|
||||
- Reuse package exports from `@nodedc/ui-react`, `@nodedc/ui-core`,
|
||||
`@nodedc/tokens`, and `@nodedc/page-patterns`.
|
||||
- Do not create a local substitute for a button, icon button, segmented switch,
|
||||
select, dropdown, glass surface, status, window, focus state, hover state, or
|
||||
page pattern.
|
||||
- Treat an absent generic visual entity as a product decision. Stop, describe
|
||||
the user need and proposed generic API, and obtain explicit approval.
|
||||
- After approval, implement the generic entity in the Design Guideline first,
|
||||
including registry, documentation, interaction states, catalog example, and
|
||||
validation. Consume it from Mission Core only afterward.
|
||||
- Allow Mission Core domain renderers, data adapters, and domain layouts. Build
|
||||
their controls from canonical exports.
|
||||
|
||||
Explicit approval for one entity does not authorize unrelated visual entities.
|
||||
|
||||
## Product surface design
|
||||
|
||||
Keep product grammar stable without forcing one layout:
|
||||
|
||||
- Reuse the canonical shell, controls, states, icons, focus, motion, and
|
||||
responsive behavior.
|
||||
- Never add decorative perimeter borders, outlines, inset rings, or persistent
|
||||
edge strokes to Mission Core cards, panels, topology nodes, telemetry blocks,
|
||||
task stages, or LAB content surfaces. Separate product hierarchy with
|
||||
approved surface tones, spacing, typography, and elevation.
|
||||
- Preserve canonical keyboard focus indicators and accessibility outlines from
|
||||
the Design Guideline. The no-outline rule applies to decoration, never to
|
||||
focus visibility, data-visualization grids, or a necessary structural
|
||||
divider.
|
||||
- Change composition when the operator job, primary entity, lifecycle, or
|
||||
action model changes materially.
|
||||
- Do not treat the LAB template as the default for non-LAB work.
|
||||
- Choose the smallest complete surface: view mode, inspector/panel, floating
|
||||
window, existing section, dedicated workspace, product root, plugin slot, or
|
||||
LAB result.
|
||||
|
||||
Before adding or relocating a workspace or changing primary navigation:
|
||||
|
||||
1. State the operator job, trigger/frequency, primary entity/lifecycle,
|
||||
evidence, authority, actions, and complete state grammar.
|
||||
2. Compare at least two credible placements or compositions.
|
||||
3. Resolve the Design Guideline primitives and page patterns.
|
||||
4. Classify the novelty:
|
||||
- domain content in an admitted composition;
|
||||
- new Mission Core-specific composition;
|
||||
- new shared Design Guideline entity/pattern;
|
||||
- new product root or platform meaning.
|
||||
5. Present the selected placement and rejected alternative with UX reasoning.
|
||||
6. Obtain product-owner agreement for a new/relocated workspace, primary
|
||||
navigation change, new root, or shared visual pattern.
|
||||
7. Implement only after the decision; do not add placeholder product routes or
|
||||
temporary navigation.
|
||||
|
||||
Valid task-specific compositions include scene-first, map-first,
|
||||
timeline-first, list/detail queue, table/catalog, form/editor, graph/topology,
|
||||
dashboard, and immutable LAB review.
|
||||
|
||||
## Application architecture
|
||||
|
||||
- Preserve `core → components/renderers → workspaces → composition/App`.
|
||||
- Core owns contracts, adapters, state machines, and domain hooks. It must not
|
||||
import visual adapters, workspaces, or App.
|
||||
- Reusable components may consume Core and canonical UI exports. They must not
|
||||
import workspaces or own product navigation.
|
||||
- Add every new domain or LAB run as a bounded feature module. Do not append its
|
||||
API adapter, renderer, selectors, or styles to `App.tsx`,
|
||||
`Workspaces.tsx`, or `styles/workspaces.css`.
|
||||
- Keep LAB composition under `workspaces/laboratory` and pass generic host
|
||||
viewers through typed contracts.
|
||||
- Treat `productModel.ts` as the application registry. Do not duplicate root,
|
||||
workspace, icon, or capability lists in the shell.
|
||||
- Do not introduce a runtime ontology merely to organize code. Use the typed
|
||||
registry, executable contracts, and experimental vocabulary until the
|
||||
multi-consumer admission gate in the architecture canon is met.
|
||||
- Never raise an architecture line-count ratchet to fit a new feature. Split
|
||||
the feature and retain the boundary test.
|
||||
|
||||
## Laboratory product contract
|
||||
|
||||
Use the executable template
|
||||
`missioncore.laboratory-report/v1`:
|
||||
|
||||
`selectors → compact summary → evidence → result → optional reusable details`
|
||||
|
||||
For every new bounded `workspaces/laboratory/ENNResult.tsx`:
|
||||
|
||||
1. Pass typed values into `LaboratorySummary`; always supply `question`,
|
||||
`approach`, `principalResult`, and `limitation`.
|
||||
2. Put visual evidence in `LaboratoryEvidence`.
|
||||
3. Pass result metrics and `proved`, `notProved`, and `decision` into
|
||||
`LaboratoryResultSummary`.
|
||||
4. Do not render the `laboratory-summary`, `laboratory-result-summary`,
|
||||
`laboratory-result-metrics`, or `laboratory-result-conclusion` classes in the
|
||||
LAB module.
|
||||
5. Do not change slot order or shared report geometry for one run.
|
||||
|
||||
Treat a missing field as missing product evidence, not as permission to omit
|
||||
the section. An admitted diagnostic viewer may own the result interaction only
|
||||
when the existing canon explicitly identifies that viewer as the result
|
||||
instrument.
|
||||
|
||||
Changing the v1 anatomy requires product-owner agreement, a new template
|
||||
version, migration of every consumer, updated canon, and an updated
|
||||
architecture test. Never drift v1 incrementally from a single LAB.
|
||||
|
||||
The compact summary must cover:
|
||||
|
||||
- decision question and purpose;
|
||||
- immutable source and tested bounds;
|
||||
- pipeline, execution class, models, algorithms, tools, and worker/runtime;
|
||||
- experimental feature or configuration;
|
||||
- principal result and limitation;
|
||||
- retained authority.
|
||||
|
||||
Keep manifests, long hashes, implementation files, validation logs, regressions,
|
||||
rejected approaches, and next-stage planning in the Mission Core Ops report.
|
||||
|
||||
Do not expose roadmap steps, next-gate checklists, internal reason taxonomies,
|
||||
debug controls, placeholder blocks, or implementation scaffolding in product
|
||||
UI.
|
||||
|
||||
## Evidence viewers
|
||||
|
||||
- Use one reusable viewer frame for related representations.
|
||||
- Provide canonical expand/restore through `IconButton` and `Icon`.
|
||||
- Provide mode switching through `SegmentedControl`.
|
||||
- Preserve selected evidence and mode across resize.
|
||||
- Use 3D for spatial shape, range, height, topology, support, and occupied
|
||||
volume.
|
||||
- Use 2D for camera-plane reprojection, bbox/mask agreement, calibration,
|
||||
field-of-view, occlusion, and pixel correspondence.
|
||||
- When both are useful, keep them as modes of the same viewer and share source
|
||||
indices and selected case.
|
||||
- Keep renderer colors semantic and token-derived. Never use them to invent
|
||||
control states or persistent colored outlines.
|
||||
|
||||
## Ops report boundary
|
||||
|
||||
When the task includes reporting a completed LAB or architecture milestone, use
|
||||
the direct `nodedc-ops-agent` tools and write titled structured blocks in this
|
||||
order:
|
||||
|
||||
1. Objective and architecture stage.
|
||||
2. Decision question and hypothesis.
|
||||
3. Immutable source evidence and bounds.
|
||||
4. Method, preprocessing, models, algorithms, tools, and identities.
|
||||
5. Worker/runtime topology and resource policy.
|
||||
6. Experimental implementation.
|
||||
7. Validation and reproduced evidence.
|
||||
8. Results.
|
||||
9. Regressions, rejected approaches, and limitations.
|
||||
10. Decision.
|
||||
11. Next stage and forbidden authority.
|
||||
12. Acceptance checker.
|
||||
|
||||
Do not turn the product UI into a duplicate Ops report.
|
||||
|
||||
## Validation
|
||||
|
||||
Before handoff:
|
||||
|
||||
1. Search the changed product UI for raw local controls, hard-coded product
|
||||
colors, decorative perimeter borders/outlines, temporary copy,
|
||||
gate/checklist UI, and per-LAB page branches. Justify every remaining
|
||||
`border`, `outline`, or inset `box-shadow` as a canonical focus state,
|
||||
data-visualization grid, or necessary structural divider.
|
||||
2. Verify every changed or new bounded `ENNResult.tsx` uses
|
||||
`LaboratorySummary`, `LaboratoryEvidence`, and `LaboratoryResultSummary`
|
||||
without raw canonical report markup.
|
||||
3. For a new surface, verify the product-surface brief names the user job,
|
||||
selected placement, rejected alternative, state grammar, and real acceptance
|
||||
evidence.
|
||||
4. Under the local workstation resource gate, run
|
||||
`test/applicationArchitecture.test.mjs`, then the full frontend typecheck,
|
||||
unit tests, and production build sequentially. Never overlap these jobs.
|
||||
5. After the build/test processes have exited and memory headroom is confirmed,
|
||||
use the in-app browser to verify normal and expanded modes, every new view
|
||||
mode, keyboard Escape, and the canonical control states.
|
||||
6. Verify the default composition answers the primary operator question.
|
||||
7. Leave a real evidence case open for product-owner review.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Mission Core Product UI"
|
||||
short_description: "Mission Core surfaces and LAB product architecture"
|
||||
default_prompt: "Use $mission-core-product-ui to design or review a Mission Core product surface and its placement."
|
||||
@@ -24,6 +24,33 @@ and the boundary between Mission Core and vendor-specific integration code.
|
||||
- Do not install or alter Homebrew/system components unless the user explicitly
|
||||
authorizes that concrete change.
|
||||
|
||||
## Local operator resource envelope
|
||||
|
||||
- The local operator machine is a 14-inch 2023 MacBook Pro with only 18 GB of
|
||||
physical memory. Treat local memory and swap as a hard shared operational
|
||||
limit, not as disposable build capacity.
|
||||
- Run frontend tests, production builds, Docker builds, runtime startup, and
|
||||
browser QA sequentially. Do not launch parallel heavy local jobs.
|
||||
- Do not start Docker Desktop, a Compose stack, a second browser automation
|
||||
session, or a full test/build pass merely for convenience. First prove that
|
||||
the operation is necessary, prefer the narrowest focused check, and keep no
|
||||
more than one memory-intensive validation job active at a time.
|
||||
- Do not run load or stress tests on the Mac. Run bounded synthetic load only on
|
||||
Worker 006, and remove every temporary process after the measurement.
|
||||
- Use only the canonical Mission Core endpoint on port `8000`; do not start
|
||||
duplicate application servers to work around stale state.
|
||||
- Before a memory-intensive local operation, inspect current memory pressure and
|
||||
active Docker workload. If memory pressure is elevated or swap is growing,
|
||||
stop and remove temporary jobs before continuing. Prefer focused tests and
|
||||
existing build artifacts when they are sufficient for acceptance.
|
||||
- After every local test, build, replay, browser-QA, or Docker operation, stop
|
||||
temporary workers, watchers, replay publishers, duplicate viewers, and
|
||||
containers that are not durable operator state. Keep only the canonical
|
||||
Mission Core service on `8000` and explicitly required operator services.
|
||||
- Docker Desktop's configured VM ceiling is not evidence that the host can
|
||||
safely supply that memory. Do not change Docker Desktop CPU or memory limits
|
||||
without explicit owner approval.
|
||||
|
||||
## Evidence rules
|
||||
|
||||
- Reference inputs under `docs/reference/` are immutable; write corrections in
|
||||
@@ -39,3 +66,103 @@ and the boundary between Mission Core and vendor-specific integration code.
|
||||
|
||||
Follow the gates in `docs/01_IMPLEMENTATION_PLAN.md`. Do not build heavy
|
||||
decoders before BLE/Wi-Fi/data-session evidence exists.
|
||||
|
||||
## Docker runtime governance
|
||||
|
||||
- Every new or migrated durable NODE.DC-owned Docker container, Compose project,
|
||||
network, named volume, and first-party image uses the lowercase `ndc-`
|
||||
namespace. The prefix is mandatory for anything that may leave a laboratory
|
||||
workstation; a legacy project/network name may survive only behind a bounded,
|
||||
documented migration barrier.
|
||||
- Do not retag or rename unrelated third-party runtime owned by another product.
|
||||
Express NODE.DC ownership through the managed object name and
|
||||
`com.nodedc.product`, `com.nodedc.stack`, `com.nodedc.role`, and
|
||||
`com.nodedc.managed-by` labels.
|
||||
- One operator-managed contour may be one Compose project while still using
|
||||
separate single-purpose containers. Do not merge a broker, normalizer,
|
||||
database, or application process into a multi-process container merely to
|
||||
make Docker Desktop show one row.
|
||||
- Treat Compose and worker orchestration declarations as the source of truth.
|
||||
A live `docker rename` is allowed only as a bounded migration with exact
|
||||
predecessor IDs, a persisted declaration change, rollback, and post-change
|
||||
health acceptance.
|
||||
- Keep bounded legacy-name fallback only for an explicit migration window.
|
||||
New defaults, UI probes, runbooks, and durable runtime names must use `ndc-`.
|
||||
|
||||
## Product UI governance
|
||||
|
||||
- For every Control Station, LAB, viewer, or product-presentation change, use
|
||||
`.codex/skills/mission-core-product-ui/SKILL.md` and follow
|
||||
`docs/17_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md` and
|
||||
`docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md`.
|
||||
- The integrated Control Station and API have one canonical local endpoint:
|
||||
`http://127.0.0.1:8000`. Never start a second Mission Core backend on another
|
||||
port to bypass a stale process. TCP `8765` is legacy Foxglove regression
|
||||
only, not an operator path. Restart the canonical `8000` process and finish
|
||||
with no Mission Core backend listening on `8765`.
|
||||
- The canonical `8000` process is durable operator state. A completed test,
|
||||
build, browser QA pass, Codex turn, or implementation increment never
|
||||
authorizes stopping it. Every Control Station task starts by checking and, if
|
||||
necessary, starting the integrated service; every handoff verifies and leaves
|
||||
that exact service running on `8000`. A rebuild may replace it only when the
|
||||
replacement is confirmed listening before handoff.
|
||||
- `NODEDC_DESIGN_GUIDELINE` is the only visual-design source of truth. Before
|
||||
editing UI, read its `registry/registry.json`, `registry/components.json`,
|
||||
`registry/icons.json`, and the relevant component documentation.
|
||||
- Reuse `@nodedc/ui-react`, `@nodedc/ui-core`, tokens, icons, and page patterns.
|
||||
Do not create an application-local visual control, interaction state,
|
||||
geometry, color language, or copy of a design-system component.
|
||||
- If the required visual entity is absent from the Design Guideline, stop and
|
||||
obtain explicit product-owner approval. After approval, add it to the Design
|
||||
Guideline first with registry, documentation, states, and validation; only
|
||||
then consume it here.
|
||||
- Domain renderers may remain Mission Core code when they visualize Mission
|
||||
Core data. Their controls and containing surfaces must still be composed from
|
||||
canonical Design Guideline exports.
|
||||
- Product UI must not expose implementation steps, roadmap gates, internal
|
||||
reason taxonomies, debug controls, placeholder status blocks, or temporary
|
||||
experiment scaffolding. Keep these in Ops, engineering reports, or developer
|
||||
tooling.
|
||||
- Extend one reusable application pattern instead of adding per-LAB layouts.
|
||||
A new LAB supplies data and renderer configuration; it does not invent a new
|
||||
page hierarchy or visual language.
|
||||
- Do not generalize the LAB hierarchy into a universal application template.
|
||||
For a new non-LAB interface, follow
|
||||
`docs/19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md`: identify the operator job,
|
||||
compare placement/composition alternatives, classify the novelty, and obtain
|
||||
product-owner agreement before adding a workspace, changing primary
|
||||
navigation, or introducing a new product root.
|
||||
- Preserve one product grammar while allowing task-specific composition. A
|
||||
scene, map, timeline, queue, editor, topology, dashboard, and LAB review may
|
||||
have different layouts when their entity, lifecycle, or action model differs.
|
||||
- Preserve the internal dependency direction:
|
||||
`core → components/renderers → workspaces → composition/App`. Core never
|
||||
imports workspaces or visual adapters; reusable components never import
|
||||
workspaces. New domains and LAB runs get their own feature module instead of
|
||||
growing `App.tsx`, `Workspaces.tsx`, or generic CSS buckets.
|
||||
- Treat `productModel.ts`, executable versioned contracts, and the experimental
|
||||
vocabulary as the current local semantic sources. Do not add a parallel
|
||||
runtime ontology unless the admission conditions in
|
||||
`docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md` are met.
|
||||
|
||||
## Laboratory presentation and reporting
|
||||
|
||||
- Use one fixed laboratory presentation contract: selectors, one compact
|
||||
canonical summary, admitted evidence viewer(s), result, and optional reusable
|
||||
technical details.
|
||||
- Keep `missioncore.laboratory-report/v1` executable: bounded `ENNResult.tsx`
|
||||
modules supply all four `LaboratorySummary` brief fields and use
|
||||
`LaboratoryResultSummary` for metrics plus proved/not-proved/decision. They
|
||||
never own canonical summary/result markup. Anatomy changes require a new
|
||||
version and product-owner agreement.
|
||||
- The compact summary must explain the decision question, immutable source,
|
||||
tested method/models/algorithms, experimental mode, principal result,
|
||||
limitations, and retained authority. It is not the engineering report.
|
||||
- The complete engineering report belongs in the Mission Core Ops card as
|
||||
titled structured blocks: objective and architecture stage, source evidence,
|
||||
method/models/algorithms, worker/runtime, implementation, validation,
|
||||
results, regressions and limitations, decision, next stage, and acceptance
|
||||
checker.
|
||||
- Spatial evidence uses 3D by default. Image-space reprojection may be offered
|
||||
as a 2D diagnostic mode inside the same reusable viewer. Primary evidence
|
||||
viewers must provide the canonical expand/restore action.
|
||||
|
||||
@@ -121,6 +121,78 @@ performance, direct worker transport and bounded live fan-out remain open. See
|
||||
[ADR 0014](docs/adr/0014-bounded-external-perception-worker.md)
|
||||
and the [external worker contract](docs/10_EXTERNAL_PERCEPTION_WORKER.md).
|
||||
|
||||
## LiDAR Dataset Gateway
|
||||
|
||||
Mission Core now distinguishes a single native LiDAR scan, a normalized
|
||||
sensor-frame scan and a pose-registered rolling local map. The first Dataset
|
||||
Gateway adapter reads GOOSE/SemanticKITTI XYZI plus point-wise semantic and
|
||||
instance labels without silently accumulating or transforming them. Current
|
||||
K1 `lio_pcl` remains a post-LIO vendor-map increment and is not promoted to a
|
||||
raw scan. Large dataset bytes are admitted only on the Windows/WSL worker under
|
||||
`D:\NDC_MISSIONCORE\datasets`; no archive is downloaded automatically.
|
||||
|
||||
That representation boundary does not reject K1 as the production starting
|
||||
sensor. The passively received real-time `lio_pcl` plus `lio_pose` stream is
|
||||
the primary K1 input for two separate derivatives: a short-TTL local world
|
||||
model and a persistent territory reconstruction. `RAVNOVES00` is the immutable
|
||||
private replay dataset for this work. No K1 firmware change, onboard exporter
|
||||
or new device command is part of the LiDAR roadmap, and the historical
|
||||
`1.27 m` handheld-height experiment is not a runtime constant.
|
||||
|
||||
The first passive replay derivative is now implemented as
|
||||
`missioncore.k1-local-surface/v1`. It binds the immutable `RAVNOVES00` pack,
|
||||
estimates a rolling local surface from map points plus compatible pose, and
|
||||
publishes height, slope, roughness, confidence and conservative observed
|
||||
surface/occupied/unknown evidence. The complete pack contains `4,489` timeline
|
||||
frames, `3,928` available/valid LiDAR frames and `9,207,270` points; there were
|
||||
no stale-pose, insufficient-surface or fit-failed frames. Prior-only
|
||||
next-frame qualification produced `3,927` samples with `0.04065 m` p50 median
|
||||
residual and `205` strict temporal jumps. The p95 residual tail remains too
|
||||
large for planner use. The current complete derivative is the first item in
|
||||
**Парк → Диагностика LiDAR**.
|
||||
|
||||
The earlier content-bound `526`-frame review slice remains available as
|
||||
historical laboratory evidence. Its deterministic triage contains `37`
|
||||
attention frames in `21` episodes and four high-priority frames. The selected
|
||||
frame can switch to the prior-plane residual view; source frame `1254` has
|
||||
`36/98` evaluated cells above the `0.16 m` band and none below it. This
|
||||
localizes a positive residual structure without naming an object or granting
|
||||
safety authority.
|
||||
|
||||
The same estimator now runs behind
|
||||
`missioncore.k1-local-surface-shadow-runtime/v1`, a capacity-two
|
||||
latest-wins queue with a bounded diagnostic result ring. LAB E27 passed a
|
||||
`14.995 s` RAVNOVES00 slice at recorded 1× pace: `143/143` available frames
|
||||
were consumed, maximum queue depth was `1/2`, no frame was replaced, p95
|
||||
processing latency was `14.777 ms`, and state, point classes, step candidates
|
||||
and scalar results matched the immutable replay derivative exactly. This is a
|
||||
recorded-source-paced execution gate only; physical K1 acquisition,
|
||||
free-space, commands, navigation and safety authority remain unavailable.
|
||||
|
||||
The authenticated external-worker seam is now implemented and replay-qualified.
|
||||
It binds decoded map-frame `lio_pcl` and `lio_pose` in either arrival order
|
||||
through
|
||||
`missioncore.k1-local-surface-pose-binder/v1`, then feeds the accepted estimator
|
||||
without blocking camera cadence. Point input and estimator work are
|
||||
bounded separately: the authenticated ingress has LiDAR depth eight, while the
|
||||
freshness-first estimator remains capacity two. LAB E28 replayed the complete
|
||||
recording at 1× through the actual NVIDIA worker: all `4,570` LiDAR and `4,598`
|
||||
pose events arrived with zero gaps, all point frames bound, and `4,206`
|
||||
results were persisted at `9.1945 Hz`. Explicitly superseded work was
|
||||
`364/4,570` (`7.965%`), p95 result age was `89.530 ms`, and all acceptance
|
||||
checks passed. Physical K1 acquisition remains a separate commissioning test;
|
||||
it is not required for replay algorithm development.
|
||||
|
||||
The complete RELLIS-3D v1.1 release is now admitted there and its full
|
||||
`2,413`-frame validation split is available in **Полигон → Датасеты**. The
|
||||
sealed Current/Patchwork++ comparison rejected Patchwork++ for navigation:
|
||||
despite much lower latency and a small Ground-IoU gain, obstacle non-ground
|
||||
recall regressed from `80.46%` to `69.70%`.
|
||||
|
||||
See [the Dataset Gateway plan](docs/14_LIDAR_DATASET_GATEWAY.md),
|
||||
[the laboratory run canon](docs/15_LABORATORY_RUN_CANON.md) and
|
||||
[ADR 0021](docs/adr/0021-dataset-gateway-representation-boundary.md).
|
||||
|
||||
## Simulation Polygon
|
||||
|
||||
Mission Core now has a parallel Polygon product branch for reproducible
|
||||
@@ -129,6 +201,15 @@ lifecycle, authority, canonical contracts, provenance and reports; Gazebo, PX4
|
||||
SITL, ROS 2, Nav2 and viewers remain replaceable providers. Simulation, replay,
|
||||
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
|
||||
loopback-only process isolation, exact provider pins, a 2 ms physics step,
|
||||
1×/2× RTF/resource budgets and disabled real/direct actuator authority. Two
|
||||
@@ -142,18 +223,135 @@ uv run missioncore-sim s0 doctor \
|
||||
--json
|
||||
```
|
||||
|
||||
This accepts simulation infrastructure, not navigation behavior, safety
|
||||
behavior, S1 command authority or real actuator control.
|
||||
S1A now adds the first server-owned domain boundary: typed immutable
|
||||
`QualificationRun` identities, legal lifecycle transitions, canonical
|
||||
Ackermann/Differential commands, authority generation and TTL validation, and
|
||||
an append-only crash-safe run/event/command/artifact repository. Active runs
|
||||
recover as failed/interrupted rather than silently resuming, and reset creates
|
||||
a new linked run/episode. This is repository-level lifecycle acceptance only;
|
||||
PX4 command delivery, watchdog/failsafe behavior, navigation behavior and real
|
||||
actuator control remain unaccepted.
|
||||
|
||||
The S1B repository increment adds the transport-neutral
|
||||
`SimulationApplicationService`, an exact-S0/D-only worker admission guard and a
|
||||
POSIX process supervisor. Start is idempotent, a second active owner is
|
||||
rejected, provider processes receive dedicated PGIDs and run-scoped logs, and
|
||||
shutdown follows the S0 dependency order with residue reported as failure.
|
||||
Pause/resume/step/reset/stop are owned by the application service through a
|
||||
worker port.
|
||||
|
||||
Commit `6cb1495` has now passed the real D-only `MissionCore-Sim` S1B lifecycle:
|
||||
the exact immutable source generation started Micro XRCE-DDS Agent and the PX4
|
||||
v1.17.0/Gazebo Harmonic stock Ackermann rover in a fresh loopback-only
|
||||
namespace, admitted the run only after world/startup/DDS-writer health markers,
|
||||
and stopped both owned PGIDs with zero residue. The persisted run reached
|
||||
`completed`, contains eight lifecycle events and zero control commands. This
|
||||
run is now visible through UI-0: a direct internal read-only Control Station
|
||||
surface backed by the same append-only qualification repository. It exposes
|
||||
identity, terminal reason, provider pins, events, command count, artifact
|
||||
metadata and the virtual-only authority boundary. Live pause/resume/step/reset,
|
||||
canonical telemetry, PX4 command mapping, watchdog/failsafe evidence and the
|
||||
accepted command path remain gated.
|
||||
|
||||
S1C commit `b7ccca3` adds the first registered live-worker vertical without
|
||||
collapsing that boundary. An unprivileged worker agent owns the existing
|
||||
application service and providers inside a fresh loopback-only network
|
||||
namespace. Mission Core reaches it through a mode-`0600` Unix socket; React
|
||||
reaches only same-origin Polygon API routes. The live browser panel can
|
||||
start/stop the stock rover and show worker/run/provider status. UI-2 commit
|
||||
`60e7916` adds a worker-gated `Полигон` header root and a browser-native
|
||||
Three.js Ackermann Rover scene with orbit, zoom, pan, follow and reset. The
|
||||
deep-link remains for exact-run review; it is no longer the primary operator
|
||||
entry. Gazebo dynamic-pose ground truth remains explicitly `diagnostic`, not
|
||||
accepted PX4/ROS 2 telemetry.
|
||||
|
||||
The exact D-only UI-2 generation passed 35 target tests and a real UI-driven run
|
||||
`s1c-60e7916-20260724t182006z-2f164e`: both providers reached readiness, the
|
||||
browser received live `VehicleState`, the run reached `completed` after
|
||||
27.74 seconds of Gazebo time, and shutdown left no PX4/Gazebo/XRCE process
|
||||
residue. The run contained zero commands, so this result does not yet prove
|
||||
rover motion, PX4 command delivery, watchdog/failsafe behavior, navigation or
|
||||
safety acceptance.
|
||||
|
||||
S1D exact code generation `49b0f47` closes the first virtual Ackermann command
|
||||
and motion slice. React sends bounded speed/steering intents through the
|
||||
same-origin Mission Core API and private worker socket. The worker journals each
|
||||
accepted command before mapping it to PX4 rover-level Offboard setpoints:
|
||||
`RoverThrottleSetpoint` is calibrated against the stock model's
|
||||
`RO_MAX_THR_SPEED=3.1`, `RoverSteeringSetpoint` carries normalized steering,
|
||||
and PX4 retains arming, Offboard loss handling and control allocation. No
|
||||
`ActuatorMotors` or `ActuatorServos` topic is exposed or published by Mission
|
||||
Core.
|
||||
|
||||
The immutable D-only generation passed 40 focused target tests. Its first cold
|
||||
start completed in one request after 32.08 seconds. Run
|
||||
`s1c-49b0f47-20260724t195259z-62edd2` proved 3.74 m straight motion, a curved
|
||||
turn, reverse motion, 44.6 ms sampled command admission, a 250 ms TTL transition
|
||||
(`ttl_expired_count` 4 -> 5), stable stopped pose and clean provider shutdown
|
||||
with no PX4/Gazebo/XRCE residue. This does not accept calibrated closed-loop
|
||||
speed tracking, Differential Rover, canonical PX4/ROS 2 telemetry,
|
||||
pause/step/reset, heartbeat/offboard/link-loss cases, Nav2, perception or safety
|
||||
behavior. Real actuator authority remains false.
|
||||
|
||||
Start the worker from the exact immutable D source generation:
|
||||
|
||||
```bash
|
||||
sudo simulation/s1/worker-agent.sh <exact-40-character-mission-core-commit>
|
||||
```
|
||||
|
||||
Then configure the Mission Core backend. When the worker status is available,
|
||||
open `Полигон` from the Control Station header:
|
||||
|
||||
```bash
|
||||
export MISSIONCORE_POLYGON_RUNS_ROOT=/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/runs
|
||||
export MISSIONCORE_POLYGON_WORKER_SOCKET=/run/missioncore-sim/worker.sock
|
||||
export MISSIONCORE_POLYGON_WORKER_CONTROL=internal-virtual-only
|
||||
export MISSIONCORE_COMMIT=<exact-40-character-mission-core-commit>
|
||||
uv run k1link serve
|
||||
```
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8000/
|
||||
```
|
||||
|
||||
The direct `?workspace=polygon-run&run=<qualification-run-id>` URL remains a
|
||||
compatible evidence deep-link. The archive API remains GET-only. The worker API
|
||||
adds status/live GETs and explicit start, command and stop POSTs; POSTs remain
|
||||
`403` unless the backend has the `internal-virtual-only` gate and an exact
|
||||
commit. Missing configuration, transport drift and corrupt evidence fail
|
||||
closed. The browser receives no Unix socket, D root, artifact bytes, PX4
|
||||
endpoint or direct provider command.
|
||||
|
||||
Do not launch a second integrated backend on another port when `8000` is
|
||||
already occupied. Stop or restart the stale Mission Core process on `8000`.
|
||||
TCP `8765` remains reserved for the legacy Foxglove regression module and is
|
||||
not a Control Station operator path.
|
||||
|
||||
See the [Polygon product/SRS](docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md),
|
||||
[ADR 0015](docs/adr/0015-simulation-polygon-qualification-boundary.md) and the
|
||||
[SIM S0 worker runbook](docs/runbooks/SIM_S0_AI_WORKER.md).
|
||||
[ADR 0015](docs/adr/0015-simulation-polygon-qualification-boundary.md),
|
||||
[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
|
||||
launch and acceptance procedure is in
|
||||
[SIM S1C live-worker runbook](docs/runbooks/SIM_S1C_LIVE_WORKER.md).
|
||||
|
||||
Mission Core remains a distributed browser product rather than a
|
||||
platform-specific desktop executable. React runs in the operator browser;
|
||||
Gateway/API and Control Plane run as long-lived services; Gazebo/PX4/ROS 2/Nav2
|
||||
run headless on a registered simulation worker; and a future field vehicle runs
|
||||
a headless onboard Linux Edge Agent with local-first evidence and
|
||||
store-and-forward synchronization. Native Gazebo GUI is worker-local
|
||||
diagnostics. The accepted live rover scene uses canonical state in Three.js;
|
||||
Rerun remains the recorded spatial-evidence path. Field execution never depends
|
||||
on streaming a simulator desktop.
|
||||
|
||||
## Mission Core Control Station and visualization adapters
|
||||
|
||||
The browser application is the universal Mission Core Control Station rather than
|
||||
a K1-specific Foxglove launcher. Its fixed shell contains six architectural
|
||||
sections — Center, Fleet, Observation, Missions, Data and System — while the K1
|
||||
a K1-specific Foxglove launcher. Its fixed shell contains Center, Fleet,
|
||||
Observation, Missions, Data and System; a seventh Polygon section is inserted
|
||||
only while an admitted Simulation Worker is available. The K1
|
||||
BLE/Wi-Fi/live workflow remains isolated as the first real device adapter. Its
|
||||
React provisioning, acquisition/replay and diagnostic blocks live beside the
|
||||
plugin manifest under `plugins/xgrids-k1/frontend`; the generic application
|
||||
@@ -185,8 +383,9 @@ telemetry and the stop action. Operator-manual acquisition still finalizes only
|
||||
local reception; plugin-commanded v0.5.0 acquisition uses the separately gated
|
||||
canonical K1 START/STOP dialogue.
|
||||
|
||||
Plugin v0.6.0 makes the local connection direction explicit. Bridge remains the
|
||||
default and accepted product path; Direct Connect sends the reviewed station
|
||||
Plugin v0.7.0 supervises the v0.6.0 local connection matrix with separate
|
||||
desired, configured and active modes plus exact DeviceInfo-backed Ready.
|
||||
Bridge remains the default and accepted product path; Direct Connect sends the reviewed station
|
||||
provisioning frame for an already-running controller hotspot. Quick Connect
|
||||
sends one separately reviewed AP-enable frame and can associate a prepared Mac
|
||||
through CoreWLAN. Its device activation and prepared-host association were
|
||||
@@ -247,9 +446,11 @@ standalone release install. The frontend consumes sibling `file:` packages from
|
||||
revision or content hash. Publishing/vendoring those packages or enforcing an
|
||||
immutable donor revision remains a packaging and CI prerequisite.
|
||||
|
||||
Polygon is planned as a seventh section, but UI delivery is gated on the
|
||||
accepted clock/frame/authority contracts and a proven S1 backend lifecycle with
|
||||
persisted run history. It is not present in the current shell.
|
||||
Polygon is implemented as a capability-gated seventh section. When Mission Core
|
||||
confirms an admitted Simulation Worker, its header button opens one direct
|
||||
workspace with live 3D inspection and the server-owned qualification
|
||||
repository. `?workspace=polygon-run[&run=<id>]` remains a compatibility
|
||||
deep-link. Command and safety acceptance remain separate gates.
|
||||
|
||||
The Observation spatial workspace embeds the open-source Rerun Web Viewer
|
||||
inside the Mission Core shell. It can open a compatible RRD over same-origin
|
||||
@@ -362,6 +563,11 @@ present.
|
||||
|
||||
- [Technical audit](docs/00_TECHNICAL_AUDIT.md)
|
||||
- [Implementation gates](docs/01_IMPLEMENTATION_PLAN.md)
|
||||
- [Architecture audit execution roadmap](docs/16_ARCHITECTURE_AUDIT_EXECUTION_ROADMAP.md)
|
||||
- [Laboratory run canon](docs/15_LABORATORY_RUN_CANON.md)
|
||||
- [Product UI and laboratory presentation canon](docs/17_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md)
|
||||
- [Application component architecture](docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md)
|
||||
- [Product surface extension protocol](docs/19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md)
|
||||
- [First lab runbook](docs/02_FIRST_LAB_RUNBOOK.md)
|
||||
- [Artifact and secret policy](docs/03_ARTIFACT_POLICY.md)
|
||||
- [Reviewed BLE Wi-Fi profile](docs/04_K1_WIFI_PROVISIONING_PROFILE.md)
|
||||
@@ -382,6 +588,9 @@ present.
|
||||
- [Plugin-owned frontend device workflows](docs/adr/0010-plugin-owned-frontend-device-workflows.md)
|
||||
- [Laboratory plugin runtime handshake and transport seam](docs/adr/0011-laboratory-plugin-runtime-handshake-and-transport-seam.md)
|
||||
- [Device-bound K1 command authority](docs/adr/0012-device-bound-k1-command-authority.md)
|
||||
- [Simulation Polygon qualification boundary](docs/adr/0015-simulation-polygon-qualification-boundary.md)
|
||||
- [Distributed product, edge and worker topology](docs/adr/0016-distributed-product-edge-and-worker-topology.md)
|
||||
- [Sensor representation capabilities and free-space admission](docs/adr/0023-sensor-representation-capabilities-and-free-space-admission.md)
|
||||
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
|
||||
- [Canonical control and durable archive milestone](docs/lab/004_K1_CANONICAL_CONTROL_ARCHIVE_20260719.redacted.md)
|
||||
- [Session manifest schema](schemas/session-manifest.schema.json)
|
||||
|
||||
@@ -102,12 +102,13 @@ revisioned API. Смена 2D/3D/карты и семантические сло
|
||||
|
||||
| Раздел | Назначение |
|
||||
| --- | --- |
|
||||
| Центр | Оперативный обзор, состояние контура и активность оператора. |
|
||||
| Центр | Живое состояние вычислительных узлов, процессов, сети и подключённых устройств. |
|
||||
| Парк | Аппараты, текущее устройство, сенсоры и конфигурации борта. |
|
||||
| Наблюдение | Пространственная сцена, камеры, карта, объекты, телеметрия и время. |
|
||||
| Наблюдение | Только live-пространственная сцена, камеры, карта, объекты, телеметрия и время. |
|
||||
| Миссии | Планировщик, маршруты, сценарии и исполнение. Командный backend отключён. |
|
||||
| Данные | Сессии, потоки, сущности, playback и экспорт доказательств. |
|
||||
| Данные | Сохранённые сессии, replay, публичные датасеты, потоки, сущности и экспорт. |
|
||||
| Система | Модули, интеграции, сеть, аудит и настройки платформы. |
|
||||
| Тестировочный контур | Архив лабораторных работ, конфигурации экспериментов и визуальные результаты. |
|
||||
|
||||
Карточки возможностей имеют четыре честных уровня: работает сейчас, готово к
|
||||
источнику, интерфейсный контракт и последующий этап. Каталог не следует читать
|
||||
@@ -192,7 +193,7 @@ production-сборки запускается командой `npm run preview
|
||||
только после реальных сообщений.
|
||||
11. Физически остановить K1, дождаться steady green, затем остановить локальный
|
||||
приём из device workflow или spatial block, чтобы запечатать evidence.
|
||||
12. После нормального stop или recovery открыть **Сохранённые сессии**. Дождаться
|
||||
12. После нормального stop или recovery открыть **Данные → Сессии и записи**. Дождаться
|
||||
состояния **Готово**, выбрать запись и использовать host timeline. Evidence
|
||||
сохраняется автоматически; disk action сохраняет только workspace layout.
|
||||
|
||||
@@ -320,10 +321,12 @@ facts и старые presentation-поля `phase`, `message`, `devices`,
|
||||
активным между сессиями; для его закрытия нужно остановить `k1link serve`.
|
||||
- Пароль Wi-Fi находится только в React memory, передаётся в JSON POST body,
|
||||
очищается после успешного ответа и не сохраняется в URL/local storage.
|
||||
- Bridge и Direct Connect выполняют только отдельно рассмотренную provisioning-
|
||||
запись; Quick Connect не пишет в GATT и передаёт пароль короткоживущему
|
||||
CoreWLAN helper только через stdin. Случайные GATT writes и автоматические
|
||||
повторы запрещены.
|
||||
- Bridge и Direct Connect выполняют только отдельно рассмотренную 99-байтную
|
||||
provisioning-запись. Quick Connect выполняет отдельную рассмотренную
|
||||
100-байтную AP-enable запись и после AP-ready допускает ровно одну системную
|
||||
ассоциацию с заранее материализованным exact-firmware профилем. Пароль не
|
||||
попадает в браузер/API/argv; системный Wi-Fi Keychain, post-write prompt и
|
||||
автоматические повторы запрещены.
|
||||
- MQTT data live/replay остаётся subscribe-only. Команды изолированы в
|
||||
plugin-owned canonical control session и доступны только через отдельные
|
||||
operator-present UI checkpoints.
|
||||
|
||||
Generated
+97
-4
@@ -10,17 +10,21 @@
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
|
||||
"@nodedc/page-patterns": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/page-patterns",
|
||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
"@rerun-io/web-viewer": "0.34.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
"react-dom": "^19.1.0",
|
||||
"three": "0.185.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@types/three": "0.185.1",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.0",
|
||||
@@ -30,25 +34,43 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react": {
|
||||
"name": "@nodedc/map-cesium-react",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"cesium": "1.143.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.0",
|
||||
"react": "^19.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"../../../NODEDC_DESIGN_GUIDELINE/packages/page-patterns": {
|
||||
"name": "@nodedc/page-patterns",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"../../../NODEDC_DESIGN_GUIDELINE/packages/tokens": {
|
||||
"name": "@nodedc/tokens",
|
||||
"version": "0.6.0"
|
||||
},
|
||||
"../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core": {
|
||||
"name": "@nodedc/ui-core",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"@nodedc/tokens": "0.6.0"
|
||||
}
|
||||
},
|
||||
"../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react": {
|
||||
"name": "@nodedc/ui-react",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@nodedc/ui-core": "0.6.0",
|
||||
"@nodedc/ui-core": "0.7.0",
|
||||
"lucide-react": "^0.468.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -344,6 +366,13 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier3d-compat": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
@@ -848,6 +877,14 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodedc/map-cesium-react": {
|
||||
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@nodedc/page-patterns": {
|
||||
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/page-patterns",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@nodedc/tokens": {
|
||||
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"link": true
|
||||
@@ -1223,6 +1260,13 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1305,6 +1349,35 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/stats.js": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.185.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.1.tgz",
|
||||
"integrity": "sha512-db1xTb+EgYF2didW+eudSvVPtn75zo+fGsY8ShQrJY/B5ZBmC2Fiaykv3aImHAlCNEGuMPkPGXBJGLwzu5mC7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": ">=0.5.17",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
@@ -1503,6 +1576,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1571,6 +1651,13 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
||||
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -1758,6 +1845,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.185.1",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
|
||||
"integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
|
||||
@@ -13,17 +13,21 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
|
||||
"@nodedc/page-patterns": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/page-patterns",
|
||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
"@rerun-io/web-viewer": "0.34.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
"react-dom": "^19.1.0",
|
||||
"three": "0.185.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@types/three": "0.185.1",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.0",
|
||||
|
||||
@@ -8,10 +8,8 @@ import {
|
||||
Checker,
|
||||
ColorField,
|
||||
ControlRow,
|
||||
HeaderAvatar,
|
||||
HeaderNavigation,
|
||||
HeaderProfile,
|
||||
HeaderProfileButton,
|
||||
HeaderWorkspace,
|
||||
Icon,
|
||||
Inspector,
|
||||
@@ -19,14 +17,20 @@ import {
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
UserProfileMenu,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
useApplicationWorkspace,
|
||||
type ApplicationPanelUtilityAction,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { LandingStage } from "./components/LandingStage";
|
||||
import { EnvironmentSettingsWindow } from "./components/EnvironmentSettingsWindow";
|
||||
import { ObservationSessionSelect } from "./components/ObservationSessionSelect";
|
||||
import { SystemNavigationPanel } from "./components/system/SystemNavigationPanel";
|
||||
import { SystemWorkspaceSelector } from "./components/system/SystemWorkspaceSelector";
|
||||
import { useComputeContourSettings } from "./components/system/useComputeContourSettings";
|
||||
import { useApplicationPanelActions } from "./components/useApplicationPanelActions";
|
||||
import { useEnvironmentSettings } from "./core/environment/useEnvironmentSettings";
|
||||
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
|
||||
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
|
||||
import type { ViewerSettings } from "./core/runtime/contracts";
|
||||
@@ -46,6 +50,8 @@ import type {
|
||||
} from "./core/observation/sessionArchive";
|
||||
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
|
||||
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
|
||||
import { viewerSettingsTargetIdentity } from "./core/observation/viewerSettingsTarget";
|
||||
import { resolvePolygonRunRoute } from "./core/polygon/runArchive";
|
||||
import {
|
||||
OBSERVATION_WORKSPACE_ID,
|
||||
OBSERVATION_WORKSPACE_LAYOUT_VERSION,
|
||||
@@ -58,7 +64,7 @@ import {
|
||||
workspacesForRoot,
|
||||
type RootId,
|
||||
} from "./productModel";
|
||||
import { backendLabel, phaseLabel, phaseTone } from "./presentation";
|
||||
import { backendLabel, localConnectionPhaseLabel, phaseTone } from "./presentation";
|
||||
import {
|
||||
defaultSceneSettings,
|
||||
type PointColorMode,
|
||||
@@ -67,8 +73,8 @@ import {
|
||||
} from "./sceneSettings";
|
||||
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
|
||||
import { WorkspaceRenderer } from "./workspaces/Workspaces";
|
||||
import { useLaboratoryAnnotationHeader } from "./components/laboratory/useLaboratoryAnnotationHeader";
|
||||
import "./styles/scene-windows.css";
|
||||
|
||||
type SceneToolWindowId = "sources" | "display" | "layers";
|
||||
const viewerSettingsQuietPeriodMs = 750;
|
||||
|
||||
@@ -148,13 +154,22 @@ function mergeViewerSettings(
|
||||
|
||||
export default function App() {
|
||||
const runtime = useMissionRuntime();
|
||||
const environment = useEnvironmentSettings();
|
||||
const computeContourSettings = useComputeContourSettings();
|
||||
const { selection } = useDevicePluginHost();
|
||||
const polygonDatasetRoute = useMemo(
|
||||
() => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search),
|
||||
[],
|
||||
);
|
||||
const workspace = useApplicationWorkspace<string>({
|
||||
navigationOpen: false,
|
||||
contentExpanded: true,
|
||||
});
|
||||
|
||||
const [activeRoot, setActiveRoot] = useState<RootId | null>(null);
|
||||
const [activeRoot, setActiveRoot] = useState<RootId | null>(
|
||||
polygonDatasetRoute.active ? "data" : null,
|
||||
);
|
||||
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
|
||||
const [sourceUrl, setSourceUrl] = useState("");
|
||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
||||
@@ -165,6 +180,7 @@ export default function App() {
|
||||
const [layerInspectorOpen, setLayerInspectorOpen] = useState(false);
|
||||
const [sceneWindowOrder, setSceneWindowOrder] = useState<SceneToolWindowId[]>([]);
|
||||
const [layoutSaveNotice, setLayoutSaveNotice] = useState<string | null>(null);
|
||||
const laboratoryAnnotation = useLaboratoryAnnotationHeader();
|
||||
const [sceneSettings, setSceneSettings] = useState<SceneSettings>(defaultSceneSettings);
|
||||
const [displayDraft, setDisplayDraft] = useState<SceneSettings>(defaultSceneSettings);
|
||||
const [livePerceptionLayers, setLivePerceptionLayers] = useState<LivePerceptionLayers>(
|
||||
@@ -185,24 +201,53 @@ export default function App() {
|
||||
const livePerceptionLayersRef = useRef<LivePerceptionLayers>(defaultLivePerceptionLayers);
|
||||
const livePerceptionRevisionRef = useRef(0);
|
||||
const replayActiveRef = useRef(false);
|
||||
const selectedRecordedSessionIdRef = useRef<string | null>(null);
|
||||
const sceneSettingsCommitterActiveRef = useRef(true);
|
||||
const sceneSettingsCommitterRef = useRef<LatestAsyncCommitter<SceneSettings> | null>(null);
|
||||
const polygonDatasetRouteOpenedRef = useRef(false);
|
||||
|
||||
const currentRoot = rootById(activeRoot);
|
||||
const visibleRoots = roots;
|
||||
const activeEnvironmentSurface = activeRoot ?? "home";
|
||||
const landingPage = environment.settings.pages[activeEnvironmentSurface];
|
||||
const landingQuickActions = [
|
||||
landingPage.primaryWorkspaceId,
|
||||
landingPage.secondaryWorkspaceId,
|
||||
].flatMap((workspaceId) => {
|
||||
const definition = workspaceById(workspaceId);
|
||||
if (!definition) return [];
|
||||
if (activeRoot !== null && definition.root !== activeRoot) return [];
|
||||
return [definition];
|
||||
});
|
||||
const activeDefinition = workspaceById(workspace.activeView);
|
||||
const spatialWorkspaceActive = Boolean(
|
||||
workspace.contentOpen && activeDefinition?.kind === "spatial",
|
||||
const sceneWorkspaceActive = Boolean(
|
||||
workspace.contentOpen
|
||||
&& activeDefinition
|
||||
&& ["spatial", "recordings"].includes(activeDefinition.kind),
|
||||
);
|
||||
const archiveWorkspaceActive = Boolean(
|
||||
activeDefinition
|
||||
&& ["recordings", "lab-archive"].includes(activeDefinition.kind),
|
||||
);
|
||||
const rootWorkspaces = workspacesForRoot(activeRoot);
|
||||
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
|
||||
const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? "";
|
||||
const effectiveSourceUrl = replayTransitioning ? "" : sourceUrl || automaticSourceUrl;
|
||||
const effectiveSourceUrl = archiveWorkspaceActive
|
||||
? replayTransitioning ? "" : sourceUrl
|
||||
: automaticSourceUrl;
|
||||
const replayActive = Boolean(recordedReplay && sourceUrl === recordedReplay.sourceUrl);
|
||||
const replayPresented = archiveWorkspaceActive && replayActive;
|
||||
const recordedSessionAdmission = useRecordedSessionAdmission(
|
||||
replayActive ? recordedReplay : null,
|
||||
replayPresented ? recordedReplay : null,
|
||||
);
|
||||
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
|
||||
replayActiveRef.current = replayActive;
|
||||
replayActiveRef.current = replayPresented;
|
||||
|
||||
useEffect(() => {
|
||||
if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return;
|
||||
polygonDatasetRouteOpenedRef.current = true;
|
||||
workspace.openView("datasets");
|
||||
}, [polygonDatasetRoute.active, workspace]);
|
||||
|
||||
if (!sceneSettingsCommitterRef.current) {
|
||||
sceneSettingsCommitterRef.current = createLatestAsyncCommitter<SceneSettings>({
|
||||
@@ -230,11 +275,11 @@ export default function App() {
|
||||
() => recordedObservationSources(recordedReplay),
|
||||
[recordedReplay],
|
||||
);
|
||||
const activeObservationSources = replayActive
|
||||
const activeObservationSources = replayPresented
|
||||
? replaySources
|
||||
: runtime.state?.observationSources ?? [];
|
||||
const activeRuntimeState = useMemo(() => {
|
||||
if (!replayActive || !recordedReplay) return runtime.state;
|
||||
if (!replayPresented || !recordedReplay) return runtime.state;
|
||||
return {
|
||||
...(runtime.state ?? { phase: "replaying" as const }),
|
||||
phase: "replaying" as const,
|
||||
@@ -257,18 +302,11 @@ export default function App() {
|
||||
},
|
||||
},
|
||||
};
|
||||
}, [recordedReplay, replayActive, replaySources, runtime.state]);
|
||||
const viewerSettingsTargetIdentity = [
|
||||
runtime.state?.activeDevice?.pluginId,
|
||||
runtime.state?.activeDevice?.modelId,
|
||||
runtime.state?.activeDevice?.instanceId,
|
||||
runtime.state?.deviceSession?.sessionId,
|
||||
runtime.state?.deviceSession?.deviceId,
|
||||
runtime.state?.acquisition?.acquisitionId,
|
||||
].filter(Boolean).join(":") || "local-runtime";
|
||||
}, [recordedReplay, replayPresented, replaySources, runtime.state]);
|
||||
const viewerSettingsTarget = viewerSettingsTargetIdentity(runtime.state);
|
||||
const observationLayout = useObservationLayout(
|
||||
activeObservationSources,
|
||||
replayActive ? undefined : runtime.setObservationSourceActive,
|
||||
replayPresented ? undefined : runtime.setObservationSourceActive,
|
||||
);
|
||||
const workspaceLayoutProfile = useWorkspaceLayoutProfile();
|
||||
const focusedObservationSource = activeObservationSources.find(
|
||||
@@ -277,6 +315,8 @@ export default function App() {
|
||||
const observationFullscreenActive = Boolean(
|
||||
activeDefinition?.kind === "spatial"
|
||||
? observationLayout.maximizedFloatingSourceId || focusedObservationSource?.modality === "point-cloud"
|
||||
: activeDefinition?.kind === "lab-archive"
|
||||
? observationLayout.maximizedFloatingSourceId || focusedObservationSource?.modality === "point-cloud"
|
||||
: activeDefinition?.kind === "cameras" && focusedObservationSource,
|
||||
);
|
||||
|
||||
@@ -320,14 +360,14 @@ export default function App() {
|
||||
}, [observationLayout.restore, workspaceLayoutProfile.profile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (spatialWorkspaceActive) return;
|
||||
if (sceneWorkspaceActive) return;
|
||||
// Scene tools are transient children of the spatial workspace. They must
|
||||
// never survive a route/root/content close or appear over the landing page.
|
||||
setSourceWindowOpen(false);
|
||||
setDisplayWindowOpen(false);
|
||||
setLayerInspectorOpen(false);
|
||||
setSceneWindowOrder([]);
|
||||
}, [spatialWorkspaceActive]);
|
||||
}, [sceneWorkspaceActive]);
|
||||
|
||||
useEffect(() => {
|
||||
sceneSettingsCommitterActiveRef.current = true;
|
||||
@@ -343,7 +383,7 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
const profile = workspaceLayoutProfile.profile;
|
||||
const applicationKey = profile
|
||||
? `${profile.revision}:${viewerSettingsTargetIdentity}`
|
||||
? `${profile.revision}:${viewerSettingsTarget}`
|
||||
: null;
|
||||
if (
|
||||
!profile ||
|
||||
@@ -364,7 +404,7 @@ export default function App() {
|
||||
}, [
|
||||
runtime.backendStatus,
|
||||
runtime.updateViewerSettings,
|
||||
viewerSettingsTargetIdentity,
|
||||
viewerSettingsTarget,
|
||||
workspaceLayoutProfile.profile,
|
||||
]);
|
||||
|
||||
@@ -383,7 +423,7 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!spatialWorkspaceActive || !activeSceneWindow) return;
|
||||
if (!sceneWorkspaceActive || !activeSceneWindow) return;
|
||||
|
||||
const closeActiveWindow = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented) return;
|
||||
@@ -395,12 +435,18 @@ export default function App() {
|
||||
|
||||
document.addEventListener("keydown", closeActiveWindow);
|
||||
return () => document.removeEventListener("keydown", closeActiveWindow);
|
||||
}, [activeSceneWindow, closeSceneWindow, spatialWorkspaceActive]);
|
||||
}, [activeSceneWindow, closeSceneWindow, sceneWorkspaceActive]);
|
||||
|
||||
const selectRoot = (rootId: RootId) => {
|
||||
setActiveRoot(rootId);
|
||||
workspace.closeView();
|
||||
workspace.openNavigation();
|
||||
if (rootId === "system") {
|
||||
workspace.openView(
|
||||
activeDefinition?.root === "system" ? activeDefinition.id : "modules",
|
||||
);
|
||||
} else {
|
||||
workspace.closeView();
|
||||
}
|
||||
};
|
||||
|
||||
const openView = (viewId: string) => {
|
||||
@@ -411,7 +457,7 @@ export default function App() {
|
||||
};
|
||||
|
||||
const openSource = () => {
|
||||
if (!spatialWorkspaceActive) return;
|
||||
if (!sceneWorkspaceActive) return;
|
||||
setSourceDraft(sourceUrl);
|
||||
setSourceWindowOpen(true);
|
||||
activateSceneWindow("sources");
|
||||
@@ -452,13 +498,13 @@ export default function App() {
|
||||
}, [commitDisplaySettings]);
|
||||
|
||||
const openDisplay = () => {
|
||||
if (!spatialWorkspaceActive) return;
|
||||
if (!sceneWorkspaceActive) return;
|
||||
setDisplayWindowOpen(true);
|
||||
activateSceneWindow("display");
|
||||
};
|
||||
|
||||
const openLayers = () => {
|
||||
if (!spatialWorkspaceActive) return;
|
||||
if (!sceneWorkspaceActive) return;
|
||||
setLayerInspectorOpen(true);
|
||||
activateSceneWindow("layers");
|
||||
};
|
||||
@@ -486,6 +532,7 @@ export default function App() {
|
||||
// produced a validated launch descriptor. Keep the old scene mounted
|
||||
// before this point; now perform one controlled receiver teardown before
|
||||
// accepting the already-ready archive.
|
||||
selectedRecordedSessionIdRef.current = null;
|
||||
setReplayTransitioning(true);
|
||||
setRecordedReplay(null);
|
||||
setRecordedReplayLabel(null);
|
||||
@@ -503,6 +550,7 @@ export default function App() {
|
||||
if (sourceSwitchBlockedRef.current) {
|
||||
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
|
||||
}
|
||||
selectedRecordedSessionIdRef.current = session.id;
|
||||
setRecordedReplay(launch);
|
||||
setRecordedReplayLabel(session.label);
|
||||
setSourceUrl(launch.sourceUrl);
|
||||
@@ -514,25 +562,39 @@ export default function App() {
|
||||
if (outcome !== "accepted") setReplayTransitioning(false);
|
||||
}, []);
|
||||
|
||||
const releaseRecordedReplayForDelete = useCallback(async (sessionId: string) => {
|
||||
if (selectedRecordedSessionIdRef.current !== sessionId) return;
|
||||
// Deleting the selected archive is a source transition. Unmount Rerun and
|
||||
// recorded media first so no admission request can outlive the evidence.
|
||||
selectedRecordedSessionIdRef.current = null;
|
||||
setReplayTransitioning(true);
|
||||
setRecordedReplay(null);
|
||||
setRecordedReplayLabel(null);
|
||||
setSourceUrl("");
|
||||
setSourceDraft("");
|
||||
await new Promise<void>((resolve) => {
|
||||
window.requestAnimationFrame(() => window.setTimeout(resolve, 0));
|
||||
});
|
||||
if (selectedRecordedSessionIdRef.current === null) setReplayTransitioning(false);
|
||||
}, []);
|
||||
|
||||
const activateAutomaticSpatialSource = useCallback(() => {
|
||||
// A plugin-owned live/file-replay start must release any host-selected
|
||||
// archive or manual URL before the new acquisition becomes non-terminal.
|
||||
// This transition is an internal start boundary, not an operator source
|
||||
// switch, so it intentionally does not consult the acquisition guard.
|
||||
selectedRecordedSessionIdRef.current = null;
|
||||
setReplayTransitioning(false);
|
||||
setRecordedReplay(null);
|
||||
setRecordedReplayLabel(null);
|
||||
setSourceUrl("");
|
||||
setSourceDraft("");
|
||||
}, []);
|
||||
|
||||
observationLayout.activateAutomaticDefaults();
|
||||
}, [observationLayout.activateAutomaticDefaults]);
|
||||
useEffect(() => {
|
||||
// ObservationSessionSelect owns the cancellable request and unmounts when
|
||||
// the operator leaves the spatial workspace. Its unmount cannot safely
|
||||
// call back into this owner, so clear the transient blanking state here.
|
||||
// Returning to Observation then starts from a deterministic idle source
|
||||
// instead of an orphaned `replayTransitioning=true` state.
|
||||
if (activeDefinition?.kind !== "spatial") setReplayTransitioning(false);
|
||||
if (!["recordings", "lab-archive"].includes(activeDefinition?.kind ?? "")) {
|
||||
setReplayTransitioning(false);
|
||||
}
|
||||
}, [activeDefinition?.kind]);
|
||||
|
||||
const applyScenePatch = (patch: Partial<SceneSettings>) => commitDisplayPatch(patch);
|
||||
@@ -567,36 +629,20 @@ export default function App() {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [layoutSaveNotice]);
|
||||
|
||||
const contentActions = useMemo<ApplicationPanelUtilityAction[]>(() => {
|
||||
const actions: ApplicationPanelUtilityAction[] = [];
|
||||
|
||||
if (activeDefinition?.kind === "device") {
|
||||
actions.push({
|
||||
label: "Обновить состояние локального контура",
|
||||
icon: "refresh",
|
||||
onClick: () => void runtime.refresh(),
|
||||
});
|
||||
}
|
||||
|
||||
if (activeDefinition?.kind === "spatial") {
|
||||
actions.push(
|
||||
{
|
||||
label: workspaceLayoutProfile.state === "saving"
|
||||
? "Сохраняем компоновку"
|
||||
: "Сохранить компоновку",
|
||||
icon: "save",
|
||||
disabled: workspaceLayoutProfile.state === "saving",
|
||||
onClick: () => void saveWorkspaceLayout(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return actions;
|
||||
}, [activeDefinition?.kind, runtime, saveWorkspaceLayout, workspaceLayoutProfile.state]);
|
||||
const contentActions = useApplicationPanelActions({
|
||||
definition: activeDefinition,
|
||||
refreshRuntime: runtime.refresh,
|
||||
resetConnectionScenario: runtime.resetConnectionScenario,
|
||||
connectionScenarioResetting: runtime.pendingAction === "mode",
|
||||
saveWorkspaceLayout,
|
||||
workspaceLayoutSaving: workspaceLayoutProfile.state === "saving",
|
||||
systemUtilityActions: computeContourSettings.utilityActions,
|
||||
});
|
||||
|
||||
const header = (
|
||||
<AppHeader
|
||||
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
|
||||
brandHref="/"
|
||||
brandLabel="NODEDC MISSION CORE"
|
||||
center={
|
||||
<>
|
||||
@@ -604,18 +650,35 @@ export default function App() {
|
||||
<HeaderNavigation
|
||||
label="Архитектурные блоки пункта управления"
|
||||
value={activeRoot ?? undefined}
|
||||
items={roots.map((root) => ({ value: root.id, label: root.label }))}
|
||||
items={visibleRoots.map((root) => ({
|
||||
value: root.id,
|
||||
label: environment.settings.pages[root.id].headerLabel,
|
||||
}))}
|
||||
onChange={selectRoot}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<HeaderProfile>
|
||||
<HeaderProfileButton onClick={() => void runtime.refresh()} title="Обновить локальный контур">
|
||||
<span className="api-dot" data-status={runtime.backendStatus} aria-hidden="true" />
|
||||
{backendLabel(runtime.backendStatus)}
|
||||
</HeaderProfileButton>
|
||||
<HeaderAvatar label="DC" />
|
||||
<UserProfileMenu
|
||||
displayName="DC"
|
||||
subtitle="Mission Core"
|
||||
triggerLabel={null}
|
||||
actions={[
|
||||
{
|
||||
id: "refresh",
|
||||
label: "Обновить контур",
|
||||
icon: "refresh",
|
||||
onSelect: () => void runtime.refresh(),
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "Настройки",
|
||||
icon: "settings",
|
||||
onSelect: () => setEnvironmentSettingsOpen(true),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</HeaderProfile>
|
||||
}
|
||||
/>
|
||||
@@ -627,7 +690,9 @@ export default function App() {
|
||||
data-nodedc-ui
|
||||
className="control-station"
|
||||
data-observation-fullscreen={observationFullscreenActive ? "true" : undefined}
|
||||
navigationOpen={workspace.navigationOpen && activeRoot !== null}
|
||||
navigationOpen={
|
||||
workspace.navigationOpen && activeRoot !== null
|
||||
}
|
||||
contentOpen={workspace.contentOpen && activeDefinition !== null}
|
||||
contentExpanded={workspace.contentExpanded}
|
||||
header={header}
|
||||
@@ -637,11 +702,18 @@ export default function App() {
|
||||
backendStatus={runtime.backendStatus}
|
||||
phase={runtime.state?.phase}
|
||||
message={runtime.state?.message}
|
||||
onOpenObservation={() => openView("spatial-scene")}
|
||||
onOpenDevice={() => openView("local-device")}
|
||||
page={landingPage}
|
||||
quickActions={landingQuickActions}
|
||||
onOpenWorkspace={openView}
|
||||
/>
|
||||
}
|
||||
navigation={currentRoot ? (
|
||||
navigation={currentRoot && activeRoot === "system" ? (
|
||||
<SystemNavigationPanel
|
||||
title={currentRoot.title}
|
||||
onAdd={computeContourSettings.openCreate}
|
||||
onClose={workspace.closeNavigation}
|
||||
/>
|
||||
) : currentRoot ? (
|
||||
<AdminNavigationPanel
|
||||
eyebrow="MISSION CORE"
|
||||
title={currentRoot.title}
|
||||
@@ -652,12 +724,11 @@ export default function App() {
|
||||
{
|
||||
id: "local-contour",
|
||||
label: "Локальный контур",
|
||||
description:
|
||||
runtime.state?.activeDevice?.endpointLabel ||
|
||||
selection?.model.displayName ||
|
||||
"Модель не выбрана",
|
||||
description: selection ? "Подключение" : "Модель не выбрана",
|
||||
icon: <Icon name="network" />,
|
||||
active: runtime.backendStatus !== "offline" && runtime.backendStatus !== "unconfigured",
|
||||
active:
|
||||
runtime.backendStatus !== "offline"
|
||||
&& runtime.backendStatus !== "unconfigured",
|
||||
},
|
||||
]}
|
||||
items={rootWorkspaces.map((item) => ({
|
||||
@@ -672,7 +743,12 @@ export default function App() {
|
||||
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true">
|
||||
<Icon name="activity" />
|
||||
</span>
|
||||
<span>{rootWorkspaces.length} рабочих поверхностей</span>
|
||||
<span>
|
||||
{rootWorkspaces.length}{" "}
|
||||
{rootWorkspaces.length === 1
|
||||
? "рабочая поверхность"
|
||||
: "рабочих поверхностей"}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -682,7 +758,9 @@ export default function App() {
|
||||
key={activeDefinition.id}
|
||||
eyebrow={activeDefinition.eyebrow}
|
||||
title={
|
||||
activeDefinition.kind === "spatial" && replayActive && recordedReplayLabel
|
||||
activeDefinition.kind === "recordings"
|
||||
&& replayPresented
|
||||
&& recordedReplayLabel
|
||||
? `${activeDefinition.title}: ${recordedReplayLabel}`
|
||||
: activeDefinition.title
|
||||
}
|
||||
@@ -692,17 +770,13 @@ export default function App() {
|
||||
headerTools={
|
||||
activeDefinition.kind === "device" ? (
|
||||
<StatusBadge tone={phaseTone(runtime.state?.phase)}>
|
||||
{phaseLabel(runtime.state?.phase)}
|
||||
{localConnectionPhaseLabel(runtime.state?.phase)}
|
||||
</StatusBadge>
|
||||
) : activeDefinition.kind === "spatial" ? (
|
||||
<div className="observation-header-tools">
|
||||
<ObservationSessionSelect
|
||||
disabled={runtime.pendingAction !== null || sourceSwitchBlocked}
|
||||
blockedReason={sourceSwitchBlockedReason}
|
||||
onReplayBegin={beginRecordedReplaySwitch}
|
||||
onReplayAccepted={acceptRecordedReplay}
|
||||
onReplaySettled={(_session, outcome) => settleRecordedReplaySwitch(outcome)}
|
||||
/>
|
||||
<StatusBadge tone={["live", "replay"].includes(runtime.state?.sourceMode ?? "") ? "success" : "neutral"}>
|
||||
{runtime.state?.sourceMode === "live" ? "Эфир" : runtime.state?.sourceMode === "replay" ? "Повтор записи" : "Ожидание эфира"}
|
||||
</StatusBadge>
|
||||
{layoutSaveNotice || workspaceLayoutProfile.error ? (
|
||||
<span
|
||||
className="workspace-layout-feedback"
|
||||
@@ -718,6 +792,25 @@ export default function App() {
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : activeDefinition.kind === "recordings" ? (
|
||||
<ObservationSessionSelect
|
||||
disabled={runtime.pendingAction !== null || sourceSwitchBlocked}
|
||||
blockedReason={sourceSwitchBlockedReason}
|
||||
onReplayBegin={beginRecordedReplaySwitch}
|
||||
onReplayAccepted={acceptRecordedReplay}
|
||||
onReplaySettled={(_session, outcome) =>
|
||||
settleRecordedReplaySwitch(outcome)}
|
||||
onDeleteBegin={releaseRecordedReplayForDelete}
|
||||
/>
|
||||
) : activeDefinition.kind === "datasets" ? (
|
||||
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
||||
) : activeDefinition.kind === "lab-archive" ? (
|
||||
laboratoryAnnotation.control
|
||||
) : activeDefinition.root === "system" ? (
|
||||
<SystemWorkspaceSelector
|
||||
value={activeDefinition.id}
|
||||
onChange={openView}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||
)
|
||||
@@ -736,7 +829,7 @@ export default function App() {
|
||||
state={activeRuntimeState}
|
||||
backendStatus={runtime.backendStatus}
|
||||
sourceUrl={effectiveSourceUrl}
|
||||
recordedReplay={replayActive ? recordedReplay : null}
|
||||
recordedReplay={replayPresented ? recordedReplay : null}
|
||||
recordedSessionAdmission={recordedSessionAdmission}
|
||||
sceneSettings={sceneSettings}
|
||||
accumulationSeconds={displayDraft.accumulationSeconds}
|
||||
@@ -746,12 +839,24 @@ export default function App() {
|
||||
livePerceptionLayers={livePerceptionLayers}
|
||||
onLivePerceptionLayersChange={changeLivePerceptionLayers}
|
||||
observationLayout={observationLayout}
|
||||
deviceLabel={selection?.model.displayName ?? null}
|
||||
spatialControls={selection?.SpatialControlsView
|
||||
? {
|
||||
View: selection.SpatialControlsView,
|
||||
model: selection.model,
|
||||
}
|
||||
: null}
|
||||
sessionArchive={{
|
||||
disabled: runtime.pendingAction !== null || sourceSwitchBlocked,
|
||||
blockedReason: sourceSwitchBlockedReason,
|
||||
onReplayBegin: beginRecordedReplaySwitch,
|
||||
onReplayAccepted: acceptRecordedReplay,
|
||||
onReplaySettled: (_session, outcome) =>
|
||||
settleRecordedReplaySwitch(outcome),
|
||||
onDeleteBegin: releaseRecordedReplayForDelete,
|
||||
}}
|
||||
onLaboratoryAnnotationActionChange={laboratoryAnnotation.setAction}
|
||||
onLaboratoryViewActionChange={laboratoryAnnotation.setViewAction}
|
||||
navigation={{
|
||||
openView,
|
||||
openSource,
|
||||
@@ -765,8 +870,19 @@ export default function App() {
|
||||
) : null}
|
||||
/>
|
||||
|
||||
<EnvironmentSettingsWindow
|
||||
open={environmentSettingsOpen}
|
||||
settings={environment.settings}
|
||||
state={environment.state}
|
||||
error={environment.error}
|
||||
onClose={() => setEnvironmentSettingsOpen(false)}
|
||||
onSave={environment.save}
|
||||
onUpload={environment.upload}
|
||||
/>
|
||||
{computeContourSettings.window}
|
||||
|
||||
<Window
|
||||
open={spatialWorkspaceActive && sourceWindowOpen}
|
||||
open={sceneWorkspaceActive && sourceWindowOpen}
|
||||
title="Визуальный движок"
|
||||
subtitle="Rerun gRPC и записи пространственной сцены"
|
||||
placement="end"
|
||||
@@ -788,6 +904,7 @@ export default function App() {
|
||||
title={sourceSwitchBlockedReason ?? undefined}
|
||||
onClick={() => {
|
||||
if (sourceSwitchBlockedRef.current) return;
|
||||
selectedRecordedSessionIdRef.current = null;
|
||||
setSourceDraft("");
|
||||
setSourceUrl("");
|
||||
setRecordedReplay(null);
|
||||
@@ -803,6 +920,7 @@ export default function App() {
|
||||
title={sourceSwitchBlockedReason ?? undefined}
|
||||
onClick={() => {
|
||||
if (sourceSwitchBlockedRef.current) return;
|
||||
selectedRecordedSessionIdRef.current = null;
|
||||
setSourceUrl(sourceDraft.trim());
|
||||
setRecordedReplay(null);
|
||||
setRecordedReplayLabel(null);
|
||||
@@ -894,7 +1012,7 @@ export default function App() {
|
||||
</Window>
|
||||
|
||||
<Window
|
||||
open={spatialWorkspaceActive && displayWindowOpen}
|
||||
open={sceneWorkspaceActive && displayWindowOpen}
|
||||
title="Отображение"
|
||||
subtitle="Параметры пространственной сцены"
|
||||
placement="end"
|
||||
@@ -973,7 +1091,7 @@ export default function App() {
|
||||
</ControlRow>
|
||||
</div>
|
||||
) : null}
|
||||
{replayActive ? (
|
||||
{replayPresented ? (
|
||||
<p className="scene-window-note">
|
||||
Первый новый режим читает индекс архивных точек. Следующие палитры
|
||||
переключаются из подготовленного цветового кэша без переэкспорта геометрии.
|
||||
@@ -1038,7 +1156,7 @@ export default function App() {
|
||||
</Window>
|
||||
|
||||
<Window
|
||||
open={spatialWorkspaceActive && layerInspectorOpen}
|
||||
open={sceneWorkspaceActive && layerInspectorOpen}
|
||||
title="Слои сцены"
|
||||
subtitle="Сущности пространственной сцены"
|
||||
placement="end"
|
||||
@@ -1119,7 +1237,6 @@ export default function App() {
|
||||
]}
|
||||
/>
|
||||
</Window>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type {
|
||||
EnvironmentBackground,
|
||||
EnvironmentMediaItem,
|
||||
} from "../core/environment/environmentSettings";
|
||||
|
||||
type ReadyEnvironmentMediaItem = EnvironmentMediaItem & {
|
||||
url: string;
|
||||
mediaKind: "image" | "video";
|
||||
};
|
||||
|
||||
function isReadyMediaItem(
|
||||
item: EnvironmentMediaItem,
|
||||
): item is ReadyEnvironmentMediaItem {
|
||||
return Boolean(item.url && item.mediaKind);
|
||||
}
|
||||
|
||||
export function EnvironmentBackgroundMedia({
|
||||
background,
|
||||
}: {
|
||||
background: EnvironmentBackground;
|
||||
}) {
|
||||
const items = useMemo(
|
||||
() => background.items.filter(isReadyMediaItem),
|
||||
[background.items],
|
||||
);
|
||||
const playlistIdentity = items.map((item) => `${item.id}:${item.url}`).join("|");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [failedIds, setFailedIds] = useState<Set<string>>(new Set());
|
||||
const playableItems = items.filter((item) => !failedIds.has(item.id));
|
||||
const activeItem = playableItems[activeIndex] ?? playableItems[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
setFailedIds(new Set());
|
||||
}, [playlistIdentity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!background.enabled
|
||||
|| !activeItem
|
||||
|| activeItem.mediaKind !== "image"
|
||||
|| playableItems.length < 2
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
setActiveIndex((current) => (current + 1) % playableItems.length);
|
||||
}, background.imageDurationSeconds * 1_000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
activeItem,
|
||||
background.enabled,
|
||||
background.imageDurationSeconds,
|
||||
playableItems.length,
|
||||
]);
|
||||
|
||||
if (!background.enabled || !activeItem) return null;
|
||||
|
||||
return (
|
||||
<div className="landing-stage__media" aria-hidden="true">
|
||||
{activeItem.mediaKind === "video" ? (
|
||||
<video
|
||||
key={activeItem.url}
|
||||
src={activeItem.url}
|
||||
autoPlay
|
||||
muted
|
||||
loop={playableItems.length === 1}
|
||||
playsInline
|
||||
onEnded={() => setActiveIndex((current) => (
|
||||
(current + 1) % playableItems.length
|
||||
))}
|
||||
onError={() => {
|
||||
setFailedIds((current) => new Set(current).add(activeItem.id));
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={activeItem.url}
|
||||
src={activeItem.url}
|
||||
alt=""
|
||||
onError={() => {
|
||||
setFailedIds((current) => new Set(current).add(activeItem.id));
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Icon,
|
||||
IconButton,
|
||||
MediaSourceField,
|
||||
RangeControl,
|
||||
SortableList,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
appendEnvironmentMediaItem,
|
||||
inferEnvironmentMediaKind,
|
||||
maxEnvironmentMediaItems,
|
||||
removeEnvironmentMediaItem,
|
||||
type EnvironmentBackground,
|
||||
type EnvironmentMediaItem,
|
||||
type EnvironmentSurfaceId,
|
||||
type UploadedEnvironmentMedia,
|
||||
} from "../core/environment/environmentSettings";
|
||||
|
||||
interface EnvironmentMediaPlaylistEditorProps {
|
||||
surfaceId: EnvironmentSurfaceId;
|
||||
background: EnvironmentBackground;
|
||||
disabled: boolean;
|
||||
error: string | null;
|
||||
onChange: (background: EnvironmentBackground) => void;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
onUpload: (
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
itemId: string,
|
||||
file: File,
|
||||
) => Promise<UploadedEnvironmentMedia>;
|
||||
}
|
||||
|
||||
const acceptedEnvironmentMedia = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".avif",
|
||||
".mp4",
|
||||
".webm",
|
||||
".mov",
|
||||
].join(",");
|
||||
|
||||
function patchItem(
|
||||
background: EnvironmentBackground,
|
||||
itemId: string,
|
||||
patch: Partial<EnvironmentMediaItem>,
|
||||
): EnvironmentBackground {
|
||||
return {
|
||||
...background,
|
||||
items: background.items.map((item) => (
|
||||
item.id === itemId ? { ...item, ...patch } : item
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
export function EnvironmentMediaPlaylistEditor({
|
||||
surfaceId,
|
||||
background,
|
||||
disabled,
|
||||
error,
|
||||
onChange,
|
||||
onBusyChange,
|
||||
onUpload,
|
||||
}: EnvironmentMediaPlaylistEditorProps) {
|
||||
const [uploadingIds, setUploadingIds] = useState<Set<string>>(new Set());
|
||||
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
|
||||
const backgroundRef = useRef(background);
|
||||
backgroundRef.current = background;
|
||||
const displayedItems = useMemo(
|
||||
() => [...background.items].reverse(),
|
||||
[background.items],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onBusyChange(uploadingIds.size > 0);
|
||||
}, [onBusyChange, uploadingIds.size]);
|
||||
|
||||
useEffect(() => () => onBusyChange(false), [onBusyChange]);
|
||||
|
||||
const setItemError = (itemId: string, message?: string) => {
|
||||
setItemErrors((current) => {
|
||||
const next = { ...current };
|
||||
if (message) next[itemId] = message;
|
||||
else delete next[itemId];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const uploadFile = async (itemId: string, file?: File) => {
|
||||
if (!file) return;
|
||||
setUploadingIds((current) => new Set(current).add(itemId));
|
||||
setItemError(itemId);
|
||||
try {
|
||||
const uploaded = await onUpload(surfaceId, itemId, file);
|
||||
onChange(patchItem(backgroundRef.current, itemId, {
|
||||
source: "file",
|
||||
url: uploaded.url,
|
||||
mediaKind: uploaded.mediaKind,
|
||||
fileName: uploaded.fileName,
|
||||
}));
|
||||
} catch (reason) {
|
||||
setItemError(
|
||||
itemId,
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось загрузить медиаконтент.",
|
||||
);
|
||||
} finally {
|
||||
setUploadingIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(itemId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="environment-media-playlist">
|
||||
<div className="environment-media-playlist__head">
|
||||
<div>
|
||||
<span>Видео / картинка</span>
|
||||
<p>MP4, WebM, MOV, PNG, JPEG, GIF, WebP или AVIF · до 256 МБ.</p>
|
||||
</div>
|
||||
<IconButton
|
||||
label="Добавить медиаконтент"
|
||||
disabled={disabled || background.items.length >= maxEnvironmentMediaItems}
|
||||
onClick={() => onChange(appendEnvironmentMediaItem(background))}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{displayedItems.length ? (
|
||||
<SortableList
|
||||
items={displayedItems}
|
||||
getId={(item) => item.id}
|
||||
className="environment-media-playlist__items"
|
||||
onReorder={(items) => onChange({
|
||||
...background,
|
||||
items: [...items].reverse(),
|
||||
})}
|
||||
>
|
||||
{(item, { handle }) => {
|
||||
const playbackIndex = background.items.findIndex(
|
||||
(candidate) => candidate.id === item.id,
|
||||
);
|
||||
return (
|
||||
<div className="environment-media-playlist__item">
|
||||
<MediaSourceField
|
||||
label={`Медиаконтент ${String(playbackIndex + 1).padStart(2, "0")}`}
|
||||
kindLabel={item.mediaKind ?? "media"}
|
||||
source={item.source}
|
||||
url={item.url ?? ""}
|
||||
fileName={item.fileName}
|
||||
uploading={uploadingIds.has(item.id)}
|
||||
previewSrc={item.url}
|
||||
previewKind={item.mediaKind}
|
||||
accept={acceptedEnvironmentMedia}
|
||||
path={`${surfaceId}.background.items[${playbackIndex}] → server environment media`}
|
||||
hint="Файл сохраняется в Mission Core data root. URL должен вести прямо на media по HTTP(S)."
|
||||
error={itemErrors[item.id] ?? (
|
||||
playbackIndex === background.items.length - 1 ? error : null
|
||||
)}
|
||||
onSourceChange={(source) => {
|
||||
if (source === item.source) return;
|
||||
setItemError(item.id);
|
||||
onChange(patchItem(background, item.id, {
|
||||
source,
|
||||
url: null,
|
||||
mediaKind: null,
|
||||
fileName: null,
|
||||
}));
|
||||
}}
|
||||
onUrlChange={(url) => {
|
||||
setItemError(item.id);
|
||||
onChange(patchItem(background, item.id, {
|
||||
source: "url",
|
||||
url: url || null,
|
||||
mediaKind: url ? inferEnvironmentMediaKind(url) : null,
|
||||
fileName: null,
|
||||
}));
|
||||
}}
|
||||
onFileChange={(file) => void uploadFile(item.id, file)}
|
||||
/>
|
||||
<div className="environment-media-playlist__item-actions">
|
||||
<IconButton
|
||||
label={`Удалить медиаконтент ${playbackIndex + 1}`}
|
||||
disabled={disabled || uploadingIds.has(item.id)}
|
||||
onClick={() => {
|
||||
setItemError(item.id);
|
||||
onChange(removeEnvironmentMediaItem(background, item.id));
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</IconButton>
|
||||
{handle}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</SortableList>
|
||||
) : (
|
||||
<>
|
||||
<p className="environment-media-playlist__empty">
|
||||
Добавьте первый файл или прямую ссылку на медиаконтент.
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="environment-media-playlist__error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="environment-media-playlist__timing">
|
||||
<RangeControl
|
||||
label="Показывать изображение"
|
||||
value={background.imageDurationSeconds}
|
||||
min={1}
|
||||
max={60}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
formatValue={(value) => `${value} с`}
|
||||
onChange={(imageDurationSeconds) => onChange({
|
||||
...background,
|
||||
imageDurationSeconds,
|
||||
})}
|
||||
/>
|
||||
<span>
|
||||
Новые элементы появляются сверху. Воспроизведение начинается снизу;
|
||||
перетаскивание меняет порядок.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
FeatureSettingsWindow,
|
||||
Select,
|
||||
SettingsCard,
|
||||
Switch,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
WindowFooterActions,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
cloneEnvironmentSettings,
|
||||
type EnvironmentBackground,
|
||||
type EnvironmentPage,
|
||||
type EnvironmentSettings,
|
||||
type EnvironmentSurfaceId,
|
||||
type UploadedEnvironmentMedia,
|
||||
} from "../core/environment/environmentSettings";
|
||||
import { EnvironmentMediaPlaylistEditor } from "./EnvironmentMediaPlaylistEditor";
|
||||
import {
|
||||
roots,
|
||||
workspaces,
|
||||
workspacesForRoot,
|
||||
} from "../productModel";
|
||||
|
||||
interface EnvironmentSettingsWindowProps {
|
||||
open: boolean;
|
||||
settings: EnvironmentSettings;
|
||||
state: "loading" | "ready" | "saving" | "error";
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>;
|
||||
onUpload: (
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
itemId: string,
|
||||
file: File,
|
||||
) => Promise<UploadedEnvironmentMedia>;
|
||||
}
|
||||
|
||||
function patchPage(
|
||||
draft: EnvironmentSettings,
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
patch: Partial<EnvironmentPage>,
|
||||
): EnvironmentSettings {
|
||||
return {
|
||||
...draft,
|
||||
pages: {
|
||||
...draft.pages,
|
||||
[surfaceId]: {
|
||||
...draft.pages[surfaceId],
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function patchBackground(
|
||||
draft: EnvironmentSettings,
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
patch: Partial<EnvironmentBackground>,
|
||||
): EnvironmentSettings {
|
||||
const page = draft.pages[surfaceId];
|
||||
return patchPage(draft, surfaceId, {
|
||||
background: {
|
||||
...page.background,
|
||||
...patch,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function EnvironmentSettingsWindow({
|
||||
open,
|
||||
settings,
|
||||
state,
|
||||
error,
|
||||
onClose,
|
||||
onSave,
|
||||
onUpload,
|
||||
}: EnvironmentSettingsWindowProps) {
|
||||
const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings));
|
||||
const [surfaceId, setSurfaceId] = useState<EnvironmentSurfaceId>("fleet");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDraft(cloneEnvironmentSettings(settings));
|
||||
setLocalError(null);
|
||||
}, [open, settings]);
|
||||
|
||||
const selectedPage = draft.pages[surfaceId];
|
||||
const selectedBackground = selectedPage.background;
|
||||
const pageOptions = useMemo(() => [
|
||||
{
|
||||
value: "home" as const,
|
||||
label: draft.pages.home.headerLabel,
|
||||
description: "Главная страница продукта",
|
||||
},
|
||||
...roots.map((root) => ({
|
||||
value: root.id,
|
||||
label: draft.pages[root.id].headerLabel,
|
||||
description: `Стартовая страница раздела «${draft.pages[root.id].title}»`,
|
||||
})),
|
||||
], [draft.pages]);
|
||||
const quickActionWorkspaces = useMemo(
|
||||
() => surfaceId === "home"
|
||||
? workspaces.filter((workspace) => !workspace.internalOnly)
|
||||
: workspacesForRoot(surfaceId),
|
||||
[surfaceId],
|
||||
);
|
||||
const quickActionOptions = useMemo(() => [
|
||||
{
|
||||
value: "none",
|
||||
label: "Не показывать",
|
||||
description: "Кнопка скрыта на стартовом экране",
|
||||
},
|
||||
...quickActionWorkspaces.map((workspace) => ({
|
||||
value: workspace.id,
|
||||
label: workspace.label,
|
||||
description: workspace.description,
|
||||
})),
|
||||
], [quickActionWorkspaces]);
|
||||
const dirty = useMemo(
|
||||
() => JSON.stringify(draft) !== JSON.stringify(settings),
|
||||
[draft, settings],
|
||||
);
|
||||
const busy = state === "saving" || uploading;
|
||||
|
||||
const updatePage = (patch: Partial<EnvironmentPage>) => {
|
||||
setDraft((current) => patchPage(current, surfaceId, patch));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const invalid = Object.entries(draft.pages).find(([, page]) => (
|
||||
!page.headerLabel.trim()
|
||||
|| !page.eyebrow.trim()
|
||||
|| !page.title.trim()
|
||||
|| !page.description.trim()
|
||||
));
|
||||
if (invalid) {
|
||||
setLocalError("Название, надзаголовок, заголовок и описание не могут быть пустыми.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selectedPage.primaryWorkspaceId
|
||||
&& selectedPage.primaryWorkspaceId === selectedPage.secondaryWorkspaceId
|
||||
) {
|
||||
setLocalError("Быстрые кнопки должны вести на разные рабочие поверхности.");
|
||||
return;
|
||||
}
|
||||
const invalidMedia = Object.entries(draft.pages).find(([, page]) => (
|
||||
(page.background.enabled && !page.background.items.length)
|
||||
|| page.background.items.some((item) => {
|
||||
if (!item.url || !item.mediaKind) return true;
|
||||
if (item.source !== "url") return false;
|
||||
try {
|
||||
const parsed = new URL(item.url);
|
||||
return !["http:", "https:"].includes(parsed.protocol);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
));
|
||||
if (invalidMedia) {
|
||||
setSurfaceId(invalidMedia[0] as EnvironmentSurfaceId);
|
||||
setLocalError(
|
||||
"Каждый элемент фона должен содержать загруженный файл или прямой HTTP(S) URL.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
try {
|
||||
await onSave({
|
||||
...draft,
|
||||
pages: Object.fromEntries(
|
||||
Object.entries(draft.pages).map(([id, page]) => [id, {
|
||||
...page,
|
||||
headerLabel: page.headerLabel.trim(),
|
||||
eyebrow: page.eyebrow.trim(),
|
||||
title: page.title.trim(),
|
||||
description: page.description.trim(),
|
||||
}]),
|
||||
) as EnvironmentSettings["pages"],
|
||||
});
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
setLocalError(reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось сохранить настройки окружения.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FeatureSettingsWindow
|
||||
open={open}
|
||||
title="Настройки Mission Core"
|
||||
subtitle="Локальное операторское окружение"
|
||||
identity={{
|
||||
title: "DC",
|
||||
subtitle: "Mission Core",
|
||||
avatarLabel: "DC",
|
||||
}}
|
||||
sections={[
|
||||
{
|
||||
id: "environment",
|
||||
label: "Окружение",
|
||||
group: "MISSION CORE",
|
||||
icon: "settings",
|
||||
},
|
||||
]}
|
||||
activeSection="environment"
|
||||
onSectionChange={() => undefined}
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<WindowFooterActions>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!dirty || busy}
|
||||
onClick={() => {
|
||||
setDraft(cloneEnvironmentSettings(settings));
|
||||
setLocalError(null);
|
||||
}}
|
||||
>
|
||||
Сбросить изменения
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!dirty || busy}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{state === "saving" ? "Сохраняем…" : "Сохранить"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
)}
|
||||
>
|
||||
<div className="environment-settings">
|
||||
<SettingsCard
|
||||
eyebrow="ОКРУЖЕНИЕ"
|
||||
title="Основные элементы управления"
|
||||
description="Выберите страницу и настройте её название в шапке, содержание стартового экрана, подложку и быстрые переходы."
|
||||
actions={(
|
||||
<Switch
|
||||
checked={selectedBackground.enabled}
|
||||
label="Показывать фон"
|
||||
onChange={(enabled) => {
|
||||
if (enabled && !selectedBackground.items.length) {
|
||||
setLocalError("Сначала добавьте медиаконтент.");
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
setDraft((current) =>
|
||||
patchBackground(current, surfaceId, { enabled }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="environment-settings__editor">
|
||||
<div className="environment-settings__surface">
|
||||
<span>Страница</span>
|
||||
<Select
|
||||
label="Выбрать страницу окружения"
|
||||
value={surfaceId}
|
||||
options={pageOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => {
|
||||
setSurfaceId(value);
|
||||
setLocalError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="environment-settings__copy">
|
||||
<TextField
|
||||
label={surfaceId === "home" ? "Название продукта" : "Название в шапке"}
|
||||
value={selectedPage.headerLabel}
|
||||
maxLength={40}
|
||||
onChange={(event) => updatePage({
|
||||
headerLabel: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextField
|
||||
label="Надзаголовок"
|
||||
value={selectedPage.eyebrow}
|
||||
maxLength={80}
|
||||
onChange={(event) => updatePage({
|
||||
eyebrow: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextField
|
||||
label="Основной заголовок"
|
||||
value={selectedPage.title}
|
||||
maxLength={120}
|
||||
onChange={(event) => updatePage({
|
||||
title: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextAreaField
|
||||
label="Описание"
|
||||
value={selectedPage.description}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
onChange={(event) => updatePage({
|
||||
description: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="environment-settings__quick-actions">
|
||||
<div>
|
||||
<span>Кнопка 1</span>
|
||||
<Select
|
||||
label="Выбрать первую быструю кнопку"
|
||||
value={selectedPage.primaryWorkspaceId ?? "none"}
|
||||
options={quickActionOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => updatePage({
|
||||
primaryWorkspaceId: value === "none" ? null : value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span>Кнопка 2</span>
|
||||
<Select
|
||||
label="Выбрать вторую быструю кнопку"
|
||||
value={selectedPage.secondaryWorkspaceId ?? "none"}
|
||||
options={quickActionOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => updatePage({
|
||||
secondaryWorkspaceId: value === "none" ? null : value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnvironmentMediaPlaylistEditor
|
||||
surfaceId={surfaceId}
|
||||
background={selectedBackground}
|
||||
disabled={busy}
|
||||
error={localError ?? error}
|
||||
onBusyChange={setUploading}
|
||||
onChange={(background) => {
|
||||
setLocalError(null);
|
||||
setDraft((current) =>
|
||||
patchBackground(current, surfaceId, background));
|
||||
}}
|
||||
onUpload={onUpload}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
</FeatureSettingsWindow>
|
||||
);
|
||||
}
|
||||
@@ -123,9 +123,12 @@ export function FloatingObservationWindow({
|
||||
return () => observer.disconnect();
|
||||
}, [boundsRef]);
|
||||
|
||||
const initialRect = bounds ? initialObservationWindowRect(index, count, bounds) : null;
|
||||
// Camera media admission must not wait for the viewport observer. A source
|
||||
// can arrive in the same commit as a workspace transition; mounting it with
|
||||
// the canonical fallback geometry keeps the browser transport alive until
|
||||
// the real bounds are measured immediately afterward.
|
||||
const initialRect = initialObservationWindowRect(index, count, bounds ?? undefined);
|
||||
const windowRect = rect ?? initialRect;
|
||||
if (!windowRect) return null;
|
||||
|
||||
return (
|
||||
<WorkspaceWindow
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
|
||||
import type { RootDefinition } from "../productModel";
|
||||
import type { EnvironmentPage } from "../core/environment/environmentSettings";
|
||||
import type { RootDefinition, WorkspaceDefinition } from "../productModel";
|
||||
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
|
||||
import { EnvironmentBackgroundMedia } from "./EnvironmentBackgroundMedia";
|
||||
|
||||
export interface LandingStageProps {
|
||||
root: RootDefinition | null;
|
||||
backendStatus: BackendStatus;
|
||||
phase?: RuntimePhase | null;
|
||||
message?: string | null;
|
||||
onOpenObservation: () => void;
|
||||
onOpenDevice: () => void;
|
||||
page: EnvironmentPage;
|
||||
quickActions: readonly WorkspaceDefinition[];
|
||||
onOpenWorkspace: (workspaceId: string) => void;
|
||||
}
|
||||
|
||||
export function LandingStage({
|
||||
@@ -18,26 +21,40 @@ export function LandingStage({
|
||||
backendStatus,
|
||||
phase,
|
||||
message,
|
||||
onOpenObservation,
|
||||
onOpenDevice,
|
||||
page,
|
||||
quickActions,
|
||||
onOpenWorkspace,
|
||||
}: LandingStageProps) {
|
||||
const { background } = page;
|
||||
const hasMedia = background.enabled && background.items.some(
|
||||
(item) => item.url && item.mediaKind,
|
||||
);
|
||||
return (
|
||||
<section className="landing-stage" data-root={root?.id ?? "home"}>
|
||||
<section
|
||||
className="landing-stage"
|
||||
data-root={root?.id ?? "home"}
|
||||
data-has-media={hasMedia ? "true" : undefined}
|
||||
>
|
||||
<EnvironmentBackgroundMedia background={background} />
|
||||
<div className="landing-stage__shade" aria-hidden="true" />
|
||||
<div className="landing-stage__copy">
|
||||
<span className="section-eyebrow">{root?.eyebrow ?? "NODEDC / MISSION CORE"}</span>
|
||||
<h1>{root?.title ?? "Mission Core"}</h1>
|
||||
<p>
|
||||
{root?.statement ??
|
||||
"Наблюдение, планирование и корректировка миссий в одной модульной рабочей области."}
|
||||
</p>
|
||||
<div className="landing-stage__actions">
|
||||
<Button variant="primary" icon={<Icon name="globe" />} onClick={onOpenObservation}>
|
||||
Пространственная сцена
|
||||
</Button>
|
||||
<Button variant="secondary" icon={<Icon name="network" />} onClick={onOpenDevice}>
|
||||
Локальное устройство
|
||||
</Button>
|
||||
</div>
|
||||
<span className="section-eyebrow">{page.eyebrow}</span>
|
||||
<h1>{page.title}</h1>
|
||||
<p>{page.description}</p>
|
||||
{quickActions.length ? (
|
||||
<div className="landing-stage__actions">
|
||||
{quickActions.map((workspace, index) => (
|
||||
<Button
|
||||
key={workspace.id}
|
||||
variant={index === 0 ? "primary" : "secondary"}
|
||||
icon={<Icon name={workspace.icon} />}
|
||||
onClick={() => onOpenWorkspace(workspace.id)}
|
||||
>
|
||||
{workspace.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="landing-stage__status">
|
||||
|
||||
@@ -2,6 +2,21 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Icon } from "@nodedc/ui-react";
|
||||
|
||||
import type { ObservationSourceDelivery } from "../core/runtime/contracts";
|
||||
import {
|
||||
cameraBrowserTransportIdentity,
|
||||
cameraTransportCallbackIsCurrent,
|
||||
initialCameraPlaybackRecoveryState,
|
||||
reduceCameraPlaybackRecovery,
|
||||
type CameraPlaybackRecoveryEvent,
|
||||
} from "../core/observation/liveCameraRecovery";
|
||||
import {
|
||||
createLiveViewerDiagnosticLifecycle,
|
||||
createLiveViewerInstanceId,
|
||||
createLiveViewerLineage,
|
||||
subscribeToLiveViewerBuildFence,
|
||||
type LiveViewerDiagnosticLifecycle,
|
||||
type LiveViewerFailureStage,
|
||||
} from "../core/observation/liveViewerDiagnostics";
|
||||
|
||||
type PlayerStatus = "connecting" | "buffering" | "playing" | "error";
|
||||
|
||||
@@ -10,7 +25,94 @@ export interface CameraLeaseRetryBudget {
|
||||
count: number;
|
||||
}
|
||||
|
||||
const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000] as const;
|
||||
const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000, 5_000] as const;
|
||||
export const CAMERA_FIRST_MEDIA_TIMEOUT_MS = 8_000;
|
||||
export const CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS = 8_000;
|
||||
// The gateway may release an 8 MiB / 64-fragment slow-reader backlog after a
|
||||
// main-thread stall. Keep one bounded append margin above that complete batch;
|
||||
// crossing either limit replaces the MSE epoch instead of dropping fragments.
|
||||
export const CAMERA_PENDING_QUEUE_MAX_BYTES = 12 * 1024 * 1024;
|
||||
export const CAMERA_PENDING_QUEUE_MAX_SEGMENTS = 96;
|
||||
|
||||
export type CameraStartupWatchdogStage = "first-media" | "first-playable-frame";
|
||||
|
||||
export interface CameraStartupWatchdog {
|
||||
armFirstMedia: () => void;
|
||||
markMediaReceived: () => void;
|
||||
markPlaying: () => void;
|
||||
clear: () => void;
|
||||
pendingStage: () => CameraStartupWatchdogStage | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supervise only the disposable browser transport. One fixed first-media
|
||||
* deadline covers both an MSE that never opens and an open-but-silent socket.
|
||||
* The first non-empty fragment then starts a separate first-playable-frame
|
||||
* deadline. Every subsequent fragment extends that deadline: a browser that is
|
||||
* still receiving the authoritative stream must not destroy its partial MSE
|
||||
* decode solely because the main thread or decoder needed longer to start.
|
||||
*/
|
||||
export function createCameraStartupWatchdog({
|
||||
schedule,
|
||||
cancel,
|
||||
onTimeout,
|
||||
firstMediaTimeoutMs = CAMERA_FIRST_MEDIA_TIMEOUT_MS,
|
||||
firstPlayableFrameTimeoutMs = CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS,
|
||||
}: {
|
||||
schedule: (callback: () => void, timeoutMs: number) => number;
|
||||
cancel: (handle: number) => void;
|
||||
onTimeout: (stage: CameraStartupWatchdogStage) => void;
|
||||
firstMediaTimeoutMs?: number;
|
||||
firstPlayableFrameTimeoutMs?: number;
|
||||
}): CameraStartupWatchdog {
|
||||
let timer: number | undefined;
|
||||
let stage: CameraStartupWatchdogStage | null = null;
|
||||
let mediaReceived = false;
|
||||
let playing = false;
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer !== undefined) cancel(timer);
|
||||
timer = undefined;
|
||||
stage = null;
|
||||
};
|
||||
const arm = (nextStage: CameraStartupWatchdogStage, timeoutMs: number) => {
|
||||
clearTimer();
|
||||
stage = nextStage;
|
||||
timer = schedule(() => {
|
||||
if (stage !== nextStage || playing) return;
|
||||
timer = undefined;
|
||||
stage = null;
|
||||
onTimeout(nextStage);
|
||||
}, timeoutMs);
|
||||
};
|
||||
|
||||
return {
|
||||
armFirstMedia() {
|
||||
if (mediaReceived || playing || stage !== null) return;
|
||||
arm("first-media", firstMediaTimeoutMs);
|
||||
},
|
||||
markMediaReceived() {
|
||||
if (playing) return;
|
||||
mediaReceived = true;
|
||||
arm("first-playable-frame", firstPlayableFrameTimeoutMs);
|
||||
},
|
||||
markPlaying() {
|
||||
if (playing) return;
|
||||
playing = true;
|
||||
clearTimer();
|
||||
},
|
||||
clear: clearTimer,
|
||||
pendingStage: () => stage,
|
||||
};
|
||||
}
|
||||
|
||||
export function cameraStartupWatchdogRecoveryMessage(
|
||||
stage: CameraStartupWatchdogStage,
|
||||
): string {
|
||||
return stage === "first-media"
|
||||
? "Камера подключена, но не передаёт медиаданные; восстанавливаем browser-preview."
|
||||
: "Медиаданные поступают, но первый кадр не воспроизводится; пересоздаём decoder.";
|
||||
}
|
||||
|
||||
export function resetCameraLeaseRetryBudget(deliveryId: string): CameraLeaseRetryBudget {
|
||||
return { deliveryId, count: 0 };
|
||||
@@ -19,15 +121,69 @@ export function resetCameraLeaseRetryBudget(deliveryId: string): CameraLeaseRetr
|
||||
export function consumeCameraLeaseRetry(
|
||||
current: CameraLeaseRetryBudget,
|
||||
deliveryId: string,
|
||||
): { budget: CameraLeaseRetryBudget; delay: number | null } {
|
||||
): { budget: CameraLeaseRetryBudget; delay: number } {
|
||||
const count = current.deliveryId === deliveryId ? current.count : 0;
|
||||
const delay = CAMERA_LEASE_RETRY_DELAYS[count] ?? null;
|
||||
const delayIndex = Math.min(count, CAMERA_LEASE_RETRY_DELAYS.length - 1);
|
||||
const delay = CAMERA_LEASE_RETRY_DELAYS[delayIndex];
|
||||
return {
|
||||
budget: { deliveryId, count: delay === null ? count : count + 1 },
|
||||
budget: {
|
||||
deliveryId,
|
||||
count: Math.min(count + 1, CAMERA_LEASE_RETRY_DELAYS.length),
|
||||
},
|
||||
delay,
|
||||
};
|
||||
}
|
||||
|
||||
export function cameraTransportRecoveryIsCurrent(
|
||||
activeAuthorityIdentity: string | null,
|
||||
expectedAuthorityIdentity: string | null,
|
||||
activeEpoch: number,
|
||||
expectedEpoch: number,
|
||||
disposed: boolean,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
expectedAuthorityIdentity
|
||||
&& activeAuthorityIdentity === expectedAuthorityIdentity
|
||||
&& cameraTransportCallbackIsCurrent(activeEpoch, expectedEpoch, disposed),
|
||||
);
|
||||
}
|
||||
|
||||
export function cameraTransportCanOpen(uiBuildStale: boolean): boolean {
|
||||
return !uiBuildStale;
|
||||
}
|
||||
|
||||
export function cameraPendingQueueCanAccept(
|
||||
queuedBytes: number,
|
||||
queuedSegments: number,
|
||||
incomingBytes: number,
|
||||
): boolean {
|
||||
return incomingBytes > 0
|
||||
&& queuedBytes + incomingBytes <= CAMERA_PENDING_QUEUE_MAX_BYTES
|
||||
&& queuedSegments + 1 <= CAMERA_PENDING_QUEUE_MAX_SEGMENTS;
|
||||
}
|
||||
|
||||
export function cameraTransportCloseRecoveryMessage(code: number): string {
|
||||
if (code === 4_008) {
|
||||
return "Browser-reader отстал от эфира; восстанавливаем текущую камеру.";
|
||||
}
|
||||
if (code === 1_008) {
|
||||
return "Camera adapter освобождает прежний browser-reader; переподключаемся.";
|
||||
}
|
||||
if (code === 1_000) {
|
||||
return "Browser-preview завершился; восстанавливаем текущую камеру.";
|
||||
}
|
||||
return "Browser-preview прерван; восстанавливаем текущую камеру.";
|
||||
}
|
||||
|
||||
export function cameraPlaybackRecoveryFailureStage(
|
||||
event: CameraPlaybackRecoveryEvent,
|
||||
): LiveViewerFailureStage {
|
||||
if (event.type === "heartbeat") return "camera-heartbeat-reopen";
|
||||
if (event.type === "network-online") return "camera-network-online-reopen";
|
||||
if (event.type === "page-restore") return "camera-page-restore-reopen";
|
||||
return "camera-visibility-reopen";
|
||||
}
|
||||
|
||||
function websocketUrl(path: string): string {
|
||||
const url = new URL(path, window.location.href);
|
||||
if (url.protocol === "http:") url.protocol = "ws:";
|
||||
@@ -41,87 +197,266 @@ function websocketUrl(path: string): string {
|
||||
export function MseFmp4WebSocketPlayer({
|
||||
delivery,
|
||||
label,
|
||||
recoveryAuthorityIdentity,
|
||||
}: {
|
||||
delivery: ObservationSourceDelivery & { kind: "mse-fmp4-websocket" };
|
||||
label: string;
|
||||
recoveryAuthorityIdentity: string | null;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const leaseRetryRef = useRef(resetCameraLeaseRetryBudget(delivery.id));
|
||||
const activeAuthorityRef = useRef(recoveryAuthorityIdentity);
|
||||
const recoveryPendingAuthorityRef = useRef<string | null>(null);
|
||||
const transportHealthyRef = useRef(false);
|
||||
const transportEpochRef = useRef(0);
|
||||
const transportAuthorityRef = useRef(recoveryAuthorityIdentity);
|
||||
const activeTransportDisposeRef = useRef<(() => void) | null>(null);
|
||||
const activeDiagnosticRef = useRef<LiveViewerDiagnosticLifecycle | null>(null);
|
||||
const diagnosticInstanceIdRef = useRef<string | null>(null);
|
||||
const diagnosticGenerationRef = useRef(0);
|
||||
const uiBuildStaleRef = useRef(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [uiBuildStale, setUiBuildStale] = useState(false);
|
||||
const [status, setStatus] = useState<PlayerStatus>("connecting");
|
||||
const [message, setMessage] = useState("Подключение к локальному видеопотоку");
|
||||
activeAuthorityRef.current = recoveryAuthorityIdentity;
|
||||
const transportIdentity = cameraBrowserTransportIdentity(
|
||||
delivery,
|
||||
recoveryAuthorityIdentity,
|
||||
);
|
||||
|
||||
useEffect(() => subscribeToLiveViewerBuildFence(() => {
|
||||
// Setting this local fence tears down the effect-owned WebSocket and MSE
|
||||
// buffer. It intentionally never invokes a plugin or device command.
|
||||
uiBuildStaleRef.current = true;
|
||||
activeTransportDisposeRef.current?.();
|
||||
setUiBuildStale(true);
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (uiBuildStale || !cameraTransportCanOpen(uiBuildStaleRef.current)) {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
return;
|
||||
}
|
||||
|
||||
if (transportAuthorityRef.current !== recoveryAuthorityIdentity) {
|
||||
transportAuthorityRef.current = recoveryAuthorityIdentity;
|
||||
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
const transportEpoch = transportEpochRef.current + 1;
|
||||
transportEpochRef.current = transportEpoch;
|
||||
diagnosticInstanceIdRef.current ??= createLiveViewerInstanceId();
|
||||
diagnosticGenerationRef.current += 1;
|
||||
const diagnosticLifecycle = createLiveViewerDiagnosticLifecycle({
|
||||
lineage: createLiveViewerLineage(
|
||||
diagnosticInstanceIdRef.current,
|
||||
diagnosticGenerationRef.current,
|
||||
),
|
||||
});
|
||||
activeDiagnosticRef.current = diagnosticLifecycle;
|
||||
diagnosticLifecycle.verifyBuild();
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_camera_player_effect_started",
|
||||
streamId: delivery.id,
|
||||
transportEpoch,
|
||||
});
|
||||
if (!recoveryAuthorityIdentity) {
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_camera_playback_authority_missing",
|
||||
streamId: delivery.id,
|
||||
transportEpoch,
|
||||
});
|
||||
}
|
||||
const transportIsCurrent = () => cameraTransportCallbackIsCurrent(
|
||||
transportEpochRef.current,
|
||||
transportEpoch,
|
||||
disposed || uiBuildStaleRef.current,
|
||||
);
|
||||
let socket: WebSocket | null = null;
|
||||
let sourceBuffer: SourceBuffer | null = null;
|
||||
let onBufferUpdateEnd: (() => void) | null = null;
|
||||
let onBufferError: (() => void) | null = null;
|
||||
let objectUrl = "";
|
||||
let retryTimer: number | undefined;
|
||||
let receivedMedia = false;
|
||||
let failed = false;
|
||||
let playingReported = false;
|
||||
let startupWatchdog: CameraStartupWatchdog | null = null;
|
||||
transportHealthyRef.current = false;
|
||||
const recovering = Boolean(
|
||||
activeAuthorityRef.current
|
||||
&& recoveryPendingAuthorityRef.current === activeAuthorityRef.current,
|
||||
);
|
||||
const queue: ArrayBuffer[] = [];
|
||||
let queuedBytes = 0;
|
||||
|
||||
const fail = (copy: string) => {
|
||||
if (disposed || failed) return;
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
startupWatchdog?.clear();
|
||||
failed = true;
|
||||
transportHealthyRef.current = false;
|
||||
recoveryPendingAuthorityRef.current = null;
|
||||
setStatus("error");
|
||||
setMessage(copy);
|
||||
try {
|
||||
if (socket && socket.readyState < WebSocket.CLOSING) {
|
||||
socket.close(1011, "Live video buffer reset");
|
||||
socket.close(4_000, "Live video buffer reset");
|
||||
}
|
||||
} catch {
|
||||
// The manual reconnect button will create a fresh transport and MSE buffer.
|
||||
}
|
||||
};
|
||||
|
||||
const retryLease = () => {
|
||||
const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id);
|
||||
leaseRetryRef.current = retry.budget;
|
||||
if (retry.delay === null) {
|
||||
fail("Camera adapter ещё занят предыдущим окном. Подключитесь повторно.");
|
||||
const retryTransport = (
|
||||
copy: string,
|
||||
failureStage: LiveViewerFailureStage,
|
||||
websocketCloseCode?: number,
|
||||
appendFailure?: {
|
||||
cameraAppendErrorName: string;
|
||||
cameraMediaSourceState: "closed" | "open" | "ended";
|
||||
cameraVideoErrorCode: number | null;
|
||||
},
|
||||
) => {
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
if (!cameraTransportRecoveryIsCurrent(
|
||||
activeAuthorityRef.current,
|
||||
recoveryAuthorityIdentity,
|
||||
transportEpochRef.current,
|
||||
transportEpoch,
|
||||
disposed || uiBuildStaleRef.current,
|
||||
)) {
|
||||
fail(copy);
|
||||
return;
|
||||
}
|
||||
startupWatchdog?.clear();
|
||||
failed = true;
|
||||
transportHealthyRef.current = false;
|
||||
const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id);
|
||||
leaseRetryRef.current = retry.budget;
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_camera_transport_restart_requested",
|
||||
failureStage,
|
||||
streamId: delivery.id,
|
||||
cameraQueueBytes: queuedBytes,
|
||||
cameraQueueSegments: queue.length,
|
||||
cameraRetryCount: retry.budget.count,
|
||||
websocketCloseCode,
|
||||
transportEpoch,
|
||||
...appendFailure,
|
||||
});
|
||||
queue.length = 0;
|
||||
queuedBytes = 0;
|
||||
recoveryPendingAuthorityRef.current = recoveryAuthorityIdentity;
|
||||
setStatus("connecting");
|
||||
setMessage("Освобождение предыдущего окна камеры");
|
||||
setMessage(copy);
|
||||
try {
|
||||
if (socket && socket.readyState < WebSocket.CLOSING) {
|
||||
socket.close(4_001, "Live video transport recovery");
|
||||
}
|
||||
} catch {
|
||||
// The replacement effect still fences and releases this transport.
|
||||
}
|
||||
retryTimer = window.setTimeout(() => {
|
||||
if (!disposed) setAttempt((value) => value + 1);
|
||||
if (cameraTransportRecoveryIsCurrent(
|
||||
activeAuthorityRef.current,
|
||||
recoveryAuthorityIdentity,
|
||||
transportEpochRef.current,
|
||||
transportEpoch,
|
||||
disposed || uiBuildStaleRef.current,
|
||||
)) {
|
||||
setAttempt((value) => value + 1);
|
||||
}
|
||||
}, retry.delay);
|
||||
};
|
||||
|
||||
if (recoveryAuthorityIdentity) {
|
||||
startupWatchdog = createCameraStartupWatchdog({
|
||||
schedule: (callback, timeoutMs) => window.setTimeout(callback, timeoutMs),
|
||||
cancel: (handle) => window.clearTimeout(handle),
|
||||
onTimeout: (stage) => {
|
||||
retryTransport(
|
||||
cameraStartupWatchdogRecoveryMessage(stage),
|
||||
stage === "first-media"
|
||||
? "camera-first-media-timeout"
|
||||
: "camera-first-playable-timeout",
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const onPlaying = () => {
|
||||
if (disposed) return;
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
startupWatchdog?.markPlaying();
|
||||
transportHealthyRef.current = true;
|
||||
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
|
||||
recoveryPendingAuthorityRef.current = null;
|
||||
setStatus("playing");
|
||||
setMessage("");
|
||||
if (!playingReported) {
|
||||
playingReported = true;
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_camera_transport_playing",
|
||||
streamId: delivery.id,
|
||||
cameraQueueBytes: queuedBytes,
|
||||
cameraQueueSegments: queue.length,
|
||||
transportEpoch,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener("playing", onPlaying);
|
||||
|
||||
const appendNext = () => {
|
||||
if (disposed || !sourceBuffer || sourceBuffer.updating || queue.length === 0) return;
|
||||
if (
|
||||
!transportIsCurrent()
|
||||
|| failed
|
||||
|| !sourceBuffer
|
||||
|| sourceBuffer.updating
|
||||
|| queue.length === 0
|
||||
) return;
|
||||
const chunk = queue.shift();
|
||||
if (!chunk) return;
|
||||
queuedBytes -= chunk.byteLength;
|
||||
try {
|
||||
sourceBuffer.appendBuffer(chunk);
|
||||
} catch (error) {
|
||||
fail(error instanceof DOMException && error.name === "QuotaExceededError"
|
||||
? "Live-буфер переполнен и сброшен, чтобы не накапливать задержку."
|
||||
: "Не удалось добавить видеосегмент. Повторите подключение.");
|
||||
const quotaExceeded = error instanceof DOMException
|
||||
&& error.name === "QuotaExceededError";
|
||||
retryTransport(
|
||||
quotaExceeded
|
||||
? "Live-буфер переполнен; восстанавливаем канал без накопленной задержки."
|
||||
: "Не удалось добавить видеосегмент; восстанавливаем browser-preview.",
|
||||
quotaExceeded ? "camera-append-quota" : "camera-append-error",
|
||||
undefined,
|
||||
{
|
||||
cameraAppendErrorName: error instanceof DOMException
|
||||
? error.name
|
||||
: error instanceof Error
|
||||
? error.name
|
||||
: "UnknownError",
|
||||
cameraMediaSourceState: mediaSource.readyState,
|
||||
cameraVideoErrorCode: video.error?.code ?? null,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const enqueue = (chunk: ArrayBuffer) => {
|
||||
if (disposed || chunk.byteLength === 0) return;
|
||||
if (!transportIsCurrent() || failed || chunk.byteLength === 0) return;
|
||||
startupWatchdog?.markMediaReceived();
|
||||
// Never drop arbitrary fMP4 fragments: the following samples may depend
|
||||
// on them. A bounded reset is safer and keeps live latency deterministic.
|
||||
if (queuedBytes + chunk.byteLength > 2 * 1024 * 1024) {
|
||||
fail("Видеодекодер не успевает за эфиром. Live-буфер сброшен.");
|
||||
if (!cameraPendingQueueCanAccept(queuedBytes, queue.length, chunk.byteLength)) {
|
||||
retryTransport(
|
||||
"Видеодекодер отстал от эфира; восстанавливаем live-буфер.",
|
||||
"camera-queue-capacity",
|
||||
);
|
||||
return;
|
||||
}
|
||||
queue.push(chunk);
|
||||
@@ -142,13 +477,19 @@ export function MseFmp4WebSocketPlayer({
|
||||
}
|
||||
|
||||
setStatus("connecting");
|
||||
setMessage("Подключение к локальному видеопотоку");
|
||||
setMessage(recovering
|
||||
? "Восстановление камеры после разрыва браузерного канала"
|
||||
: "Подключение к локальному видеопотоку");
|
||||
const mediaSource = new MediaSource();
|
||||
objectUrl = URL.createObjectURL(mediaSource);
|
||||
video.src = objectUrl;
|
||||
|
||||
const onSourceOpen = () => {
|
||||
if (disposed) return;
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_camera_media_source_open",
|
||||
streamId: delivery.id,
|
||||
transportEpoch,
|
||||
});
|
||||
try {
|
||||
sourceBuffer = mediaSource.addSourceBuffer(mediaType);
|
||||
} catch {
|
||||
@@ -156,8 +497,8 @@ export function MseFmp4WebSocketPlayer({
|
||||
return;
|
||||
}
|
||||
|
||||
sourceBuffer.addEventListener("updateend", () => {
|
||||
if (disposed || !sourceBuffer) return;
|
||||
onBufferUpdateEnd = () => {
|
||||
if (!transportIsCurrent() || failed || !sourceBuffer) return;
|
||||
const buffered = sourceBuffer.buffered;
|
||||
if (buffered.length > 0) {
|
||||
const end = buffered.end(buffered.length - 1);
|
||||
@@ -166,7 +507,9 @@ export function MseFmp4WebSocketPlayer({
|
||||
if (!receivedMedia) {
|
||||
receivedMedia = true;
|
||||
setStatus("buffering");
|
||||
setMessage("Запуск первого декодированного кадра");
|
||||
setMessage(recovering
|
||||
? "Запуск первого кадра восстановленной камеры"
|
||||
: "Запуск первого декодированного кадра");
|
||||
void video.play().catch(() => undefined);
|
||||
}
|
||||
const removeBefore = end - 3;
|
||||
@@ -180,10 +523,15 @@ export function MseFmp4WebSocketPlayer({
|
||||
}
|
||||
}
|
||||
appendNext();
|
||||
});
|
||||
sourceBuffer.addEventListener("error", () => {
|
||||
fail("MSE сообщил об ошибке декодирования видеосегмента.");
|
||||
});
|
||||
};
|
||||
onBufferError = () => {
|
||||
retryTransport(
|
||||
"MSE сбросил видеосегмент; восстанавливаем decoder.",
|
||||
"camera-source-buffer-error",
|
||||
);
|
||||
};
|
||||
sourceBuffer.addEventListener("updateend", onBufferUpdateEnd);
|
||||
sourceBuffer.addEventListener("error", onBufferError);
|
||||
|
||||
try {
|
||||
socket = new WebSocket(websocketUrl(delivery.url));
|
||||
@@ -193,41 +541,70 @@ export function MseFmp4WebSocketPlayer({
|
||||
}
|
||||
socket.binaryType = "arraybuffer";
|
||||
socket.addEventListener("open", () => {
|
||||
if (disposed) return;
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_camera_websocket_open",
|
||||
streamId: delivery.id,
|
||||
transportEpoch,
|
||||
});
|
||||
startupWatchdog?.armFirstMedia();
|
||||
setStatus("buffering");
|
||||
setMessage("Ожидание первого видеокадра");
|
||||
setMessage(recovering
|
||||
? "Ожидание первого кадра после восстановления"
|
||||
: "Ожидание первого видеокадра");
|
||||
});
|
||||
socket.addEventListener("message", (event) => {
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
enqueue(event.data);
|
||||
} else if (event.data instanceof Blob) {
|
||||
void event.data.arrayBuffer().then(enqueue).catch(() => {
|
||||
fail("Получен повреждённый видеосегмент.");
|
||||
retryTransport(
|
||||
"Получен повреждённый видеосегмент; восстанавливаем канал.",
|
||||
"camera-fragment-decode",
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
socket.addEventListener("error", () => {
|
||||
fail("Соединение с локальным video adapter потеряно.");
|
||||
retryTransport(
|
||||
"Связь с локальным video adapter потеряна; переподключаемся.",
|
||||
"camera-websocket-error",
|
||||
);
|
||||
});
|
||||
socket.addEventListener("close", (event) => {
|
||||
if (!disposed && !failed && event.code === 1008) {
|
||||
retryLease();
|
||||
} else if (!disposed) {
|
||||
fail(event.code === 1000
|
||||
? "Видеопоток завершён. Можно подключиться повторно."
|
||||
: "Видеопоток прерван. Проверьте устройство и повторите подключение.");
|
||||
}
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
retryTransport(
|
||||
cameraTransportCloseRecoveryMessage(event.code),
|
||||
"camera-websocket-close",
|
||||
event.code,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Register before assigning the object URL. `video.src` is the action that
|
||||
// lets the browser open this MediaSource; a fast sequential MSE lease must
|
||||
// not fire `sourceopen` in the gap before its only listener exists.
|
||||
mediaSource.addEventListener("sourceopen", onSourceOpen, { once: true });
|
||||
startupWatchdog?.armFirstMedia();
|
||||
video.src = objectUrl;
|
||||
|
||||
return () => {
|
||||
const disposeTransport = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
transportHealthyRef.current = false;
|
||||
startupWatchdog?.clear();
|
||||
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
|
||||
queue.length = 0;
|
||||
socket?.close(1000, "Источник скрыт оператором");
|
||||
socket?.close(1000, "Browser preview transport replaced or hidden");
|
||||
video.removeEventListener("playing", onPlaying);
|
||||
mediaSource.removeEventListener("sourceopen", onSourceOpen);
|
||||
if (sourceBuffer && onBufferUpdateEnd) {
|
||||
sourceBuffer.removeEventListener("updateend", onBufferUpdateEnd);
|
||||
}
|
||||
if (sourceBuffer && onBufferError) {
|
||||
sourceBuffer.removeEventListener("error", onBufferError);
|
||||
}
|
||||
try {
|
||||
if (sourceBuffer?.updating) sourceBuffer.abort();
|
||||
} catch {
|
||||
@@ -243,7 +620,80 @@ export function MseFmp4WebSocketPlayer({
|
||||
video.load();
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [attempt, delivery.id, delivery.mediaType, delivery.url]);
|
||||
activeTransportDisposeRef.current = disposeTransport;
|
||||
return () => {
|
||||
if (activeTransportDisposeRef.current === disposeTransport) {
|
||||
activeTransportDisposeRef.current = null;
|
||||
}
|
||||
if (activeDiagnosticRef.current === diagnosticLifecycle) {
|
||||
activeDiagnosticRef.current = null;
|
||||
}
|
||||
diagnosticLifecycle.dispose();
|
||||
disposeTransport();
|
||||
};
|
||||
}, [attempt, recoveryAuthorityIdentity, transportIdentity, uiBuildStale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!recoveryAuthorityIdentity
|
||||
|| uiBuildStale
|
||||
|| !cameraTransportCanOpen(uiBuildStaleRef.current)
|
||||
) return;
|
||||
|
||||
let recovery = initialCameraPlaybackRecoveryState(
|
||||
recoveryAuthorityIdentity,
|
||||
Date.now(),
|
||||
);
|
||||
const dispatchRecovery = (event: CameraPlaybackRecoveryEvent) => {
|
||||
if (uiBuildStaleRef.current) return;
|
||||
const decision = reduceCameraPlaybackRecovery(recovery, event, {
|
||||
activeAuthorityIdentity: activeAuthorityRef.current,
|
||||
now: Date.now(),
|
||||
documentVisible: document.visibilityState === "visible",
|
||||
networkOnline: navigator.onLine !== false,
|
||||
transportHealthy: transportHealthyRef.current,
|
||||
});
|
||||
recovery = decision.state;
|
||||
if (!decision.reopen) return;
|
||||
activeDiagnosticRef.current?.post({
|
||||
eventCode: "live_camera_transport_restart_requested",
|
||||
failureStage: cameraPlaybackRecoveryFailureStage(event),
|
||||
streamId: delivery.id,
|
||||
transportEpoch: transportEpochRef.current,
|
||||
});
|
||||
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
|
||||
recoveryPendingAuthorityRef.current = recoveryAuthorityIdentity;
|
||||
setStatus("connecting");
|
||||
setMessage("Восстановление камеры после разрыва браузерного канала");
|
||||
// The transport effect cleans up the old WebSocket and MSE object before
|
||||
// opening their replacement. No plugin action or device command occurs.
|
||||
setAttempt((value) => value + 1);
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
dispatchRecovery({
|
||||
type: document.visibilityState === "hidden"
|
||||
? "document-hidden"
|
||||
: "document-visible",
|
||||
});
|
||||
};
|
||||
const onOnline = () => dispatchRecovery({ type: "network-online" });
|
||||
const onPageShow = (event: PageTransitionEvent) => {
|
||||
dispatchRecovery({ type: "page-restore", persisted: event.persisted });
|
||||
};
|
||||
const heartbeat = window.setInterval(() => {
|
||||
dispatchRecovery({ type: "heartbeat" });
|
||||
}, 1_000);
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
window.addEventListener("online", onOnline);
|
||||
window.addEventListener("pageshow", onPageShow);
|
||||
return () => {
|
||||
window.clearInterval(heartbeat);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
window.removeEventListener("online", onOnline);
|
||||
window.removeEventListener("pageshow", onPageShow);
|
||||
};
|
||||
}, [delivery.id, recoveryAuthorityIdentity, uiBuildStale]);
|
||||
|
||||
return (
|
||||
<div className="mse-fmp4-player" data-status={status}>
|
||||
@@ -265,6 +715,7 @@ export function MseFmp4WebSocketPlayer({
|
||||
type="button"
|
||||
onClick={() => {
|
||||
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
|
||||
recoveryPendingAuthorityRef.current = null;
|
||||
setAttempt((value) => value + 1);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -92,17 +92,7 @@ function sessionDescription(session: ObservationSessionSummary): string {
|
||||
return `${formatStartedAt(session.startedAtUtc)} · ${formatDuration(session.durationSeconds)} · ${modalities}`;
|
||||
}
|
||||
|
||||
export function ObservationSessionSelect({
|
||||
limit = 100,
|
||||
disabled = false,
|
||||
blockedReason = null,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
}: {
|
||||
limit?: number;
|
||||
disabled?: boolean;
|
||||
blockedReason?: string | null;
|
||||
export interface ObservationSessionReplayCallbacks {
|
||||
onReplayBegin?: (
|
||||
session: ObservationSessionSummary,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
@@ -115,14 +105,31 @@ export function ObservationSessionSelect({
|
||||
session: ObservationSessionSummary,
|
||||
outcome: ObservationReplayOutcome,
|
||||
) => void | Promise<void>;
|
||||
onDeleteBegin?: (sessionId: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function ObservationSessionSelect({
|
||||
limit = 100,
|
||||
disabled = false,
|
||||
blockedReason = null,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
onDeleteBegin,
|
||||
}: ObservationSessionReplayCallbacks & {
|
||||
limit?: number;
|
||||
disabled?: boolean;
|
||||
blockedReason?: string | null;
|
||||
}) {
|
||||
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
|
||||
const sessions = useObservationSessions({
|
||||
limit,
|
||||
scope: "source",
|
||||
replayEnabled: blockedReason === null,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
onDeleteBegin,
|
||||
});
|
||||
const triggerCopy = sessions.replayProgress
|
||||
? progressCopy(sessions.replayProgress.phase)
|
||||
@@ -300,3 +307,178 @@ export function ObservationSessionSelect({
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
export function ObservationSessionArchive({
|
||||
limit = 100,
|
||||
labsOnly = false,
|
||||
disabled = false,
|
||||
blockedReason = null,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
onDeleteBegin,
|
||||
}: ObservationSessionReplayCallbacks & {
|
||||
limit?: number;
|
||||
labsOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
blockedReason?: string | null;
|
||||
}) {
|
||||
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
|
||||
const sessions = useObservationSessions({
|
||||
limit,
|
||||
scope: labsOnly ? "laboratory" : "source",
|
||||
replayEnabled: blockedReason === null,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
onDeleteBegin,
|
||||
});
|
||||
const items = sessions.items;
|
||||
|
||||
return <>
|
||||
<section
|
||||
className="observation-session-archive"
|
||||
aria-label={labsOnly ? "Лабораторные записи" : "Сохранённые сессии"}
|
||||
>
|
||||
<header className="observation-session-archive__head">
|
||||
<div>
|
||||
<span className="section-eyebrow">
|
||||
{labsOnly ? "ВОСПРОИЗВОДИМЫЕ ЛАБОРАТОРНЫЕ ЗАПИСИ" : "АРХИВ НАБЛЮДЕНИЯ"}
|
||||
</span>
|
||||
<h2>{labsOnly ? "Записанные лабораторные работы" : "Сохранённые сессии"}</h2>
|
||||
<p>
|
||||
{labsOnly
|
||||
? "Каждая запись связана с LAB-конфигурацией и открывается как самостоятельный визуальный результат."
|
||||
: "Выберите запись: сцена и синхронные каналы откроются ниже, не меняя эфир наблюдения."}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="observation-session-archive__refresh"
|
||||
aria-label="Обновить каталог сессий"
|
||||
disabled={sessions.state === "loading"}
|
||||
onClick={() => void sessions.refresh()}
|
||||
>
|
||||
<Icon name="refresh" size={14} />
|
||||
<span>Обновить</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className="observation-session-archive__list">
|
||||
{items.map((session) => {
|
||||
const pending = sessions.replayingSessionId === session.id;
|
||||
const deleting = sessions.deletingSessionId === session.id;
|
||||
const failed = sessions.failedSessionId === session.id;
|
||||
const visualState = observationSessionVisualState(session, { pending, failed });
|
||||
return (
|
||||
<article
|
||||
key={session.id}
|
||||
className="observation-session-archive__item"
|
||||
data-deleting={deleting ? "true" : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="observation-session-archive__open"
|
||||
disabled={disabled || pending || deleting || !session.replayable}
|
||||
title={blockedReason ?? undefined}
|
||||
onClick={() => void sessions.replay(session.id)}
|
||||
>
|
||||
<i data-session-visual-state={visualState} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>
|
||||
{session.lab &&
|
||||
!session.label.startsWith(`${session.lab.labId} ·`) ? (
|
||||
<small>{session.lab.labId}</small>
|
||||
) : null}
|
||||
{session.label}
|
||||
</strong>
|
||||
<small>{sessionDescription(session)}</small>
|
||||
</span>
|
||||
<em>
|
||||
{pending && sessions.replayProgress
|
||||
? progressCopy(sessions.replayProgress.phase)
|
||||
: observationSessionVisualLabel(visualState)}
|
||||
</em>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="observation-session-archive__delete"
|
||||
aria-label={`Удалить сохранённую сессию ${session.label}`}
|
||||
disabled={pending || deleting}
|
||||
onClick={() => setDeleteTarget(session)}
|
||||
>
|
||||
<Icon name="trash" size={15} />
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : sessions.state === "loading" ? (
|
||||
<div className="observation-session-archive__empty" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<strong>Читаем каталог</strong>
|
||||
</div>
|
||||
) : (
|
||||
<div className="observation-session-archive__empty">
|
||||
<Icon name={sessions.error ? "alert" : "database"} size={18} />
|
||||
<strong>
|
||||
{sessions.error
|
||||
? "Каталог недоступен"
|
||||
: labsOnly
|
||||
? "LAB-записей пока нет"
|
||||
: "Сессий пока нет"}
|
||||
</strong>
|
||||
<span>
|
||||
{sessions.error
|
||||
?? (labsOnly
|
||||
? "Зафиксированные лабораторные записи появятся здесь автоматически."
|
||||
: "Завершённые записи появятся здесь автоматически.")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions.error && items.length > 0 ? (
|
||||
<footer className="observation-session-menu__error" role="alert">
|
||||
<Icon name="alert" size={13} />
|
||||
<span>{sessions.error}</span>
|
||||
{sessions.failedSessionId ? (
|
||||
<button type="button" onClick={() => void sessions.retry()}>
|
||||
Повторить
|
||||
</button>
|
||||
) : null}
|
||||
</footer>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<ConfirmationModal
|
||||
open={deleteTarget !== null}
|
||||
title="Удалить сохранённую сессию?"
|
||||
description={deleteTarget ? <>
|
||||
<strong>{deleteTarget.label}</strong>
|
||||
{deleteTarget.lab ? (
|
||||
<p>
|
||||
Будут удалены только LAB-запись из каталога и её подготовленный кэш.
|
||||
Исходная запись {deleteTarget.lab.sourceSessionId} и зафиксированный
|
||||
лабораторный результат останутся неизменными.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Сессия, исходные данные наблюдения, подготовленная Rerun-запись и
|
||||
видеоматериалы будут удалены с этого сервера без возможности восстановления.
|
||||
</p>
|
||||
)}
|
||||
</> : null}
|
||||
confirmLabel="Удалить сессию"
|
||||
pendingLabel="Удаление…"
|
||||
danger
|
||||
onClose={() => {
|
||||
if (sessions.deletingSessionId === null) setDeleteTarget(null);
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (!deleteTarget) return;
|
||||
if (await sessions.remove(deleteTarget.id)) setDeleteTarget(null);
|
||||
}}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
RecordedAdmissionPhase,
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
import { liveCameraPlaybackAuthorityIdentity } from "../core/observation/liveCameraRecovery";
|
||||
|
||||
const sourceIcon: Record<ObservationSourceModality, IconName> = {
|
||||
"point-cloud": "globe",
|
||||
@@ -63,7 +64,13 @@ export function ObservationMedia({
|
||||
source.delivery?.kind === "mse-fmp4-websocket" &&
|
||||
source.modality === "video"
|
||||
) {
|
||||
return <MseFmp4WebSocketPlayer delivery={source.delivery} label={source.label} />;
|
||||
return (
|
||||
<MseFmp4WebSocketPlayer
|
||||
delivery={source.delivery}
|
||||
label={source.label}
|
||||
recoveryAuthorityIdentity={liveCameraPlaybackAuthorityIdentity(source)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Icon } from "@nodedc/ui-react";
|
||||
import { Button, Icon, Select } from "@nodedc/ui-react";
|
||||
|
||||
import type { ObservationTimelineMode } from "../core/runtime/contracts";
|
||||
|
||||
@@ -14,6 +14,9 @@ export function ObservationTimeline({
|
||||
onSeek,
|
||||
onPlayingChange,
|
||||
onJumpToEnd,
|
||||
showJumpToEnd = true,
|
||||
playbackRate,
|
||||
onPlaybackRateChange,
|
||||
accumulationSeconds,
|
||||
onAccumulationChange,
|
||||
onAccumulationCommit,
|
||||
@@ -30,6 +33,9 @@ export function ObservationTimeline({
|
||||
onSeek?: (timeNs: number) => void;
|
||||
onPlayingChange?: (playing: boolean) => void;
|
||||
onJumpToEnd?: () => void;
|
||||
showJumpToEnd?: boolean;
|
||||
playbackRate?: number;
|
||||
onPlaybackRateChange?: (rate: number) => void;
|
||||
accumulationSeconds?: number;
|
||||
onAccumulationChange?: (value: number) => void;
|
||||
onAccumulationCommit?: () => void;
|
||||
@@ -102,6 +108,20 @@ export function ObservationTimeline({
|
||||
>
|
||||
{buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
|
||||
</Button>
|
||||
{buffered && playbackRate !== undefined && onPlaybackRateChange ? (
|
||||
<Select
|
||||
label="Скорость воспроизведения"
|
||||
value={String(playbackRate)}
|
||||
options={[
|
||||
{ value: "0.5", label: "0,5×" },
|
||||
{ value: "1", label: "1×" },
|
||||
{ value: "2", label: "2×" },
|
||||
]}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => onPlaybackRateChange(Number(value))}
|
||||
/>
|
||||
) : null}
|
||||
<input
|
||||
className="observation-timeline__track"
|
||||
type="range"
|
||||
@@ -126,15 +146,17 @@ export function ObservationTimeline({
|
||||
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="observation-timeline__follow"
|
||||
data-active={!buffered && active ? "true" : undefined}
|
||||
disabled={buffered ? !onJumpToEnd : true}
|
||||
onClick={onJumpToEnd}
|
||||
>
|
||||
{buffered ? "К КОНЦУ" : "ЭФИР"}
|
||||
</button>
|
||||
{showJumpToEnd ? (
|
||||
<button
|
||||
type="button"
|
||||
className="observation-timeline__follow"
|
||||
data-active={!buffered && active ? "true" : undefined}
|
||||
disabled={buffered ? !onJumpToEnd : true}
|
||||
onClick={onJumpToEnd}
|
||||
>
|
||||
{buffered ? "К КОНЦУ" : "ЭФИР"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||
export interface RecordedObservationPlayback {
|
||||
currentSeconds: number;
|
||||
playing: boolean;
|
||||
rate?: number;
|
||||
}
|
||||
|
||||
export interface RecordedMediaArchive {
|
||||
@@ -234,21 +235,27 @@ async function mountRecordedEpochStream(
|
||||
export function RecordedFmp4Player({
|
||||
source,
|
||||
playback,
|
||||
interactive = false,
|
||||
prepare = true,
|
||||
sessionGate = "ready",
|
||||
admissionKey = null,
|
||||
onAdmissionChange,
|
||||
onPlaybackChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
playback?: RecordedObservationPlayback | null;
|
||||
interactive?: boolean;
|
||||
prepare?: boolean;
|
||||
sessionGate?: RecordedAdmissionPhase;
|
||||
admissionKey?: string | null;
|
||||
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
|
||||
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const onAdmissionChangeRef = useRef(onAdmissionChange);
|
||||
onAdmissionChangeRef.current = onAdmissionChange;
|
||||
const onPlaybackChangeRef = useRef(onPlaybackChange);
|
||||
onPlaybackChangeRef.current = onPlaybackChange;
|
||||
const workerRef = useRef<{ admissionKey: string | null; generation: number } | null>(null);
|
||||
if (!workerRef.current || workerRef.current.admissionKey !== admissionKey) {
|
||||
recordedMediaWorkerGeneration += 1;
|
||||
@@ -284,6 +291,9 @@ export function RecordedFmp4Player({
|
||||
const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
|
||||
const [bufferRevision, setBufferRevision] = useState(0);
|
||||
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
|
||||
const playbackRate = playback?.rate && Number.isFinite(playback.rate)
|
||||
? Math.min(4, Math.max(0.25, playback.rate))
|
||||
: 1;
|
||||
const epoch = useMemo(
|
||||
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
|
||||
[archive?.manifest.epochs, currentSeconds],
|
||||
@@ -454,22 +464,66 @@ export function RecordedFmp4Player({
|
||||
return;
|
||||
}
|
||||
}
|
||||
video.playbackRate = playbackRate;
|
||||
if (playback?.playing) {
|
||||
void video.play().catch(() => undefined);
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}, [archive?.byteLength, bufferRevision, currentSeconds, epoch, playback?.playing, visualState]);
|
||||
}, [
|
||||
archive?.byteLength,
|
||||
bufferRevision,
|
||||
currentSeconds,
|
||||
epoch,
|
||||
playback?.playing,
|
||||
playbackRate,
|
||||
visualState,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!interactive || !video || !epoch || visualState !== "ready") return;
|
||||
let videoFrameRequest: number | null = null;
|
||||
const emitPlayback = () => {
|
||||
onPlaybackChangeRef.current?.({
|
||||
currentSeconds: epoch.timelineStartSeconds + video.currentTime,
|
||||
playing: !video.paused && !video.ended,
|
||||
rate: video.playbackRate,
|
||||
});
|
||||
};
|
||||
const scheduleVideoFrame = () => {
|
||||
if (typeof video.requestVideoFrameCallback !== "function") return;
|
||||
videoFrameRequest = video.requestVideoFrameCallback(() => {
|
||||
emitPlayback();
|
||||
scheduleVideoFrame();
|
||||
});
|
||||
};
|
||||
const events = ["play", "pause", "seeking", "seeked", "timeupdate", "ended"] as const;
|
||||
for (const event of events) video.addEventListener(event, emitPlayback);
|
||||
scheduleVideoFrame();
|
||||
emitPlayback();
|
||||
return () => {
|
||||
for (const event of events) video.removeEventListener(event, emitPlayback);
|
||||
if (
|
||||
videoFrameRequest !== null &&
|
||||
typeof video.cancelVideoFrameCallback === "function"
|
||||
) {
|
||||
video.cancelVideoFrameCallback(videoFrameRequest);
|
||||
}
|
||||
};
|
||||
}, [epoch, interactive, visualState]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="recorded-media-player"
|
||||
data-state={visualState}
|
||||
data-interactive={interactive ? "true" : undefined}
|
||||
aria-busy={visualState === "loading"}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="observation-media__asset"
|
||||
controls={interactive}
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import {
|
||||
advanceLiveReceiverOpenWatchdog,
|
||||
advanceLiveReceiverWatchdog,
|
||||
initialLiveReceiverOpenWatchdogState,
|
||||
initialLiveReceiverRecoveryState,
|
||||
initialLiveReceiverWatchdogState,
|
||||
LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
|
||||
LIVE_RECEIVER_OPEN_CHECK_INTERVAL_MS,
|
||||
requestLiveReceiverRecovery,
|
||||
} from "../core/observation/liveReceiverWatchdog";
|
||||
import {
|
||||
createLiveViewerDiagnosticLifecycle,
|
||||
createLiveViewerInstanceId,
|
||||
createLiveViewerLineage,
|
||||
subscribeToLiveViewerBuildFence,
|
||||
type LiveViewerFailureStage,
|
||||
} from "../core/observation/liveViewerDiagnostics";
|
||||
import {
|
||||
fetchPerceptionPreparationStatus,
|
||||
perceptionPreparationMessage,
|
||||
} from "../core/observation/perceptionPreparation";
|
||||
import type { RecordedAdmissionPhase } from "../core/observation/recordedSessionAdmission";
|
||||
|
||||
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
|
||||
@@ -66,6 +87,9 @@ export interface RerunViewportProps {
|
||||
sourceUrl: string;
|
||||
recordedArtifact?: RecordedRrdArtifactDescriptor | null;
|
||||
followLive?: boolean;
|
||||
liveActivitySequence?: number | null;
|
||||
liveStreamId?: string | null;
|
||||
liveRecoveryAuthorityIdentity?: string | null;
|
||||
autoplayWhenReady?: boolean;
|
||||
presentationGate?: RecordedAdmissionPhase;
|
||||
expectedTimelineStartSeconds?: number;
|
||||
@@ -182,6 +206,51 @@ export function createRecordedOpenWatchdog<T>({
|
||||
};
|
||||
}
|
||||
|
||||
export function createReentrantViewerDisposer(
|
||||
cleanupOnce: () => void,
|
||||
releaseNativeViewer: () => void,
|
||||
): () => void {
|
||||
let cleanupComplete = false;
|
||||
return () => {
|
||||
try {
|
||||
if (!cleanupComplete) {
|
||||
cleanupComplete = true;
|
||||
cleanupOnce();
|
||||
}
|
||||
} finally {
|
||||
// `viewer.start()` can resolve after an earlier pre-ready stop. Reapply
|
||||
// native release on every disposal boundary so that a stale viewer can
|
||||
// never reopen after React and diagnostics have already unmounted it.
|
||||
releaseNativeViewer();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface ActiveLiveViewerOwner {
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
let activeLiveViewerOwner: ActiveLiveViewerOwner | null = null;
|
||||
|
||||
/**
|
||||
* Own exactly one native live receiver per application document.
|
||||
*
|
||||
* React route/StrictMode transitions can overlap two mounted workspaces for a
|
||||
* render turn. Rerun keeps each native gRPC receiver alive independently, so
|
||||
* the overlap used to consume the bounded live replay slots and leave the
|
||||
* operator's visible canvas black. Claiming the next owner synchronously
|
||||
* retires the previous native receiver before the next one starts.
|
||||
*/
|
||||
export function claimExclusiveLiveViewer(release: () => void): () => void {
|
||||
const owner = { release };
|
||||
const previous = activeLiveViewerOwner;
|
||||
activeLiveViewerOwner = owner;
|
||||
previous?.release();
|
||||
return () => {
|
||||
if (activeLiveViewerOwner === owner) activeLiveViewerOwner = null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RecordedPlaybackBufferState {
|
||||
bufferedEndNs: number | null;
|
||||
expectedStartNs: number | null;
|
||||
@@ -206,6 +275,55 @@ export function isUsableRecordedPlaybackRange(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A live receiver is presentable only after the exact browser store exposes
|
||||
* real timeline data that the backend has also confirmed publishing.
|
||||
* `WebViewer.start()` and a non-null active recording id are transport setup,
|
||||
* not evidence that the spatial scene can render.
|
||||
*/
|
||||
export function isLiveRerunPresentationReady(
|
||||
viewerStarted: boolean,
|
||||
rangeNs: { min: number; max: number } | null,
|
||||
backendActivitySequence: number | null,
|
||||
): boolean {
|
||||
return viewerStarted &&
|
||||
Number.isSafeInteger(backendActivitySequence) &&
|
||||
(backendActivitySequence ?? 0) > 0 &&
|
||||
isUsableRecordedPlaybackRange(rangeNs);
|
||||
}
|
||||
|
||||
/**
|
||||
* `recording_open` can arrive before Rerun has registered the live timeline.
|
||||
* Selecting it at that point is a silent no-op, so keep retrying only until
|
||||
* the exact live timeline is both available and active.
|
||||
*/
|
||||
export function liveTimelineNeedsSynchronization(
|
||||
followLive: boolean,
|
||||
activeTimeline: string | null | undefined,
|
||||
rangeNs: { min: number; max: number } | null,
|
||||
): boolean {
|
||||
return followLive &&
|
||||
isUsableRecordedPlaybackRange(rangeNs) &&
|
||||
activeTimeline !== "stream_time";
|
||||
}
|
||||
|
||||
/**
|
||||
* Key the native receiver to its data-plane binding. Recovery authority is a
|
||||
* retry fence projected from changing supervisor snapshots; it must not tear
|
||||
* down a healthy WebViewer while this acquisition and URL remain unchanged.
|
||||
*/
|
||||
export function liveRerunReceiverBindingIdentity(
|
||||
sourceUrl: string,
|
||||
liveStreamId: string | null,
|
||||
followLive: boolean,
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
followLive ? "live" : "recorded",
|
||||
sourceUrl.trim(),
|
||||
followLive ? liveStreamId?.trim() ?? "" : "",
|
||||
]);
|
||||
}
|
||||
|
||||
/** Describe progressive archive availability without gating first rendering. */
|
||||
export function recordedPlaybackBufferState(
|
||||
rangeNs: { min: number; max: number } | null,
|
||||
@@ -482,6 +600,12 @@ export function rerunViewerInitialSource(
|
||||
return resolvedSourceUrl;
|
||||
}
|
||||
|
||||
export function rerunViewerOpenOptions(
|
||||
followLive: boolean,
|
||||
): { follow_if_http: true } | null {
|
||||
return followLive ? { follow_if_http: true } : null;
|
||||
}
|
||||
|
||||
export function resolveRecordedBlueprintUrl(sourceUrl: string, origin: string): string | null {
|
||||
const normalized = sourceUrl.trim();
|
||||
if (!RECORDED_RRD_PATH.test(normalized)) return null;
|
||||
@@ -796,6 +920,9 @@ export function RerunViewport({
|
||||
sourceUrl,
|
||||
recordedArtifact = null,
|
||||
followLive = false,
|
||||
liveActivitySequence = null,
|
||||
liveStreamId = null,
|
||||
liveRecoveryAuthorityIdentity = null,
|
||||
autoplayWhenReady = false,
|
||||
presentationGate = "ready",
|
||||
expectedTimelineStartSeconds,
|
||||
@@ -824,6 +951,18 @@ export function RerunViewport({
|
||||
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
const [recordingBufferProgress, setRecordingBufferProgress] = useState<number | null>(null);
|
||||
const [retryNonce, setRetryNonce] = useState(0);
|
||||
const liveActivitySequenceRef = useRef<number | null>(liveActivitySequence);
|
||||
liveActivitySequenceRef.current = liveActivitySequence;
|
||||
const liveStreamIdRef = useRef<string | null>(liveStreamId);
|
||||
liveStreamIdRef.current = liveStreamId;
|
||||
const liveRecoveryAuthorityRef = useRef<string | null>(liveRecoveryAuthorityIdentity);
|
||||
liveRecoveryAuthorityRef.current = liveRecoveryAuthorityIdentity;
|
||||
const liveRecoveryRef = useRef(initialLiveReceiverRecoveryState());
|
||||
const liveViewerInstanceIdRef = useRef<string | null>(null);
|
||||
liveViewerInstanceIdRef.current ??= createLiveViewerInstanceId();
|
||||
const liveViewerLifecycleGenerationRef = useRef(0);
|
||||
const activeViewerLifecycleRef = useRef<(() => void) | null>(null);
|
||||
const uiBuildStaleRef = useRef(false);
|
||||
const blueprintChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||
const perceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||
const loadedPerceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||
@@ -848,11 +987,28 @@ export function RerunViewport({
|
||||
presentationGate,
|
||||
recordedArtifact !== null,
|
||||
);
|
||||
const liveReceiverBindingIdentity = liveRerunReceiverBindingIdentity(
|
||||
sourceUrl,
|
||||
liveStreamId,
|
||||
followLive,
|
||||
);
|
||||
|
||||
useEffect(() => subscribeToLiveViewerBuildFence(() => {
|
||||
uiBuildStaleRef.current = true;
|
||||
// A stale document may only release its own browser transports. Device
|
||||
// START/STOP remains owned by the acquisition authority while the fresh
|
||||
// application document is loaded.
|
||||
activeViewerLifecycleRef.current?.();
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
|
||||
}, [liveReceiverBindingIdentity]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedSource = sourceUrl.trim();
|
||||
const host = hostRef.current;
|
||||
if (!normalizedSource || !host) {
|
||||
if (!normalizedSource || !host || uiBuildStaleRef.current) {
|
||||
setStatus("idle");
|
||||
setRecordingBufferProgress(null);
|
||||
onStatusChange?.("idle");
|
||||
@@ -861,6 +1017,7 @@ export function RerunViewport({
|
||||
onPlaybackControllerChange?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const isRecordedSource = RECORDED_RRD_PATH.test(normalizedSource);
|
||||
let resolvedSource: string;
|
||||
try {
|
||||
@@ -886,18 +1043,33 @@ export function RerunViewport({
|
||||
return;
|
||||
}
|
||||
|
||||
liveViewerLifecycleGenerationRef.current += 1;
|
||||
const diagnosticLifecycle = createLiveViewerDiagnosticLifecycle({
|
||||
lineage: createLiveViewerLineage(
|
||||
liveViewerInstanceIdRef.current!,
|
||||
liveViewerLifecycleGenerationRef.current,
|
||||
),
|
||||
});
|
||||
diagnosticLifecycle.verifyBuild();
|
||||
|
||||
let disposed = false;
|
||||
let disposeViewer: (() => void) | undefined;
|
||||
let recordingOpenTimer: number | undefined;
|
||||
let recordedOpenWatchdog: {
|
||||
arm: () => void;
|
||||
clear: () => void;
|
||||
pending: () => boolean;
|
||||
} | null = null;
|
||||
let playbackRangeTimer: number | undefined;
|
||||
let liveRecoveryRetryTimer: number | undefined;
|
||||
let recordingOpened = false;
|
||||
let recordingOpenTimedOut = false;
|
||||
// A new browser receiver has not observed any backend publication yet.
|
||||
// Starting from the already-consumed runtime sequence makes the first
|
||||
// four-second check look stalled and destroys a healthy partial replay.
|
||||
let liveOpenWatchdog = initialLiveReceiverOpenWatchdogState(null);
|
||||
let viewerStartResolved = false;
|
||||
let latestLiveRangeMaxNs: number | null = null;
|
||||
let liveWatchdog = initialLiveReceiverWatchdogState();
|
||||
// The backend serves this exact digest-bound URL or rejects it with 412.
|
||||
// Presentation still waits for Rerun to decode the complete declared range.
|
||||
const artifactVerified = true;
|
||||
@@ -919,15 +1091,41 @@ export function RerunViewport({
|
||||
}
|
||||
};
|
||||
const clearRecordedAdmissionWatchdog = () => recordedOpenWatchdog?.clear();
|
||||
const clearLiveRecordingOpenTimer = () => {
|
||||
if (recordingOpenTimer !== undefined) {
|
||||
window.clearTimeout(recordingOpenTimer);
|
||||
recordingOpenTimer = undefined;
|
||||
}
|
||||
const clearLiveRecordingOpenTimer = diagnosticLifecycle.clearAdmissionTimeout;
|
||||
const armLiveRecordingOpenTimer = () => {
|
||||
clearLiveRecordingOpenTimer();
|
||||
diagnosticLifecycle.armAdmissionTimeout(() => {
|
||||
if (disposed || recordingOpened) return;
|
||||
const observed = advanceLiveReceiverOpenWatchdog(
|
||||
liveOpenWatchdog,
|
||||
liveRecoveryRef.current,
|
||||
liveActivitySequenceRef.current,
|
||||
Date.now(),
|
||||
);
|
||||
liveOpenWatchdog = observed.state;
|
||||
liveRecoveryRef.current = observed.recoveryState;
|
||||
if (observed.signal === "wait-for-store") {
|
||||
// The backend is still publishing this exact acquisition. Preserve
|
||||
// the receiver and its partially replayed store instead of throwing
|
||||
// away startup work on every fixed timeout.
|
||||
armLiveRecordingOpenTimer();
|
||||
return;
|
||||
}
|
||||
recordingOpenTimedOut = true;
|
||||
requestLiveRecovery("recording-open-timeout");
|
||||
}, LIVE_RECEIVER_OPEN_CHECK_INTERVAL_MS);
|
||||
};
|
||||
const clearLiveRecordingDiscoveryTimer = diagnosticLifecycle.clearAdmissionInterval;
|
||||
const clearLiveRecoveryRetryTimer = () => {
|
||||
if (liveRecoveryRetryTimer === undefined) return;
|
||||
window.clearTimeout(liveRecoveryRetryTimer);
|
||||
liveRecoveryRetryTimer = undefined;
|
||||
};
|
||||
const clearRecordingTimers = () => {
|
||||
clearRecordedAdmissionWatchdog();
|
||||
clearLiveRecordingOpenTimer();
|
||||
clearLiveRecordingDiscoveryTimer();
|
||||
clearLiveRecoveryRetryTimer();
|
||||
};
|
||||
const clearPlaybackRangeTimer = () => {
|
||||
if (playbackRangeTimer === undefined) return;
|
||||
@@ -950,11 +1148,155 @@ export function RerunViewport({
|
||||
requestFrame: (callback) => window.requestAnimationFrame(callback),
|
||||
cancelFrame: (handle) => window.cancelAnimationFrame(handle),
|
||||
});
|
||||
const reportError = (message: string) => {
|
||||
const reportError = (message: string, failureStage?: LiveViewerFailureStage) => {
|
||||
if (disposed) return;
|
||||
if (followLive) {
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_receiver_error",
|
||||
failureStage,
|
||||
streamId: liveStreamIdRef.current,
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
viewerRangeMaxNs: latestLiveRangeMaxNs,
|
||||
recoveryAttempt: liveRecoveryRef.current.attempts || null,
|
||||
});
|
||||
}
|
||||
setStatus("error");
|
||||
onStatusChange?.("error", message);
|
||||
};
|
||||
const requestLiveRecovery = (
|
||||
failureStage: LiveViewerFailureStage,
|
||||
stalledForMs = 0,
|
||||
emitErrorEvent = true,
|
||||
) => {
|
||||
if (!followLive || disposed) return false;
|
||||
if (emitErrorEvent) {
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_receiver_error",
|
||||
failureStage,
|
||||
streamId: liveStreamIdRef.current,
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
viewerRangeMaxNs: latestLiveRangeMaxNs,
|
||||
stalledForMs: Math.round(stalledForMs) || null,
|
||||
recoveryAttempt: liveRecoveryRef.current.attempts || null,
|
||||
});
|
||||
}
|
||||
const currentRecoveryAuthorityIdentity = liveRecoveryAuthorityRef.current;
|
||||
const recovery = requestLiveReceiverRecovery(liveRecoveryRef.current, {
|
||||
activeAuthorityIdentity: currentRecoveryAuthorityIdentity,
|
||||
expectedAuthorityIdentity: currentRecoveryAuthorityIdentity,
|
||||
disposed,
|
||||
});
|
||||
liveRecoveryRef.current = recovery.state;
|
||||
if (recovery.signal === "stale") {
|
||||
// A newer runtime snapshot owns the next effect. This retired viewer
|
||||
// may release itself but cannot schedule a receiver for that lineage.
|
||||
disposeViewer?.();
|
||||
return true;
|
||||
}
|
||||
if (recovery.signal === "exhausted") {
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_receiver_recovery_exhausted",
|
||||
failureStage,
|
||||
streamId: liveStreamIdRef.current,
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
viewerRangeMaxNs: latestLiveRangeMaxNs,
|
||||
stalledForMs: Math.round(stalledForMs) || null,
|
||||
recoveryAttempt: LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
|
||||
});
|
||||
setStatus("error");
|
||||
onStatusChange?.(
|
||||
"error",
|
||||
"Живой поток продолжает поступать, но визуализатор не восстановился после трёх переподключений.",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_receiver_restart_requested",
|
||||
failureStage,
|
||||
streamId: liveStreamIdRef.current,
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
viewerRangeMaxNs: latestLiveRangeMaxNs,
|
||||
stalledForMs: Math.round(stalledForMs) || null,
|
||||
recoveryAttempt: recovery.attempt,
|
||||
});
|
||||
setStatus("loading");
|
||||
onStatusChange?.(
|
||||
"loading",
|
||||
"Живой визуализатор переподключается к продолжающемуся потоку.",
|
||||
);
|
||||
disposeViewer?.();
|
||||
clearLiveRecoveryRetryTimer();
|
||||
liveRecoveryRetryTimer = window.setTimeout(() => {
|
||||
liveRecoveryRetryTimer = undefined;
|
||||
if (
|
||||
disposed
|
||||
|| liveRecoveryAuthorityRef.current !== liveRecoveryAuthorityIdentity
|
||||
) return;
|
||||
setRetryNonce((nonce) => nonce + 1);
|
||||
}, recovery.delayMs ?? 0);
|
||||
return true;
|
||||
};
|
||||
const observeLiveReceiver = (viewerRangeMaxNs: number | null) => {
|
||||
if (!followLive || disposed) return;
|
||||
latestLiveRangeMaxNs = viewerRangeMaxNs;
|
||||
const receiverOpenedAfterRecovery =
|
||||
liveRecoveryRef.current.awaitingRecovery &&
|
||||
liveWatchdog.lastViewerRangeMaxNs === null &&
|
||||
viewerRangeMaxNs !== null;
|
||||
const observed = advanceLiveReceiverWatchdog(liveWatchdog, {
|
||||
nowMs: Date.now(),
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
viewerRangeMaxNs,
|
||||
});
|
||||
liveWatchdog = observed.state;
|
||||
if (
|
||||
(receiverOpenedAfterRecovery || observed.signal === "receiver-advanced") &&
|
||||
liveRecoveryRef.current.awaitingRecovery
|
||||
) {
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_receiver_recovered",
|
||||
streamId: liveStreamIdRef.current,
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
viewerRangeMaxNs,
|
||||
recoveryAttempt: liveRecoveryRef.current.attempts,
|
||||
});
|
||||
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
|
||||
return;
|
||||
}
|
||||
if (observed.signal !== "stalled") return;
|
||||
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_receiver_stalled",
|
||||
failureStage: "receiver-stalled",
|
||||
streamId: liveStreamIdRef.current,
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
viewerRangeMaxNs,
|
||||
stalledForMs: Math.round(observed.stalledForMs),
|
||||
recoveryAttempt: Math.min(
|
||||
liveRecoveryRef.current.attempts + 1,
|
||||
liveRecoveryAuthorityRef.current
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
|
||||
),
|
||||
});
|
||||
requestLiveRecovery("receiver-stalled", observed.stalledForMs, false);
|
||||
};
|
||||
|
||||
let relinquishLiveViewerOwnership: () => void = () => {};
|
||||
const disposeActiveViewerLifecycle = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
relinquishLiveViewerOwnership();
|
||||
diagnosticLifecycle.dispose();
|
||||
disposeViewer?.();
|
||||
host.replaceChildren();
|
||||
};
|
||||
if (followLive) {
|
||||
relinquishLiveViewerOwnership = claimExclusiveLiveViewer(
|
||||
disposeActiveViewerLifecycle,
|
||||
);
|
||||
}
|
||||
activeViewerLifecycleRef.current = disposeActiveViewerLifecycle;
|
||||
|
||||
host.replaceChildren();
|
||||
appliedPointColorKeyRef.current = null;
|
||||
@@ -970,10 +1312,7 @@ export function RerunViewport({
|
||||
if (disposed) return;
|
||||
|
||||
const viewer = new WebViewer();
|
||||
let viewerDisposed = false;
|
||||
disposeViewer = () => {
|
||||
if (viewerDisposed) return;
|
||||
viewerDisposed = true;
|
||||
disposeViewer = createReentrantViewerDisposer(() => {
|
||||
clearRecordingTimers();
|
||||
clearPlaybackRangeTimer();
|
||||
playbackTimeUpdates.cancel();
|
||||
@@ -1007,6 +1346,7 @@ export function RerunViewport({
|
||||
} catch {
|
||||
// The viewer may already have closed all auxiliary channels.
|
||||
}
|
||||
}, () => {
|
||||
try {
|
||||
if (viewer.ready) viewer.close(resolvedSource);
|
||||
} catch {
|
||||
@@ -1016,10 +1356,10 @@ export function RerunViewport({
|
||||
viewer.stop();
|
||||
} catch {
|
||||
// A partially initialized WASM handle can already be gone after a
|
||||
// startup failure. The host still has to be cleared below.
|
||||
// startup failure.
|
||||
}
|
||||
host.replaceChildren();
|
||||
};
|
||||
});
|
||||
if (isRecordedSource && recordedArtifact) {
|
||||
recordedOpenWatchdog = createRecordedOpenWatchdog({
|
||||
byteLength: recordedArtifact.byteLength,
|
||||
@@ -1034,14 +1374,21 @@ export function RerunViewport({
|
||||
});
|
||||
}
|
||||
|
||||
unsubscribers.push(
|
||||
viewer.on("recording_open", (event) => {
|
||||
const admitRecording = (event: {
|
||||
application_id: string;
|
||||
recording_id: string;
|
||||
}) => {
|
||||
if (
|
||||
disposed ||
|
||||
recordingOpened ||
|
||||
(isRecordedSource && event.application_id !== "nodedc_mission_core_recorded")
|
||||
) return;
|
||||
recordingOpened = true;
|
||||
if (!isRecordedSource) {
|
||||
// Store discovery only establishes a candidate. Admission is
|
||||
// committed below after this recording exposes a usable live
|
||||
// range backed by an actual published frame.
|
||||
}
|
||||
if (
|
||||
recordedBlueprintUrl &&
|
||||
event.application_id === "nodedc_mission_core_recorded" &&
|
||||
@@ -1060,7 +1407,6 @@ export function RerunViewport({
|
||||
setPerceptionChannelRevision((revision) => revision + 1);
|
||||
}
|
||||
}
|
||||
if (!isRecordedSource) clearLiveRecordingOpenTimer();
|
||||
// Durable Mission Core recordings use a zero-based session clock.
|
||||
// Keeping wall-clock capture_time as a secondary RRD timeline is
|
||||
// useful for audit, but the operator scrubber must remain a small,
|
||||
@@ -1078,6 +1424,7 @@ export function RerunViewport({
|
||||
let rangeNs = viewer.get_time_range(event.recording_id, timeline);
|
||||
let currentNs = viewer.get_current_time(event.recording_id, timeline);
|
||||
let playing = viewer.get_playing(event.recording_id);
|
||||
let liveTimelineSynchronized = !followLive;
|
||||
if (!followLive && playing) {
|
||||
try {
|
||||
viewer.set_playing(event.recording_id, false);
|
||||
@@ -1139,11 +1486,23 @@ export function RerunViewport({
|
||||
if (disposed || !playbackState) return;
|
||||
try {
|
||||
rangeNs = viewer.get_time_range(event.recording_id, timeline);
|
||||
if (followLive && !liveTimelineSynchronized) {
|
||||
let activeTimeline = viewer.get_active_timeline(event.recording_id);
|
||||
if (liveTimelineNeedsSynchronization(followLive, activeTimeline, rangeNs)) {
|
||||
// The first selection above may have raced timeline
|
||||
// creation. Once a real range exists this retry is safe
|
||||
// and preserves Rerun's native live-following cursor.
|
||||
viewer.set_active_timeline(event.recording_id, timeline);
|
||||
activeTimeline = viewer.get_active_timeline(event.recording_id);
|
||||
}
|
||||
liveTimelineSynchronized = activeTimeline === timeline;
|
||||
}
|
||||
currentNs = viewer.get_current_time(event.recording_id, timeline);
|
||||
playing = viewer.get_playing(event.recording_id);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
observeLiveReceiver(rangeNs?.max ?? null);
|
||||
const recordedBuffer = recordedPlaybackBufferState(
|
||||
rangeNs,
|
||||
expectedTimelineEndSeconds,
|
||||
@@ -1153,7 +1512,11 @@ export function RerunViewport({
|
||||
setRecordingBufferProgress(recordedBuffer.bufferProgress);
|
||||
}
|
||||
const readyToRender = followLive
|
||||
? viewerStartResolved
|
||||
? liveTimelineSynchronized && isLiveRerunPresentationReady(
|
||||
viewerStartResolved,
|
||||
rangeNs,
|
||||
liveActivitySequenceRef.current,
|
||||
)
|
||||
: isRecordedPlaybackReady(viewerStartResolved, artifactVerified, recordedBuffer);
|
||||
const presentationReady = presentationGateRef.current === "ready";
|
||||
if (!followLive && (!readyToRender || !presentationReady) && playing) {
|
||||
@@ -1205,6 +1568,25 @@ export function RerunViewport({
|
||||
});
|
||||
if (readyToRender && !readyPublished) {
|
||||
readyPublished = true;
|
||||
if (followLive) {
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_receiver_active_store_admitted",
|
||||
streamId: liveStreamIdRef.current,
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
viewerRangeMaxNs: rangeNs?.max ?? null,
|
||||
});
|
||||
diagnosticLifecycle.markAdmitted();
|
||||
if (liveRecoveryRef.current.awaitingRecovery) {
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_receiver_recovered",
|
||||
streamId: liveStreamIdRef.current,
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
viewerRangeMaxNs: rangeNs?.max ?? null,
|
||||
recoveryAttempt: liveRecoveryRef.current.attempts,
|
||||
});
|
||||
}
|
||||
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
|
||||
}
|
||||
if (!followLive) clearRecordedAdmissionWatchdog();
|
||||
if (!followLive) setRecordingBufferProgress(1);
|
||||
recordedSceneAdmitted = true;
|
||||
@@ -1225,8 +1607,26 @@ export function RerunViewport({
|
||||
clearPlaybackRangeTimer();
|
||||
publishBufferedState();
|
||||
playbackRangeTimer = window.setInterval(publishBufferedState, 500);
|
||||
}),
|
||||
};
|
||||
unsubscribers.push(
|
||||
viewer.on("recording_open", admitRecording),
|
||||
);
|
||||
const discoverActiveLiveRecording = () => {
|
||||
if (isRecordedSource || disposed || recordingOpened || !viewer.ready) return;
|
||||
try {
|
||||
const recordingId = viewer.get_active_recording_id();
|
||||
if (!recordingId) return;
|
||||
// Rerun 0.34.1 may ingest an SDK gRPC store without forwarding its
|
||||
// recording_open event to the JavaScript wrapper. The active store
|
||||
// is the authoritative fallback and avoids hiding a ready canvas.
|
||||
admitRecording({
|
||||
application_id: "nodedc_mission_core_spatial",
|
||||
recording_id: recordingId,
|
||||
});
|
||||
} catch {
|
||||
// The native receiver is still opening; the bounded timer retries.
|
||||
}
|
||||
};
|
||||
|
||||
unsubscribers.push(
|
||||
viewer.on("time_update", (event) => {
|
||||
@@ -1288,7 +1688,7 @@ export function RerunViewport({
|
||||
rerunViewerInitialSource(resolvedSource),
|
||||
host,
|
||||
viewerOptions,
|
||||
null,
|
||||
rerunViewerOpenOptions(followLive),
|
||||
);
|
||||
if (disposed) {
|
||||
disposeViewer();
|
||||
@@ -1316,35 +1716,46 @@ export function RerunViewport({
|
||||
setPerceptionChannelRevision((revision) => revision + 1);
|
||||
}
|
||||
|
||||
if (!recordingOpened && !isRecordedSource) {
|
||||
recordingOpenTimer = window.setTimeout(() => {
|
||||
recordingOpenTimedOut = true;
|
||||
disposeViewer?.();
|
||||
reportError("Визуализатор запущен, но поток записи не открылся.");
|
||||
}, 12_000);
|
||||
if (!isRecordedSource) {
|
||||
// Measure native store admission from the resolved viewer start,
|
||||
// not from dynamic module import or React effect setup time.
|
||||
liveOpenWatchdog = initialLiveReceiverOpenWatchdogState(null, Date.now());
|
||||
discoverActiveLiveRecording();
|
||||
if (!recordingOpened) {
|
||||
diagnosticLifecycle.armAdmissionInterval(
|
||||
discoverActiveLiveRecording,
|
||||
100,
|
||||
);
|
||||
armLiveRecordingOpenTimer();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
disposeViewer();
|
||||
if (!recordingOpenTimedOut && !disposed) {
|
||||
reportError(isRecordedSource
|
||||
const message = isRecordedSource
|
||||
? "Запись не прошла атомарную проверку целостности."
|
||||
: "Не удалось запустить встроенный визуализатор.");
|
||||
: "Не удалось запустить встроенный визуализатор.";
|
||||
if (!isRecordedSource && requestLiveRecovery("viewer-start")) return;
|
||||
reportError(message);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (disposed) return;
|
||||
disposeViewer?.();
|
||||
if (requestLiveRecovery("module-load")) return;
|
||||
reportError("Не удалось загрузить модуль визуализатора.");
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (activeViewerLifecycleRef.current === disposeActiveViewerLifecycle) {
|
||||
activeViewerLifecycleRef.current = null;
|
||||
}
|
||||
disposeActiveViewerLifecycle();
|
||||
clearRecordingTimers();
|
||||
clearPlaybackRangeTimer();
|
||||
playbackTimeUpdates.cancel();
|
||||
unsubscribeAll();
|
||||
disposeViewer?.();
|
||||
onSelectionChange?.(null);
|
||||
onPlaybackControllerChange?.(null);
|
||||
onPlaybackChange?.(null);
|
||||
@@ -1353,7 +1764,7 @@ export function RerunViewport({
|
||||
autoplayWhenReady,
|
||||
expectedTimelineEndSeconds,
|
||||
expectedTimelineStartSeconds,
|
||||
followLive,
|
||||
liveReceiverBindingIdentity,
|
||||
initialPlaybackStartSeconds,
|
||||
onPlaybackChange,
|
||||
onPlaybackControllerChange,
|
||||
@@ -1364,7 +1775,6 @@ export function RerunViewport({
|
||||
recordedArtifact?.sourceUrl,
|
||||
recordedArtifact?.viewerSourceUrl,
|
||||
retryNonce,
|
||||
sourceUrl,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1397,6 +1807,9 @@ export function RerunViewport({
|
||||
}
|
||||
const abort = new AbortController();
|
||||
let lastReportedPercent = -1;
|
||||
let requestSettled = false;
|
||||
let transferStarted = false;
|
||||
let statusTimer: number | null = null;
|
||||
onPerceptionLoadChange?.({
|
||||
phase: "loading",
|
||||
receivedBytes: 0,
|
||||
@@ -1404,10 +1817,51 @@ export function RerunViewport({
|
||||
progress: null,
|
||||
message: "Сервер готовит AI-слои.",
|
||||
});
|
||||
const pollPreparationStatus = () => {
|
||||
void fetchPerceptionPreparationStatus(
|
||||
recordedPerceptionUrl,
|
||||
identity.recordingId,
|
||||
{
|
||||
origin: window.location.origin,
|
||||
signal: abort.signal,
|
||||
},
|
||||
).then((status) => {
|
||||
if (
|
||||
abort.signal.aborted ||
|
||||
requestSettled ||
|
||||
transferStarted ||
|
||||
perceptionChannelRef.current !== active
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const message = perceptionPreparationMessage(status.phase);
|
||||
if (message) {
|
||||
onPerceptionLoadChange?.({
|
||||
phase: "loading",
|
||||
receivedBytes: 0,
|
||||
totalBytes: status.byteLength,
|
||||
progress: null,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}).catch(() => {
|
||||
// The primary RRD request remains authoritative when status polling is unavailable.
|
||||
}).finally(() => {
|
||||
if (!abort.signal.aborted && !requestSettled && !transferStarted) {
|
||||
statusTimer = window.setTimeout(pollPreparationStatus, 750);
|
||||
}
|
||||
});
|
||||
};
|
||||
pollPreparationStatus();
|
||||
void fetchRecordedPerceptionRrd(recordedPerceptionUrl, identity, {
|
||||
origin: window.location.origin,
|
||||
signal: abort.signal,
|
||||
onProgress: (receivedBytes, totalBytes) => {
|
||||
transferStarted = true;
|
||||
if (statusTimer !== null) {
|
||||
window.clearTimeout(statusTimer);
|
||||
statusTimer = null;
|
||||
}
|
||||
const progress = totalBytes > 0 ? receivedBytes / totalBytes : null;
|
||||
const percent = progress === null ? -1 : Math.floor(progress * 100);
|
||||
if (percent === lastReportedPercent && receivedBytes !== totalBytes) return;
|
||||
@@ -1462,8 +1916,18 @@ export function RerunViewport({
|
||||
: "AI-слои недоступны.",
|
||||
});
|
||||
// The base recording remains available when no admitted perception layer exists.
|
||||
}).finally(() => {
|
||||
requestSettled = true;
|
||||
if (statusTimer !== null) {
|
||||
window.clearTimeout(statusTimer);
|
||||
statusTimer = null;
|
||||
}
|
||||
});
|
||||
return () => abort.abort();
|
||||
return () => {
|
||||
requestSettled = true;
|
||||
if (statusTimer !== null) window.clearTimeout(statusTimer);
|
||||
abort.abort();
|
||||
};
|
||||
}, [
|
||||
onPerceptionLoadChange,
|
||||
perceptionChannelRevision,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import type { E30EngineeringGeneration } from "../../core/laboratory/e30Engineering";
|
||||
|
||||
export function E30EngineeringGenerationSummary({
|
||||
generation,
|
||||
}: {
|
||||
generation: E30EngineeringGeneration;
|
||||
}) {
|
||||
const summary = generation.summary;
|
||||
const confirmed = summary.verdictDistribution.confirmed ?? 0;
|
||||
const corrected = summary.verdictDistribution.corrected ?? 0;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="e30-engineering-generation"
|
||||
aria-label="Результат A3 engineering generation"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">A3 · IMMUTABLE ENGINEERING GENERATION</span>
|
||||
</div>
|
||||
<StatusBadge
|
||||
tone={summary.humanExceptionCount ? "warning" : "accent"}
|
||||
>
|
||||
{summary.reviewedItemCount} / {summary.itemCount}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Подтверждено</dt>
|
||||
<dd>{confirmed.toLocaleString("ru-RU")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Исправлено</dt>
|
||||
<dd>{corrected.toLocaleString("ru-RU")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Исключения</dt>
|
||||
<dd>{summary.humanExceptionCount.toLocaleString("ru-RU")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Средняя уверенность</dt>
|
||||
<dd>
|
||||
{(summary.meanConfidence * 100).toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
%
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { E30ReviewItemDetail } from "../../core/laboratory/e30Review";
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: 3 });
|
||||
}
|
||||
|
||||
function evidenceRange(item: E30ReviewItemDetail): string {
|
||||
const value = item.snapshot.rangeM ?? item.snapshot.nearestRangeM;
|
||||
return value === null ? "недоступна" : `${formatNumber(value)} м`;
|
||||
}
|
||||
|
||||
export function E30EvidenceTelemetry({
|
||||
detail,
|
||||
mode,
|
||||
}: {
|
||||
detail: E30ReviewItemDetail;
|
||||
mode: "camera" | "3d";
|
||||
}) {
|
||||
return (
|
||||
<aside
|
||||
className="e30-evidence-telemetry"
|
||||
aria-label="Параметры доказательства"
|
||||
>
|
||||
<span>
|
||||
{mode === "camera"
|
||||
? "Точный camera frame · LiDAR projection"
|
||||
: "Map frame · Z вверх"}
|
||||
</span>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Кадр / время</dt>
|
||||
<dd>
|
||||
{formatNumber(detail.sourceFrameIndex)}
|
||||
{" · "}
|
||||
{formatNumber(detail.sessionSeconds)} с
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>LiDAR</dt>
|
||||
<dd>{formatNumber(detail.materialization.projectedPointCount)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Кандидаты</dt>
|
||||
<dd>{formatNumber(detail.materialization.candidatePointCount)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Выбрано / отклонено</dt>
|
||||
<dd>
|
||||
{formatNumber(detail.materialization.selectedPointCount)}
|
||||
{" / "}
|
||||
{formatNumber(detail.materialization.rejectedCandidatePointCount)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Класс E29</dt>
|
||||
<dd>{detail.snapshot.geometryStatus}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Дальность</dt>
|
||||
<dd>{evidenceRange(detail)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div>
|
||||
<span>Точные индексы LiDAR</span>
|
||||
<code>
|
||||
{detail.selected.sourceIndices.length
|
||||
? detail.selected.sourceIndices.slice(0, 18).join(", ")
|
||||
: "нет выбранных точек"}
|
||||
{detail.selected.sourceIndices.length > 18 ? " …" : ""}
|
||||
</code>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
Icon,
|
||||
IconButton,
|
||||
SegmentedControl,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
export interface LaboratoryEvidenceViewerMode<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function LaboratoryEvidenceViewer<
|
||||
T extends string,
|
||||
U extends string = string,
|
||||
>({
|
||||
label,
|
||||
className,
|
||||
mode,
|
||||
modes,
|
||||
expanded,
|
||||
onModeChange,
|
||||
onExpandedChange,
|
||||
actions,
|
||||
secondaryMode,
|
||||
overlay,
|
||||
transport,
|
||||
trailingActions,
|
||||
modeControlsVisible = true,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
className?: string;
|
||||
mode: T;
|
||||
modes: readonly LaboratoryEvidenceViewerMode<T>[];
|
||||
expanded: boolean;
|
||||
onModeChange: (mode: T) => void;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
actions?: ReactNode;
|
||||
secondaryMode?: {
|
||||
value: U;
|
||||
modes: readonly LaboratoryEvidenceViewerMode<U>[];
|
||||
label: string;
|
||||
onChange: (mode: U) => void;
|
||||
};
|
||||
overlay?: ReactNode;
|
||||
transport?: ReactNode;
|
||||
trailingActions?: ReactNode;
|
||||
modeControlsVisible?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const expandButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const viewerRef = useRef<HTMLDialogElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const viewer = viewerRef.current;
|
||||
if (!viewer) return;
|
||||
if (viewer.open) viewer.close();
|
||||
if (expanded) {
|
||||
viewer.showModal();
|
||||
} else {
|
||||
viewer.show();
|
||||
}
|
||||
}, [expanded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
onExpandedChange(false);
|
||||
window.requestAnimationFrame(() => expandButtonRef.current?.focus());
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [expanded, onExpandedChange]);
|
||||
|
||||
const viewer = (
|
||||
<dialog
|
||||
ref={viewerRef}
|
||||
role="region"
|
||||
className={[
|
||||
"laboratory-evidence-viewer",
|
||||
className,
|
||||
].filter(Boolean).join(" ")}
|
||||
data-expanded={expanded ? "true" : undefined}
|
||||
data-mode-controls={modeControlsVisible ? undefined : "content"}
|
||||
aria-label={label}
|
||||
>
|
||||
<div className="laboratory-evidence-viewer__stage">
|
||||
{children}
|
||||
</div>
|
||||
{overlay}
|
||||
{transport ? (
|
||||
<div className="laboratory-evidence-viewer__transport">
|
||||
{transport}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="laboratory-evidence-viewer__controls">
|
||||
{actions}
|
||||
{modeControlsVisible && secondaryMode ? (
|
||||
<SegmentedControl
|
||||
value={secondaryMode.value}
|
||||
items={[...secondaryMode.modes]}
|
||||
label={secondaryMode.label}
|
||||
onChange={secondaryMode.onChange}
|
||||
/>
|
||||
) : null}
|
||||
{modeControlsVisible ? (
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
items={[...modes]}
|
||||
label={`${label}: режим представления`}
|
||||
onChange={onModeChange}
|
||||
/>
|
||||
) : null}
|
||||
{trailingActions}
|
||||
<IconButton
|
||||
ref={expandButtonRef}
|
||||
label={expanded ? `Свернуть ${label}` : `Развернуть ${label}`}
|
||||
onClick={() => onExpandedChange(!expanded)}
|
||||
>
|
||||
<Icon name={expanded ? "minimize" : "expand"} size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
|
||||
return viewer;
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
import {
|
||||
forwardRef,
|
||||
type CSSProperties,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||
|
||||
import {
|
||||
recordedEvidenceSemanticCssColor,
|
||||
resolveRecordedEvidenceSemanticRgb,
|
||||
type RecordedEvidenceSemanticClass,
|
||||
type RecordedEvidenceSemanticPaletteEntry,
|
||||
} from "./RecordedEvidenceSemanticMaskOverlay";
|
||||
|
||||
export type LaboratoryMetricPoint3 = readonly [number, number, number];
|
||||
export type LaboratoryMetricDecision = "threat" | "not-threat" | "unknown";
|
||||
export type LaboratoryMetricSceneMode = "3d" | "plan";
|
||||
|
||||
export interface LaboratoryMetricObstacleVisual {
|
||||
id: string;
|
||||
decision: LaboratoryMetricDecision;
|
||||
state: "current" | "retained" | "held" | "expired";
|
||||
centroidBodyXyzM: LaboratoryMetricPoint3;
|
||||
cellCentersBodyXyzM: readonly LaboratoryMetricPoint3[];
|
||||
}
|
||||
|
||||
export interface LaboratoryMetricRigVisual {
|
||||
lengthM: number;
|
||||
widthM: number;
|
||||
nominalSensorHeightM: number;
|
||||
}
|
||||
|
||||
export interface LaboratoryMetricCorridorVisual {
|
||||
forwardLengthM: number;
|
||||
rearMarginM: number;
|
||||
halfWidthM: number;
|
||||
}
|
||||
|
||||
function tokenColor(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
): THREE.Color {
|
||||
const value = getComputedStyle(host).getPropertyValue(token).trim();
|
||||
if (value.startsWith("#")) return new THREE.Color(value);
|
||||
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return new THREE.Color(red / 255, green / 255, blue / 255);
|
||||
}
|
||||
|
||||
function disposeRenderable(object: THREE.Object3D): void {
|
||||
const renderable = object as THREE.Object3D & {
|
||||
geometry?: THREE.BufferGeometry;
|
||||
material?: THREE.Material | THREE.Material[];
|
||||
};
|
||||
renderable.geometry?.dispose();
|
||||
const materials = Array.isArray(renderable.material)
|
||||
? renderable.material
|
||||
: renderable.material
|
||||
? [renderable.material]
|
||||
: [];
|
||||
materials.forEach((material) => material.dispose());
|
||||
}
|
||||
|
||||
function clearGroup(group: THREE.Group): void {
|
||||
while (group.children.length) {
|
||||
const child = group.children[0];
|
||||
if (!child) break;
|
||||
group.remove(child);
|
||||
child.traverse(disposeRenderable);
|
||||
}
|
||||
}
|
||||
|
||||
function scenePoint(point: LaboratoryMetricPoint3): LaboratoryMetricPoint3 {
|
||||
return [point[0], point[2], -point[1]];
|
||||
}
|
||||
|
||||
function positions(points: readonly LaboratoryMetricPoint3[]): Float32Array {
|
||||
const result = new Float32Array(points.length * 3);
|
||||
points.forEach((point, index) => {
|
||||
const [x, y, z] = scenePoint(point);
|
||||
const offset = index * 3;
|
||||
result[offset] = x;
|
||||
result[offset + 1] = y;
|
||||
result[offset + 2] = z;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function decisionColor(
|
||||
host: HTMLElement,
|
||||
decision: LaboratoryMetricDecision,
|
||||
): THREE.Color {
|
||||
if (decision === "threat") {
|
||||
return tokenColor(host, "--nodedc-danger-rgb", [255, 104, 112]);
|
||||
}
|
||||
if (decision === "not-threat") {
|
||||
return tokenColor(host, "--nodedc-success-rgb", [181, 255, 90]);
|
||||
}
|
||||
return tokenColor(host, "--nodedc-warning-rgb", [255, 197, 92]);
|
||||
}
|
||||
|
||||
export interface LaboratoryMetricEvidenceSceneHandle {
|
||||
resetView: () => void;
|
||||
}
|
||||
|
||||
export const LaboratoryMetricEvidenceScene = forwardRef<
|
||||
LaboratoryMetricEvidenceSceneHandle,
|
||||
{
|
||||
pointCloudBodyXyzM: readonly LaboratoryMetricPoint3[];
|
||||
localSurfaceBodyXyzM: readonly LaboratoryMetricPoint3[];
|
||||
obstacles: readonly LaboratoryMetricObstacleVisual[];
|
||||
rig: LaboratoryMetricRigVisual;
|
||||
corridor: LaboratoryMetricCorridorVisual;
|
||||
occupiedVoxelSizeM: number;
|
||||
mode: LaboratoryMetricSceneMode;
|
||||
label: string;
|
||||
showCurrentIncrement: boolean;
|
||||
showLocalSurface: boolean;
|
||||
showRollingMap: boolean;
|
||||
pointSemanticClassIds?: readonly (number | null)[];
|
||||
semanticClasses?: readonly RecordedEvidenceSemanticClass[];
|
||||
semanticPalette?: readonly RecordedEvidenceSemanticPaletteEntry[];
|
||||
}
|
||||
>(function LaboratoryMetricEvidenceScene({
|
||||
pointCloudBodyXyzM,
|
||||
localSurfaceBodyXyzM,
|
||||
obstacles,
|
||||
rig,
|
||||
corridor,
|
||||
occupiedVoxelSizeM,
|
||||
mode,
|
||||
label,
|
||||
showCurrentIncrement,
|
||||
showLocalSurface,
|
||||
showRollingMap,
|
||||
pointSemanticClassIds,
|
||||
semanticClasses,
|
||||
semanticPalette,
|
||||
}, ref) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const controlsRef = useRef<OrbitControls | null>(null);
|
||||
const staticContentRef = useRef<THREE.Group | null>(null);
|
||||
const dynamicContentRef = useRef<THREE.Group | null>(null);
|
||||
const [renderError, setRenderError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: false,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
} catch {
|
||||
setRenderError("Браузер не смог создать метрическую 3D-сцену.");
|
||||
return;
|
||||
}
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setClearColor(tokenColor(host, "--nodedc-canvas", [5, 5, 6]), 1);
|
||||
renderer.domElement.setAttribute("aria-label", label);
|
||||
renderer.domElement.setAttribute("role", "img");
|
||||
host.prepend(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 300);
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
controls.enablePan = true;
|
||||
controls.enableZoom = true;
|
||||
controls.screenSpacePanning = true;
|
||||
controls.minDistance = 0.4;
|
||||
controls.maxDistance = 80;
|
||||
const staticContent = new THREE.Group();
|
||||
const dynamicContent = new THREE.Group();
|
||||
scene.add(staticContent, dynamicContent);
|
||||
sceneRef.current = scene;
|
||||
cameraRef.current = camera;
|
||||
controlsRef.current = controls;
|
||||
staticContentRef.current = staticContent;
|
||||
dynamicContentRef.current = dynamicContent;
|
||||
|
||||
const resize = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height, false);
|
||||
};
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(host);
|
||||
resize();
|
||||
|
||||
let animationFrame = 0;
|
||||
const render = () => {
|
||||
animationFrame = window.requestAnimationFrame(render);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
render();
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
observer.disconnect();
|
||||
controls.dispose();
|
||||
scene.traverse(disposeRenderable);
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
sceneRef.current = null;
|
||||
cameraRef.current = null;
|
||||
controlsRef.current = null;
|
||||
staticContentRef.current = null;
|
||||
dynamicContentRef.current = null;
|
||||
};
|
||||
}, [label]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const content = dynamicContentRef.current;
|
||||
if (!host || !content) return;
|
||||
clearGroup(content);
|
||||
|
||||
if (showLocalSurface) {
|
||||
const localSurfaceGeometry = new THREE.BufferGeometry();
|
||||
localSurfaceGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(positions(localSurfaceBodyXyzM), 3),
|
||||
);
|
||||
content.add(new THREE.Points(
|
||||
localSurfaceGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]),
|
||||
size: 1.3,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.42,
|
||||
depthWrite: false,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
if (showCurrentIncrement) {
|
||||
const contextGeometry = new THREE.BufferGeometry();
|
||||
contextGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(positions(pointCloudBodyXyzM), 3),
|
||||
);
|
||||
const hasAlignedSemanticClasses =
|
||||
pointSemanticClassIds !== undefined
|
||||
&& pointSemanticClassIds.length === pointCloudBodyXyzM.length
|
||||
&& semanticClasses !== undefined
|
||||
&& semanticPalette !== undefined;
|
||||
if (hasAlignedSemanticClasses) {
|
||||
const declaredIds = new Set(semanticClasses.map((item) => item.id));
|
||||
const colorsByClassId = new Map<number, readonly [number, number, number]>();
|
||||
for (const entry of semanticPalette) {
|
||||
if (!declaredIds.has(entry.classId)) continue;
|
||||
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
|
||||
if (rgb) colorsByClassId.set(entry.classId, rgb);
|
||||
}
|
||||
const context = tokenColor(host, "--nodedc-text-muted", [147, 151, 159]);
|
||||
const pointColors = new Float32Array(pointCloudBodyXyzM.length * 3);
|
||||
pointSemanticClassIds.forEach((classId, index) => {
|
||||
const rgb = classId === null ? undefined : colorsByClassId.get(classId);
|
||||
const offset = index * 3;
|
||||
pointColors[offset] = rgb ? rgb[0] / 255 : context.r;
|
||||
pointColors[offset + 1] = rgb ? rgb[1] / 255 : context.g;
|
||||
pointColors[offset + 2] = rgb ? rgb[2] / 255 : context.b;
|
||||
});
|
||||
contextGeometry.setAttribute(
|
||||
"color",
|
||||
new THREE.BufferAttribute(pointColors, 3),
|
||||
);
|
||||
}
|
||||
content.add(new THREE.Points(
|
||||
contextGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color: hasAlignedSemanticClasses
|
||||
? new THREE.Color(1, 1, 1)
|
||||
: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
|
||||
vertexColors: hasAlignedSemanticClasses,
|
||||
size: 1.55,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.58,
|
||||
depthWrite: false,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
for (const obstacle of obstacles) {
|
||||
if (
|
||||
(obstacle.state === "current" && !showCurrentIncrement)
|
||||
|| (obstacle.state === "retained" && !showRollingMap)
|
||||
|| obstacle.state === "held"
|
||||
|| obstacle.state === "expired"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const color = decisionColor(host, obstacle.decision);
|
||||
if (obstacle.state === "retained") {
|
||||
const geometry = new THREE.BoxGeometry(
|
||||
occupiedVoxelSizeM * 0.82,
|
||||
occupiedVoxelSizeM * 0.82,
|
||||
occupiedVoxelSizeM * 0.82,
|
||||
);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
wireframe: true,
|
||||
transparent: true,
|
||||
opacity: 0.34,
|
||||
depthWrite: false,
|
||||
});
|
||||
const voxels = new THREE.InstancedMesh(
|
||||
geometry,
|
||||
material,
|
||||
obstacle.cellCentersBodyXyzM.length,
|
||||
);
|
||||
const matrix = new THREE.Matrix4();
|
||||
obstacle.cellCentersBodyXyzM.forEach((point, index) => {
|
||||
matrix.makeTranslation(...scenePoint(point));
|
||||
voxels.setMatrixAt(index, matrix);
|
||||
});
|
||||
voxels.instanceMatrix.needsUpdate = true;
|
||||
voxels.userData.evidenceId = obstacle.id;
|
||||
content.add(voxels);
|
||||
} else {
|
||||
const cellsGeometry = new THREE.BufferGeometry();
|
||||
cellsGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(positions(obstacle.cellCentersBodyXyzM), 3),
|
||||
);
|
||||
content.add(new THREE.Points(
|
||||
cellsGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color,
|
||||
size: 4.4,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.96,
|
||||
depthWrite: false,
|
||||
}),
|
||||
));
|
||||
const centroid = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.065, 12, 8),
|
||||
new THREE.MeshBasicMaterial({ color }),
|
||||
);
|
||||
centroid.position.fromArray(scenePoint(obstacle.centroidBodyXyzM));
|
||||
centroid.userData.evidenceId = obstacle.id;
|
||||
content.add(centroid);
|
||||
}
|
||||
}
|
||||
|
||||
}, [
|
||||
obstacles,
|
||||
occupiedVoxelSizeM,
|
||||
localSurfaceBodyXyzM,
|
||||
pointCloudBodyXyzM,
|
||||
pointSemanticClassIds,
|
||||
semanticClasses,
|
||||
semanticPalette,
|
||||
showCurrentIncrement,
|
||||
showLocalSurface,
|
||||
showRollingMap,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const content = staticContentRef.current;
|
||||
if (!host || !content) return;
|
||||
clearGroup(content);
|
||||
|
||||
const corridorLength = rig.lengthM / 2 + corridor.forwardLengthM + corridor.rearMarginM;
|
||||
const corridorCenterX = (rig.lengthM / 2 + corridor.forwardLengthM - corridor.rearMarginM) / 2;
|
||||
const corridorMesh = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(corridorLength, corridor.halfWidthM * 2),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: tokenColor(host, "--nodedc-accent-rgb", [232, 56, 126]),
|
||||
transparent: true,
|
||||
opacity: 0.11,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
corridorMesh.rotation.x = -Math.PI / 2;
|
||||
corridorMesh.position.set(corridorCenterX, 0.01, 0);
|
||||
content.add(corridorMesh);
|
||||
const corridorOutline = new THREE.LineSegments(
|
||||
new THREE.EdgesGeometry(new THREE.BoxGeometry(corridorLength, 0.01, corridor.halfWidthM * 2)),
|
||||
new THREE.LineBasicMaterial({
|
||||
color: tokenColor(host, "--nodedc-accent-rgb", [232, 56, 126]),
|
||||
transparent: true,
|
||||
opacity: 0.7,
|
||||
}),
|
||||
);
|
||||
corridorOutline.position.set(corridorCenterX, 0.015, 0);
|
||||
content.add(corridorOutline);
|
||||
|
||||
const body = new THREE.LineSegments(
|
||||
new THREE.EdgesGeometry(new THREE.BoxGeometry(rig.lengthM, 0.34, rig.widthM)),
|
||||
new THREE.LineBasicMaterial({
|
||||
color: tokenColor(host, "--nodedc-foreground-rgb", [245, 245, 245]),
|
||||
transparent: true,
|
||||
opacity: 0.86,
|
||||
}),
|
||||
);
|
||||
body.position.y = 0.17;
|
||||
content.add(body);
|
||||
const lidar = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.08, 0.08, 0.08, 20),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: tokenColor(host, "--nodedc-foreground-rgb", [245, 245, 245]),
|
||||
}),
|
||||
);
|
||||
lidar.position.y = rig.nominalSensorHeightM;
|
||||
content.add(lidar);
|
||||
|
||||
const grid = new THREE.GridHelper(
|
||||
Math.max(20, corridor.forwardLengthM * 2.5),
|
||||
40,
|
||||
tokenColor(host, "--nodedc-text-muted", [96, 99, 106]),
|
||||
tokenColor(host, "--nodedc-glass-outline", [48, 50, 56]),
|
||||
);
|
||||
const gridMaterials = Array.isArray(grid.material) ? grid.material : [grid.material];
|
||||
gridMaterials.forEach((material) => {
|
||||
material.transparent = true;
|
||||
material.opacity = 0.15;
|
||||
material.depthWrite = false;
|
||||
});
|
||||
content.add(grid);
|
||||
}, [corridor, rig]);
|
||||
|
||||
const resetView = () => {
|
||||
const camera = cameraRef.current;
|
||||
const controls = controlsRef.current;
|
||||
if (!camera || !controls) return;
|
||||
controls.target.set(corridor.forwardLengthM * 0.35, 0.6, 0);
|
||||
if (mode === "plan") {
|
||||
camera.position.set(corridor.forwardLengthM * 0.35, 15, 0.001);
|
||||
camera.up.set(0, 0, -1);
|
||||
} else {
|
||||
camera.position.set(-4.5, 4.8, 8.5);
|
||||
camera.up.set(0, 1, 0);
|
||||
}
|
||||
camera.updateProjectionMatrix();
|
||||
controls.update();
|
||||
};
|
||||
|
||||
useEffect(resetView, [corridor.forwardLengthM, mode]);
|
||||
useImperativeHandle(ref, () => ({ resetView }));
|
||||
|
||||
const semanticLegendEntries = (() => {
|
||||
if (
|
||||
!showCurrentIncrement
|
||||
|| !pointSemanticClassIds
|
||||
|| pointSemanticClassIds.length !== pointCloudBodyXyzM.length
|
||||
|| !semanticClasses
|
||||
|| !semanticPalette
|
||||
) return [];
|
||||
const presentIds = new Set(pointSemanticClassIds.filter((item): item is number => item !== null));
|
||||
const classesById = new Map(semanticClasses.map((item) => [item.id, item]));
|
||||
return semanticPalette.flatMap((entry) => {
|
||||
const semanticClass = classesById.get(entry.classId);
|
||||
if (!semanticClass || !presentIds.has(entry.classId) || entry.color.kind === "transparent") return [];
|
||||
return [{
|
||||
id: entry.classId,
|
||||
label: semanticClass.label,
|
||||
cssColor: recordedEvidenceSemanticCssColor(entry.color),
|
||||
}];
|
||||
});
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="laboratory-metric-evidence-scene">
|
||||
<div ref={hostRef} className="laboratory-metric-evidence-scene__viewport">
|
||||
{renderError ? <p>{renderError}</p> : null}
|
||||
</div>
|
||||
<div className="laboratory-metric-evidence-scene__legend">
|
||||
<span data-decision="threat">Угроза</span>
|
||||
<span data-decision="not-threat">Вне коридора</span>
|
||||
<span data-decision="unknown">Неизвестно</span>
|
||||
<span data-decision="context">Current increment</span>
|
||||
<span data-decision="local-surface">Local SLAM surface</span>
|
||||
<span data-decision="rolling">Rolling-map occupied</span>
|
||||
{semanticLegendEntries.map((entry) => (
|
||||
<span
|
||||
key={entry.id}
|
||||
data-decision="semantic"
|
||||
style={{ "--laboratory-metric-legend-color": entry.cssColor } as CSSProperties}
|
||||
>
|
||||
{entry.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Select, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
export interface LaboratoryOption<T extends string> {
|
||||
id: T;
|
||||
label: string;
|
||||
status?: "progress" | "retained" | "failed" | "unreviewed";
|
||||
}
|
||||
|
||||
export type LaboratoryExecutionClass =
|
||||
| "deterministic"
|
||||
| "ai-inference"
|
||||
| "hybrid";
|
||||
export type LaboratoryMethodCompleteness = "complete" | "legacy-partial";
|
||||
export type LaboratoryEvidenceKind = "recorded-replay" | "diagnostic-model";
|
||||
|
||||
export interface LaboratoryMethodComponent {
|
||||
kind: "source" | "tool" | "model" | "algorithm" | "runtime";
|
||||
name: string;
|
||||
version: string;
|
||||
role: string;
|
||||
identitySha256: string | null;
|
||||
}
|
||||
|
||||
export interface LaboratoryMethod {
|
||||
completeness: LaboratoryMethodCompleteness;
|
||||
executionClass: LaboratoryExecutionClass;
|
||||
pipelineId: string;
|
||||
components: readonly LaboratoryMethodComponent[];
|
||||
}
|
||||
|
||||
export interface LaboratoryBrief {
|
||||
question: string;
|
||||
approach: string;
|
||||
principalResult: string;
|
||||
limitation: string;
|
||||
}
|
||||
|
||||
export interface LaboratoryResultMetric {
|
||||
label: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export interface LaboratoryResultConclusion {
|
||||
proved: string;
|
||||
notProved: string;
|
||||
decision: string;
|
||||
}
|
||||
|
||||
export const LABORATORY_REPORT_TEMPLATE_VERSION =
|
||||
"missioncore.laboratory-report/v1";
|
||||
|
||||
const EXECUTION_LABELS: Record<LaboratoryExecutionClass, string> = {
|
||||
deterministic: "Детерминированный",
|
||||
"ai-inference": "AI inference",
|
||||
hybrid: "Гибридный",
|
||||
};
|
||||
|
||||
const COMPONENT_LABELS: Record<LaboratoryMethodComponent["kind"], string> = {
|
||||
source: "Источник",
|
||||
tool: "Инструмент",
|
||||
model: "Модель",
|
||||
algorithm: "Алгоритм",
|
||||
runtime: "Runtime",
|
||||
};
|
||||
|
||||
export function LaboratorySelector<T extends string>({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
disabled = false,
|
||||
searchable = false,
|
||||
onChange,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
label: string;
|
||||
value: T;
|
||||
options: readonly LaboratoryOption<T>[];
|
||||
disabled?: boolean;
|
||||
searchable?: boolean;
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="laboratory-selector">
|
||||
<div>
|
||||
<span className="section-eyebrow">{eyebrow}</span>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className="laboratory-selector__control">
|
||||
<span>{label}</span>
|
||||
<Select
|
||||
label={`Выбрать: ${label}`}
|
||||
value={value}
|
||||
options={options.map((option) => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
icon: option.status ? (
|
||||
<span
|
||||
className="laboratory-status-dot"
|
||||
data-status={option.status}
|
||||
aria-label={option.status === "progress"
|
||||
? "Есть подтверждённый прогресс"
|
||||
: option.status === "retained"
|
||||
? "Сохранено для сравнения"
|
||||
: option.status === "failed"
|
||||
? "Gate не пройден"
|
||||
: "Требует классификации"}
|
||||
>
|
||||
</span>
|
||||
) : undefined,
|
||||
}))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
disabled={disabled}
|
||||
searchable={searchable}
|
||||
searchPlaceholder={`Найти: ${label.toLocaleLowerCase("ru-RU")}`}
|
||||
onChange={(next) => onChange(next)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratorySummary({
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
statusTone = "neutral",
|
||||
facts,
|
||||
brief,
|
||||
method,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
statusTone?: "neutral" | "success" | "warning" | "danger" | "accent";
|
||||
facts: readonly { label: string; value: string }[];
|
||||
brief: LaboratoryBrief;
|
||||
method?: LaboratoryMethod | null;
|
||||
}) {
|
||||
const methodComplete = method?.completeness === "complete";
|
||||
return (
|
||||
<section className="laboratory-summary">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">ЛАБОРАТОРНАЯ РАБОТА</span>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<StatusBadge tone={statusTone}>{status}</StatusBadge>
|
||||
</header>
|
||||
|
||||
<dl className="laboratory-summary__facts">
|
||||
{facts.map((fact) => (
|
||||
<div key={fact.label}>
|
||||
<dt>{fact.label}</dt>
|
||||
<dd>{fact.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<dl className="laboratory-summary__brief">
|
||||
<div>
|
||||
<dt>Задача</dt>
|
||||
<dd>{brief.question}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Как проверяли</dt>
|
||||
<dd>{brief.approach}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Главный результат</dt>
|
||||
<dd>{brief.principalResult}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Ограничение</dt>
|
||||
<dd>{brief.limitation}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{method ? (
|
||||
<div className="laboratory-summary__method">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">МЕТОД</span>
|
||||
<strong>{method.pipelineId}</strong>
|
||||
</div>
|
||||
<small>
|
||||
{EXECUTION_LABELS[method.executionClass]}
|
||||
{" · "}
|
||||
{methodComplete ? "полная идентичность" : "legacy · частично"}
|
||||
</small>
|
||||
</header>
|
||||
<dl className="laboratory-summary__components">
|
||||
{method.components.map((component, index) => (
|
||||
<div key={`${component.kind}:${component.name}:${index}`}>
|
||||
<dt>{COMPONENT_LABELS[component.kind]}</dt>
|
||||
<dd>
|
||||
<strong>{component.name}</strong>
|
||||
<small>
|
||||
{component.role}
|
||||
{" · "}
|
||||
{component.version}
|
||||
{component.identitySha256
|
||||
? ` · ${component.identitySha256.slice(0, 12)}`
|
||||
: ""}
|
||||
</small>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratoryEvidence({
|
||||
eyebrow,
|
||||
title,
|
||||
kind,
|
||||
resizable = false,
|
||||
children,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
kind: LaboratoryEvidenceKind;
|
||||
resizable?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="lab-result-surface"
|
||||
data-evidence-kind={kind}
|
||||
data-resizable={resizable ? "true" : undefined}
|
||||
>
|
||||
<header>
|
||||
<span className="section-eyebrow">{eyebrow}</span>
|
||||
<strong>{title}</strong>
|
||||
</header>
|
||||
<div className="laboratory-evidence-frame">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratoryConclusion({
|
||||
proved,
|
||||
notProved,
|
||||
decision,
|
||||
}: {
|
||||
proved: string;
|
||||
notProved: string;
|
||||
decision: string;
|
||||
}) {
|
||||
return (
|
||||
<dl className="laboratory-result-conclusion">
|
||||
<div>
|
||||
<dt>Доказано</dt>
|
||||
<dd>{proved}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Не доказано</dt>
|
||||
<dd>{notProved}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Решение</dt>
|
||||
<dd>{decision}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratoryMetricGrid({
|
||||
metrics,
|
||||
}: {
|
||||
metrics: readonly LaboratoryResultMetric[];
|
||||
}) {
|
||||
return (
|
||||
<div className="laboratory-result-metrics">
|
||||
{metrics.map((metric) => (
|
||||
<div key={metric.label}>
|
||||
<span>{metric.label}</span>
|
||||
<strong>{metric.value}</strong>
|
||||
<small>{metric.hint}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratoryResultSummary({
|
||||
title,
|
||||
status,
|
||||
statusTone = "neutral",
|
||||
metrics,
|
||||
conclusion,
|
||||
}: {
|
||||
title: string;
|
||||
status: string;
|
||||
statusTone?: "neutral" | "success" | "warning" | "danger" | "accent";
|
||||
metrics: readonly LaboratoryResultMetric[];
|
||||
conclusion: LaboratoryResultConclusion;
|
||||
}) {
|
||||
return (
|
||||
<section className="laboratory-result-summary">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">РЕЗУЛЬТАТ И ВЫВОД</span>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
<StatusBadge tone={statusTone}>{status}</StatusBadge>
|
||||
</header>
|
||||
<LaboratoryMetricGrid metrics={metrics} />
|
||||
<LaboratoryConclusion {...conclusion} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratoryWorkTemplate({
|
||||
summary,
|
||||
evidence,
|
||||
result = null,
|
||||
details = null,
|
||||
}: {
|
||||
summary: ReactNode;
|
||||
evidence: ReactNode;
|
||||
result?: ReactNode;
|
||||
details?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="laboratory-work-template"
|
||||
data-template-contract={LABORATORY_REPORT_TEMPLATE_VERSION}
|
||||
>
|
||||
{summary}
|
||||
{evidence}
|
||||
{result}
|
||||
{details}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export type RecordedEvidenceBoxTone =
|
||||
| "accent"
|
||||
| "danger"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "neutral";
|
||||
|
||||
export interface RecordedEvidenceBox {
|
||||
boxXyxy: readonly [number, number, number, number];
|
||||
label: string;
|
||||
tone: RecordedEvidenceBoxTone;
|
||||
dashed?: boolean;
|
||||
}
|
||||
|
||||
function rgba(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
alpha = 1,
|
||||
): string {
|
||||
const channels = getComputedStyle(host)
|
||||
.getPropertyValue(token)
|
||||
.trim()
|
||||
.match(/[\d.]+/g)
|
||||
?.slice(0, 3)
|
||||
.map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
function toneColor(host: HTMLElement, tone: RecordedEvidenceBoxTone): string {
|
||||
if (tone === "danger") return rgba(host, "--nodedc-danger-rgb", [255, 104, 112]);
|
||||
if (tone === "success") return rgba(host, "--nodedc-success-rgb", [181, 255, 90]);
|
||||
if (tone === "warning") return rgba(host, "--nodedc-warning-rgb", [255, 197, 92]);
|
||||
if (tone === "neutral") return rgba(host, "--nodedc-foreground-rgb", [245, 245, 245], 0.7);
|
||||
return rgba(host, "--nodedc-accent-rgb", [232, 56, 126]);
|
||||
}
|
||||
|
||||
export function RecordedEvidenceBoxOverlay({
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
boxes,
|
||||
ariaLabel,
|
||||
}: {
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
boxes: readonly RecordedEvidenceBox[];
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const host = canvas?.parentElement;
|
||||
if (!host || !canvas) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
|
||||
canvas.width = Math.round(width * pixelRatio);
|
||||
canvas.height = Math.round(height * pixelRatio);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
|
||||
const scale = Math.min(width / imageWidth, height / imageHeight);
|
||||
const drawWidth = imageWidth * scale;
|
||||
const drawHeight = imageHeight * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
for (const item of boxes) {
|
||||
const [left, top, right, bottom] = item.boxXyxy;
|
||||
const x = offsetX + left * scale;
|
||||
const y = offsetY + top * scale;
|
||||
const boxWidth = (right - left) * scale;
|
||||
const boxHeight = (bottom - top) * scale;
|
||||
const stroke = toneColor(host, item.tone);
|
||||
context.strokeStyle = stroke;
|
||||
context.lineWidth = Math.max(1.5, 2 * scale);
|
||||
context.setLineDash(item.dashed ? [5, 4] : []);
|
||||
context.strokeRect(x, y, boxWidth, boxHeight);
|
||||
context.setLineDash([]);
|
||||
|
||||
const fontSize = Math.max(9, 10 * scale);
|
||||
context.font = `650 ${fontSize}px Inter, system-ui, sans-serif`;
|
||||
const labelWidth = Math.min(drawWidth, context.measureText(item.label).width + 8);
|
||||
const labelHeight = fontSize + 6;
|
||||
const labelX = Math.min(offsetX + drawWidth - labelWidth, Math.max(offsetX, x));
|
||||
const labelY = Math.max(offsetY, y - labelHeight);
|
||||
context.fillStyle = rgba(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.9);
|
||||
context.fillRect(labelX, labelY, labelWidth, labelHeight);
|
||||
context.fillStyle = stroke;
|
||||
context.fillText(item.label, labelX + 4, labelY + fontSize + 1, labelWidth - 8);
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [boxes, imageHeight, imageWidth]);
|
||||
|
||||
return <canvas ref={canvasRef} role="img" aria-label={ariaLabel} />;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Icon } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
RecordedEvidenceBoxOverlay,
|
||||
type RecordedEvidenceBox,
|
||||
} from "./RecordedEvidenceBoxOverlay";
|
||||
import {
|
||||
RecordedEvidenceSemanticMaskOverlay,
|
||||
type RecordedEvidenceSemanticOverlay,
|
||||
} from "./RecordedEvidenceSemanticMaskOverlay";
|
||||
|
||||
export function RecordedEvidenceImageScene({
|
||||
src,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
boxes,
|
||||
semanticOverlay,
|
||||
ariaLabel,
|
||||
}: {
|
||||
src: string;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
boxes: readonly RecordedEvidenceBox[];
|
||||
semanticOverlay?: RecordedEvidenceSemanticOverlay;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||
|
||||
useEffect(() => setState("loading"), [src]);
|
||||
|
||||
return (
|
||||
<div className="recorded-evidence-image-scene">
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
draggable={false}
|
||||
onLoad={() => setState("ready")}
|
||||
onError={() => setState("error")}
|
||||
/>
|
||||
{state === "ready" ? (
|
||||
<>
|
||||
{semanticOverlay ? (
|
||||
<RecordedEvidenceSemanticMaskOverlay
|
||||
{...semanticOverlay}
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
/>
|
||||
) : null}
|
||||
<RecordedEvidenceBoxOverlay
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
boxes={boxes}
|
||||
ariaLabel={ariaLabel}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
{state === "loading" ? (
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
) : (
|
||||
<Icon name="alert" size={18} />
|
||||
)}
|
||||
<span>
|
||||
{state === "loading"
|
||||
? "Декодируем один точный CAMERA-кадр"
|
||||
: "Точный CAMERA-кадр недоступен."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+642
@@ -0,0 +1,642 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface RecordedEvidenceSemanticClass {
|
||||
id: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type RecordedEvidenceSemanticToken =
|
||||
| "--nodedc-accent-rgb"
|
||||
| "--nodedc-danger-rgb"
|
||||
| "--nodedc-foreground-rgb"
|
||||
| "--nodedc-success-rgb"
|
||||
| "--nodedc-text-muted"
|
||||
| "--nodedc-warning-rgb";
|
||||
|
||||
export type RecordedEvidenceSemanticColor =
|
||||
| {
|
||||
kind: "token";
|
||||
token: RecordedEvidenceSemanticToken;
|
||||
}
|
||||
| {
|
||||
kind: "diagnostic";
|
||||
rgb: readonly [number, number, number];
|
||||
}
|
||||
| {
|
||||
kind: "transparent";
|
||||
};
|
||||
|
||||
export interface RecordedEvidenceSemanticPaletteEntry {
|
||||
classId: number;
|
||||
color: RecordedEvidenceSemanticColor;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export interface RecordedEvidenceSemanticOverlay {
|
||||
src: string;
|
||||
prefetchSrcs?: readonly string[];
|
||||
classes: readonly RecordedEvidenceSemanticClass[];
|
||||
palette: readonly RecordedEvidenceSemanticPaletteEntry[];
|
||||
opacity?: number;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
interface DecodedSemanticMask {
|
||||
key: string;
|
||||
width: number;
|
||||
height: number;
|
||||
source: ImageBitmap | HTMLImageElement;
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
interface PendingSemanticMask {
|
||||
controller: AbortController;
|
||||
subscribers: number;
|
||||
promise: Promise<DecodedSemanticMask>;
|
||||
}
|
||||
|
||||
const MASK_CACHE_LIMIT = 20;
|
||||
const MASK_PREFETCH_LIMIT = 12;
|
||||
const decodedMaskCache = new Map<string, DecodedSemanticMask>();
|
||||
const pendingMaskCache = new Map<string, PendingSemanticMask>();
|
||||
|
||||
const TOKEN_FALLBACKS: Record<RecordedEvidenceSemanticToken, readonly [number, number, number]> = {
|
||||
"--nodedc-accent-rgb": [232, 56, 126],
|
||||
"--nodedc-danger-rgb": [255, 104, 112],
|
||||
"--nodedc-foreground-rgb": [245, 245, 245],
|
||||
"--nodedc-success-rgb": [181, 255, 90],
|
||||
"--nodedc-text-muted": [147, 151, 159],
|
||||
"--nodedc-warning-rgb": [255, 197, 92],
|
||||
};
|
||||
|
||||
function clampChannel(value: number): number {
|
||||
return Math.max(0, Math.min(255, Math.round(value)));
|
||||
}
|
||||
|
||||
function clampOpacity(value: number | undefined, fallback: number): number {
|
||||
return Math.max(0, Math.min(1, Number.isFinite(value) ? Number(value) : fallback));
|
||||
}
|
||||
|
||||
function semanticMaskKey(src: string, width: number, height: number): string {
|
||||
return `${width}x${height}:${src}`;
|
||||
}
|
||||
|
||||
function rememberMask(mask: DecodedSemanticMask): void {
|
||||
const previous = decodedMaskCache.get(mask.key);
|
||||
if (previous && previous !== mask) previous.release();
|
||||
decodedMaskCache.delete(mask.key);
|
||||
decodedMaskCache.set(mask.key, mask);
|
||||
while (decodedMaskCache.size > MASK_CACHE_LIMIT) {
|
||||
const oldest = decodedMaskCache.keys().next().value;
|
||||
if (typeof oldest !== "string") break;
|
||||
const evicted = decodedMaskCache.get(oldest);
|
||||
decodedMaskCache.delete(oldest);
|
||||
evicted?.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function imageSourceFromBlob(
|
||||
blob: Blob,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ source: ImageBitmap | HTMLImageElement; width: number; height: number; release: () => void }> {
|
||||
if (typeof createImageBitmap === "function") {
|
||||
const bitmap = await createImageBitmap(blob, {
|
||||
colorSpaceConversion: "none",
|
||||
premultiplyAlpha: "none",
|
||||
});
|
||||
if (signal.aborted) {
|
||||
bitmap.close();
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
return {
|
||||
source: bitmap,
|
||||
width: bitmap.width,
|
||||
height: bitmap.height,
|
||||
release: () => bitmap.close(),
|
||||
};
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const image = new Image();
|
||||
image.decoding = "async";
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
image.removeEventListener("load", onLoad);
|
||||
image.removeEventListener("error", onError);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
const onLoad = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = () => {
|
||||
cleanup();
|
||||
reject(new Error("Semantic mask image decode failed"));
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
image.addEventListener("load", onLoad, { once: true });
|
||||
image.addEventListener("error", onError, { once: true });
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
image.src = objectUrl;
|
||||
});
|
||||
return {
|
||||
source: image,
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight,
|
||||
release: () => URL.revokeObjectURL(objectUrl),
|
||||
};
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function decodeSemanticMask(
|
||||
key: string,
|
||||
src: string,
|
||||
expectedWidth: number,
|
||||
expectedHeight: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<DecodedSemanticMask> {
|
||||
const response = await fetch(src, { cache: "force-cache", signal });
|
||||
if (!response.ok) throw new Error(`Semantic mask request failed: ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
const decoded = await imageSourceFromBlob(blob, signal);
|
||||
if (decoded.width !== expectedWidth || decoded.height !== expectedHeight) {
|
||||
decoded.release();
|
||||
throw new Error(
|
||||
`Semantic mask dimensions ${decoded.width}x${decoded.height} do not match ${expectedWidth}x${expectedHeight}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
key,
|
||||
width: decoded.width,
|
||||
height: decoded.height,
|
||||
source: decoded.source,
|
||||
release: decoded.release,
|
||||
};
|
||||
}
|
||||
|
||||
function subscribeToSemanticMask(
|
||||
src: string,
|
||||
width: number,
|
||||
height: number,
|
||||
): { promise: Promise<DecodedSemanticMask>; release: () => void } {
|
||||
const key = semanticMaskKey(src, width, height);
|
||||
const cached = decodedMaskCache.get(key);
|
||||
if (cached) {
|
||||
decodedMaskCache.delete(key);
|
||||
decodedMaskCache.set(key, cached);
|
||||
return { promise: Promise.resolve(cached), release: () => undefined };
|
||||
}
|
||||
|
||||
let pending = pendingMaskCache.get(key);
|
||||
if (!pending) {
|
||||
const controller = new AbortController();
|
||||
let next: PendingSemanticMask;
|
||||
const promise = decodeSemanticMask(key, src, width, height, controller.signal)
|
||||
.then((mask) => {
|
||||
rememberMask(mask);
|
||||
return mask;
|
||||
})
|
||||
.finally(() => {
|
||||
if (pendingMaskCache.get(key) === next) pendingMaskCache.delete(key);
|
||||
});
|
||||
next = { controller, subscribers: 0, promise };
|
||||
pendingMaskCache.set(key, next);
|
||||
pending = next;
|
||||
}
|
||||
pending.subscribers += 1;
|
||||
let released = false;
|
||||
return {
|
||||
promise: pending.promise,
|
||||
release: () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
pending!.subscribers -= 1;
|
||||
if (pending!.subscribers > 0 || decodedMaskCache.has(key)) return;
|
||||
pending!.controller.abort();
|
||||
if (pendingMaskCache.get(key) === pending) pendingMaskCache.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function recordedEvidenceSemanticCssColor(
|
||||
color: RecordedEvidenceSemanticColor,
|
||||
): string {
|
||||
if (color.kind === "transparent") return "transparent";
|
||||
if (color.kind === "token") return `rgb(var(${color.token}))`;
|
||||
const [red, green, blue] = color.rgb.map(clampChannel);
|
||||
return `rgb(${red} ${green} ${blue})`;
|
||||
}
|
||||
|
||||
export function resolveRecordedEvidenceSemanticRgb(
|
||||
host: HTMLElement,
|
||||
color: RecordedEvidenceSemanticColor,
|
||||
): readonly [number, number, number] | null {
|
||||
if (color.kind === "transparent") return null;
|
||||
if (color.kind === "diagnostic") return color.rgb.map(clampChannel) as [number, number, number];
|
||||
const channels = getComputedStyle(host)
|
||||
.getPropertyValue(color.token)
|
||||
.trim()
|
||||
.match(/[\d.]+/g)
|
||||
?.slice(0, 3)
|
||||
.map(Number);
|
||||
return channels?.length === 3
|
||||
? channels.map(clampChannel) as [number, number, number]
|
||||
: TOKEN_FALLBACKS[color.token];
|
||||
}
|
||||
|
||||
interface SemanticMaskRenderer {
|
||||
clear: (width: number, height: number, pixelRatio: number) => void;
|
||||
render: (
|
||||
mask: DecodedSemanticMask,
|
||||
palette: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
pixelRatio: number,
|
||||
) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
function compileSemanticShader(
|
||||
gl: WebGLRenderingContext,
|
||||
type: number,
|
||||
source: string,
|
||||
): WebGLShader | null {
|
||||
const shader = gl.createShader(type);
|
||||
if (!shader) return null;
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
|
||||
gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
|
||||
function createSemanticMaskRenderer(canvas: HTMLCanvasElement): SemanticMaskRenderer | null {
|
||||
const gl = canvas.getContext("webgl", {
|
||||
alpha: true,
|
||||
antialias: false,
|
||||
depth: false,
|
||||
premultipliedAlpha: false,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
if (!gl) return null;
|
||||
const vertexShader = compileSemanticShader(gl, gl.VERTEX_SHADER, `
|
||||
attribute vec2 a_position;
|
||||
attribute vec2 a_uv;
|
||||
varying vec2 v_uv;
|
||||
void main() {
|
||||
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||
v_uv = a_uv;
|
||||
}
|
||||
`);
|
||||
const fragmentShader = compileSemanticShader(gl, gl.FRAGMENT_SHADER, `
|
||||
precision mediump float;
|
||||
uniform sampler2D u_mask;
|
||||
uniform sampler2D u_palette;
|
||||
varying vec2 v_uv;
|
||||
void main() {
|
||||
// DOM image sources use a top-left origin while WebGL texture coordinates
|
||||
// use a bottom-left origin. UNPACK_FLIP_Y_WEBGL is ignored for
|
||||
// ImageBitmap uploads, so keep upload semantics stable and flip V here.
|
||||
vec2 mask_uv = vec2(v_uv.x, 1.0 - v_uv.y);
|
||||
float class_id = floor(texture2D(u_mask, mask_uv).r * 255.0 + 0.5);
|
||||
gl_FragColor = texture2D(u_palette, vec2((class_id + 0.5) / 256.0, 0.5));
|
||||
}
|
||||
`);
|
||||
if (!vertexShader || !fragmentShader) {
|
||||
if (vertexShader) gl.deleteShader(vertexShader);
|
||||
if (fragmentShader) gl.deleteShader(fragmentShader);
|
||||
return null;
|
||||
}
|
||||
const program = gl.createProgram();
|
||||
if (!program) {
|
||||
gl.deleteShader(vertexShader);
|
||||
gl.deleteShader(fragmentShader);
|
||||
return null;
|
||||
}
|
||||
gl.attachShader(program, vertexShader);
|
||||
gl.attachShader(program, fragmentShader);
|
||||
gl.linkProgram(program);
|
||||
gl.deleteShader(vertexShader);
|
||||
gl.deleteShader(fragmentShader);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
gl.deleteProgram(program);
|
||||
return null;
|
||||
}
|
||||
const buffer = gl.createBuffer();
|
||||
const maskTexture = gl.createTexture();
|
||||
const paletteTexture = gl.createTexture();
|
||||
if (!buffer || !maskTexture || !paletteTexture) {
|
||||
if (buffer) gl.deleteBuffer(buffer);
|
||||
if (maskTexture) gl.deleteTexture(maskTexture);
|
||||
if (paletteTexture) gl.deleteTexture(paletteTexture);
|
||||
gl.deleteProgram(program);
|
||||
return null;
|
||||
}
|
||||
const positionLocation = gl.getAttribLocation(program, "a_position");
|
||||
const uvLocation = gl.getAttribLocation(program, "a_uv");
|
||||
const maskLocation = gl.getUniformLocation(program, "u_mask");
|
||||
const paletteLocation = gl.getUniformLocation(program, "u_palette");
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
||||
-1, -1, 0, 0,
|
||||
1, -1, 1, 0,
|
||||
-1, 1, 0, 1,
|
||||
1, 1, 1, 1,
|
||||
]), gl.STATIC_DRAW);
|
||||
|
||||
const resize = (width: number, height: number, pixelRatio: number) => {
|
||||
const pixelWidth = Math.max(1, Math.round(width * pixelRatio));
|
||||
const pixelHeight = Math.max(1, Math.round(height * pixelRatio));
|
||||
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
|
||||
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
};
|
||||
|
||||
const clear = (width: number, height: number, pixelRatio: number) => {
|
||||
resize(width, height, pixelRatio);
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
};
|
||||
|
||||
return {
|
||||
clear,
|
||||
render: (mask, palette, width, height, pixelRatio) => {
|
||||
if (gl.isContextLost()) throw new Error("Semantic WebGL context lost");
|
||||
clear(width, height, pixelRatio);
|
||||
gl.useProgram(program);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
gl.enableVertexAttribArray(positionLocation);
|
||||
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 16, 0);
|
||||
gl.enableVertexAttribArray(uvLocation);
|
||||
gl.vertexAttribPointer(uvLocation, 2, gl.FLOAT, false, 16, 8);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, maskTexture);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, mask.source);
|
||||
gl.uniform1i(maskLocation, 0);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, paletteTexture);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 256, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, palette);
|
||||
gl.uniform1i(paletteLocation, 1);
|
||||
|
||||
const scale = Math.min(width / mask.width, height / mask.height);
|
||||
const drawWidth = mask.width * scale;
|
||||
const drawHeight = mask.height * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
gl.viewport(
|
||||
Math.round(offsetX * pixelRatio),
|
||||
Math.round(offsetY * pixelRatio),
|
||||
Math.max(1, Math.round(drawWidth * pixelRatio)),
|
||||
Math.max(1, Math.round(drawHeight * pixelRatio)),
|
||||
);
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
},
|
||||
dispose: () => {
|
||||
gl.deleteTexture(maskTexture);
|
||||
gl.deleteTexture(paletteTexture);
|
||||
gl.deleteBuffer(buffer);
|
||||
gl.deleteProgram(program);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function semanticPalettePixels(
|
||||
host: HTMLElement,
|
||||
classes: readonly RecordedEvidenceSemanticClass[],
|
||||
palette: readonly RecordedEvidenceSemanticPaletteEntry[],
|
||||
opacity: number,
|
||||
): Uint8Array {
|
||||
const pixels = new Uint8Array(256 * 4);
|
||||
const declaredClassIds = new Set(
|
||||
classes
|
||||
.map((item) => item.id)
|
||||
.filter((classId) => Number.isInteger(classId) && classId >= 0 && classId <= 255),
|
||||
);
|
||||
for (const entry of palette) {
|
||||
if (!declaredClassIds.has(entry.classId)) continue;
|
||||
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
|
||||
if (!rgb) continue;
|
||||
const offset = entry.classId * 4;
|
||||
pixels[offset] = rgb[0];
|
||||
pixels[offset + 1] = rgb[1];
|
||||
pixels[offset + 2] = rgb[2];
|
||||
pixels[offset + 3] = Math.round(
|
||||
clampOpacity(entry.opacity, 1) * clampOpacity(opacity, 0.46) * 255,
|
||||
);
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
|
||||
function renderSemanticMask2d(
|
||||
canvas: HTMLCanvasElement,
|
||||
mask: DecodedSemanticMask,
|
||||
palette: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
pixelRatio: number,
|
||||
) {
|
||||
canvas.width = Math.max(1, Math.round(width * pixelRatio));
|
||||
canvas.height = Math.max(1, Math.round(height * pixelRatio));
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
const colorCanvas = document.createElement("canvas");
|
||||
colorCanvas.width = mask.width;
|
||||
colorCanvas.height = mask.height;
|
||||
const colorContext = colorCanvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!colorContext) return;
|
||||
colorContext.drawImage(mask.source, 0, 0);
|
||||
const imageData = colorContext.getImageData(0, 0, mask.width, mask.height);
|
||||
for (let offset = 0; offset < imageData.data.length; offset += 4) {
|
||||
const paletteOffset = (imageData.data[offset] ?? 0) * 4;
|
||||
imageData.data[offset] = palette[paletteOffset] ?? 0;
|
||||
imageData.data[offset + 1] = palette[paletteOffset + 1] ?? 0;
|
||||
imageData.data[offset + 2] = palette[paletteOffset + 2] ?? 0;
|
||||
imageData.data[offset + 3] = palette[paletteOffset + 3] ?? 0;
|
||||
}
|
||||
colorContext.putImageData(imageData, 0, 0);
|
||||
const scale = Math.min(width / mask.width, height / mask.height);
|
||||
const drawWidth = mask.width * scale;
|
||||
const drawHeight = mask.height * scale;
|
||||
context.imageSmoothingEnabled = false;
|
||||
context.drawImage(
|
||||
colorCanvas,
|
||||
(width - drawWidth) / 2,
|
||||
(height - drawHeight) / 2,
|
||||
drawWidth,
|
||||
drawHeight,
|
||||
);
|
||||
}
|
||||
|
||||
export function RecordedEvidenceSemanticMaskOverlay({
|
||||
src,
|
||||
prefetchSrcs = [],
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
classes,
|
||||
palette,
|
||||
opacity = 0.46,
|
||||
ariaLabel,
|
||||
}: RecordedEvidenceSemanticOverlay & {
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const rendererRef = useRef<SemanticMaskRenderer | null | undefined>(undefined);
|
||||
const [rendererMode, setRendererMode] = useState<"webgl" | "2d">("webgl");
|
||||
const [mask, setMask] = useState<DecodedSemanticMask | null>(null);
|
||||
const [failure, setFailure] = useState<string | null>(null);
|
||||
const expectedKey = semanticMaskKey(src, imageWidth, imageHeight);
|
||||
const prefetchSignature = prefetchSrcs.join("\n");
|
||||
const renderMask = failure
|
||||
? null
|
||||
: mask?.key === expectedKey
|
||||
? mask
|
||||
: decodedMaskCache.get(expectedKey) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setFailure(null);
|
||||
const subscription = subscribeToSemanticMask(src, imageWidth, imageHeight);
|
||||
let current = true;
|
||||
void subscription.promise.then((decoded) => {
|
||||
if (!current || decoded.key !== expectedKey) return;
|
||||
setMask(decoded);
|
||||
}).catch((error: unknown) => {
|
||||
if (!current || (error instanceof DOMException && error.name === "AbortError")) return;
|
||||
setFailure("Semantic mask не прошла проверку или декодирование.");
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
subscription.release();
|
||||
};
|
||||
}, [expectedKey, imageHeight, imageWidth, src]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscriptions = prefetchSignature
|
||||
.split("\n")
|
||||
.filter((candidate) => candidate && candidate !== src)
|
||||
.slice(0, MASK_PREFETCH_LIMIT)
|
||||
.map((candidate) => subscribeToSemanticMask(candidate, imageWidth, imageHeight));
|
||||
for (const subscription of subscriptions) {
|
||||
void subscription.promise
|
||||
.catch(() => undefined)
|
||||
.finally(subscription.release);
|
||||
}
|
||||
}, [imageHeight, imageWidth, prefetchSignature, src]);
|
||||
|
||||
useEffect(() => () => {
|
||||
rendererRef.current?.dispose();
|
||||
rendererRef.current = undefined;
|
||||
}, [rendererMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || rendererMode !== "webgl") return;
|
||||
const onContextLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
setRendererMode("2d");
|
||||
};
|
||||
canvas.addEventListener("webglcontextlost", onContextLost);
|
||||
return () => canvas.removeEventListener("webglcontextlost", onContextLost);
|
||||
}, [rendererMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const host = canvas?.parentElement;
|
||||
if (!canvas || !host) return;
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
|
||||
if (rendererMode === "webgl" && rendererRef.current === undefined) {
|
||||
rendererRef.current = createSemanticMaskRenderer(canvas);
|
||||
if (!rendererRef.current) {
|
||||
setRendererMode("2d");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const renderer = rendererMode === "webgl" ? rendererRef.current : null;
|
||||
if (!renderMask) {
|
||||
if (renderer) {
|
||||
renderer.clear(width, height, pixelRatio);
|
||||
} else {
|
||||
canvas.width = Math.max(1, Math.round(width * pixelRatio));
|
||||
canvas.height = Math.max(1, Math.round(height * pixelRatio));
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const resolvedPalette = semanticPalettePixels(host, classes, palette, opacity);
|
||||
if (renderer) {
|
||||
try {
|
||||
renderer.render(renderMask, resolvedPalette, width, height, pixelRatio);
|
||||
} catch {
|
||||
setRendererMode("2d");
|
||||
}
|
||||
} else {
|
||||
renderSemanticMask2d(
|
||||
canvas,
|
||||
renderMask,
|
||||
resolvedPalette,
|
||||
width,
|
||||
height,
|
||||
pixelRatio,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [classes, expectedKey, imageHeight, imageWidth, opacity, palette, rendererMode, renderMask]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas
|
||||
key={rendererMode}
|
||||
ref={canvasRef}
|
||||
className="recorded-evidence-semantic-mask-overlay"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
aria-busy={!failure && !renderMask}
|
||||
data-state={failure ? "error" : renderMask ? "ready" : "loading"}
|
||||
data-renderer={rendererMode}
|
||||
style={{ zIndex: 1 }}
|
||||
/>
|
||||
{failure ? (
|
||||
<div className="recorded-evidence-semantic-mask-overlay__error" role="alert">
|
||||
{failure}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
RecordedFmp4Player,
|
||||
type RecordedObservationPlayback,
|
||||
} from "../RecordedFmp4Player";
|
||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||
import {
|
||||
RecordedEvidenceBoxOverlay,
|
||||
type RecordedEvidenceBox,
|
||||
type RecordedEvidenceBoxTone,
|
||||
} from "./RecordedEvidenceBoxOverlay";
|
||||
import {
|
||||
RecordedEvidenceSemanticMaskOverlay,
|
||||
type RecordedEvidenceSemanticOverlay,
|
||||
} from "./RecordedEvidenceSemanticMaskOverlay";
|
||||
|
||||
export type { RecordedEvidenceBox, RecordedEvidenceBoxTone };
|
||||
|
||||
export function RecordedEvidenceVideoScene({
|
||||
source,
|
||||
playback,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
boxes,
|
||||
semanticOverlay,
|
||||
ariaLabel,
|
||||
interactive = true,
|
||||
onPlaybackChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
playback: RecordedObservationPlayback;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
boxes: readonly RecordedEvidenceBox[];
|
||||
semanticOverlay?: RecordedEvidenceSemanticOverlay;
|
||||
ariaLabel: string;
|
||||
interactive?: boolean;
|
||||
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="recorded-evidence-video-scene">
|
||||
<RecordedFmp4Player
|
||||
source={source}
|
||||
playback={playback}
|
||||
interactive={interactive}
|
||||
prepare
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
/>
|
||||
{semanticOverlay ? (
|
||||
<RecordedEvidenceSemanticMaskOverlay
|
||||
{...semanticOverlay}
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
/>
|
||||
) : null}
|
||||
<RecordedEvidenceBoxOverlay
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
boxes={boxes}
|
||||
ariaLabel={ariaLabel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Button, Icon } from "@nodedc/ui-react";
|
||||
|
||||
import type {
|
||||
LaboratoryAnnotationAction,
|
||||
LaboratoryViewAction,
|
||||
} from "../../workspaces/contracts";
|
||||
|
||||
export function useLaboratoryAnnotationHeader(): {
|
||||
control: ReactNode;
|
||||
setAction: (action: LaboratoryAnnotationAction | null) => void;
|
||||
setViewAction: (action: LaboratoryViewAction | null) => void;
|
||||
} {
|
||||
const [action, setAction] = useState<LaboratoryAnnotationAction | null>(null);
|
||||
const [viewAction, setViewAction] = useState<LaboratoryViewAction | null>(null);
|
||||
return {
|
||||
setAction,
|
||||
setViewAction,
|
||||
control: action || viewAction ? (
|
||||
<div className="laboratory-header-tools">
|
||||
{viewAction ? (
|
||||
<Button shape="pill" variant="secondary" onClick={viewAction.onClick}>
|
||||
{viewAction.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{action ? (
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant="accent"
|
||||
icon={<Icon name="edit" size={16} />}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { RecordedObservationPlayback } from "../RecordedFmp4Player";
|
||||
|
||||
export interface RecordedEvidencePlaybackRange {
|
||||
startSeconds: number;
|
||||
endSeconds: number;
|
||||
}
|
||||
|
||||
function validRange(
|
||||
range: RecordedEvidencePlaybackRange | null,
|
||||
): range is RecordedEvidencePlaybackRange {
|
||||
return Boolean(
|
||||
range
|
||||
&& Number.isFinite(range.startSeconds)
|
||||
&& Number.isFinite(range.endSeconds)
|
||||
&& range.endSeconds > range.startSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
export function clampRecordedEvidenceSeconds(
|
||||
seconds: number,
|
||||
range: RecordedEvidencePlaybackRange,
|
||||
): number {
|
||||
return Math.min(range.endSeconds, Math.max(range.startSeconds, seconds));
|
||||
}
|
||||
|
||||
export function advanceRecordedEvidencePlayback(
|
||||
playback: RecordedObservationPlayback,
|
||||
elapsedSeconds: number,
|
||||
range: RecordedEvidencePlaybackRange,
|
||||
): RecordedObservationPlayback {
|
||||
if (!playback.playing || !Number.isFinite(elapsedSeconds) || elapsedSeconds <= 0) {
|
||||
return playback;
|
||||
}
|
||||
const next = playback.currentSeconds + elapsedSeconds * (playback.rate ?? 1);
|
||||
if (next >= range.endSeconds) {
|
||||
return { ...playback, currentSeconds: range.endSeconds, playing: false };
|
||||
}
|
||||
return { ...playback, currentSeconds: clampRecordedEvidenceSeconds(next, range) };
|
||||
}
|
||||
|
||||
export function useRecordedEvidencePlayback(
|
||||
range: RecordedEvidencePlaybackRange | null,
|
||||
) {
|
||||
const [playback, setPlayback] = useState<RecordedObservationPlayback>({
|
||||
currentSeconds: 0,
|
||||
playing: false,
|
||||
rate: 1,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!validRange(range)) return;
|
||||
setPlayback((current) => ({
|
||||
...current,
|
||||
currentSeconds: current.currentSeconds === 0
|
||||
? range.startSeconds
|
||||
: clampRecordedEvidenceSeconds(current.currentSeconds, range),
|
||||
}));
|
||||
}, [range]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!validRange(range) || !playback.playing) return;
|
||||
let animationFrame = 0;
|
||||
let previous = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const elapsed = Math.max(0, now - previous);
|
||||
if (elapsed >= 32) {
|
||||
previous = now;
|
||||
setPlayback((current) => {
|
||||
if (!current.playing) return current;
|
||||
return advanceRecordedEvidencePlayback(current, elapsed / 1_000, range);
|
||||
});
|
||||
}
|
||||
animationFrame = window.requestAnimationFrame(tick);
|
||||
};
|
||||
animationFrame = window.requestAnimationFrame(tick);
|
||||
return () => window.cancelAnimationFrame(animationFrame);
|
||||
}, [playback.playing, range]);
|
||||
|
||||
const seek = useCallback((seconds: number, pause = true) => {
|
||||
if (!validRange(range)) return;
|
||||
setPlayback((current) => ({
|
||||
...current,
|
||||
currentSeconds: clampRecordedEvidenceSeconds(seconds, range),
|
||||
playing: pause ? false : current.playing,
|
||||
}));
|
||||
}, [range]);
|
||||
|
||||
const setPlaying = useCallback((playing: boolean) => {
|
||||
if (!validRange(range)) return;
|
||||
setPlayback((current) => ({
|
||||
...current,
|
||||
currentSeconds: playing && current.currentSeconds >= range.endSeconds
|
||||
? range.startSeconds
|
||||
: current.currentSeconds,
|
||||
playing,
|
||||
}));
|
||||
}, [range]);
|
||||
|
||||
const setRate = useCallback((rate: number) => {
|
||||
if (![0.5, 1, 2].includes(rate)) return;
|
||||
setPlayback((current) => ({ ...current, rate }));
|
||||
}, []);
|
||||
|
||||
return useMemo(() => ({
|
||||
playback,
|
||||
seek,
|
||||
setPlaying,
|
||||
setRate,
|
||||
}), [playback, seek, setPlaying, setRate]);
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import {
|
||||
Button,
|
||||
Checker,
|
||||
ColorField,
|
||||
ControlRow,
|
||||
InspectorSelectField,
|
||||
RangeControl,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type {
|
||||
MapGhostPinLabelMode,
|
||||
MapViewDocument,
|
||||
} from "../../core/map/mapView";
|
||||
|
||||
export function GhostPinSettingsPanel({
|
||||
draft,
|
||||
updateDraft,
|
||||
onGenerate,
|
||||
onClear,
|
||||
}: {
|
||||
draft: MapViewDocument;
|
||||
updateDraft: (
|
||||
update: (current: MapViewDocument) => MapViewDocument,
|
||||
) => void;
|
||||
onGenerate: () => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const configuration = draft.view.ghostPins;
|
||||
const profile = configuration.presentation;
|
||||
return (
|
||||
<div className="mission-map__inspector-controls">
|
||||
<ControlRow label="Контур">
|
||||
<StatusBadge tone="warning">Временная симуляция</StatusBadge>
|
||||
</ControlRow>
|
||||
<RangeControl
|
||||
label="Количество пинов"
|
||||
value={configuration.count}
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
formatValue={(value) => `${value}`}
|
||||
onChange={(count) => updateDraft((current) => {
|
||||
current.view.ghostPins.count = count;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Радиус от центра view"
|
||||
value={configuration.radiusMeters}
|
||||
min={100}
|
||||
max={100_000}
|
||||
step={100}
|
||||
formatValue={(value) => value >= 1_000
|
||||
? `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)} км`
|
||||
: `${value} м`}
|
||||
onChange={(radiusMeters) => updateDraft((current) => {
|
||||
current.view.ghostPins.radiusMeters = radiusMeters;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Максимальная скорость"
|
||||
value={configuration.maximumSpeedMetersPerSecond}
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
formatValue={(value) => `${value} м/с`}
|
||||
onChange={(maximumSpeedMetersPerSecond) => updateDraft((current) => {
|
||||
current.view.ghostPins.maximumSpeedMetersPerSecond =
|
||||
maximumSpeedMetersPerSecond;
|
||||
current.view.ghostPins.positions =
|
||||
current.view.ghostPins.positions.map((pin) => ({
|
||||
...pin,
|
||||
speedMetersPerSecond: Math.min(
|
||||
pin.speedMetersPerSecond,
|
||||
maximumSpeedMetersPerSecond,
|
||||
),
|
||||
}));
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="Симуляция движения"
|
||||
checked={configuration.simulationEnabled}
|
||||
onChange={(simulationEnabled) => updateDraft((current) => {
|
||||
current.view.ghostPins.simulationEnabled = simulationEnabled;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<ControlRow label="Позиции">
|
||||
<StatusBadge tone={configuration.positions.length ? "success" : "neutral"}>
|
||||
{configuration.positions.length
|
||||
? `${configuration.positions.length} на карте`
|
||||
: "Не созданы"}
|
||||
</StatusBadge>
|
||||
</ControlRow>
|
||||
<Button variant="secondary" size="compact" onClick={onGenerate}>
|
||||
Разместить в текущем view
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="compact"
|
||||
disabled={configuration.positions.length === 0}
|
||||
onClick={onClear}
|
||||
>
|
||||
Очистить позиции на карте
|
||||
</Button>
|
||||
|
||||
<Checker
|
||||
label="Показывать Ghost Pin"
|
||||
checked={draft.view.layerVisibility.targets}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.layerVisibility.targets = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<ControlRow label="Цвет пина">
|
||||
<ColorField
|
||||
label="Цвет Ghost Pin"
|
||||
value={profile.color}
|
||||
onChange={(color) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.color = color;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</ControlRow>
|
||||
<RangeControl
|
||||
label="Прозрачность пина"
|
||||
value={Math.round(profile.opacity * 100)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.opacity = value / 100;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Высота таргета"
|
||||
value={profile.stemHeightMeters}
|
||||
min={100}
|
||||
max={10_000}
|
||||
step={50}
|
||||
formatValue={(value) => `${value} м`}
|
||||
onChange={(stemHeightMeters) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.stemHeightMeters = stemHeightMeters;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Размер головки"
|
||||
value={profile.headSizePx}
|
||||
min={1}
|
||||
max={32}
|
||||
step={1}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(headSizePx) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.headSizePx = headSizePx;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Толщина стержня"
|
||||
value={profile.stemWidthPx}
|
||||
min={0.25}
|
||||
max={12}
|
||||
step={0.25}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(stemWidthPx) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.stemWidthPx = stemWidthPx;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<ControlRow label="Цвет обводки">
|
||||
<ColorField
|
||||
label="Цвет обводки головки"
|
||||
value={profile.outlineColor}
|
||||
onChange={(outlineColor) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.outlineColor = outlineColor;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</ControlRow>
|
||||
<RangeControl
|
||||
label="Прозрачность обводки"
|
||||
value={Math.round(profile.outlineOpacity * 100)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.outlineOpacity = value / 100;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Толщина обводки"
|
||||
value={profile.outlineWidthPx}
|
||||
min={0}
|
||||
max={8}
|
||||
step={0.5}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(outlineWidthPx) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.outlineWidthPx = outlineWidthPx;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<InspectorSelectField<MapGhostPinLabelMode>
|
||||
label="Подпись"
|
||||
value={profile.labelMode}
|
||||
options={[
|
||||
{
|
||||
value: "attributes",
|
||||
label: "Имя",
|
||||
description: "Случайное числовое имя Ghost Pin",
|
||||
},
|
||||
{
|
||||
value: "subject_id",
|
||||
label: "ID",
|
||||
description: "Стабильный синтетический идентификатор",
|
||||
},
|
||||
{
|
||||
value: "none",
|
||||
label: "Нет",
|
||||
description: "Не показывать плашку",
|
||||
},
|
||||
]}
|
||||
onChange={(labelMode) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelMode = labelMode;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Насыщенность шрифта"
|
||||
value={profile.labelFontWeight}
|
||||
min={400}
|
||||
max={700}
|
||||
step={100}
|
||||
formatValue={(value) => `${value}`}
|
||||
onChange={(labelFontWeight) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelFontWeight = labelFontWeight;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Размер подписи"
|
||||
value={profile.labelSizePx}
|
||||
min={8}
|
||||
max={32}
|
||||
step={1}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(labelSizePx) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelSizePx = labelSizePx;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<ControlRow label="Цвет подписи">
|
||||
<ColorField
|
||||
label="Цвет текста подписи"
|
||||
value={profile.labelColor}
|
||||
onChange={(labelColor) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelColor = labelColor;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</ControlRow>
|
||||
<ControlRow label="Фон плашки">
|
||||
<ColorField
|
||||
label="Цвет фона подписи"
|
||||
value={profile.labelBackgroundColor}
|
||||
onChange={(labelBackgroundColor) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelBackgroundColor =
|
||||
labelBackgroundColor;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</ControlRow>
|
||||
<RangeControl
|
||||
label="Прозрачность плашки"
|
||||
value={Math.round(profile.labelBackgroundOpacity * 100)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelBackgroundOpacity =
|
||||
value / 100;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Отступ плашки X"
|
||||
value={profile.labelPaddingX}
|
||||
min={0}
|
||||
max={32}
|
||||
step={1}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(labelPaddingX) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelPaddingX = labelPaddingX;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Отступ плашки Y"
|
||||
value={profile.labelPaddingY}
|
||||
min={0}
|
||||
max={32}
|
||||
step={1}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(labelPaddingY) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelPaddingY = labelPaddingY;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Смещение подписи X"
|
||||
value={profile.labelOffsetX}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={1}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(labelOffsetX) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelOffsetX = labelOffsetX;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Смещение подписи Y"
|
||||
value={profile.labelOffsetY}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={1}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(labelOffsetY) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelOffsetY = labelOffsetY;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Максимальная длина подписи"
|
||||
value={profile.labelMaxLength}
|
||||
min={1}
|
||||
max={64}
|
||||
step={1}
|
||||
formatValue={(value) => `${value}`}
|
||||
onChange={(labelMaxLength) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelMaxLength = labelMaxLength;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Скрывать подпись выше"
|
||||
value={profile.labelHideCameraHeightMeters}
|
||||
min={1_000}
|
||||
max={500_000}
|
||||
step={1_000}
|
||||
formatValue={(value) => `${Math.round(value / 1_000)} км`}
|
||||
onChange={(labelHideCameraHeightMeters) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelHideCameraHeightMeters =
|
||||
labelHideCameraHeightMeters;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Скрывать таргет выше"
|
||||
value={profile.targetHideCameraHeightMeters}
|
||||
min={1_000}
|
||||
max={500_000}
|
||||
step={1_000}
|
||||
formatValue={(value) => `${Math.round(value / 1_000)} км`}
|
||||
onChange={(targetHideCameraHeightMeters) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.targetHideCameraHeightMeters =
|
||||
targetHideCameraHeightMeters;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
import {
|
||||
Checker,
|
||||
ColorField,
|
||||
ControlRow,
|
||||
Inspector,
|
||||
RangeControl,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type {
|
||||
MapInspectorSection,
|
||||
MapViewDocument,
|
||||
} from "../../core/map/mapView";
|
||||
import type { useMapGatewayHealth } from "../../core/map/useMapGatewayHealth";
|
||||
import { GhostPinSettingsPanel } from "./GhostPinSettingsPanel";
|
||||
|
||||
export const editableMapInspectorSections = new Set<MapInspectorSection>([
|
||||
"base-terrain",
|
||||
"atmosphere-light",
|
||||
"buildings",
|
||||
"ghost-pins",
|
||||
"grid-lod",
|
||||
"camera",
|
||||
"tile-cache",
|
||||
]);
|
||||
|
||||
export function MapSettingsInspector({
|
||||
draft,
|
||||
updateDraft,
|
||||
gatewayHealth,
|
||||
onGenerateGhostPins,
|
||||
onClearGhostPins,
|
||||
}: {
|
||||
draft: MapViewDocument;
|
||||
updateDraft: (
|
||||
update: (current: MapViewDocument) => MapViewDocument,
|
||||
) => void;
|
||||
gatewayHealth: ReturnType<typeof useMapGatewayHealth>;
|
||||
onGenerateGhostPins: () => void;
|
||||
onClearGhostPins: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Inspector
|
||||
singleOpen
|
||||
sections={createInspectorSections(
|
||||
draft,
|
||||
updateDraft,
|
||||
gatewayHealth,
|
||||
onGenerateGhostPins,
|
||||
onClearGhostPins,
|
||||
)}
|
||||
openSections={draft.view.inspectorOpenSections.filter(
|
||||
(section) => editableMapInspectorSections.has(section),
|
||||
)}
|
||||
activeId={draft.view.inspectorOpenSections[0]}
|
||||
onOpenSectionsChange={(sections) => {
|
||||
updateDraft((current) => {
|
||||
current.view.inspectorOpenSections = sections.filter(
|
||||
(section): section is MapInspectorSection =>
|
||||
editableMapInspectorSections.has(section as MapInspectorSection),
|
||||
).slice(0, 1);
|
||||
return current;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function createInspectorSections(
|
||||
draft: MapViewDocument,
|
||||
updateDraft: (
|
||||
update: (current: MapViewDocument) => MapViewDocument,
|
||||
) => void,
|
||||
gatewayHealth: ReturnType<typeof useMapGatewayHealth>,
|
||||
onGenerateGhostPins: () => void,
|
||||
onClearGhostPins: () => void,
|
||||
) {
|
||||
const view = draft.view;
|
||||
const settings = view.visualSettings;
|
||||
return [
|
||||
{
|
||||
id: "base-terrain",
|
||||
label: "Подложка и terrain",
|
||||
description: "provider-neutral surface",
|
||||
content: (
|
||||
<div className="mission-map__inspector-controls">
|
||||
<ControlRow label="Подложка">
|
||||
<strong>Cesium World Imagery</strong>
|
||||
</ControlRow>
|
||||
<Checker
|
||||
label="Спутниковая подложка"
|
||||
checked={view.layerVisibility.imagery}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.layerVisibility.imagery = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="Terrain"
|
||||
checked={view.layerVisibility.terrain}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.layerVisibility.terrain = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Вертикальное преувеличение рельефа"
|
||||
value={settings.terrainExaggeration * 100}
|
||||
min={25}
|
||||
max={300}
|
||||
step={1}
|
||||
formatValue={(value) => `${(value / 100).toFixed(2)}×`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.terrainExaggeration = value / 100;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="Монохромная поверхность"
|
||||
checked={settings.monochromeEnabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.visualSettings.monochromeEnabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<ControlRow label="Цвет монохрома">
|
||||
<ColorField
|
||||
label="Цвет монохромной поверхности"
|
||||
value={settings.monochromeColor}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.monochromeColor = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</ControlRow>
|
||||
<RangeControl
|
||||
label="Яркость"
|
||||
value={settings.imageryBrightness}
|
||||
min={0}
|
||||
max={200}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.imageryBrightness = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Контраст"
|
||||
value={settings.imageryContrast}
|
||||
min={0}
|
||||
max={200}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.imageryContrast = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Насыщенность"
|
||||
value={settings.imagerySaturation}
|
||||
min={0}
|
||||
max={200}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.imagerySaturation = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Гамма"
|
||||
value={settings.imageryGamma}
|
||||
min={0}
|
||||
max={300}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.imageryGamma = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Оттенок"
|
||||
value={settings.imageryHue}
|
||||
min={-180}
|
||||
max={180}
|
||||
formatValue={(value) => `${value}°`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.imageryHue = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Прозрачность imagery"
|
||||
value={settings.imageryAlpha}
|
||||
min={0}
|
||||
max={100}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.imageryAlpha = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<ControlRow label="Цвет планеты">
|
||||
<ColorField
|
||||
label="Цвет terrain без imagery"
|
||||
value={settings.globeColor}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.globeColor = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</ControlRow>
|
||||
<ControlRow label="Фон сцены">
|
||||
<ColorField
|
||||
label="Цвет фона сцены"
|
||||
value={settings.backgroundColor}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.backgroundColor = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</ControlRow>
|
||||
<RangeControl
|
||||
label="Высота карты"
|
||||
value={view.mapHeight}
|
||||
min={420}
|
||||
max={1080}
|
||||
step={1}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.mapHeight = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "atmosphere-light",
|
||||
label: "Атмосфера и освещение",
|
||||
description: "scene / color correction",
|
||||
content: (
|
||||
<div className="mission-map__inspector-controls">
|
||||
<Checker
|
||||
label="Показывать атмосферу"
|
||||
checked={settings.atmosphereEnabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.visualSettings.atmosphereEnabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Атмосфера: оттенок"
|
||||
value={settings.atmosphereHue}
|
||||
min={-100}
|
||||
max={100}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.atmosphereHue = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Атмосфера: насыщенность"
|
||||
value={settings.atmosphereSaturation}
|
||||
min={-100}
|
||||
max={100}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.atmosphereSaturation = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Атмосфера: яркость"
|
||||
value={settings.atmosphereBrightness}
|
||||
min={-100}
|
||||
max={100}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.atmosphereBrightness = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="Туман"
|
||||
checked={settings.fogEnabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.visualSettings.fogEnabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Плотность тумана"
|
||||
value={settings.fogDensity}
|
||||
min={0}
|
||||
max={100}
|
||||
formatValue={(value) => `${(value / 10_000).toFixed(4)}`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.fogDensity = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="Солнечное освещение"
|
||||
checked={settings.sunEnabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.visualSettings.sunEnabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Час солнца"
|
||||
value={settings.sunHour}
|
||||
min={0}
|
||||
max={24}
|
||||
formatValue={(value) => `${value}:00 UTC`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.sunHour = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Интенсивность света"
|
||||
value={settings.sunIntensity}
|
||||
min={0}
|
||||
max={200}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.sunIntensity = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="Тени"
|
||||
checked={settings.shadowsEnabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.visualSettings.shadowsEnabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "buildings",
|
||||
label: "3D здания",
|
||||
description: "3D Tiles / detail",
|
||||
content: (
|
||||
<div className="mission-map__inspector-controls">
|
||||
<Checker
|
||||
label="Показывать 3D здания"
|
||||
checked={view.layerVisibility.buildings}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.layerVisibility.buildings = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<ControlRow label="Цвет">
|
||||
<ColorField
|
||||
label="Цвет зданий"
|
||||
value={settings.buildingsColor}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.buildingsColor = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</ControlRow>
|
||||
<RangeControl
|
||||
label="Прозрачность"
|
||||
value={Math.round(settings.buildingsOpacity * 100)}
|
||||
min={0}
|
||||
max={100}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.buildingsOpacity = value / 100;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Детализация"
|
||||
value={settings.buildingsMaximumScreenSpaceError}
|
||||
min={4}
|
||||
max={32}
|
||||
formatValue={(value) => `SSE ${value}`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.buildingsMaximumScreenSpaceError = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "ghost-pins",
|
||||
label: "Гост-пин",
|
||||
description: "временная симуляция",
|
||||
content: (
|
||||
<GhostPinSettingsPanel
|
||||
draft={draft}
|
||||
updateDraft={updateDraft}
|
||||
onGenerate={onGenerateGhostPins}
|
||||
onClear={onClearGhostPins}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "grid-lod",
|
||||
label: "Сетка и LOD",
|
||||
description: "first adapter control",
|
||||
content: (
|
||||
<div className="mission-map__inspector-controls">
|
||||
<Checker
|
||||
label="3D-сетка"
|
||||
checked={view.layerVisibility.grid}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.layerVisibility.grid = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="LOD по высоте камеры"
|
||||
checked={settings.gridLodEnabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridLodEnabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Высота над поверхностью"
|
||||
value={settings.gridHeightMeters}
|
||||
min={0}
|
||||
max={1000}
|
||||
formatValue={(value) => `${value} м`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridHeightMeters = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="LOD 1: до высоты"
|
||||
value={settings.gridLod1MaxHeightKm}
|
||||
min={1}
|
||||
max={50}
|
||||
formatValue={(value) => `${value} км`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridLod1MaxHeightKm = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="LOD 1: шаг"
|
||||
value={settings.gridLod1StepKm}
|
||||
min={1}
|
||||
max={10}
|
||||
formatValue={(value) => `${value} км`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridLod1StepKm = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="LOD 2: до высоты"
|
||||
value={settings.gridLod2MaxHeightKm}
|
||||
min={10}
|
||||
max={200}
|
||||
formatValue={(value) => `${value} км`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridLod2MaxHeightKm = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="LOD 2: шаг"
|
||||
value={settings.gridLod2StepKm}
|
||||
min={1}
|
||||
max={25}
|
||||
formatValue={(value) => `${value} км`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridLod2StepKm = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="LOD 3: шаг"
|
||||
value={settings.gridLod3StepKm}
|
||||
min={5}
|
||||
max={100}
|
||||
formatValue={(value) => `${value} км`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridLod3StepKm = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Радиус сетки"
|
||||
value={settings.gridRadiusKm}
|
||||
min={5}
|
||||
max={150}
|
||||
formatValue={(value) => `${value} км`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridRadiusKm = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<ControlRow label="Цвет линий">
|
||||
<ColorField
|
||||
label="Цвет линий сетки"
|
||||
value={settings.gridColor}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridColor = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</ControlRow>
|
||||
<RangeControl
|
||||
label="Толщина линий"
|
||||
value={settings.gridLineWidth}
|
||||
min={1}
|
||||
max={8}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridLineWidth = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Прозрачность сетки"
|
||||
value={settings.gridOpacity}
|
||||
min={0}
|
||||
max={100}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridOpacity = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="Точки в пересечениях"
|
||||
checked={settings.gridDotsEnabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridDotsEnabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Размер точки"
|
||||
value={settings.gridDotsSize}
|
||||
min={2}
|
||||
max={28}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridDotsSize = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<ControlRow label="Цвет точек">
|
||||
<ColorField
|
||||
label="Цвет точек сетки"
|
||||
value={settings.gridDotsColor}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridDotsColor = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</ControlRow>
|
||||
<RangeControl
|
||||
label="Прозрачность точек"
|
||||
value={settings.gridDotsOpacity}
|
||||
min={0}
|
||||
max={100}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridDotsOpacity = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "camera",
|
||||
label: "Анимация камеры",
|
||||
description: "geodesic spiral survey",
|
||||
content: (
|
||||
<div className="mission-map__inspector-controls">
|
||||
<Checker
|
||||
label="Разрешить анимацию камеры"
|
||||
checked={settings.cameraAnimationEnabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.visualSettings.cameraAnimationEnabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<ControlRow label="Сохранённая позиция">
|
||||
<StatusBadge tone={view.camera ? "success" : "neutral"}>
|
||||
{view.camera ? "Есть" : "Не задана"}
|
||||
</StatusBadge>
|
||||
</ControlRow>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "tile-cache",
|
||||
label: "TileCache",
|
||||
description: "Platform Map Gateway",
|
||||
content: (
|
||||
<div
|
||||
className="mission-map__inspector-controls"
|
||||
data-health-code={gatewayHealth.code ?? undefined}
|
||||
>
|
||||
<Checker
|
||||
label="Использовать общий TileCache"
|
||||
checked={view.cacheIntent.enabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.cacheIntent.enabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="Не перезаписывать существующие тайлы"
|
||||
checked={view.cacheIntent.noOverwrite}
|
||||
disabled={!view.cacheIntent.enabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.cacheIntent.noOverwrite = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<CacheHealth health={gatewayHealth} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function CacheHealth({
|
||||
health,
|
||||
}: {
|
||||
health: ReturnType<typeof useMapGatewayHealth>;
|
||||
}) {
|
||||
if (health.state === "loading" || health.state === "idle") {
|
||||
return (
|
||||
<ControlRow label="Состояние">
|
||||
<StatusBadge tone="neutral">Проверяем</StatusBadge>
|
||||
</ControlRow>
|
||||
);
|
||||
}
|
||||
if (!health.snapshot) {
|
||||
return (
|
||||
<ControlRow label="Состояние">
|
||||
<StatusBadge tone="danger">Недоступен</StatusBadge>
|
||||
</ControlRow>
|
||||
);
|
||||
}
|
||||
const { cache } = health.snapshot;
|
||||
return (
|
||||
<dl className="mission-map__cache-health">
|
||||
<div>
|
||||
<dt>Хранилище</dt>
|
||||
<dd>
|
||||
<StatusBadge tone={cache.persistent ? "success" : "warning"}>
|
||||
{cache.persistent ? "Постоянное" : "Временное"}
|
||||
</StatusBadge>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Объекты</dt>
|
||||
<dd>{cache.entries.toLocaleString("ru-RU")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Объём</dt>
|
||||
<dd>
|
||||
{formatBytes(cache.bytes)}
|
||||
{cache.maxBytes === null ? "" : ` / ${formatBytes(cache.maxBytes)}`}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Заполнение</dt>
|
||||
<dd>
|
||||
<StatusBadge
|
||||
tone={
|
||||
cache.atCapacity || health.state === "stale"
|
||||
? "warning"
|
||||
: "success"
|
||||
}
|
||||
>
|
||||
{cache.atCapacity
|
||||
? "Лимит"
|
||||
: health.state === "stale"
|
||||
? "Устарело"
|
||||
: "Норма"}
|
||||
</StatusBadge>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) return `${value} Б`;
|
||||
const units = ["КБ", "МБ", "ГБ", "ТБ"];
|
||||
let amount = value / 1024;
|
||||
let unit = units[0];
|
||||
for (let index = 1; index < units.length && amount >= 1024; index += 1) {
|
||||
amount /= 1024;
|
||||
unit = units[index];
|
||||
}
|
||||
return `${amount.toFixed(amount >= 10 ? 1 : 2)} ${unit}`;
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
FeatureSettingsWindow,
|
||||
FieldFrame,
|
||||
Icon,
|
||||
Select,
|
||||
SettingsCard,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
WindowFooterActions,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
applyComputeContourNetwork,
|
||||
fetchComputeContourAgentInstall,
|
||||
fetchComputeContourNetwork,
|
||||
type ComputeContour,
|
||||
type ComputeContourAgentInstall,
|
||||
type ComputeContourDraft,
|
||||
type ComputeContourNetworkStatus,
|
||||
type ComputeContourPlatform,
|
||||
} from "../../core/system/computeContours";
|
||||
import {
|
||||
DEFAULT_TELEMETRY_POLL_INTERVAL_SECONDS,
|
||||
parseTelemetryPollIntervalDraft,
|
||||
resolveTelemetryPollIntervalDraft,
|
||||
} from "../../core/system/telemetryPollInterval";
|
||||
|
||||
interface ComputeContourSettingsWindowProps {
|
||||
open: boolean;
|
||||
contour: ComputeContour | null;
|
||||
mode: "create" | "edit";
|
||||
onClose: () => void;
|
||||
onCreate: (draft: ComputeContourDraft) => Promise<ComputeContour>;
|
||||
onUpdate: (
|
||||
contour: ComputeContour,
|
||||
draft: ComputeContourDraft,
|
||||
) => Promise<ComputeContour>;
|
||||
}
|
||||
|
||||
const platformOptions = [
|
||||
{ value: "windows", label: "Windows", description: "Worker с Windows service" },
|
||||
{ value: "linux", label: "Linux", description: "Worker с systemd service" },
|
||||
{ value: "unknown", label: "Не определено", description: "Платформа будет уточнена" },
|
||||
] satisfies Array<{
|
||||
value: ComputeContourPlatform;
|
||||
label: string;
|
||||
description: string;
|
||||
}>;
|
||||
|
||||
function emptyDraft(): ComputeContourDraft {
|
||||
return {
|
||||
display_name: "",
|
||||
expected_node_id: "",
|
||||
platform: "unknown",
|
||||
telemetry_mode: "agent-mqtt",
|
||||
address: "",
|
||||
ssh_port: 22,
|
||||
mqtt_host: "127.0.0.1",
|
||||
mqtt_port: 1883,
|
||||
mqtt_bind_address: "127.0.0.1",
|
||||
telemetry_poll_interval_seconds: DEFAULT_TELEMETRY_POLL_INTERVAL_SECONDS,
|
||||
mqtt_publish_interval_seconds: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function draftFromContour(contour: ComputeContour | null): ComputeContourDraft {
|
||||
if (!contour) return emptyDraft();
|
||||
return {
|
||||
display_name: contour.display_name,
|
||||
expected_node_id: contour.expected_node_id,
|
||||
platform: contour.platform,
|
||||
telemetry_mode: contour.telemetry_mode,
|
||||
address: contour.address,
|
||||
ssh_port: contour.ssh_port,
|
||||
mqtt_host: contour.mqtt_host,
|
||||
mqtt_port: contour.mqtt_port,
|
||||
mqtt_bind_address: contour.mqtt_bind_address,
|
||||
telemetry_poll_interval_seconds: contour.telemetry_poll_interval_seconds,
|
||||
mqtt_publish_interval_seconds: contour.mqtt_publish_interval_seconds,
|
||||
};
|
||||
}
|
||||
|
||||
export function ComputeContourSettingsWindow({
|
||||
open,
|
||||
contour,
|
||||
mode,
|
||||
onClose,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
}: ComputeContourSettingsWindowProps) {
|
||||
const [draft, setDraft] = useState<ComputeContourDraft>(() => draftFromContour(contour));
|
||||
const [telemetryPollIntervalDraft, setTelemetryPollIntervalDraft] = useState(
|
||||
() => String(draftFromContour(contour).telemetry_poll_interval_seconds),
|
||||
);
|
||||
const [mqttPublishIntervalDraft, setMqttPublishIntervalDraft] = useState(
|
||||
() => String(draftFromContour(contour).mqtt_publish_interval_seconds),
|
||||
);
|
||||
const [activeSection, setActiveSection] = useState<"connection" | "agent">("connection");
|
||||
const [install, setInstall] = useState<ComputeContourAgentInstall | null>(null);
|
||||
const [network, setNetwork] = useState<ComputeContourNetworkStatus | null>(null);
|
||||
const [networkAction, setNetworkAction] = useState<
|
||||
"refresh" | "broker" | "worker" | null
|
||||
>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const nextDraft = draftFromContour(mode === "edit" ? contour : null);
|
||||
setDraft(nextDraft);
|
||||
setTelemetryPollIntervalDraft(String(nextDraft.telemetry_poll_interval_seconds));
|
||||
setMqttPublishIntervalDraft(String(nextDraft.mqtt_publish_interval_seconds));
|
||||
setActiveSection("connection");
|
||||
setInstall(null);
|
||||
setNetwork(null);
|
||||
setNetworkAction(null);
|
||||
setError(null);
|
||||
}, [contour, mode, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || mode !== "edit" || !contour) return;
|
||||
const controller = new AbortController();
|
||||
setNetworkAction("refresh");
|
||||
void fetchComputeContourNetwork(contour.contour_id, controller.signal)
|
||||
.then((document) => {
|
||||
if (!controller.signal.aborted) setNetwork(document);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(reason instanceof Error ? reason.message : "Сетевая проверка недоступна.");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setNetworkAction(null);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [contour, mode, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || mode !== "edit" || !contour) return;
|
||||
const controller = new AbortController();
|
||||
void fetchComputeContourAgentInstall(contour.contour_id, controller.signal)
|
||||
.then((document) => {
|
||||
if (!controller.signal.aborted) setInstall(document);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(reason instanceof Error ? reason.message : "Инструкция агента недоступна.");
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [contour, mode, open]);
|
||||
|
||||
const telemetryPollIntervalSeconds = useMemo(
|
||||
() => parseTelemetryPollIntervalDraft(telemetryPollIntervalDraft),
|
||||
[telemetryPollIntervalDraft],
|
||||
);
|
||||
const mqttPublishIntervalSeconds = useMemo(
|
||||
() => parseTelemetryPollIntervalDraft(mqttPublishIntervalDraft),
|
||||
[mqttPublishIntervalDraft],
|
||||
);
|
||||
|
||||
const valid = useMemo(() => (
|
||||
Boolean(draft.display_name.trim())
|
||||
&& Boolean(draft.expected_node_id.trim())
|
||||
&& Boolean(draft.mqtt_host.trim())
|
||||
&& Boolean(draft.mqtt_bind_address.trim())
|
||||
&& Number.isInteger(draft.ssh_port)
|
||||
&& draft.ssh_port > 0
|
||||
&& Number.isInteger(draft.mqtt_port)
|
||||
&& draft.mqtt_port > 0
|
||||
&& telemetryPollIntervalSeconds !== null
|
||||
&& mqttPublishIntervalSeconds !== null
|
||||
), [draft, mqttPublishIntervalSeconds, telemetryPollIntervalSeconds]);
|
||||
|
||||
const networkProfileDirty = Boolean(
|
||||
contour
|
||||
&& (
|
||||
draft.mqtt_host !== contour.mqtt_host
|
||||
|| draft.mqtt_port !== contour.mqtt_port
|
||||
|| draft.mqtt_bind_address !== contour.mqtt_bind_address
|
||||
|| draft.mqtt_publish_interval_seconds !== contour.mqtt_publish_interval_seconds
|
||||
),
|
||||
);
|
||||
|
||||
const commitTelemetryPollInterval = () => {
|
||||
const resolution = resolveTelemetryPollIntervalDraft(
|
||||
telemetryPollIntervalDraft,
|
||||
draft.telemetry_poll_interval_seconds,
|
||||
);
|
||||
setTelemetryPollIntervalDraft(resolution.draft);
|
||||
if (!resolution.accepted) return;
|
||||
setDraft((current) => (
|
||||
current.telemetry_poll_interval_seconds === resolution.seconds
|
||||
? current
|
||||
: {
|
||||
...current,
|
||||
telemetry_poll_interval_seconds: resolution.seconds,
|
||||
}
|
||||
));
|
||||
};
|
||||
|
||||
const commitMqttPublishInterval = () => {
|
||||
const resolution = resolveTelemetryPollIntervalDraft(
|
||||
mqttPublishIntervalDraft,
|
||||
draft.mqtt_publish_interval_seconds,
|
||||
);
|
||||
setMqttPublishIntervalDraft(resolution.draft);
|
||||
if (!resolution.accepted) return;
|
||||
setDraft((current) => (
|
||||
current.mqtt_publish_interval_seconds === resolution.seconds
|
||||
? current
|
||||
: {
|
||||
...current,
|
||||
mqtt_publish_interval_seconds: resolution.seconds,
|
||||
}
|
||||
));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (
|
||||
!valid
|
||||
|| busy
|
||||
|| telemetryPollIntervalSeconds === null
|
||||
|| mqttPublishIntervalSeconds === null
|
||||
) return;
|
||||
const committedDraft = {
|
||||
...draft,
|
||||
telemetry_poll_interval_seconds: telemetryPollIntervalSeconds,
|
||||
mqtt_publish_interval_seconds: mqttPublishIntervalSeconds,
|
||||
};
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (mode === "edit" && contour) {
|
||||
await onUpdate(contour, committedDraft);
|
||||
} else {
|
||||
await onCreate(committedDraft);
|
||||
}
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Контур не сохранён.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshNetwork = async () => {
|
||||
if (!contour || networkAction) return;
|
||||
setNetworkAction("refresh");
|
||||
setError(null);
|
||||
try {
|
||||
setNetwork(await fetchComputeContourNetwork(contour.contour_id));
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Сетевая проверка недоступна.");
|
||||
} finally {
|
||||
setNetworkAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const applyNetwork = async (target: "broker" | "worker") => {
|
||||
if (!contour || networkAction || networkProfileDirty) return;
|
||||
setNetworkAction(target);
|
||||
setError(null);
|
||||
try {
|
||||
await applyComputeContourNetwork(contour.contour_id, target);
|
||||
setNetwork(await fetchComputeContourNetwork(contour.contour_id));
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : "Сетевой профиль не применён.",
|
||||
);
|
||||
} finally {
|
||||
setNetworkAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FeatureSettingsWindow
|
||||
open={open}
|
||||
title={mode === "create" ? "Добавить вычислительный контур" : "Настройки контура"}
|
||||
subtitle="Локальная переносимая конфигурация"
|
||||
identity={{
|
||||
title: contour?.display_name ?? "Новый контур",
|
||||
subtitle: contour?.expected_node_id ?? "Node ID не назначен",
|
||||
avatarLabel: contour?.display_name.slice(0, 2).toUpperCase() ?? "WC",
|
||||
}}
|
||||
sections={[
|
||||
{
|
||||
id: "connection",
|
||||
label: "Подключение",
|
||||
group: "ВЫЧИСЛИТЕЛЬНЫЙ КОНТУР",
|
||||
icon: "network",
|
||||
},
|
||||
{
|
||||
id: "agent",
|
||||
label: "Агент",
|
||||
group: "ТЕЛЕМЕТРИЯ",
|
||||
icon: "activity",
|
||||
disabled: mode === "create",
|
||||
},
|
||||
]}
|
||||
activeSection={activeSection}
|
||||
onSectionChange={setActiveSection}
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<WindowFooterActions>
|
||||
<Button variant="secondary" onClick={onClose}>Отмена</Button>
|
||||
<Button variant="primary" disabled={!valid || busy} onClick={() => void save()}>
|
||||
{busy ? "Сохраняем…" : mode === "create" ? "Добавить контур" : "Сохранить"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
)}
|
||||
>
|
||||
<div className="compute-contour-settings">
|
||||
{activeSection === "connection" ? (
|
||||
<>
|
||||
<SettingsCard
|
||||
eyebrow="КОНТУР"
|
||||
title="Идентичность и локальная сеть"
|
||||
description="Node ID проверяется независимо от IP-адреса. Адрес можно менять при переносе комплекта в другую локальную сеть."
|
||||
>
|
||||
<div className="compute-contour-settings__form">
|
||||
<TextField
|
||||
label="Название"
|
||||
value={draft.display_name}
|
||||
placeholder="Worker 006"
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
display_name: event.currentTarget.value,
|
||||
}))}
|
||||
/>
|
||||
<TextField
|
||||
label="Ожидаемый Node ID"
|
||||
value={draft.expected_node_id}
|
||||
placeholder="DESKTOP-OPJ8J04"
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
expected_node_id: event.currentTarget.value,
|
||||
}))}
|
||||
/>
|
||||
<FieldFrame label="Операционная система">
|
||||
<Select
|
||||
label="Операционная система"
|
||||
value={draft.platform}
|
||||
options={platformOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(platform) => setDraft((current) => ({ ...current, platform }))}
|
||||
/>
|
||||
</FieldFrame>
|
||||
<TextField
|
||||
label="Адрес узла"
|
||||
hint="IP или hostname в текущей LAN"
|
||||
value={draft.address}
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
address: event.currentTarget.value,
|
||||
}))}
|
||||
/>
|
||||
<TextField
|
||||
label="MQTT broker"
|
||||
hint="Стабильный hostname рекомендуется вместо выдаваемого DHCP IP"
|
||||
value={draft.mqtt_host}
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
mqtt_host: event.currentTarget.value,
|
||||
}))}
|
||||
/>
|
||||
<TextField
|
||||
label="MQTT port"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={String(draft.mqtt_port)}
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
mqtt_port: Number(event.currentTarget.value),
|
||||
}))}
|
||||
/>
|
||||
<TextField
|
||||
label="Публикация broker"
|
||||
hint="IP текущего Mac в разрешённой LAN · 0.0.0.0 запрещён"
|
||||
value={draft.mqtt_bind_address}
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
mqtt_bind_address: event.currentTarget.value,
|
||||
}))}
|
||||
/>
|
||||
<TextField
|
||||
label="Обновление интерфейса"
|
||||
hint="1–60 с"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
aria-invalid={telemetryPollIntervalSeconds === null}
|
||||
value={telemetryPollIntervalDraft}
|
||||
onChange={(event) => setTelemetryPollIntervalDraft(event.currentTarget.value)}
|
||||
onBlur={commitTelemetryPollInterval}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
commitTelemetryPollInterval();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setTelemetryPollIntervalDraft(
|
||||
String(draft.telemetry_poll_interval_seconds),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Интервал MQTT агента"
|
||||
hint="1–60 с · применяется конфигурацией агента"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
aria-invalid={mqttPublishIntervalSeconds === null}
|
||||
value={mqttPublishIntervalDraft}
|
||||
onChange={(event) => setMqttPublishIntervalDraft(event.currentTarget.value)}
|
||||
onBlur={commitMqttPublishInterval}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
commitMqttPublishInterval();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setMqttPublishIntervalDraft(
|
||||
String(draft.mqtt_publish_interval_seconds),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{draft.telemetry_mode === "legacy-ssh" ? (
|
||||
<TextField
|
||||
label="SSH port"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={String(draft.ssh_port)}
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
ssh_port: Number(event.currentTarget.value),
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
eyebrow="СЕТЕВОЙ ПРОФИЛЬ"
|
||||
title="MQTT · Mac ↔ Worker"
|
||||
description="Mesh-точки одной LAN не закрепляются по отдельности. Профиль хранит стабильное имя Mac и адрес публикации broker в текущей сети."
|
||||
actions={(
|
||||
<StatusBadge tone={network?.broker.ready ? "success" : "warning"}>
|
||||
{network?.broker.ready ? "Broker доступен" : "Нужна проверка"}
|
||||
</StatusBadge>
|
||||
)}
|
||||
>
|
||||
<div className="compute-contour-settings__network">
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Endpoint Worker</dt>
|
||||
<dd>{network
|
||||
? `${network.broker.configured_host}:${network.broker.configured_port}`
|
||||
: "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Текущий IP</dt>
|
||||
<dd>{network?.broker.resolved_addresses.join(", ") || "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Публикация Mac</dt>
|
||||
<dd>{network?.broker.bind_address ?? "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{contour?.display_name ?? "Worker"}</dt>
|
||||
<dd>{network?.worker.reachable
|
||||
? `${network.worker.mqtt_host ?? "—"}:${network.worker.mqtt_port ?? "—"}`
|
||||
: "Недоступен"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="compute-contour-settings__network-statuses">
|
||||
<StatusBadge tone={network?.broker.bind_reachable ? "success" : "danger"}>
|
||||
{network?.broker.bind_reachable
|
||||
? "Listener Mac доступен"
|
||||
: "Listener Mac недоступен"}
|
||||
</StatusBadge>
|
||||
<StatusBadge tone={network?.worker.broker_reachable ? "success" : "danger"}>
|
||||
{network?.worker.broker_reachable
|
||||
? "Worker видит broker"
|
||||
: "Worker не видит broker"}
|
||||
</StatusBadge>
|
||||
<StatusBadge tone={network?.worker.matches_profile ? "success" : "warning"}>
|
||||
{network?.worker.matches_profile
|
||||
? "Профиль синхронизирован"
|
||||
: "Профиль отличается"}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div className="compute-contour-settings__network-actions">
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={Boolean(networkAction)}
|
||||
icon={<Icon name="refresh" size={14} />}
|
||||
onClick={() => void refreshNetwork()}
|
||||
>
|
||||
{networkAction === "refresh" ? "Проверяем…" : "Проверить"}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={Boolean(networkAction) || networkProfileDirty}
|
||||
onClick={() => void applyNetwork("broker")}
|
||||
>
|
||||
{networkAction === "broker" ? "Применяем…" : "Применить на Mac"}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={Boolean(networkAction) || networkProfileDirty}
|
||||
onClick={() => void applyNetwork("worker")}
|
||||
>
|
||||
{networkAction === "worker" ? "Применяем…" : "Применить на Worker"}
|
||||
</Button>
|
||||
</div>
|
||||
{networkProfileDirty ? (
|
||||
<p>Сначала сохраните изменённый сетевой профиль, затем примените его.</p>
|
||||
) : null}
|
||||
{error ? <StatusBadge tone="danger">{error}</StatusBadge> : null}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{activeSection === "agent" ? (
|
||||
<SettingsCard
|
||||
eyebrow="АГЕНТ"
|
||||
title="Telegraf · универсальный сборщик"
|
||||
description="Hardware и runtime собирает готовый агент. Mission Core нормализует только контур, LAB/run и стадии обработки."
|
||||
actions={(
|
||||
<StatusBadge tone={install?.ready ? "success" : "warning"}>
|
||||
{install?.ready ? "Готов к установке" : "Нужен scoped MQTT credential"}
|
||||
</StatusBadge>
|
||||
)}
|
||||
>
|
||||
{install ? (
|
||||
<div className="compute-contour-settings__install">
|
||||
<dl>
|
||||
<div><dt>Шаблон</dt><dd>{install.agent.configuration_template}</dd></div>
|
||||
<div><dt>Секрет</dt><dd>интерактивный ввод · не хранится в UI</dd></div>
|
||||
</dl>
|
||||
<code>{install.command}</code>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
icon={<Icon name="copy" size={14} />}
|
||||
onClick={() => void navigator.clipboard.writeText(install.command)}
|
||||
>
|
||||
Копировать команду
|
||||
</Button>
|
||||
{install.blocked_reason ? <p>{install.blocked_reason}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="compute-contour-settings__empty">
|
||||
Сохраните контур, чтобы получить платформенную инструкцию установки.
|
||||
</p>
|
||||
)}
|
||||
{error ? <StatusBadge tone="danger">{error}</StatusBadge> : null}
|
||||
</SettingsCard>
|
||||
) : null}
|
||||
</div>
|
||||
</FeatureSettingsWindow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
AdminNavigationPanel,
|
||||
Icon,
|
||||
IconButton,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { useComputeContours } from "../../core/system/ComputeContourContext";
|
||||
|
||||
interface SystemNavigationPanelProps {
|
||||
title: string;
|
||||
onAdd: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SystemNavigationPanel({
|
||||
title,
|
||||
onAdd,
|
||||
onClose,
|
||||
}: SystemNavigationPanelProps) {
|
||||
const { contours, selectedContour, selectContour } = useComputeContours();
|
||||
const contourCountLabel = contours.length === 1
|
||||
? "1 конфигурация"
|
||||
: `${contours.length} конфигураций`;
|
||||
|
||||
return (
|
||||
<AdminNavigationPanel
|
||||
eyebrow="MISSION CORE"
|
||||
title={title}
|
||||
closeLabel={`Закрыть раздел «${title}»`}
|
||||
navigationLabel="Вычислительные контуры"
|
||||
onClose={onClose}
|
||||
headerActions={(
|
||||
<IconButton label="Добавить вычислительный контур" onClick={onAdd}>
|
||||
<Icon name="plus" size={16} strokeWidth={1.6} />
|
||||
</IconButton>
|
||||
)}
|
||||
contexts={contours.map((contour) => ({
|
||||
id: contour.contour_id,
|
||||
label: contour.display_name,
|
||||
description: contour.expected_node_id,
|
||||
icon: <Icon name="apps" />,
|
||||
active: selectedContour?.contour_id === contour.contour_id,
|
||||
onSelect: () => selectContour(contour.contour_id),
|
||||
}))}
|
||||
items={[]}
|
||||
onItemChange={() => undefined}
|
||||
footer={(
|
||||
<>
|
||||
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true">
|
||||
<Icon name="activity" />
|
||||
</span>
|
||||
<span>{contourCountLabel}</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { SegmentedControl } from "@nodedc/ui-react";
|
||||
|
||||
import { workspacesForRoot } from "../../productModel";
|
||||
|
||||
const systemWorkspaces = workspacesForRoot("system");
|
||||
const compactLabels: Record<string, string> = {
|
||||
modules: "Модули",
|
||||
integrations: "Интеграции",
|
||||
network: "Сеть",
|
||||
audit: "Аудит",
|
||||
};
|
||||
|
||||
interface SystemWorkspaceSelectorProps {
|
||||
value: string;
|
||||
onChange: (workspaceId: string) => void;
|
||||
}
|
||||
|
||||
export function SystemWorkspaceSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: SystemWorkspaceSelectorProps) {
|
||||
return (
|
||||
<SegmentedControl
|
||||
value={value}
|
||||
items={systemWorkspaces.map((definition) => ({
|
||||
value: definition.id,
|
||||
label: compactLabels[definition.id] ?? definition.label,
|
||||
}))}
|
||||
label="Рабочая поверхность вычислительного контура"
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
interface TelemetrySeriesProps {
|
||||
label: string;
|
||||
values: Array<number | null>;
|
||||
value: string;
|
||||
resource?: string | null;
|
||||
ceiling?: number;
|
||||
}
|
||||
|
||||
function linePoints(values: Array<number | null>, ceiling?: number): string {
|
||||
const finite = values.filter((value): value is number =>
|
||||
value !== null && Number.isFinite(value),
|
||||
);
|
||||
if (finite.length === 0) return "";
|
||||
const maximum = Math.max(ceiling ?? 0, ...finite, 1);
|
||||
const denominator = Math.max(1, values.length - 1);
|
||||
return values.map((value, index) => {
|
||||
const x = index / denominator * 100;
|
||||
const normalized = value === null ? 0 : Math.max(0, Math.min(maximum, value));
|
||||
const y = 36 - normalized / maximum * 34;
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
}).join(" ");
|
||||
}
|
||||
|
||||
export function TelemetrySeries({
|
||||
label,
|
||||
values,
|
||||
value,
|
||||
resource,
|
||||
ceiling,
|
||||
}: TelemetrySeriesProps) {
|
||||
const points = linePoints(values, ceiling);
|
||||
return (
|
||||
<div className="system-telemetry-series">
|
||||
<div>
|
||||
<span className="system-telemetry-series__label">
|
||||
<span>{label}</span>
|
||||
{resource ? <small>{resource}</small> : null}
|
||||
</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 100 38"
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label={`${label}: ${value}`}
|
||||
>
|
||||
<path d="M0 37 H100" />
|
||||
{points ? <polyline points={points} /> : null}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type { WorkerPipelineStage } from "../../core/system/workerTelemetry";
|
||||
|
||||
interface WorkerPipelineStagesProps {
|
||||
stages: WorkerPipelineStage[];
|
||||
}
|
||||
|
||||
function stateLabel(state: WorkerPipelineStage["state"]): string {
|
||||
if (state === "active") return "выполняется";
|
||||
if (state === "waiting") return "ожидает";
|
||||
if (state === "ready") return "готов";
|
||||
return "нет live-состояния";
|
||||
}
|
||||
|
||||
function stageDuration(value: number | null): string {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
||||
if (value < 1) return `${Math.round(value * 1000)} мс`;
|
||||
return `${new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: value < 10 ? 2 : 1,
|
||||
}).format(value)} с`;
|
||||
}
|
||||
|
||||
function stageDetail(stage: WorkerPipelineStage): string {
|
||||
if (
|
||||
typeof stage.activations !== "number"
|
||||
|| typeof stage.share_percent !== "number"
|
||||
|| !Number.isFinite(stage.share_percent)
|
||||
) {
|
||||
return "Измерений ещё нет";
|
||||
}
|
||||
const share = new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
}).format(stage.share_percent);
|
||||
return `${stage.activations} проходов · ${share}% измеренного времени`;
|
||||
}
|
||||
|
||||
export function WorkerPipelineStages({ stages }: WorkerPipelineStagesProps) {
|
||||
return (
|
||||
<ol className="worker-stage-list" aria-label="Стадии текущей задачи">
|
||||
{stages.map((stage, index) => {
|
||||
const share = typeof stage.share_percent === "number"
|
||||
? Math.max(0, Math.min(100, stage.share_percent))
|
||||
: 0;
|
||||
const style = {
|
||||
"--worker-stage-share": `${share}%`,
|
||||
} as CSSProperties;
|
||||
return (
|
||||
<li
|
||||
key={stage.id}
|
||||
data-state={stage.state}
|
||||
style={style}
|
||||
aria-label={`${stage.label}: ${stageDetail(stage)}`}
|
||||
>
|
||||
<span className="worker-stage-list__fill" aria-hidden="true" />
|
||||
<span className="worker-stage-list__index">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{stage.label}</strong>
|
||||
<small>{stageDetail(stage)}</small>
|
||||
</div>
|
||||
<div className="worker-stage-list__value">
|
||||
<strong>{stageDuration(stage.elapsed_seconds)}</strong>
|
||||
<small>{stateLabel(stage.state)}</small>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { GlassSurface, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import type { WorkerRuntime } from "../../core/system/workerTelemetry";
|
||||
import {
|
||||
formatOptionalPercent,
|
||||
runtimeStateLabel,
|
||||
runtimeTone,
|
||||
} from "./systemFormat";
|
||||
|
||||
export function WorkerRuntimeCard({ runtime }: { runtime: WorkerRuntime }) {
|
||||
return (
|
||||
<GlassSurface
|
||||
className="worker-runtime-card"
|
||||
padding="md"
|
||||
tone={runtime.external ? "soft" : "default"}
|
||||
data-external={runtime.external ? "true" : undefined}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">
|
||||
{runtime.external ? "ВНЕШНЯЯ НАГРУЗКА" : "MISSION CORE RUNTIME"}
|
||||
</span>
|
||||
<h3>{runtime.role}</h3>
|
||||
<code>{runtime.name}</code>
|
||||
</div>
|
||||
<StatusBadge tone={runtimeTone(runtime)}>
|
||||
{runtimeStateLabel(runtime)}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>CPU</dt>
|
||||
<dd>{formatOptionalPercent(runtime.cpu_percent)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Память</dt>
|
||||
<dd>{runtime.memory_usage ?? "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Сеть I/O</dt>
|
||||
<dd>{runtime.network_io ?? "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Процессы</dt>
|
||||
<dd>{runtime.pids ?? "—"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { StatusTone } from "@nodedc/ui-react";
|
||||
|
||||
import type { WorkerRuntime } from "../../core/system/workerTelemetry";
|
||||
|
||||
export function formatOptionalPercent(value: number | null | undefined): string {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 1 }).format(value)}%`
|
||||
: "—";
|
||||
}
|
||||
|
||||
export function formatBytes(value: number | null | undefined): string {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
||||
const units = ["Б", "КиБ", "МиБ", "ГиБ", "ТиБ"];
|
||||
let current = Math.max(0, value);
|
||||
let unit = 0;
|
||||
while (current >= 1024 && unit < units.length - 1) {
|
||||
current /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: current >= 100 ? 0 : 1,
|
||||
}).format(current)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
export function formatRate(value: number | null | undefined): string {
|
||||
const formatted = formatBytes(value);
|
||||
return formatted === "—" ? formatted : `${formatted}/с`;
|
||||
}
|
||||
|
||||
export function formatBitRate(value: number | null | undefined): string {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
||||
const units = ["бит/с", "Кбит/с", "Мбит/с", "Гбит/с"];
|
||||
let current = Math.max(0, value);
|
||||
let unit = 0;
|
||||
while (current >= 1000 && unit < units.length - 1) {
|
||||
current /= 1000;
|
||||
unit += 1;
|
||||
}
|
||||
return `${new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: current >= 100 ? 0 : 1,
|
||||
}).format(current)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
export function formatDuration(value: number | null | undefined): string {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
||||
const hours = Math.floor(value / 3600);
|
||||
if (hours < 24) return `${hours} ч`;
|
||||
return `${Math.floor(hours / 24)} д ${hours % 24} ч`;
|
||||
}
|
||||
|
||||
export function formatLatency(value: number | null | undefined): string {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
||||
return `${Math.round(value)} мс`;
|
||||
}
|
||||
|
||||
export function runtimeTone(runtime: WorkerRuntime): StatusTone {
|
||||
if (runtime.state !== "running") return "danger";
|
||||
if (runtime.health && runtime.health !== "healthy") return "warning";
|
||||
return runtime.external ? "neutral" : "success";
|
||||
}
|
||||
|
||||
export function runtimeStateLabel(runtime: WorkerRuntime): string {
|
||||
if (runtime.state !== "running") return "Недоступен";
|
||||
if (runtime.health === "healthy") return "Работает";
|
||||
if (runtime.health) return runtime.health;
|
||||
return "Запущен";
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import type { ApplicationPanelUtilityAction } from "@nodedc/ui-react";
|
||||
|
||||
import { useComputeContours } from "../../core/system/ComputeContourContext";
|
||||
import { ComputeContourSettingsWindow } from "./ComputeContourSettingsWindow";
|
||||
|
||||
interface ComputeContourSettingsController {
|
||||
canEdit: boolean;
|
||||
openCreate: () => void;
|
||||
openEdit: () => void;
|
||||
utilityActions: readonly ApplicationPanelUtilityAction[];
|
||||
window: ReactNode;
|
||||
}
|
||||
|
||||
export function useComputeContourSettings(): ComputeContourSettingsController {
|
||||
const contours = useComputeContours();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mode, setMode] = useState<"create" | "edit">("edit");
|
||||
const openCreate = useCallback(() => {
|
||||
setMode("create");
|
||||
setOpen(true);
|
||||
}, []);
|
||||
const openEdit = useCallback(() => {
|
||||
setMode("edit");
|
||||
setOpen(true);
|
||||
}, []);
|
||||
const utilityActions = useMemo<readonly ApplicationPanelUtilityAction[]>(() => [
|
||||
{
|
||||
label: "Обновить телеметрию выбранного контура",
|
||||
icon: "refresh",
|
||||
disabled: contours.selectedContour === null,
|
||||
onClick: contours.refreshTelemetry,
|
||||
},
|
||||
{
|
||||
label: "Настроить выбранный вычислительный контур",
|
||||
icon: "settings",
|
||||
disabled: contours.selectedContour === null,
|
||||
onClick: openEdit,
|
||||
},
|
||||
], [
|
||||
contours.refreshTelemetry,
|
||||
contours.selectedContour,
|
||||
openEdit,
|
||||
]);
|
||||
|
||||
return {
|
||||
canEdit: contours.selectedContour !== null,
|
||||
openCreate,
|
||||
openEdit,
|
||||
utilityActions,
|
||||
window: (
|
||||
<ComputeContourSettingsWindow
|
||||
open={open}
|
||||
mode={mode}
|
||||
contour={contours.selectedContour}
|
||||
onClose={() => setOpen(false)}
|
||||
onCreate={contours.createContour}
|
||||
onUpdate={contours.updateContour}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ApplicationPanelUtilityAction } from "@nodedc/ui-react";
|
||||
|
||||
import type { WorkspaceDefinition } from "../productModel";
|
||||
|
||||
interface ApplicationPanelActionsOptions {
|
||||
definition: WorkspaceDefinition | null;
|
||||
refreshRuntime: () => void;
|
||||
resetConnectionScenario?: () => Promise<boolean>;
|
||||
connectionScenarioResetting: boolean;
|
||||
saveWorkspaceLayout: () => Promise<void>;
|
||||
workspaceLayoutSaving: boolean;
|
||||
systemUtilityActions: readonly ApplicationPanelUtilityAction[];
|
||||
}
|
||||
|
||||
export function deviceRuntimeUtilityAction({
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
}: Pick<
|
||||
ApplicationPanelActionsOptions,
|
||||
"refreshRuntime" | "resetConnectionScenario" | "connectionScenarioResetting"
|
||||
>): ApplicationPanelUtilityAction {
|
||||
return {
|
||||
label: resetConnectionScenario
|
||||
? connectionScenarioResetting
|
||||
? "Сбрасываем подключение"
|
||||
: "Сбросить подключение"
|
||||
: "Обновить состояние локального контура",
|
||||
icon: resetConnectionScenario && connectionScenarioResetting
|
||||
? "activity"
|
||||
: "refresh",
|
||||
disabled: resetConnectionScenario && connectionScenarioResetting
|
||||
? true
|
||||
: undefined,
|
||||
onClick: resetConnectionScenario
|
||||
? connectionScenarioResetting
|
||||
? () => undefined
|
||||
: () => void resetConnectionScenario()
|
||||
: refreshRuntime,
|
||||
};
|
||||
}
|
||||
|
||||
export function useApplicationPanelActions({
|
||||
definition,
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
saveWorkspaceLayout,
|
||||
workspaceLayoutSaving,
|
||||
systemUtilityActions,
|
||||
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
|
||||
return useMemo(() => {
|
||||
const actions: ApplicationPanelUtilityAction[] = [];
|
||||
if (definition?.kind === "device") {
|
||||
actions.push(deviceRuntimeUtilityAction({
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
}));
|
||||
}
|
||||
if (definition && ["spatial", "recordings"].includes(definition.kind)) {
|
||||
actions.push({
|
||||
label: workspaceLayoutSaving ? "Сохраняем компоновку" : "Сохранить компоновку",
|
||||
icon: "save",
|
||||
disabled: workspaceLayoutSaving,
|
||||
onClick: () => void saveWorkspaceLayout(),
|
||||
});
|
||||
}
|
||||
if (definition?.root === "system") actions.push(...systemUtilityActions);
|
||||
return actions;
|
||||
}, [
|
||||
definition,
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
saveWorkspaceLayout,
|
||||
systemUtilityActions,
|
||||
workspaceLayoutSaving,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
export interface ArtifactScope {
|
||||
scope_id: string;
|
||||
file_count: number;
|
||||
logical_bytes: number;
|
||||
modified_at_utc: string | null;
|
||||
}
|
||||
|
||||
export interface ArtifactHealth {
|
||||
schema_version: "missioncore.artifact-health/v1";
|
||||
generated_at_utc: string;
|
||||
overall_state: "live" | "attention" | "unavailable";
|
||||
inventory: {
|
||||
state: "live" | "degraded" | "unavailable";
|
||||
scanned_at_utc: string;
|
||||
scan_duration_ms: number;
|
||||
file_count: number;
|
||||
logical_bytes: number;
|
||||
unreadable_entries: number;
|
||||
files_delta_since_previous: number | null;
|
||||
bytes_delta_since_previous: number | null;
|
||||
scopes: ArtifactScope[];
|
||||
};
|
||||
exact_audit: {
|
||||
state: "exact-current" | "exact-stale" | "unavailable";
|
||||
result_id: string | null;
|
||||
audited_at_utc: string | null;
|
||||
root_count: number;
|
||||
file_count: number;
|
||||
logical_bytes: number;
|
||||
unique_content_bytes: number;
|
||||
duplicate_bytes: number;
|
||||
duplicate_fraction: number;
|
||||
amplification_ratio: number;
|
||||
duplicate_content_groups: number;
|
||||
changed_roots: string[];
|
||||
limitations: string[];
|
||||
};
|
||||
content_references: {
|
||||
state: "not-indexed" | "indexed";
|
||||
storage_model:
|
||||
| "materialized-copies"
|
||||
| "materialized-copies-with-reference-index";
|
||||
result_id: string | null;
|
||||
indexed_at_utc: string | null;
|
||||
logical_reference_count: number;
|
||||
canonical_content_count: number;
|
||||
duplicate_reference_count: number;
|
||||
exact_duplicate_bytes: number;
|
||||
physical_reclamation_applied: false;
|
||||
storage_migration_authorized: false;
|
||||
next_gate: string;
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export async function fetchArtifactHealth(
|
||||
signal?: AbortSignal,
|
||||
): Promise<ArtifactHealth> {
|
||||
const response = await fetch("/api/v1/data/artifact-health", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`API здоровья данных вернул HTTP ${response.status}.`);
|
||||
}
|
||||
const document: unknown = await response.json();
|
||||
if (
|
||||
!isRecord(document)
|
||||
|| document.schema_version !== "missioncore.artifact-health/v1"
|
||||
|| !isRecord(document.inventory)
|
||||
|| !Array.isArray(document.inventory.scopes)
|
||||
|| !isRecord(document.exact_audit)
|
||||
|| !isRecord(document.content_references)
|
||||
|| !["not-indexed", "indexed"].includes(
|
||||
String(document.content_references.state),
|
||||
)
|
||||
|| ![
|
||||
"materialized-copies",
|
||||
"materialized-copies-with-reference-index",
|
||||
].includes(String(document.content_references.storage_model))
|
||||
|| document.content_references.physical_reclamation_applied !== false
|
||||
|| document.content_references.storage_migration_authorized !== false
|
||||
) {
|
||||
throw new Error("Ответ здоровья данных не соответствует контракту.");
|
||||
}
|
||||
return document as unknown as ArtifactHealth;
|
||||
}
|
||||
@@ -23,6 +23,64 @@ interface DevicePluginHostValue {
|
||||
|
||||
const DevicePluginHostContext = createContext<DevicePluginHostValue | null>(null);
|
||||
|
||||
export const DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY =
|
||||
"nodedc.mission-core.device-model-selection.v1";
|
||||
|
||||
export interface DeviceModelSelectionStorage {
|
||||
getItem: (key: string) => string | null;
|
||||
setItem: (key: string, value: string) => void;
|
||||
removeItem: (key: string) => void;
|
||||
}
|
||||
|
||||
function browserDeviceModelSelectionStorage(): DeviceModelSelectionStorage | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore only a model that is present in the current reviewed registry.
|
||||
* Removed/renamed models and malformed browser values fail closed to the
|
||||
* picker and are cleared so a later remount cannot keep retrying stale state.
|
||||
*/
|
||||
export function restorePersistedDeviceModelId(
|
||||
registry: DevicePluginRegistry,
|
||||
storage: DeviceModelSelectionStorage | null,
|
||||
): string | null {
|
||||
if (!storage) return null;
|
||||
try {
|
||||
const stored = storage.getItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY);
|
||||
const modelId = stored?.trim() || null;
|
||||
if (modelId && registry.resolveModel(modelId)) return modelId;
|
||||
if (stored !== null) {
|
||||
storage.removeItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist only an already-admitted host transition; browser storage is never authority. */
|
||||
export function commitPersistedDeviceModelId(
|
||||
modelId: string | null,
|
||||
storage: DeviceModelSelectionStorage | null,
|
||||
): void {
|
||||
if (!storage) return;
|
||||
try {
|
||||
if (modelId === null) {
|
||||
storage.removeItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
storage.setItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY, modelId);
|
||||
} catch {
|
||||
// A denied/full localStorage must not block the in-memory host transition.
|
||||
}
|
||||
}
|
||||
|
||||
export function DevicePluginHostProvider({
|
||||
plugins,
|
||||
children,
|
||||
@@ -31,7 +89,10 @@ export function DevicePluginHostProvider({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const registry = useMemo(() => createDevicePluginRegistry(plugins), [plugins]);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
|
||||
const selectionStorage = useMemo(browserDeviceModelSelectionStorage, []);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(() =>
|
||||
restorePersistedDeviceModelId(registry, selectionStorage)
|
||||
);
|
||||
const [selectionTransitionPending, setSelectionTransitionPending] = useState(false);
|
||||
const [selectionTransitionError, setSelectionTransitionError] = useState<string | null>(null);
|
||||
const transitionInFlight = useRef(false);
|
||||
@@ -56,7 +117,12 @@ export function DevicePluginHostProvider({
|
||||
if (nextModelId !== null && !registry.resolveModel(nextModelId)) {
|
||||
throw new Error(`Модель устройства не зарегистрирована: ${nextModelId}.`);
|
||||
}
|
||||
if (nextModelId === selectedModelId) return true;
|
||||
if (nextModelId === selectedModelId) {
|
||||
// `clearSelection()` must clear a stale persisted value even when the
|
||||
// current in-memory selection is already empty.
|
||||
commitPersistedDeviceModelId(nextModelId, selectionStorage);
|
||||
return true;
|
||||
}
|
||||
|
||||
transitionInFlight.current = true;
|
||||
setSelectionTransitionPending(true);
|
||||
@@ -88,13 +154,14 @@ export function DevicePluginHostProvider({
|
||||
}
|
||||
}
|
||||
setSelectedModelId(nextModelId);
|
||||
commitPersistedDeviceModelId(nextModelId, selectionStorage);
|
||||
return true;
|
||||
} finally {
|
||||
transitionInFlight.current = false;
|
||||
setSelectionTransitionPending(false);
|
||||
}
|
||||
},
|
||||
[registry, selectedModelId],
|
||||
[registry, selectedModelId, selectionStorage],
|
||||
);
|
||||
|
||||
const deactivationRegistrars = useMemo(
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { roots, type RootId } from "../../productModel";
|
||||
|
||||
export type EnvironmentSurfaceId = "home" | RootId;
|
||||
export type EnvironmentMediaKind = "image" | "video";
|
||||
export type EnvironmentMediaSource = "file" | "url";
|
||||
|
||||
export interface EnvironmentMediaItem {
|
||||
id: string;
|
||||
source: EnvironmentMediaSource;
|
||||
url: string | null;
|
||||
mediaKind: EnvironmentMediaKind | null;
|
||||
fileName: string | null;
|
||||
}
|
||||
|
||||
export interface EnvironmentBackground {
|
||||
enabled: boolean;
|
||||
imageDurationSeconds: number;
|
||||
items: EnvironmentMediaItem[];
|
||||
}
|
||||
|
||||
export interface EnvironmentPage {
|
||||
headerLabel: string;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryWorkspaceId: string | null;
|
||||
secondaryWorkspaceId: string | null;
|
||||
background: EnvironmentBackground;
|
||||
}
|
||||
|
||||
export interface EnvironmentSettings {
|
||||
revision: number;
|
||||
pages: Record<EnvironmentSurfaceId, EnvironmentPage>;
|
||||
}
|
||||
|
||||
export interface UploadedEnvironmentMedia {
|
||||
surfaceId: EnvironmentSurfaceId;
|
||||
itemId: string;
|
||||
url: string;
|
||||
fileName: string;
|
||||
mediaKind: EnvironmentMediaKind;
|
||||
mediaType: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
const surfaceIds: readonly EnvironmentSurfaceId[] = [
|
||||
"home",
|
||||
"fleet",
|
||||
"observation",
|
||||
"missions",
|
||||
"data",
|
||||
"system",
|
||||
"polygon",
|
||||
];
|
||||
|
||||
export const defaultEnvironmentImageDurationSeconds = 10;
|
||||
export const maxEnvironmentMediaItems = 24;
|
||||
|
||||
function emptyBackground(): EnvironmentBackground {
|
||||
return {
|
||||
enabled: false,
|
||||
imageDurationSeconds: defaultEnvironmentImageDurationSeconds,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createEnvironmentMediaItem(): EnvironmentMediaItem {
|
||||
return {
|
||||
id: `media-${crypto.randomUUID()}`,
|
||||
source: "file",
|
||||
url: null,
|
||||
mediaKind: null,
|
||||
fileName: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function appendEnvironmentMediaItem(
|
||||
background: EnvironmentBackground,
|
||||
item: EnvironmentMediaItem = createEnvironmentMediaItem(),
|
||||
): EnvironmentBackground {
|
||||
if (
|
||||
background.items.length >= maxEnvironmentMediaItems
|
||||
|| background.items.some((candidate) => candidate.id === item.id)
|
||||
) {
|
||||
return background;
|
||||
}
|
||||
return {
|
||||
...background,
|
||||
enabled: background.items.length === 0 ? true : background.enabled,
|
||||
items: [...background.items, item],
|
||||
};
|
||||
}
|
||||
|
||||
export function removeEnvironmentMediaItem(
|
||||
background: EnvironmentBackground,
|
||||
itemId: string,
|
||||
): EnvironmentBackground {
|
||||
const items = background.items.filter((item) => item.id !== itemId);
|
||||
if (items.length === background.items.length) return background;
|
||||
return {
|
||||
...background,
|
||||
enabled: items.length > 0 && background.enabled,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export function inferEnvironmentMediaKind(url: string): EnvironmentMediaKind {
|
||||
return /\.(mp4|webm|mov)(?:[?#].*)?$/i.test(url) ? "video" : "image";
|
||||
}
|
||||
|
||||
const defaultQuickActions: Record<
|
||||
EnvironmentSurfaceId,
|
||||
readonly [string | null, string | null]
|
||||
> = {
|
||||
home: ["spatial-scene", "local-device"],
|
||||
fleet: ["contour-health", "local-device"],
|
||||
observation: ["spatial-scene", "cameras"],
|
||||
missions: ["mission-planner", null],
|
||||
data: ["recordings", "datasets"],
|
||||
system: ["modules", "integrations"],
|
||||
polygon: ["lab-archive", null],
|
||||
};
|
||||
|
||||
export function defaultEnvironmentSettings(): EnvironmentSettings {
|
||||
const rootById = new Map(roots.map((root) => [root.id, root]));
|
||||
return {
|
||||
revision: 0,
|
||||
pages: Object.fromEntries(surfaceIds.map((surfaceId) => {
|
||||
const root = surfaceId === "home" ? null : rootById.get(surfaceId) ?? null;
|
||||
const [primaryWorkspaceId, secondaryWorkspaceId] = defaultQuickActions[surfaceId];
|
||||
return [surfaceId, {
|
||||
headerLabel: root?.label ?? "Mission Core",
|
||||
eyebrow: root?.eyebrow ?? "NODEDC / MISSION CORE",
|
||||
title: root?.title ?? "Mission Core",
|
||||
description: root?.statement
|
||||
?? "Наблюдение, планирование и корректировка миссий в одной модульной рабочей области.",
|
||||
primaryWorkspaceId,
|
||||
secondaryWorkspaceId,
|
||||
background: emptyBackground(),
|
||||
}];
|
||||
})) as Record<EnvironmentSurfaceId, EnvironmentPage>,
|
||||
};
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, path: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`${path} должен быть объектом.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireString(
|
||||
value: unknown,
|
||||
path: string,
|
||||
nullable = false,
|
||||
): string | null {
|
||||
if (nullable && value === null) return null;
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`${path} должен быть непустой строкой.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function decodeMediaItem(value: unknown, path: string): EnvironmentMediaItem {
|
||||
const record = requireRecord(value, path);
|
||||
if (
|
||||
typeof record.id !== "string"
|
||||
|| !/^media-[a-z0-9-]{1,58}$/.test(record.id)
|
||||
) {
|
||||
throw new Error(`${path}.id не поддерживается.`);
|
||||
}
|
||||
if (!["file", "url"].includes(String(record.source))) {
|
||||
throw new Error(`${path}.source не поддерживается.`);
|
||||
}
|
||||
if (!["image", "video"].includes(String(record.media_kind))) {
|
||||
throw new Error(`${path}.media_kind не поддерживается.`);
|
||||
}
|
||||
return {
|
||||
id: record.id,
|
||||
source: record.source as EnvironmentMediaSource,
|
||||
url: requireString(record.url, `${path}.url`),
|
||||
mediaKind: record.media_kind as EnvironmentMediaKind,
|
||||
fileName: requireString(record.file_name, `${path}.file_name`, true),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeBackground(value: unknown, path: string): EnvironmentBackground {
|
||||
const record = requireRecord(value, path);
|
||||
if (typeof record.enabled !== "boolean") {
|
||||
throw new Error(`${path}.enabled должен быть boolean.`);
|
||||
}
|
||||
if (
|
||||
typeof record.image_duration_seconds !== "number"
|
||||
|| !Number.isSafeInteger(record.image_duration_seconds)
|
||||
|| record.image_duration_seconds < 1
|
||||
|| record.image_duration_seconds > 300
|
||||
) {
|
||||
throw new Error(`${path}.image_duration_seconds некорректен.`);
|
||||
}
|
||||
if (
|
||||
!Array.isArray(record.items)
|
||||
|| record.items.length > maxEnvironmentMediaItems
|
||||
) {
|
||||
throw new Error(`${path}.items некорректен.`);
|
||||
}
|
||||
const items = record.items.map((item, index) =>
|
||||
decodeMediaItem(item, `${path}.items[${index}]`));
|
||||
if (new Set(items.map((item) => item.id)).size !== items.length) {
|
||||
throw new Error(`${path}.items содержит повторяющиеся id.`);
|
||||
}
|
||||
return {
|
||||
enabled: record.enabled,
|
||||
imageDurationSeconds: record.image_duration_seconds,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
function decodePage(value: unknown, path: string): EnvironmentPage {
|
||||
const record = requireRecord(value, path);
|
||||
return {
|
||||
headerLabel: requireString(record.header_label, `${path}.header_label`)!,
|
||||
eyebrow: requireString(record.eyebrow, `${path}.eyebrow`)!,
|
||||
title: requireString(record.title, `${path}.title`)!,
|
||||
description: requireString(record.description, `${path}.description`)!,
|
||||
primaryWorkspaceId: requireString(
|
||||
record.primary_workspace_id,
|
||||
`${path}.primary_workspace_id`,
|
||||
true,
|
||||
),
|
||||
secondaryWorkspaceId: requireString(
|
||||
record.secondary_workspace_id,
|
||||
`${path}.secondary_workspace_id`,
|
||||
true,
|
||||
),
|
||||
background: decodeBackground(record.background, `${path}.background`),
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeEnvironmentSettings(value: unknown): EnvironmentSettings {
|
||||
const record = requireRecord(value, "environment");
|
||||
if (record.schema_version !== "missioncore.operator-environment/v3") {
|
||||
throw new Error("Версия настроек окружения не поддерживается.");
|
||||
}
|
||||
if (
|
||||
typeof record.revision !== "number"
|
||||
|| !Number.isSafeInteger(record.revision)
|
||||
|| record.revision < 0
|
||||
) {
|
||||
throw new Error("Ревизия настроек окружения некорректна.");
|
||||
}
|
||||
const pages = requireRecord(record.pages, "environment.pages");
|
||||
return {
|
||||
revision: record.revision,
|
||||
pages: Object.fromEntries(surfaceIds.map((surfaceId) => [
|
||||
surfaceId,
|
||||
decodePage(pages[surfaceId], `environment.pages.${surfaceId}`),
|
||||
])) as Record<EnvironmentSurfaceId, EnvironmentPage>,
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeEnvironmentSettings(settings: EnvironmentSettings): unknown {
|
||||
return {
|
||||
revision: settings.revision,
|
||||
pages: Object.fromEntries(surfaceIds.map((surfaceId) => {
|
||||
const page = settings.pages[surfaceId];
|
||||
return [surfaceId, {
|
||||
header_label: page.headerLabel,
|
||||
eyebrow: page.eyebrow,
|
||||
title: page.title,
|
||||
description: page.description,
|
||||
primary_workspace_id: page.primaryWorkspaceId,
|
||||
secondary_workspace_id: page.secondaryWorkspaceId,
|
||||
background: {
|
||||
enabled: page.background.enabled,
|
||||
image_duration_seconds: page.background.imageDurationSeconds,
|
||||
items: page.background.items.map((item) => ({
|
||||
id: item.id,
|
||||
source: item.source,
|
||||
url: item.url,
|
||||
media_kind: item.mediaKind,
|
||||
file_name: item.fileName,
|
||||
})),
|
||||
},
|
||||
}];
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeUploadedEnvironmentMedia(
|
||||
value: unknown,
|
||||
): UploadedEnvironmentMedia {
|
||||
const record = requireRecord(value, "environment media");
|
||||
if (record.schema_version !== "missioncore.operator-environment-media/v2") {
|
||||
throw new Error("Версия загруженного media не поддерживается.");
|
||||
}
|
||||
if (!surfaceIds.includes(record.surface_id as EnvironmentSurfaceId)) {
|
||||
throw new Error("Экран загруженного media не поддерживается.");
|
||||
}
|
||||
if (
|
||||
typeof record.item_id !== "string"
|
||||
|| !/^media-[a-z0-9-]{1,58}$/.test(record.item_id)
|
||||
) {
|
||||
throw new Error("Идентификатор загруженного media не поддерживается.");
|
||||
}
|
||||
if (!["image", "video"].includes(String(record.media_kind))) {
|
||||
throw new Error("Тип загруженного media не поддерживается.");
|
||||
}
|
||||
if (
|
||||
typeof record.byte_length !== "number"
|
||||
|| !Number.isSafeInteger(record.byte_length)
|
||||
|| record.byte_length <= 0
|
||||
) {
|
||||
throw new Error("Размер загруженного media некорректен.");
|
||||
}
|
||||
return {
|
||||
surfaceId: record.surface_id as EnvironmentSurfaceId,
|
||||
itemId: record.item_id,
|
||||
url: requireString(record.url, "environment media.url")!,
|
||||
fileName: requireString(record.file_name, "environment media.file_name")!,
|
||||
mediaKind: record.media_kind as EnvironmentMediaKind,
|
||||
mediaType: requireString(record.media_type, "environment media.media_type")!,
|
||||
byteLength: record.byte_length,
|
||||
sha256: requireString(record.sha256, "environment media.sha256")!,
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneEnvironmentSettings(
|
||||
settings: EnvironmentSettings,
|
||||
): EnvironmentSettings {
|
||||
return {
|
||||
revision: settings.revision,
|
||||
pages: Object.fromEntries(surfaceIds.map((surfaceId) => [
|
||||
surfaceId,
|
||||
{
|
||||
...settings.pages[surfaceId],
|
||||
background: {
|
||||
...settings.pages[surfaceId].background,
|
||||
items: settings.pages[surfaceId].background.items.map((item) => ({
|
||||
...item,
|
||||
})),
|
||||
},
|
||||
},
|
||||
])) as Record<EnvironmentSurfaceId, EnvironmentPage>,
|
||||
};
|
||||
}
|
||||
|
||||
export const environmentSurfaceIds = surfaceIds;
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
decodeEnvironmentSettings,
|
||||
decodeUploadedEnvironmentMedia,
|
||||
defaultEnvironmentSettings,
|
||||
encodeEnvironmentSettings,
|
||||
type EnvironmentSettings,
|
||||
type EnvironmentSurfaceId,
|
||||
type UploadedEnvironmentMedia,
|
||||
} from "./environmentSettings";
|
||||
|
||||
interface EnvironmentSettingsController {
|
||||
settings: EnvironmentSettings;
|
||||
state: "loading" | "ready" | "saving" | "error";
|
||||
error: string | null;
|
||||
save: (draft: EnvironmentSettings) => Promise<EnvironmentSettings>;
|
||||
upload: (
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
itemId: string,
|
||||
file: File,
|
||||
) => Promise<UploadedEnvironmentMedia>;
|
||||
}
|
||||
|
||||
async function responseError(response: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const body = await response.json() as { detail?: unknown };
|
||||
if (typeof body.detail === "string" && body.detail.trim()) return body.detail;
|
||||
} catch {
|
||||
// A non-JSON reverse-proxy response still gets a stable product message.
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function useEnvironmentSettings(): EnvironmentSettingsController {
|
||||
const [settings, setSettings] = useState<EnvironmentSettings>(
|
||||
defaultEnvironmentSettings,
|
||||
);
|
||||
const [state, setState] = useState<EnvironmentSettingsController["state"]>(
|
||||
"loading",
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetch("/api/v1/environment/settings", {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
}).then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseError(
|
||||
response,
|
||||
"Не удалось загрузить настройки окружения.",
|
||||
));
|
||||
}
|
||||
return decodeEnvironmentSettings(await response.json());
|
||||
}).then((document) => {
|
||||
setSettings(document);
|
||||
setState("ready");
|
||||
setError(null);
|
||||
}).catch((reason: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setState("error");
|
||||
setError(reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось загрузить настройки окружения.");
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async (draft: EnvironmentSettings) => {
|
||||
setState("saving");
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/v1/environment/settings", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(encodeEnvironmentSettings(draft)),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseError(
|
||||
response,
|
||||
"Не удалось сохранить настройки окружения.",
|
||||
));
|
||||
}
|
||||
const document = decodeEnvironmentSettings(await response.json());
|
||||
setSettings(document);
|
||||
setState("ready");
|
||||
return document;
|
||||
} catch (reason) {
|
||||
const message = reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось сохранить настройки окружения.";
|
||||
setState("error");
|
||||
setError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const upload = useCallback(async (
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
itemId: string,
|
||||
file: File,
|
||||
) => {
|
||||
const response = await fetch(
|
||||
`/api/v1/environment/media/${surfaceId}/${itemId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": file.type || "application/octet-stream",
|
||||
"X-NODEDC-File-Name": encodeURIComponent(file.name),
|
||||
},
|
||||
body: file,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseError(
|
||||
response,
|
||||
"Не удалось загрузить фон окружения.",
|
||||
));
|
||||
}
|
||||
return decodeUploadedEnvironmentMedia(await response.json());
|
||||
}, []);
|
||||
|
||||
return { settings, state, error, save, upload };
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
import {
|
||||
AdvancedLaboratoryContractError,
|
||||
fetchOne,
|
||||
parseE31,
|
||||
parseE32,
|
||||
parseE33,
|
||||
parseE37,
|
||||
parseE38,
|
||||
parseE39,
|
||||
type AdvancedLaboratoryResults,
|
||||
type LaboratoryFetch,
|
||||
} from "./advancedResults";
|
||||
import { fetchE34TemporalLayerResult } from "./e34TemporalLayer";
|
||||
import { fetchE35DegradationRecoveryResult } from "./e35DegradationRecovery";
|
||||
import { fetchE40ProductGateResult } from "./e40ProductGate";
|
||||
import { fetchL3PointPillarsVisualAudit } from "./l3PointPillarsVisualAudit";
|
||||
import { fetchL31PointPillarsRavnoves } from "./l31PointPillarsRavnoves";
|
||||
import { fetchL32PointPillarsCameraReview } from "./l32PointPillarsCameraReview";
|
||||
import { fetchL33CameraFirstDetectorReview } from "./l33CameraFirstDetectorReview";
|
||||
import { fetchL34RightYoloxTruthIsland } from "./l34RightYoloxTruthIsland";
|
||||
import { fetchL34AAssistedYoloxErrorAudit } from "./l34aAssistedYoloxErrorAudit";
|
||||
import { fetchL34BNestedBoxConsolidation } from "./l34bNestedBoxConsolidation";
|
||||
import { fetchL34CTileSeamStitch } from "./l34cTileSeamStitch";
|
||||
import { fetchL34DCumulativePostprocessing } from "./l34dCumulativePostprocessing";
|
||||
import { fetchL34ESelfReviewDiagnostic } from "./l34eSelfReviewDiagnostic";
|
||||
import { fetchL34FFrozenResult } from "./l34fAdjudication";
|
||||
import { fetchE46BlindReviewResult } from "./e46BlindReview";
|
||||
import { fetchE46AAiEngineeringPreannotation } from "./e46aAiEngineeringPreannotation";
|
||||
import { fetchE46BTemporalMotion } from "./e46bTemporalMotion";
|
||||
import { fetchE46CFullReplayWorldTracks } from "./e46cFullReplayWorldTracks";
|
||||
import { fetchE46DTemporalFailureAudit } from "./e46dTemporalFailureAudit";
|
||||
import { fetchE46EReadyStack } from "./e46eReadyStack";
|
||||
import { fetchE46FDashCamBakeoff } from "./e46fDashCamBakeoff";
|
||||
import { fetchE46GRectifiedDetectorBakeoff } from "./e46gRectifiedDetectorBakeoff";
|
||||
import { fetchE46HFullRectifiedFrontReplay } from "./e46hFullRectifiedFrontReplay";
|
||||
import { fetchE46IGroundingDinoFullReplay } from "./e46iGroundingDinoFullReplay";
|
||||
import { fetchE46JRawFisheyeRealtime } from "./e46jRawFisheyeRealtime";
|
||||
import { fetchE47SemanticSlamResult } from "./e47SemanticSlam";
|
||||
import { fetchM4ThreatReplayResult } from "./m4ReplayThreat";
|
||||
import { fetchM47ReferenceGraphLab } from "./m47ReferenceGraph";
|
||||
|
||||
export type AdvancedLaboratoryWorkId =
|
||||
| "m47-reference-graph-shadow"
|
||||
| "m4-replay-threat"
|
||||
| "l3-pointpillars-visual-audit"
|
||||
| "l31-pointpillars-ravnoves"
|
||||
| "l32-pointpillars-camera-review"
|
||||
| "l33-camera-first-detector-review"
|
||||
| "e31-source-binding"
|
||||
| "e32-track-geometry"
|
||||
| "e33-worker-shadow"
|
||||
| "e34-temporal-layer"
|
||||
| "e35-degradation-recovery"
|
||||
| "e37-ravnoves-acceptance"
|
||||
| "e38-perception-baseline"
|
||||
| "e39-perception-refinement"
|
||||
| "e40-perception-product-gate"
|
||||
| "e46-detector-truth-island"
|
||||
| "e46a-ai-engineering-preannotation"
|
||||
| "e46b-temporal-motion"
|
||||
| "e46c-full-replay-world-tracks"
|
||||
| "e46d-temporal-failure-audit"
|
||||
| "e46e-ready-stack"
|
||||
| "e46f-dashcam-bakeoff"
|
||||
| "e46g-rectified-detector-bakeoff"
|
||||
| "e46h-full-rectified-front-replay"
|
||||
| "e46i-grounding-dino-full-replay"
|
||||
| "e46j-raw-fisheye-realtime"
|
||||
| "e47-semantic-slam-shadow"
|
||||
| "l34-right-yolox-truth-island-freeze"
|
||||
| "l34a-assisted-yolox-error-audit"
|
||||
| "l34b-nested-box-consolidation-shadow"
|
||||
| "l34c-tile-seam-stitch-shadow"
|
||||
| "l34d-cumulative-postprocessing-candidate"
|
||||
| "l34e-self-review-diagnostic"
|
||||
| "l34f-adjudicated-reference";
|
||||
|
||||
export interface AdvancedLaboratoryIndexItem {
|
||||
workId: AdvancedLaboratoryWorkId;
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
}
|
||||
|
||||
const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [
|
||||
"m47-reference-graph-shadow",
|
||||
"m4-replay-threat",
|
||||
"l3-pointpillars-visual-audit",
|
||||
"l31-pointpillars-ravnoves",
|
||||
"l32-pointpillars-camera-review",
|
||||
"l33-camera-first-detector-review",
|
||||
"e31-source-binding",
|
||||
"e32-track-geometry",
|
||||
"e33-worker-shadow",
|
||||
"e34-temporal-layer",
|
||||
"e35-degradation-recovery",
|
||||
"e37-ravnoves-acceptance",
|
||||
"e38-perception-baseline",
|
||||
"e39-perception-refinement",
|
||||
"e40-perception-product-gate",
|
||||
"e46-detector-truth-island",
|
||||
"e46a-ai-engineering-preannotation",
|
||||
"e46b-temporal-motion",
|
||||
"e46c-full-replay-world-tracks",
|
||||
"e46d-temporal-failure-audit",
|
||||
"e46e-ready-stack",
|
||||
"e46f-dashcam-bakeoff",
|
||||
"e46g-rectified-detector-bakeoff",
|
||||
"e46h-full-rectified-front-replay",
|
||||
"e46i-grounding-dino-full-replay",
|
||||
"e46j-raw-fisheye-realtime",
|
||||
"e47-semantic-slam-shadow",
|
||||
"l34-right-yolox-truth-island-freeze",
|
||||
"l34a-assisted-yolox-error-audit",
|
||||
"l34b-nested-box-consolidation-shadow",
|
||||
"l34c-tile-seam-stitch-shadow",
|
||||
"l34d-cumulative-postprocessing-candidate",
|
||||
"l34e-self-review-diagnostic",
|
||||
"l34f-adjudicated-reference",
|
||||
];
|
||||
|
||||
const RESULT_PREFIX: Readonly<Record<AdvancedLaboratoryWorkId, string>> = {
|
||||
"m47-reference-graph-shadow": "m47-reference-graph-lab",
|
||||
"m4-replay-threat": "m4-threat-replay",
|
||||
"l3-pointpillars-visual-audit": "l3-pointpillars-visual-audit",
|
||||
"l31-pointpillars-ravnoves": "l31-pointpillars-ravnoves",
|
||||
"l32-pointpillars-camera-review": "l32-pointpillars-camera-review",
|
||||
"l33-camera-first-detector-review": "l33-camera-first-detector-review",
|
||||
"e31-source-binding": "e31-source-qualification",
|
||||
"e32-track-geometry": "e32-track-geometry",
|
||||
"e33-worker-shadow": "e33-worker-shadow",
|
||||
"e34-temporal-layer": "e34-temporal-occupied",
|
||||
"e35-degradation-recovery": "e35-degradation-recovery",
|
||||
"e37-ravnoves-acceptance": "e37-ravnoves-acceptance",
|
||||
"e38-perception-baseline": "e38-perception-baseline",
|
||||
"e39-perception-refinement": "e39-perception-refinement",
|
||||
"e40-perception-product-gate": "e40-perception-product-gate",
|
||||
"e46-detector-truth-island": "e46-detector-truth-island",
|
||||
"e46a-ai-engineering-preannotation": "e46a-ai-engineering-preannotation",
|
||||
"e46b-temporal-motion": "e46b-temporal-motion",
|
||||
"e46c-full-replay-world-tracks": "e46c-full-replay-world-tracks",
|
||||
"e46d-temporal-failure-audit": "e46d-temporal-failure-audit",
|
||||
"e46e-ready-stack": "e46e-ready-stack",
|
||||
"e46f-dashcam-bakeoff": "e46f-dashcam-bakeoff",
|
||||
"e46g-rectified-detector-bakeoff": "e46g-rectified-detector-bakeoff",
|
||||
"e46h-full-rectified-front-replay": "e46h-full-rectified-front-replay",
|
||||
"e46i-grounding-dino-full-replay": "e46i-grounding-dino-full-replay",
|
||||
"e46j-raw-fisheye-realtime": "e46j-raw-fisheye-realtime",
|
||||
"e47-semantic-slam-shadow": "e47-semantic-slam",
|
||||
"l34-right-yolox-truth-island-freeze": "l34-right-yolox-truth-island-freeze",
|
||||
"l34a-assisted-yolox-error-audit": "l34a-assisted-yolox-error-audit",
|
||||
"l34b-nested-box-consolidation-shadow": "l34b-nested-box-consolidation-shadow",
|
||||
"l34c-tile-seam-stitch-shadow": "l34c-tile-seam-stitch-shadow",
|
||||
"l34d-cumulative-postprocessing-candidate": "l34d-cumulative-postprocessing-candidate",
|
||||
"l34e-self-review-diagnostic": "l34e-self-review-diagnostic",
|
||||
"l34f-adjudicated-reference": "l34f-adjudicated-reference",
|
||||
};
|
||||
|
||||
export function isAdvancedLaboratoryWorkId(
|
||||
value: string,
|
||||
): value is AdvancedLaboratoryWorkId {
|
||||
return WORK_IDS.includes(value as AdvancedLaboratoryWorkId);
|
||||
}
|
||||
|
||||
export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults {
|
||||
return {
|
||||
m47Graph: null,
|
||||
m4Threat: null,
|
||||
l3: null,
|
||||
l31: null,
|
||||
l32: null,
|
||||
l33: null,
|
||||
e31: null,
|
||||
e32: null,
|
||||
e33: null,
|
||||
e34: null,
|
||||
e35: null,
|
||||
e37: null,
|
||||
e38: null,
|
||||
e39: null,
|
||||
e40: null,
|
||||
e46: null,
|
||||
e46a: null,
|
||||
e46b: null,
|
||||
e46c: null,
|
||||
e46d: null,
|
||||
e46e: null,
|
||||
e46f: null,
|
||||
e46g: null,
|
||||
e46h: null,
|
||||
e46i: null,
|
||||
e46j: null,
|
||||
e47: null,
|
||||
l34: null,
|
||||
l34a: null,
|
||||
l34b: null,
|
||||
l34c: null,
|
||||
l34d: null,
|
||||
l34e: null,
|
||||
l34f: null,
|
||||
};
|
||||
}
|
||||
|
||||
function objectValue(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactString(value: unknown, expected: string, label: string): string {
|
||||
if (value !== expected) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function parseIndexItem(value: unknown): AdvancedLaboratoryIndexItem {
|
||||
const item = objectValue(value, "Индекс LAB.item");
|
||||
if (
|
||||
typeof item.work_id !== "string"
|
||||
|| !isAdvancedLaboratoryWorkId(item.work_id)
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"Индекс LAB.work_id: неизвестная лабораторная работа.",
|
||||
);
|
||||
}
|
||||
const prefix = RESULT_PREFIX[item.work_id];
|
||||
if (
|
||||
typeof item.result_id !== "string"
|
||||
|| !new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(item.result_id)
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"Индекс LAB.result_id: нарушена идентичность результата.",
|
||||
);
|
||||
}
|
||||
if (typeof item.created_at_utc !== "string" || !item.created_at_utc.trim()) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"Индекс LAB.created_at_utc: ожидалась строка.",
|
||||
);
|
||||
}
|
||||
exactString(item.access, "read-only", "Индекс LAB.item.access");
|
||||
return {
|
||||
workId: item.work_id,
|
||||
resultId: item.result_id,
|
||||
createdAtUtc: item.created_at_utc,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAdvancedLaboratoryIndex({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<readonly AdvancedLaboratoryIndexItem[]> {
|
||||
const response = await fetcher("/api/v1/laboratory/advanced-index", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
`Индекс LAB недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const payload = objectValue(await response.json(), "Индекс LAB");
|
||||
exactString(
|
||||
payload.schema_version,
|
||||
"missioncore.laboratory-advanced-index/v1",
|
||||
"Индекс LAB.schema_version",
|
||||
);
|
||||
exactString(payload.access, "read-only", "Индекс LAB.access");
|
||||
if (!Array.isArray(payload.items) || payload.items.length > WORK_IDS.length) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"Индекс LAB.items: нарушен размер каталога.",
|
||||
);
|
||||
}
|
||||
const items = payload.items.map(parseIndexItem);
|
||||
if (new Set(items.map((item) => item.workId)).size !== items.length) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"Индекс LAB.items: лабораторная работа продублирована.",
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function advancedLaboratoryResultAvailable(
|
||||
workId: AdvancedLaboratoryWorkId,
|
||||
results: AdvancedLaboratoryResults,
|
||||
): boolean {
|
||||
return workId === "m47-reference-graph-shadow" ? results.m47Graph !== null
|
||||
: workId === "m4-replay-threat" ? results.m4Threat !== null
|
||||
: workId === "l3-pointpillars-visual-audit" ? results.l3 !== null
|
||||
: workId === "l31-pointpillars-ravnoves" ? results.l31 !== null
|
||||
: workId === "l32-pointpillars-camera-review" ? results.l32 !== null
|
||||
: workId === "l33-camera-first-detector-review" ? results.l33 !== null
|
||||
: workId === "e31-source-binding" ? results.e31 !== null
|
||||
: workId === "e32-track-geometry" ? results.e32 !== null
|
||||
: workId === "e33-worker-shadow" ? results.e33 !== null
|
||||
: workId === "e34-temporal-layer" ? results.e34 !== null
|
||||
: workId === "e35-degradation-recovery" ? results.e35 !== null
|
||||
: workId === "e37-ravnoves-acceptance" ? results.e37 !== null
|
||||
: workId === "e38-perception-baseline" ? results.e38 !== null
|
||||
: workId === "e39-perception-refinement" ? results.e39 !== null
|
||||
: workId === "e40-perception-product-gate" ? results.e40 !== null
|
||||
: workId === "e46-detector-truth-island" ? results.e46 !== null
|
||||
: workId === "e46a-ai-engineering-preannotation" ? results.e46a !== null
|
||||
: workId === "e46b-temporal-motion" ? results.e46b !== null
|
||||
: workId === "e46c-full-replay-world-tracks" ? results.e46c !== null
|
||||
: workId === "e46d-temporal-failure-audit" ? results.e46d !== null
|
||||
: workId === "e46e-ready-stack" ? results.e46e !== null
|
||||
: workId === "e46f-dashcam-bakeoff" ? results.e46f !== null
|
||||
: workId === "e46g-rectified-detector-bakeoff" ? results.e46g !== null
|
||||
: workId === "e46h-full-rectified-front-replay" ? results.e46h !== null
|
||||
: workId === "e46i-grounding-dino-full-replay" ? results.e46i !== null
|
||||
: workId === "e46j-raw-fisheye-realtime" ? results.e46j !== null
|
||||
: workId === "e47-semantic-slam-shadow" ? results.e47 !== null
|
||||
: workId === "l34-right-yolox-truth-island-freeze" ? results.l34 !== null
|
||||
: workId === "l34a-assisted-yolox-error-audit" ? results.l34a !== null
|
||||
: workId === "l34b-nested-box-consolidation-shadow" ? results.l34b !== null
|
||||
: workId === "l34c-tile-seam-stitch-shadow" ? results.l34c !== null
|
||||
: workId === "l34d-cumulative-postprocessing-candidate" ? results.l34d !== null
|
||||
: workId === "l34e-self-review-diagnostic" ? results.l34e !== null
|
||||
: results.l34f !== null;
|
||||
}
|
||||
|
||||
export async function fetchAdvancedLaboratoryResult(
|
||||
workId: AdvancedLaboratoryWorkId,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
resultId,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
resultId?: string;
|
||||
} = {},
|
||||
): Promise<AdvancedLaboratoryResults> {
|
||||
const results = emptyAdvancedLaboratoryResults();
|
||||
if (workId === "m47-reference-graph-shadow") {
|
||||
if (!resultId) {
|
||||
throw new AdvancedLaboratoryContractError("M4.7 LAB identity не выбрана.");
|
||||
}
|
||||
results.m47Graph = await fetchM47ReferenceGraphLab({
|
||||
resultId,
|
||||
fetcher,
|
||||
signal,
|
||||
});
|
||||
} else if (workId === "m4-replay-threat") {
|
||||
results.m4Threat = await fetchM4ThreatReplayResult({ fetcher, signal });
|
||||
} else if (workId === "l3-pointpillars-visual-audit") {
|
||||
results.l3 = await fetchL3PointPillarsVisualAudit({ fetcher, signal });
|
||||
} else if (workId === "l31-pointpillars-ravnoves") {
|
||||
results.l31 = await fetchL31PointPillarsRavnoves({ fetcher, signal });
|
||||
} else if (workId === "l32-pointpillars-camera-review") {
|
||||
results.l32 = await fetchL32PointPillarsCameraReview({ fetcher, signal });
|
||||
} else if (workId === "l33-camera-first-detector-review") {
|
||||
results.l33 = await fetchL33CameraFirstDetectorReview({ fetcher, signal });
|
||||
} else if (workId === "e31-source-binding") {
|
||||
results.e31 = await fetchOne(
|
||||
"/api/v1/laboratory/e31/results?limit=1",
|
||||
parseE31,
|
||||
fetcher,
|
||||
signal,
|
||||
);
|
||||
} else if (workId === "e32-track-geometry") {
|
||||
results.e32 = await fetchOne(
|
||||
"/api/v1/laboratory/e32/results?limit=1",
|
||||
parseE32,
|
||||
fetcher,
|
||||
signal,
|
||||
);
|
||||
} else if (workId === "e33-worker-shadow") {
|
||||
results.e33 = await fetchOne(
|
||||
"/api/v1/laboratory/e33/results?limit=1",
|
||||
parseE33,
|
||||
fetcher,
|
||||
signal,
|
||||
);
|
||||
} else if (workId === "e34-temporal-layer") {
|
||||
results.e34 = await fetchE34TemporalLayerResult({ fetcher, signal });
|
||||
} else if (workId === "e35-degradation-recovery") {
|
||||
results.e35 = await fetchE35DegradationRecoveryResult({ fetcher, signal });
|
||||
} else if (workId === "e37-ravnoves-acceptance") {
|
||||
results.e37 = await fetchOne(
|
||||
"/api/v1/laboratory/e37/results?limit=1",
|
||||
parseE37,
|
||||
fetcher,
|
||||
signal,
|
||||
);
|
||||
} else if (workId === "e38-perception-baseline") {
|
||||
results.e38 = await fetchOne(
|
||||
"/api/v1/laboratory/e38/results?limit=1",
|
||||
parseE38,
|
||||
fetcher,
|
||||
signal,
|
||||
);
|
||||
} else if (workId === "e39-perception-refinement") {
|
||||
results.e39 = await fetchOne(
|
||||
"/api/v1/laboratory/e39/results?limit=1",
|
||||
parseE39,
|
||||
fetcher,
|
||||
signal,
|
||||
);
|
||||
} else if (workId === "e40-perception-product-gate") {
|
||||
results.e40 = await fetchE40ProductGateResult({ fetcher, signal });
|
||||
} else if (workId === "e46-detector-truth-island") {
|
||||
results.e46 = await fetchE46BlindReviewResult({ fetcher, signal });
|
||||
} else if (workId === "e46a-ai-engineering-preannotation") {
|
||||
results.e46a = await fetchE46AAiEngineeringPreannotation({ fetcher, signal });
|
||||
} else if (workId === "e46b-temporal-motion") {
|
||||
results.e46b = await fetchE46BTemporalMotion({ fetcher, signal });
|
||||
} else if (workId === "e46c-full-replay-world-tracks") {
|
||||
results.e46c = await fetchE46CFullReplayWorldTracks({ fetcher, signal });
|
||||
} else if (workId === "e46d-temporal-failure-audit") {
|
||||
results.e46d = await fetchE46DTemporalFailureAudit({ fetcher, signal });
|
||||
} else if (workId === "e46e-ready-stack") {
|
||||
results.e46e = await fetchE46EReadyStack({ fetcher, signal });
|
||||
} else if (workId === "e46f-dashcam-bakeoff") {
|
||||
results.e46f = await fetchE46FDashCamBakeoff({ fetcher, signal });
|
||||
} else if (workId === "e46g-rectified-detector-bakeoff") {
|
||||
results.e46g = await fetchE46GRectifiedDetectorBakeoff({ fetcher, signal });
|
||||
} else if (workId === "e46h-full-rectified-front-replay") {
|
||||
results.e46h = await fetchE46HFullRectifiedFrontReplay({ fetcher, signal });
|
||||
} else if (workId === "e46i-grounding-dino-full-replay") {
|
||||
results.e46i = await fetchE46IGroundingDinoFullReplay({ fetcher, signal });
|
||||
} else if (workId === "e46j-raw-fisheye-realtime") {
|
||||
results.e46j = await fetchE46JRawFisheyeRealtime({ fetcher, signal });
|
||||
} else if (workId === "e47-semantic-slam-shadow") {
|
||||
results.e47 = await fetchE47SemanticSlamResult({ fetcher, signal });
|
||||
} else if (workId === "l34-right-yolox-truth-island-freeze") {
|
||||
results.l34 = await fetchL34RightYoloxTruthIsland({ fetcher, signal });
|
||||
} else if (workId === "l34a-assisted-yolox-error-audit") {
|
||||
results.l34a = await fetchL34AAssistedYoloxErrorAudit({ fetcher, signal });
|
||||
} else if (workId === "l34b-nested-box-consolidation-shadow") {
|
||||
results.l34b = await fetchL34BNestedBoxConsolidation({ fetcher, signal });
|
||||
} else if (workId === "l34c-tile-seam-stitch-shadow") {
|
||||
results.l34c = await fetchL34CTileSeamStitch({ fetcher, signal });
|
||||
} else if (workId === "l34d-cumulative-postprocessing-candidate") {
|
||||
results.l34d = await fetchL34DCumulativePostprocessing({ fetcher, signal });
|
||||
} else if (workId === "l34e-self-review-diagnostic") {
|
||||
results.l34e = await fetchL34ESelfReviewDiagnostic({ fetcher, signal });
|
||||
} else {
|
||||
results.l34f = await fetchL34FFrozenResult({ fetcher, signal });
|
||||
}
|
||||
if (!advancedLaboratoryResultAvailable(workId, results)) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"Выбранная лабораторная работа не прошла серверную проверку.",
|
||||
);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type {
|
||||
E31LaboratoryResult,
|
||||
E32LaboratoryResult,
|
||||
E33LaboratoryResult,
|
||||
E37AcceptanceContractResult,
|
||||
E38PerceptionBaselineResult,
|
||||
E39PerceptionRefinementResult,
|
||||
} from "./advancedResults";
|
||||
import type { E34TemporalLayerResult } from "./e34TemporalLayer";
|
||||
import type { E35DegradationRecoveryResult } from "./e35DegradationRecovery";
|
||||
import type { E40PerceptionProductGateResult } from "./e40ProductGate";
|
||||
import type { L3PointPillarsVisualAuditResult } from "./l3PointPillarsVisualAudit";
|
||||
import type { L31PointPillarsRavnovesResult } from "./l31PointPillarsRavnoves";
|
||||
import type { L32PointPillarsCameraReviewResult } from "./l32PointPillarsCameraReview";
|
||||
import type { L33CameraFirstDetectorReviewResult } from "./l33CameraFirstDetectorReview";
|
||||
import type { L34RightYoloxTruthIslandResult } from "./l34RightYoloxTruthIsland";
|
||||
import type { L34AAssistedYoloxErrorAuditResult } from "./l34aAssistedYoloxErrorAudit";
|
||||
import type { L34BResult } from "./l34bNestedBoxConsolidation";
|
||||
import type { L34CResult } from "./l34cTileSeamStitch";
|
||||
import type { L34DResult } from "./l34dCumulativePostprocessing";
|
||||
import type { L34EResult } from "./l34eSelfReviewDiagnostic";
|
||||
import type { L34FFrozenResult } from "./l34fAdjudication";
|
||||
import type { E46BlindReviewResult } from "./e46BlindReview";
|
||||
import type { E46AAiEngineeringPreannotationResult } from "./e46aAiEngineeringPreannotation";
|
||||
import type { E46BTemporalMotionResult } from "./e46bTemporalMotion";
|
||||
import type { E46CFullReplayWorldTracksResult } from "./e46cFullReplayWorldTracks";
|
||||
import type { E46DTemporalFailureAuditResult } from "./e46dTemporalFailureAudit";
|
||||
import type { E46EReadyStackResult } from "./e46eReadyStack";
|
||||
import type { E46FDashCamBakeoffResult } from "./e46fDashCamBakeoff";
|
||||
import type { E46GRectifiedDetectorBakeoffResult } from "./e46gRectifiedDetectorBakeoff";
|
||||
import type { E46HFullRectifiedFrontReplayResult } from "./e46hFullRectifiedFrontReplay";
|
||||
import type { E46IGroundingDinoFullReplayResult } from "./e46iGroundingDinoFullReplay";
|
||||
import type { E46JRawFisheyeRealtimeResult } from "./e46jRawFisheyeRealtime";
|
||||
import type { E47SemanticSlamResult } from "./e47SemanticSlam";
|
||||
import type { M4ThreatReplayResult } from "./m4ReplayThreat";
|
||||
import type { M47ReferenceGraphLabResult } from "./m47ReferenceGraph";
|
||||
|
||||
export interface AdvancedLaboratoryResults {
|
||||
m47Graph: M47ReferenceGraphLabResult | null;
|
||||
m4Threat: M4ThreatReplayResult | null;
|
||||
l3: L3PointPillarsVisualAuditResult | null;
|
||||
l31: L31PointPillarsRavnovesResult | null;
|
||||
l32: L32PointPillarsCameraReviewResult | null;
|
||||
l33: L33CameraFirstDetectorReviewResult | null;
|
||||
e31: E31LaboratoryResult | null;
|
||||
e32: E32LaboratoryResult | null;
|
||||
e33: E33LaboratoryResult | null;
|
||||
e34: E34TemporalLayerResult | null;
|
||||
e35: E35DegradationRecoveryResult | null;
|
||||
e37: E37AcceptanceContractResult | null;
|
||||
e38: E38PerceptionBaselineResult | null;
|
||||
e39: E39PerceptionRefinementResult | null;
|
||||
e40: E40PerceptionProductGateResult | null;
|
||||
e46: E46BlindReviewResult | null;
|
||||
e46a: E46AAiEngineeringPreannotationResult | null;
|
||||
e46b: E46BTemporalMotionResult | null;
|
||||
e46c: E46CFullReplayWorldTracksResult | null;
|
||||
e46d: E46DTemporalFailureAuditResult | null;
|
||||
e46e: E46EReadyStackResult | null;
|
||||
e46f: E46FDashCamBakeoffResult | null;
|
||||
e46g: E46GRectifiedDetectorBakeoffResult | null;
|
||||
e46h: E46HFullRectifiedFrontReplayResult | null;
|
||||
e46i: E46IGroundingDinoFullReplayResult | null;
|
||||
e46j: E46JRawFisheyeRealtimeResult | null;
|
||||
e47: E47SemanticSlamResult | null;
|
||||
l34: L34RightYoloxTruthIslandResult | null;
|
||||
l34a: L34AAssistedYoloxErrorAuditResult | null;
|
||||
l34b: L34BResult | null;
|
||||
l34c: L34CResult | null;
|
||||
l34d: L34DResult | null;
|
||||
l34e: L34EResult | null;
|
||||
l34f: L34FFrozenResult | null;
|
||||
}
|
||||
@@ -0,0 +1,999 @@
|
||||
import { fetchE34TemporalLayerResult } from "./e34TemporalLayer";
|
||||
import { fetchE35DegradationRecoveryResult } from "./e35DegradationRecovery";
|
||||
import { fetchE40ProductGateResult } from "./e40ProductGate";
|
||||
import { settledCatalogValue } from "./catalogTransport";
|
||||
import type { AdvancedLaboratoryResults } from "./advancedLaboratoryResults";
|
||||
export type { AdvancedLaboratoryResults } from "./advancedLaboratoryResults";
|
||||
export interface E31LaboratoryResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
sourceSessionId: string;
|
||||
status: "accepted-diagnostic-source-profile";
|
||||
eligibleForE32: true;
|
||||
profileId: string;
|
||||
producerSha256: string;
|
||||
metrics: {
|
||||
frameCount: number;
|
||||
availableBindingCount: number;
|
||||
availableFraction: number;
|
||||
lidarCameraP95Ms: number;
|
||||
posePointP95Ms: number;
|
||||
selectedOffsetMs: number;
|
||||
correspondenceCount: number;
|
||||
supportedFraction: number;
|
||||
semanticSelfSampleCount: number;
|
||||
semanticSelfCollateralCount: number;
|
||||
exactGeometryCorrectionCount: number;
|
||||
geometryPointMaskStatus: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E32LaboratoryResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
sourceSessionId: string;
|
||||
status: "accepted-diagnostic-track-geometry-replay";
|
||||
profileId: string;
|
||||
producerSha256: string;
|
||||
e31ResultId: string;
|
||||
metrics: {
|
||||
framesTotal: number;
|
||||
framesSourceAvailable: number;
|
||||
semanticPublished: number;
|
||||
semanticMasked: number;
|
||||
geometryPublished: number;
|
||||
observationsArbitrated: number;
|
||||
overlappingClaimsRemoved: number;
|
||||
qualifiedPointsPublished: number;
|
||||
qualifiedPointsWithheld: number;
|
||||
frameProcessingP95Ms: number;
|
||||
buildElapsedMs: number;
|
||||
};
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E33LaboratoryResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
sourceSessionId: string;
|
||||
status: "accepted-recorded-source-paced-shadow";
|
||||
e32ResultId: string;
|
||||
pipelineId: string;
|
||||
mode: string;
|
||||
worker: {
|
||||
node: string;
|
||||
containerImage: string;
|
||||
python: string;
|
||||
numpy: string;
|
||||
};
|
||||
metrics: {
|
||||
sourceFrames: number;
|
||||
sourceAvailableFrames: number;
|
||||
sourceUnavailableFrames: number;
|
||||
deliveredFrames: number;
|
||||
inputSuperseded: number;
|
||||
resultSuperseded: number;
|
||||
effectiveDeliveryFps: number;
|
||||
sourceSpanSeconds: number;
|
||||
deadlineMissFraction: number;
|
||||
processingP95Ms: number;
|
||||
releaseLagP95Ms: number;
|
||||
resultAgeP95Ms: number;
|
||||
resultAgeMaxMs: number;
|
||||
resultDeadlineMs: number;
|
||||
processRssP95Mib: number;
|
||||
processRssMaxMib: number;
|
||||
processRssLimitMib: number;
|
||||
gpuUtilizationP95Percent: number;
|
||||
gpuVisible: boolean;
|
||||
workQueueCapacity: number;
|
||||
resultQueueCapacity: number;
|
||||
wallToIdealRatio: number;
|
||||
};
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E37AcceptanceContractResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
sourceSessionId: string;
|
||||
sourceDisplayName: string;
|
||||
status: "accepted-r0-source-scoped-contract";
|
||||
profileId: string;
|
||||
workerNode: string;
|
||||
metrics: {
|
||||
reviewedItems: number;
|
||||
developmentItems: number;
|
||||
validationItems: number;
|
||||
engineeringItems: number;
|
||||
humanExceptionItems: number;
|
||||
terminalOutcomes: number;
|
||||
accountingFraction: number;
|
||||
falseFreeClaims: number;
|
||||
};
|
||||
dimensionDistributions: {
|
||||
presence: Readonly<Record<string, number>>;
|
||||
geometryAssociation: Readonly<Record<string, number>>;
|
||||
freshness: Readonly<Record<string, number>>;
|
||||
};
|
||||
severityDistribution: Readonly<Record<string, number>>;
|
||||
labelProvenance: {
|
||||
engineeringItems: number;
|
||||
humanExceptionItems: number;
|
||||
independentGroundTruth: false;
|
||||
};
|
||||
split: {
|
||||
strategy: string;
|
||||
validationFraction: number;
|
||||
};
|
||||
targets: {
|
||||
presenceTarget: number;
|
||||
geometryAssociationTarget: number;
|
||||
freshnessTarget: number;
|
||||
};
|
||||
qualityTargetEvaluated: false;
|
||||
limitations: readonly string[];
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E38DimensionMetric {
|
||||
correct: number;
|
||||
incorrect: number;
|
||||
total: number;
|
||||
accuracy: number;
|
||||
target: number;
|
||||
passed: boolean;
|
||||
byStratum: Readonly<Record<string, {
|
||||
correct: number;
|
||||
incorrect: number;
|
||||
total: number;
|
||||
accuracy: number;
|
||||
}>>;
|
||||
confusion: readonly {
|
||||
reference: string;
|
||||
prediction: string;
|
||||
count: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface E38PerceptionBaselineResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
sourceSessionId: string;
|
||||
sourceDisplayName: string;
|
||||
status: "measured-r1-source-scoped-baseline";
|
||||
profileId: string;
|
||||
workerNode: string;
|
||||
qualityGatePassed: boolean;
|
||||
metrics: {
|
||||
developmentItems: number;
|
||||
validationItems: number;
|
||||
terminalOutcomes: number;
|
||||
accountingFraction: number;
|
||||
falseFreeClaims: number;
|
||||
highSeverityFailures: number;
|
||||
dimensions: {
|
||||
presence: E38DimensionMetric;
|
||||
geometryAssociation: E38DimensionMetric;
|
||||
freshness: E38DimensionMetric;
|
||||
};
|
||||
};
|
||||
blockingChecks: readonly string[];
|
||||
limitations: readonly string[];
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface DevelopmentDimensionMetric {
|
||||
correct: number;
|
||||
incorrect: number;
|
||||
total: number;
|
||||
accuracy: number;
|
||||
target: number;
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
export type E39DevelopmentDimensionMetric = DevelopmentDimensionMetric;
|
||||
|
||||
export interface E39PerceptionRefinementResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
sourceSessionId: string;
|
||||
sourceDisplayName: string;
|
||||
status: "measured-r1-source-scoped-refinement";
|
||||
profileId: string;
|
||||
workerNode: string;
|
||||
qualityGatePassed: boolean;
|
||||
developmentCrossValidation: {
|
||||
strategy: string;
|
||||
seed: string;
|
||||
folds: number;
|
||||
items: number;
|
||||
validationLabelsUsed: false;
|
||||
passed: boolean;
|
||||
dimensions: {
|
||||
presence: DevelopmentDimensionMetric;
|
||||
geometryAssociation: DevelopmentDimensionMetric;
|
||||
freshness: DevelopmentDimensionMetric;
|
||||
};
|
||||
};
|
||||
metrics: E38PerceptionBaselineResult["metrics"];
|
||||
blockingChecks: readonly string[];
|
||||
method: {
|
||||
summary: string;
|
||||
selection: string;
|
||||
dimensionProjection: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export class AdvancedLaboratoryContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "AdvancedLaboratoryContractError";
|
||||
}
|
||||
}
|
||||
export type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, label: string): string | null {
|
||||
return value === null ? null : stringValue(value, label);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
const parsed = numberValue(value, label);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function trueValue(value: unknown, label: string): true {
|
||||
if (value !== true) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалось true.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function falseValue(value: unknown, label: string): false {
|
||||
if (value !== false) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалось false.`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exactString<T extends string>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: неверное значение.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function strings(value: unknown, label: string): readonly string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value.map((item, index) => stringValue(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function numberRecord(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Readonly<Record<string, number>> {
|
||||
const source = record(value, label);
|
||||
return Object.fromEntries(
|
||||
Object.entries(source).map(([key, item]) => [
|
||||
key,
|
||||
integerValue(item, `${label}.${key}`),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function contentId(value: unknown, prefix: string, label: string): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: неверный content id.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function sha256(value: unknown, label: string): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: неверный SHA-256.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function diagnosticAuthority(value: unknown, label: string): void {
|
||||
const authority = record(value, label);
|
||||
if (
|
||||
authority.commands_enabled !== false
|
||||
|| authority.navigation_or_safety_accepted !== false
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: запрещённые полномочия.`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCatalog<T>(
|
||||
payload: unknown,
|
||||
parseItem: (value: unknown) => T,
|
||||
): T | null {
|
||||
const value = record(payload, "Каталог LAB");
|
||||
exactString(
|
||||
value.schema_version,
|
||||
"missioncore.laboratory-advanced-catalog/v1",
|
||||
"Каталог LAB.schema_version",
|
||||
);
|
||||
booleanValue(value.configured, "Каталог LAB.configured");
|
||||
integerValue(value.candidate_total, "Каталог LAB.candidate_total");
|
||||
integerValue(value.invalid_total, "Каталог LAB.invalid_total");
|
||||
exactString(value.access, "read-only", "Каталог LAB.access");
|
||||
if (!Array.isArray(value.items)) {
|
||||
throw new AdvancedLaboratoryContractError("Каталог LAB.items: ожидался массив.");
|
||||
}
|
||||
if (value.items.length > 1) {
|
||||
throw new AdvancedLaboratoryContractError("Каталог LAB.items: нарушен limit=1.");
|
||||
}
|
||||
return value.items.length ? parseItem(value.items[0]) : null;
|
||||
}
|
||||
|
||||
export function parseE31(value: unknown): E31LaboratoryResult {
|
||||
const item = record(value, "E31");
|
||||
const metrics = record(item.metrics, "E31.metrics");
|
||||
diagnosticAuthority(item.authority, "E31.authority");
|
||||
return {
|
||||
resultId: contentId(item.result_id, "e31-source-qualification", "E31.result_id"),
|
||||
createdAtUtc: optionalString(item.created_at_utc, "E31.created_at_utc"),
|
||||
sourceSessionId: stringValue(item.source_session_id, "E31.source_session_id"),
|
||||
status: exactString(
|
||||
item.status,
|
||||
"accepted-diagnostic-source-profile",
|
||||
"E31.status",
|
||||
),
|
||||
eligibleForE32: trueValue(item.eligible_for_e32, "E31.eligible_for_e32"),
|
||||
profileId: stringValue(item.profile_id, "E31.profile_id"),
|
||||
producerSha256: sha256(item.producer_sha256, "E31.producer_sha256"),
|
||||
metrics: {
|
||||
frameCount: integerValue(metrics.frame_count, "E31.metrics.frame_count"),
|
||||
availableBindingCount: integerValue(metrics.available_binding_count, "E31.metrics.available_binding_count"),
|
||||
availableFraction: numberValue(metrics.available_fraction, "E31.metrics.available_fraction"),
|
||||
lidarCameraP95Ms: numberValue(metrics.lidar_camera_p95_ms, "E31.metrics.lidar_camera_p95_ms"),
|
||||
posePointP95Ms: numberValue(metrics.pose_point_p95_ms, "E31.metrics.pose_point_p95_ms"),
|
||||
selectedOffsetMs: finiteNumber(metrics.selected_offset_ms, "E31.metrics.selected_offset_ms"),
|
||||
correspondenceCount: integerValue(metrics.correspondence_count, "E31.metrics.correspondence_count"),
|
||||
supportedFraction: numberValue(metrics.supported_fraction, "E31.metrics.supported_fraction"),
|
||||
semanticSelfSampleCount: integerValue(metrics.semantic_self_sample_count, "E31.metrics.semantic_self_sample_count"),
|
||||
semanticSelfCollateralCount: integerValue(metrics.semantic_self_collateral_count, "E31.metrics.semantic_self_collateral_count"),
|
||||
exactGeometryCorrectionCount: integerValue(metrics.exact_geometry_correction_count, "E31.metrics.exact_geometry_correction_count"),
|
||||
geometryPointMaskStatus: stringValue(metrics.geometry_point_mask_status, "E31.metrics.geometry_point_mask_status"),
|
||||
},
|
||||
limitations: strings(item.limitations, "E31.limitations"),
|
||||
access: exactString(item.access, "read-only", "E31.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE32(value: unknown): E32LaboratoryResult {
|
||||
const item = record(value, "E32");
|
||||
const metrics = record(item.metrics, "E32.metrics");
|
||||
diagnosticAuthority(item.authority, "E32.authority");
|
||||
return {
|
||||
resultId: contentId(item.result_id, "e32-track-geometry", "E32.result_id"),
|
||||
createdAtUtc: optionalString(item.created_at_utc, "E32.created_at_utc"),
|
||||
sourceSessionId: stringValue(item.source_session_id, "E32.source_session_id"),
|
||||
status: exactString(
|
||||
item.status,
|
||||
"accepted-diagnostic-track-geometry-replay",
|
||||
"E32.status",
|
||||
),
|
||||
profileId: stringValue(item.profile_id, "E32.profile_id"),
|
||||
producerSha256: sha256(item.producer_sha256, "E32.producer_sha256"),
|
||||
e31ResultId: contentId(item.e31_result_id, "e31-source-qualification", "E32.e31_result_id"),
|
||||
metrics: {
|
||||
framesTotal: integerValue(metrics.frames_total, "E32.metrics.frames_total"),
|
||||
framesSourceAvailable: integerValue(metrics.frames_source_available, "E32.metrics.frames_source_available"),
|
||||
semanticPublished: integerValue(metrics.semantic_published, "E32.metrics.semantic_published"),
|
||||
semanticMasked: integerValue(metrics.semantic_masked, "E32.metrics.semantic_masked"),
|
||||
geometryPublished: integerValue(metrics.geometry_published, "E32.metrics.geometry_published"),
|
||||
observationsArbitrated: integerValue(metrics.observations_arbitrated, "E32.metrics.observations_arbitrated"),
|
||||
overlappingClaimsRemoved: integerValue(metrics.overlapping_claims_removed, "E32.metrics.overlapping_claims_removed"),
|
||||
qualifiedPointsPublished: integerValue(metrics.qualified_points_published, "E32.metrics.qualified_points_published"),
|
||||
qualifiedPointsWithheld: integerValue(metrics.qualified_points_withheld, "E32.metrics.qualified_points_withheld"),
|
||||
frameProcessingP95Ms: numberValue(metrics.frame_processing_p95_ms, "E32.metrics.frame_processing_p95_ms"),
|
||||
buildElapsedMs: numberValue(metrics.build_elapsed_ms, "E32.metrics.build_elapsed_ms"),
|
||||
},
|
||||
access: exactString(item.access, "read-only", "E32.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE33(value: unknown): E33LaboratoryResult {
|
||||
const item = record(value, "E33");
|
||||
const worker = record(item.worker, "E33.worker");
|
||||
const metrics = record(item.metrics, "E33.metrics");
|
||||
diagnosticAuthority(item.authority, "E33.authority");
|
||||
return {
|
||||
resultId: contentId(item.result_id, "e33-worker-shadow", "E33.result_id"),
|
||||
createdAtUtc: optionalString(item.created_at_utc, "E33.created_at_utc"),
|
||||
sourceSessionId: stringValue(item.source_session_id, "E33.source_session_id"),
|
||||
status: exactString(
|
||||
item.status,
|
||||
"accepted-recorded-source-paced-shadow",
|
||||
"E33.status",
|
||||
),
|
||||
e32ResultId: contentId(item.e32_result_id, "e32-track-geometry", "E33.e32_result_id"),
|
||||
pipelineId: stringValue(item.pipeline_id, "E33.pipeline_id"),
|
||||
mode: stringValue(item.mode, "E33.mode"),
|
||||
worker: {
|
||||
node: stringValue(worker.node, "E33.worker.node"),
|
||||
containerImage: stringValue(worker.container_image, "E33.worker.container_image"),
|
||||
python: stringValue(worker.python, "E33.worker.python"),
|
||||
numpy: stringValue(worker.numpy, "E33.worker.numpy"),
|
||||
},
|
||||
metrics: {
|
||||
sourceFrames: integerValue(metrics.source_frames, "E33.metrics.source_frames"),
|
||||
sourceAvailableFrames: integerValue(metrics.source_available_frames, "E33.metrics.source_available_frames"),
|
||||
sourceUnavailableFrames: integerValue(metrics.source_unavailable_frames, "E33.metrics.source_unavailable_frames"),
|
||||
deliveredFrames: integerValue(metrics.delivered_frames, "E33.metrics.delivered_frames"),
|
||||
inputSuperseded: integerValue(metrics.input_superseded, "E33.metrics.input_superseded"),
|
||||
resultSuperseded: integerValue(metrics.result_superseded, "E33.metrics.result_superseded"),
|
||||
effectiveDeliveryFps: numberValue(metrics.effective_delivery_fps, "E33.metrics.effective_delivery_fps"),
|
||||
sourceSpanSeconds: numberValue(metrics.source_span_seconds, "E33.metrics.source_span_seconds"),
|
||||
deadlineMissFraction: numberValue(metrics.deadline_miss_fraction, "E33.metrics.deadline_miss_fraction"),
|
||||
processingP95Ms: numberValue(metrics.processing_p95_ms, "E33.metrics.processing_p95_ms"),
|
||||
releaseLagP95Ms: numberValue(metrics.release_lag_p95_ms, "E33.metrics.release_lag_p95_ms"),
|
||||
resultAgeP95Ms: numberValue(metrics.result_age_p95_ms, "E33.metrics.result_age_p95_ms"),
|
||||
resultAgeMaxMs: numberValue(metrics.result_age_max_ms, "E33.metrics.result_age_max_ms"),
|
||||
resultDeadlineMs: numberValue(metrics.result_deadline_ms, "E33.metrics.result_deadline_ms"),
|
||||
processRssP95Mib: numberValue(metrics.process_rss_p95_mib, "E33.metrics.process_rss_p95_mib"),
|
||||
processRssMaxMib: numberValue(metrics.process_rss_max_mib, "E33.metrics.process_rss_max_mib"),
|
||||
processRssLimitMib: numberValue(metrics.process_rss_limit_mib, "E33.metrics.process_rss_limit_mib"),
|
||||
gpuUtilizationP95Percent: numberValue(metrics.gpu_utilization_p95_percent, "E33.metrics.gpu_utilization_p95_percent"),
|
||||
gpuVisible: booleanValue(metrics.gpu_visible, "E33.metrics.gpu_visible"),
|
||||
workQueueCapacity: integerValue(metrics.work_queue_capacity, "E33.metrics.work_queue_capacity"),
|
||||
resultQueueCapacity: integerValue(metrics.result_queue_capacity, "E33.metrics.result_queue_capacity"),
|
||||
wallToIdealRatio: numberValue(metrics.wall_to_ideal_ratio, "E33.metrics.wall_to_ideal_ratio"),
|
||||
},
|
||||
access: exactString(item.access, "read-only", "E33.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE37(value: unknown): E37AcceptanceContractResult {
|
||||
const item = record(value, "E37");
|
||||
const metrics = record(item.metrics, "E37.metrics");
|
||||
const distributions = record(
|
||||
item.dimension_distributions,
|
||||
"E37.dimension_distributions",
|
||||
);
|
||||
const provenance = record(item.label_provenance, "E37.label_provenance");
|
||||
const split = record(item.split, "E37.split");
|
||||
const targets = record(item.targets, "E37.targets");
|
||||
diagnosticAuthority(item.authority, "E37.authority");
|
||||
if (item.quality_target_evaluated !== false) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"E37.quality_target_evaluated: R0 не должен объявлять метрику проверенной.",
|
||||
);
|
||||
}
|
||||
if (provenance.independent_ground_truth !== false) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"E37.label_provenance: R0 не является независимой разметкой.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
resultId: contentId(
|
||||
item.result_id,
|
||||
"e37-ravnoves-acceptance",
|
||||
"E37.result_id",
|
||||
),
|
||||
createdAtUtc: optionalString(item.created_at_utc, "E37.created_at_utc"),
|
||||
sourceSessionId: stringValue(
|
||||
item.source_session_id,
|
||||
"E37.source_session_id",
|
||||
),
|
||||
sourceDisplayName: stringValue(
|
||||
item.source_display_name,
|
||||
"E37.source_display_name",
|
||||
),
|
||||
status: exactString(
|
||||
item.status,
|
||||
"accepted-r0-source-scoped-contract",
|
||||
"E37.status",
|
||||
),
|
||||
profileId: stringValue(item.profile_id, "E37.profile_id"),
|
||||
workerNode: stringValue(item.worker_node, "E37.worker_node"),
|
||||
metrics: {
|
||||
reviewedItems: integerValue(
|
||||
metrics.reviewed_items,
|
||||
"E37.metrics.reviewed_items",
|
||||
),
|
||||
developmentItems: integerValue(
|
||||
metrics.development_items,
|
||||
"E37.metrics.development_items",
|
||||
),
|
||||
validationItems: integerValue(
|
||||
metrics.validation_items,
|
||||
"E37.metrics.validation_items",
|
||||
),
|
||||
engineeringItems: integerValue(
|
||||
metrics.engineering_items,
|
||||
"E37.metrics.engineering_items",
|
||||
),
|
||||
humanExceptionItems: integerValue(
|
||||
metrics.human_exception_items,
|
||||
"E37.metrics.human_exception_items",
|
||||
),
|
||||
terminalOutcomes: integerValue(
|
||||
metrics.terminal_outcomes,
|
||||
"E37.metrics.terminal_outcomes",
|
||||
),
|
||||
accountingFraction: numberValue(
|
||||
metrics.accounting_fraction,
|
||||
"E37.metrics.accounting_fraction",
|
||||
),
|
||||
falseFreeClaims: integerValue(
|
||||
metrics.false_free_claims,
|
||||
"E37.metrics.false_free_claims",
|
||||
),
|
||||
},
|
||||
dimensionDistributions: {
|
||||
presence: numberRecord(
|
||||
distributions.presence,
|
||||
"E37.dimension_distributions.presence",
|
||||
),
|
||||
geometryAssociation: numberRecord(
|
||||
distributions.geometry_association,
|
||||
"E37.dimension_distributions.geometry_association",
|
||||
),
|
||||
freshness: numberRecord(
|
||||
distributions.freshness,
|
||||
"E37.dimension_distributions.freshness",
|
||||
),
|
||||
},
|
||||
severityDistribution: numberRecord(
|
||||
item.severity_distribution,
|
||||
"E37.severity_distribution",
|
||||
),
|
||||
labelProvenance: {
|
||||
engineeringItems: integerValue(
|
||||
provenance.engineering_items,
|
||||
"E37.label_provenance.engineering_items",
|
||||
),
|
||||
humanExceptionItems: integerValue(
|
||||
provenance.human_exception_items,
|
||||
"E37.label_provenance.human_exception_items",
|
||||
),
|
||||
independentGroundTruth: false,
|
||||
},
|
||||
split: {
|
||||
strategy: stringValue(split.strategy, "E37.split.strategy"),
|
||||
validationFraction: numberValue(
|
||||
split.validation_fraction,
|
||||
"E37.split.validation_fraction",
|
||||
),
|
||||
},
|
||||
targets: {
|
||||
presenceTarget: numberValue(
|
||||
targets.presence_target,
|
||||
"E37.targets.presence_target",
|
||||
),
|
||||
geometryAssociationTarget: numberValue(
|
||||
targets.geometry_association_target,
|
||||
"E37.targets.geometry_association_target",
|
||||
),
|
||||
freshnessTarget: numberValue(
|
||||
targets.freshness_target,
|
||||
"E37.targets.freshness_target",
|
||||
),
|
||||
},
|
||||
qualityTargetEvaluated: false,
|
||||
limitations: strings(item.limitations, "E37.limitations"),
|
||||
access: exactString(item.access, "read-only", "E37.access"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseE38Dimension(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E38DimensionMetric {
|
||||
const source = record(value, label);
|
||||
const byStratumSource = record(source.by_stratum, `${label}.by_stratum`);
|
||||
if (!Array.isArray(source.confusion)) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
`${label}.confusion: ожидался массив.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
correct: integerValue(source.correct, `${label}.correct`),
|
||||
incorrect: integerValue(source.incorrect, `${label}.incorrect`),
|
||||
total: integerValue(source.total, `${label}.total`),
|
||||
accuracy: numberValue(source.accuracy, `${label}.accuracy`),
|
||||
target: numberValue(source.target, `${label}.target`),
|
||||
passed: booleanValue(source.passed, `${label}.passed`),
|
||||
byStratum: Object.fromEntries(
|
||||
Object.entries(byStratumSource).map(([stratum, value]) => {
|
||||
const metric = record(value, `${label}.by_stratum.${stratum}`);
|
||||
return [
|
||||
stratum,
|
||||
{
|
||||
correct: integerValue(
|
||||
metric.correct,
|
||||
`${label}.by_stratum.${stratum}.correct`,
|
||||
),
|
||||
incorrect: integerValue(
|
||||
metric.incorrect,
|
||||
`${label}.by_stratum.${stratum}.incorrect`,
|
||||
),
|
||||
total: integerValue(
|
||||
metric.total,
|
||||
`${label}.by_stratum.${stratum}.total`,
|
||||
),
|
||||
accuracy: numberValue(
|
||||
metric.accuracy,
|
||||
`${label}.by_stratum.${stratum}.accuracy`,
|
||||
),
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
confusion: source.confusion.map((value, index) => {
|
||||
const row = record(value, `${label}.confusion[${index}]`);
|
||||
return {
|
||||
reference: stringValue(
|
||||
row.reference,
|
||||
`${label}.confusion[${index}].reference`,
|
||||
),
|
||||
prediction: stringValue(
|
||||
row.prediction,
|
||||
`${label}.confusion[${index}].prediction`,
|
||||
),
|
||||
count: integerValue(
|
||||
row.count,
|
||||
`${label}.confusion[${index}].count`,
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE38(value: unknown): E38PerceptionBaselineResult {
|
||||
const item = record(value, "E38");
|
||||
const metrics = record(item.metrics, "E38.metrics");
|
||||
const dimensions = record(metrics.dimensions, "E38.metrics.dimensions");
|
||||
diagnosticAuthority(item.authority, "E38.authority");
|
||||
return {
|
||||
resultId: contentId(
|
||||
item.result_id,
|
||||
"e38-perception-baseline",
|
||||
"E38.result_id",
|
||||
),
|
||||
createdAtUtc: optionalString(item.created_at_utc, "E38.created_at_utc"),
|
||||
sourceSessionId: stringValue(
|
||||
item.source_session_id,
|
||||
"E38.source_session_id",
|
||||
),
|
||||
sourceDisplayName: stringValue(
|
||||
item.source_display_name,
|
||||
"E38.source_display_name",
|
||||
),
|
||||
status: exactString(
|
||||
item.status,
|
||||
"measured-r1-source-scoped-baseline",
|
||||
"E38.status",
|
||||
),
|
||||
profileId: stringValue(item.profile_id, "E38.profile_id"),
|
||||
workerNode: stringValue(item.worker_node, "E38.worker_node"),
|
||||
qualityGatePassed: booleanValue(
|
||||
item.quality_gate_passed,
|
||||
"E38.quality_gate_passed",
|
||||
),
|
||||
metrics: {
|
||||
developmentItems: integerValue(
|
||||
metrics.development_items,
|
||||
"E38.metrics.development_items",
|
||||
),
|
||||
validationItems: integerValue(
|
||||
metrics.validation_items,
|
||||
"E38.metrics.validation_items",
|
||||
),
|
||||
terminalOutcomes: integerValue(
|
||||
metrics.terminal_outcomes,
|
||||
"E38.metrics.terminal_outcomes",
|
||||
),
|
||||
accountingFraction: numberValue(
|
||||
metrics.accounting_fraction,
|
||||
"E38.metrics.accounting_fraction",
|
||||
),
|
||||
falseFreeClaims: integerValue(
|
||||
metrics.false_free_claims,
|
||||
"E38.metrics.false_free_claims",
|
||||
),
|
||||
highSeverityFailures: integerValue(
|
||||
metrics.high_severity_failures,
|
||||
"E38.metrics.high_severity_failures",
|
||||
),
|
||||
dimensions: {
|
||||
presence: parseE38Dimension(
|
||||
dimensions.presence,
|
||||
"E38.metrics.dimensions.presence",
|
||||
),
|
||||
geometryAssociation: parseE38Dimension(
|
||||
dimensions.geometry_association,
|
||||
"E38.metrics.dimensions.geometry_association",
|
||||
),
|
||||
freshness: parseE38Dimension(
|
||||
dimensions.freshness,
|
||||
"E38.metrics.dimensions.freshness",
|
||||
),
|
||||
},
|
||||
},
|
||||
blockingChecks: strings(item.blocking_checks, "E38.blocking_checks"),
|
||||
limitations: strings(item.limitations, "E38.limitations"),
|
||||
access: exactString(item.access, "read-only", "E38.access"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDevelopmentDimension(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): DevelopmentDimensionMetric {
|
||||
const source = record(value, label);
|
||||
return {
|
||||
correct: integerValue(source.correct, `${label}.correct`),
|
||||
incorrect: integerValue(source.incorrect, `${label}.incorrect`),
|
||||
total: integerValue(source.total, `${label}.total`),
|
||||
accuracy: numberValue(source.accuracy, `${label}.accuracy`),
|
||||
target: numberValue(source.target, `${label}.target`),
|
||||
passed: booleanValue(source.passed, `${label}.passed`),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE39(value: unknown): E39PerceptionRefinementResult {
|
||||
const item = record(value, "E39");
|
||||
const metrics = record(item.metrics, "E39.metrics");
|
||||
const dimensions = record(metrics.dimensions, "E39.metrics.dimensions");
|
||||
const developmentCrossValidation = record(
|
||||
item.development_cross_validation,
|
||||
"E39.development_cross_validation",
|
||||
);
|
||||
const developmentDimensions = record(
|
||||
developmentCrossValidation.dimensions,
|
||||
"E39.development_cross_validation.dimensions",
|
||||
);
|
||||
const method = record(item.method, "E39.method");
|
||||
diagnosticAuthority(item.authority, "E39.authority");
|
||||
return {
|
||||
resultId: contentId(
|
||||
item.result_id,
|
||||
"e39-perception-refinement",
|
||||
"E39.result_id",
|
||||
),
|
||||
createdAtUtc: optionalString(item.created_at_utc, "E39.created_at_utc"),
|
||||
sourceSessionId: stringValue(
|
||||
item.source_session_id,
|
||||
"E39.source_session_id",
|
||||
),
|
||||
sourceDisplayName: stringValue(
|
||||
item.source_display_name,
|
||||
"E39.source_display_name",
|
||||
),
|
||||
status: exactString(
|
||||
item.status,
|
||||
"measured-r1-source-scoped-refinement",
|
||||
"E39.status",
|
||||
),
|
||||
profileId: stringValue(item.profile_id, "E39.profile_id"),
|
||||
workerNode: stringValue(item.worker_node, "E39.worker_node"),
|
||||
qualityGatePassed: booleanValue(
|
||||
item.quality_gate_passed,
|
||||
"E39.quality_gate_passed",
|
||||
),
|
||||
developmentCrossValidation: {
|
||||
strategy: stringValue(
|
||||
developmentCrossValidation.strategy,
|
||||
"E39.development_cross_validation.strategy",
|
||||
),
|
||||
seed: stringValue(
|
||||
developmentCrossValidation.seed,
|
||||
"E39.development_cross_validation.seed",
|
||||
),
|
||||
folds: integerValue(
|
||||
developmentCrossValidation.folds,
|
||||
"E39.development_cross_validation.folds",
|
||||
),
|
||||
items: integerValue(
|
||||
developmentCrossValidation.items,
|
||||
"E39.development_cross_validation.items",
|
||||
),
|
||||
validationLabelsUsed: falseValue(
|
||||
developmentCrossValidation.validation_labels_used,
|
||||
"E39.development_cross_validation.validation_labels_used",
|
||||
),
|
||||
passed: booleanValue(
|
||||
developmentCrossValidation.passed,
|
||||
"E39.development_cross_validation.passed",
|
||||
),
|
||||
dimensions: {
|
||||
presence: parseDevelopmentDimension(
|
||||
developmentDimensions.presence,
|
||||
"E39.development_cross_validation.dimensions.presence",
|
||||
),
|
||||
geometryAssociation: parseDevelopmentDimension(
|
||||
developmentDimensions.geometry_association,
|
||||
"E39.development_cross_validation.dimensions.geometry_association",
|
||||
),
|
||||
freshness: parseDevelopmentDimension(
|
||||
developmentDimensions.freshness,
|
||||
"E39.development_cross_validation.dimensions.freshness",
|
||||
),
|
||||
},
|
||||
},
|
||||
metrics: {
|
||||
developmentItems: integerValue(
|
||||
metrics.development_items,
|
||||
"E39.metrics.development_items",
|
||||
),
|
||||
validationItems: integerValue(
|
||||
metrics.validation_items,
|
||||
"E39.metrics.validation_items",
|
||||
),
|
||||
terminalOutcomes: integerValue(
|
||||
metrics.terminal_outcomes,
|
||||
"E39.metrics.terminal_outcomes",
|
||||
),
|
||||
accountingFraction: numberValue(
|
||||
metrics.accounting_fraction,
|
||||
"E39.metrics.accounting_fraction",
|
||||
),
|
||||
falseFreeClaims: integerValue(
|
||||
metrics.false_free_claims,
|
||||
"E39.metrics.false_free_claims",
|
||||
),
|
||||
highSeverityFailures: integerValue(
|
||||
metrics.high_severity_failures,
|
||||
"E39.metrics.high_severity_failures",
|
||||
),
|
||||
dimensions: {
|
||||
presence: parseE38Dimension(
|
||||
dimensions.presence,
|
||||
"E39.metrics.dimensions.presence",
|
||||
),
|
||||
geometryAssociation: parseE38Dimension(
|
||||
dimensions.geometry_association,
|
||||
"E39.metrics.dimensions.geometry_association",
|
||||
),
|
||||
freshness: parseE38Dimension(
|
||||
dimensions.freshness,
|
||||
"E39.metrics.dimensions.freshness",
|
||||
),
|
||||
},
|
||||
},
|
||||
blockingChecks: strings(item.blocking_checks, "E39.blocking_checks"),
|
||||
method: {
|
||||
summary: stringValue(method.summary, "E39.method.summary"),
|
||||
selection: stringValue(method.selection, "E39.method.selection"),
|
||||
dimensionProjection: stringValue(
|
||||
method.dimension_projection,
|
||||
"E39.method.dimension_projection",
|
||||
),
|
||||
},
|
||||
limitations: strings(item.limitations, "E39.limitations"),
|
||||
access: exactString(item.access, "read-only", "E39.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchOne<T>(
|
||||
path: string,
|
||||
parser: (value: unknown) => T,
|
||||
fetcher: LaboratoryFetch,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T | null> {
|
||||
const response = await fetcher(path, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new AdvancedLaboratoryContractError(`Каталог LAB недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
return parseCatalog(await response.json(), parser);
|
||||
}
|
||||
export async function fetchAdvancedLaboratoryResults({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<AdvancedLaboratoryResults> {
|
||||
const settled = await Promise.allSettled([
|
||||
fetchOne("/api/v1/laboratory/e31/results?limit=1", parseE31, fetcher, signal),
|
||||
fetchOne("/api/v1/laboratory/e32/results?limit=1", parseE32, fetcher, signal),
|
||||
fetchOne("/api/v1/laboratory/e33/results?limit=1", parseE33, fetcher, signal),
|
||||
fetchE34TemporalLayerResult({ fetcher, signal }),
|
||||
fetchE35DegradationRecoveryResult({ fetcher, signal }),
|
||||
fetchOne("/api/v1/laboratory/e37/results?limit=1", parseE37, fetcher, signal),
|
||||
fetchOne("/api/v1/laboratory/e38/results?limit=1", parseE38, fetcher, signal),
|
||||
fetchOne("/api/v1/laboratory/e39/results?limit=1", parseE39, fetcher, signal),
|
||||
fetchE40ProductGateResult({ fetcher, signal }),
|
||||
]);
|
||||
const e31 = settledCatalogValue(settled[0]);
|
||||
const e32 = settledCatalogValue(settled[1]);
|
||||
const e33 = settledCatalogValue(settled[2]);
|
||||
const e34 = settledCatalogValue(settled[3]);
|
||||
const e35 = settledCatalogValue(settled[4]);
|
||||
const e37 = settledCatalogValue(settled[5]);
|
||||
const e38 = settledCatalogValue(settled[6]);
|
||||
const e39 = settledCatalogValue(settled[7]);
|
||||
const e40 = settledCatalogValue(settled[8]);
|
||||
return {
|
||||
m47Graph: null, m4Threat: null,
|
||||
l3: null, l31: null,
|
||||
l32: null,
|
||||
l33: null,
|
||||
e31,
|
||||
e32,
|
||||
e33,
|
||||
e34,
|
||||
e35,
|
||||
e37,
|
||||
e38,
|
||||
e39,
|
||||
e40,
|
||||
e46: null,
|
||||
e46a: null,
|
||||
e46b: null,
|
||||
e46c: null,
|
||||
e46d: null,
|
||||
e46e: null, e46f: null,
|
||||
e46g: null, e46h: null, e46i: null, e46j: null,
|
||||
e47: null,
|
||||
l34: null,
|
||||
l34a: null,
|
||||
l34b: null,
|
||||
l34c: null,
|
||||
l34d: null,
|
||||
l34e: null,
|
||||
l34f: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function settledCatalogValue<T>(
|
||||
result: PromiseSettledResult<T>,
|
||||
): T | null {
|
||||
if (result.status === "fulfilled") {
|
||||
return result.value;
|
||||
}
|
||||
const reason = result.reason;
|
||||
const isAbort = reason instanceof Error && reason.name === "AbortError";
|
||||
const isHttpTransport = (
|
||||
reason instanceof Error
|
||||
&& /Каталог LAB.*недоступен: HTTP \d+\./.test(reason.message)
|
||||
);
|
||||
if (!isAbort && (reason instanceof TypeError || isHttpTransport)) {
|
||||
return null;
|
||||
}
|
||||
throw reason;
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
export interface E29ReviewFrame {
|
||||
frameIndex: number;
|
||||
sourceFrameIndex: number;
|
||||
sessionSeconds: number;
|
||||
conflictCount: number;
|
||||
semanticObservationCount: number;
|
||||
geometryOnlyClusterCount: number;
|
||||
}
|
||||
|
||||
export interface E29EvidenceResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
status: "diagnostic-replay-complete";
|
||||
identity: {
|
||||
frameCount: number;
|
||||
timelineStartSeconds: number;
|
||||
timelineEndSeconds: number;
|
||||
profileId: string;
|
||||
producerSha256: string;
|
||||
};
|
||||
metrics: {
|
||||
frames: {
|
||||
total: number;
|
||||
sourceAvailable: number;
|
||||
localSurfaceValid: number;
|
||||
};
|
||||
semanticObservations: {
|
||||
total: number;
|
||||
current: number;
|
||||
agreementFractionOfCurrent: number;
|
||||
geometryStatus: {
|
||||
agree: number;
|
||||
cameraOnly: number;
|
||||
conflict: number;
|
||||
unknown: number;
|
||||
};
|
||||
};
|
||||
geometryOnlyOccupied: {
|
||||
clusterCount: number;
|
||||
pointCount: number;
|
||||
};
|
||||
runtime: {
|
||||
buildElapsedMs: number;
|
||||
frameProcessingP95Ms: number;
|
||||
};
|
||||
};
|
||||
decision: {
|
||||
cameraFirstContractImplemented: boolean;
|
||||
parallelGeometryOnlyLayerImplemented: boolean;
|
||||
productionPromotion: boolean;
|
||||
nextGate: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
authority: {
|
||||
commandsEnabled: false;
|
||||
navigationOrSafetyAccepted: false;
|
||||
};
|
||||
reviewFrames: readonly E29ReviewFrame[];
|
||||
linkedEvidence: {
|
||||
sourceSessionId: string;
|
||||
localSurfaceModelId: string;
|
||||
sourcePackId: string;
|
||||
sourceResultId: string;
|
||||
publishedOnControlPlane: true;
|
||||
workerCopyRequired: false;
|
||||
};
|
||||
groundTruth: false;
|
||||
}
|
||||
|
||||
export interface E29EvidenceCatalog {
|
||||
configured: boolean;
|
||||
candidateTotal: number;
|
||||
invalidTotal: number;
|
||||
items: readonly E29EvidenceResult[];
|
||||
}
|
||||
|
||||
export interface E29SemanticObservation {
|
||||
trackId: number;
|
||||
label: string;
|
||||
geometryStatus: string;
|
||||
geometryReason: string;
|
||||
rangeM: number | null;
|
||||
score: number;
|
||||
support: {
|
||||
classifiedPoints: number;
|
||||
occupiedPoints: number;
|
||||
surfacePoints: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface E29GeometryOnlyCluster {
|
||||
nearestRangeM: number;
|
||||
pointCount: number;
|
||||
voxelCount: number;
|
||||
heightRangeM: readonly [number, number];
|
||||
}
|
||||
|
||||
export interface E29EvidenceFrame {
|
||||
resultId: string;
|
||||
frameIndex: number;
|
||||
sourceFrameIndex: number;
|
||||
sessionSeconds: number;
|
||||
semanticObservations: readonly E29SemanticObservation[];
|
||||
geometryOnlyOccupied: readonly E29GeometryOnlyCluster[];
|
||||
}
|
||||
|
||||
export class E29EvidenceContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "E29EvidenceContractError";
|
||||
}
|
||||
}
|
||||
|
||||
type E29Fetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E29EvidenceContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E29EvidenceContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new E29EvidenceContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
const parsed = numberValue(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new E29EvidenceContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new E29EvidenceContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function falseValue(value: unknown, label: string): false {
|
||||
if (booleanValue(value, label) !== false) {
|
||||
throw new E29EvidenceContractError(`${label}: ожидалось false.`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trueValue(value: unknown, label: string): true {
|
||||
if (booleanValue(value, label) !== true) {
|
||||
throw new E29EvidenceContractError(`${label}: ожидалось true.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function nullableNumber(value: unknown, label: string): number | null {
|
||||
return value === null ? null : numberValue(value, label);
|
||||
}
|
||||
|
||||
function tuple2(value: unknown, label: string): readonly [number, number] {
|
||||
if (!Array.isArray(value) || value.length !== 2) {
|
||||
throw new E29EvidenceContractError(`${label}: ожидалась пара чисел.`);
|
||||
}
|
||||
return [
|
||||
numberValue(value[0], `${label}[0]`),
|
||||
numberValue(value[1], `${label}[1]`),
|
||||
];
|
||||
}
|
||||
|
||||
function parseReviewFrame(value: unknown): E29ReviewFrame {
|
||||
const source = record(value, "review_frame");
|
||||
return {
|
||||
frameIndex: integerValue(source.frame_index, "review_frame.frame_index"),
|
||||
sourceFrameIndex: integerValue(
|
||||
source.source_frame_index,
|
||||
"review_frame.source_frame_index",
|
||||
),
|
||||
sessionSeconds: numberValue(
|
||||
source.session_seconds,
|
||||
"review_frame.session_seconds",
|
||||
),
|
||||
conflictCount: integerValue(
|
||||
source.conflict_count,
|
||||
"review_frame.conflict_count",
|
||||
),
|
||||
semanticObservationCount: integerValue(
|
||||
source.semantic_observation_count,
|
||||
"review_frame.semantic_observation_count",
|
||||
),
|
||||
geometryOnlyClusterCount: integerValue(
|
||||
source.geometry_only_cluster_count,
|
||||
"review_frame.geometry_only_cluster_count",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseEvidenceResult(value: unknown): E29EvidenceResult {
|
||||
const source = record(value, "E29 result");
|
||||
const identity = record(source.identity, "E29 identity");
|
||||
const profile = record(identity.profile, "E29 identity.profile");
|
||||
const metrics = record(source.metrics, "E29 metrics");
|
||||
const frames = record(metrics.frames, "E29 metrics.frames");
|
||||
const semantic = record(
|
||||
metrics.semantic_observations,
|
||||
"E29 metrics.semantic_observations",
|
||||
);
|
||||
const geometryStatus = record(
|
||||
semantic.geometry_status,
|
||||
"E29 metrics.semantic_observations.geometry_status",
|
||||
);
|
||||
const geometryOnly = record(
|
||||
metrics.geometry_only_occupied,
|
||||
"E29 metrics.geometry_only_occupied",
|
||||
);
|
||||
const runtime = record(metrics.runtime, "E29 metrics.runtime");
|
||||
const frameProcessing = record(
|
||||
runtime.frame_processing_ms,
|
||||
"E29 metrics.runtime.frame_processing_ms",
|
||||
);
|
||||
const decision = record(source.decision, "E29 decision");
|
||||
const authority = record(source.authority, "E29 authority");
|
||||
const linked = record(source.linked_evidence, "E29 linked evidence");
|
||||
const status = stringValue(source.status, "E29 status");
|
||||
if (status !== "diagnostic-replay-complete") {
|
||||
throw new E29EvidenceContractError("E29 status не завершён.");
|
||||
}
|
||||
if (!Array.isArray(source.limitations) || !Array.isArray(source.review_frames)) {
|
||||
throw new E29EvidenceContractError("E29 collections имеют неверный формат.");
|
||||
}
|
||||
return {
|
||||
resultId: stringValue(source.result_id, "E29 result_id"),
|
||||
createdAtUtc: source.created_at_utc === null
|
||||
? null
|
||||
: stringValue(source.created_at_utc, "E29 created_at_utc"),
|
||||
status,
|
||||
identity: {
|
||||
frameCount: integerValue(identity.frame_count, "E29 identity.frame_count"),
|
||||
timelineStartSeconds: numberValue(
|
||||
identity.timeline_start_seconds,
|
||||
"E29 identity.timeline_start_seconds",
|
||||
),
|
||||
timelineEndSeconds: numberValue(
|
||||
identity.timeline_end_seconds,
|
||||
"E29 identity.timeline_end_seconds",
|
||||
),
|
||||
profileId: stringValue(profile.profile_id, "E29 identity.profile.profile_id"),
|
||||
producerSha256: stringValue(
|
||||
identity.producer_sha256,
|
||||
"E29 identity.producer_sha256",
|
||||
),
|
||||
},
|
||||
metrics: {
|
||||
frames: {
|
||||
total: integerValue(frames.total, "E29 metrics.frames.total"),
|
||||
sourceAvailable: integerValue(
|
||||
frames.source_available,
|
||||
"E29 metrics.frames.source_available",
|
||||
),
|
||||
localSurfaceValid: integerValue(
|
||||
frames.local_surface_valid,
|
||||
"E29 metrics.frames.local_surface_valid",
|
||||
),
|
||||
},
|
||||
semanticObservations: {
|
||||
total: integerValue(
|
||||
semantic.total,
|
||||
"E29 metrics.semantic_observations.total",
|
||||
),
|
||||
current: integerValue(
|
||||
semantic.current,
|
||||
"E29 metrics.semantic_observations.current",
|
||||
),
|
||||
agreementFractionOfCurrent: numberValue(
|
||||
semantic.agreement_fraction_of_current,
|
||||
"E29 metrics.semantic_observations.agreement_fraction_of_current",
|
||||
),
|
||||
geometryStatus: {
|
||||
agree: integerValue(geometryStatus.agree, "E29 geometry_status.agree"),
|
||||
cameraOnly: integerValue(
|
||||
geometryStatus["single-source-camera"],
|
||||
"E29 geometry_status.single-source-camera",
|
||||
),
|
||||
conflict: integerValue(
|
||||
geometryStatus.conflict,
|
||||
"E29 geometry_status.conflict",
|
||||
),
|
||||
unknown: integerValue(
|
||||
geometryStatus.unknown,
|
||||
"E29 geometry_status.unknown",
|
||||
),
|
||||
},
|
||||
},
|
||||
geometryOnlyOccupied: {
|
||||
clusterCount: integerValue(
|
||||
geometryOnly.cluster_count,
|
||||
"E29 geometry_only_occupied.cluster_count",
|
||||
),
|
||||
pointCount: integerValue(
|
||||
geometryOnly.point_count,
|
||||
"E29 geometry_only_occupied.point_count",
|
||||
),
|
||||
},
|
||||
runtime: {
|
||||
buildElapsedMs: numberValue(
|
||||
runtime.build_elapsed_ms,
|
||||
"E29 runtime.build_elapsed_ms",
|
||||
),
|
||||
frameProcessingP95Ms: numberValue(
|
||||
frameProcessing.p95,
|
||||
"E29 runtime.frame_processing_ms.p95",
|
||||
),
|
||||
},
|
||||
},
|
||||
decision: {
|
||||
cameraFirstContractImplemented: booleanValue(
|
||||
decision.camera_first_contract_implemented,
|
||||
"E29 decision.camera_first_contract_implemented",
|
||||
),
|
||||
parallelGeometryOnlyLayerImplemented: booleanValue(
|
||||
decision.parallel_geometry_only_layer_implemented,
|
||||
"E29 decision.parallel_geometry_only_layer_implemented",
|
||||
),
|
||||
productionPromotion: booleanValue(
|
||||
decision.production_promotion,
|
||||
"E29 decision.production_promotion",
|
||||
),
|
||||
nextGate: stringValue(decision.next_gate, "E29 decision.next_gate"),
|
||||
},
|
||||
limitations: source.limitations.map((item, index) => (
|
||||
stringValue(item, `E29 limitations[${index}]`)
|
||||
)),
|
||||
authority: {
|
||||
commandsEnabled: falseValue(
|
||||
authority.commands_enabled,
|
||||
"E29 authority.commands_enabled",
|
||||
),
|
||||
navigationOrSafetyAccepted: falseValue(
|
||||
authority.navigation_or_safety_accepted,
|
||||
"E29 authority.navigation_or_safety_accepted",
|
||||
),
|
||||
},
|
||||
reviewFrames: source.review_frames.map(parseReviewFrame),
|
||||
linkedEvidence: {
|
||||
sourceSessionId: stringValue(
|
||||
linked.source_session_id,
|
||||
"E29 linked_evidence.source_session_id",
|
||||
),
|
||||
localSurfaceModelId: stringValue(
|
||||
linked.local_surface_model_id,
|
||||
"E29 linked_evidence.local_surface_model_id",
|
||||
),
|
||||
sourcePackId: stringValue(
|
||||
linked.source_pack_id,
|
||||
"E29 linked_evidence.source_pack_id",
|
||||
),
|
||||
sourceResultId: stringValue(
|
||||
linked.source_result_id,
|
||||
"E29 linked_evidence.source_result_id",
|
||||
),
|
||||
publishedOnControlPlane: trueValue(
|
||||
linked.published_on_control_plane,
|
||||
"E29 linked_evidence.published_on_control_plane",
|
||||
),
|
||||
workerCopyRequired: falseValue(
|
||||
linked.worker_copy_required,
|
||||
"E29 linked_evidence.worker_copy_required",
|
||||
),
|
||||
},
|
||||
groundTruth: falseValue(source.ground_truth, "E29 ground_truth"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE29EvidenceCatalog(value: unknown): E29EvidenceCatalog {
|
||||
const source = record(value, "E29 catalog");
|
||||
if (source.schema_version !== "missioncore.laboratory-e29-catalog/v1") {
|
||||
throw new E29EvidenceContractError("E29 catalog schema не поддерживается.");
|
||||
}
|
||||
if (!Array.isArray(source.items)) {
|
||||
throw new E29EvidenceContractError("E29 catalog.items имеет неверный формат.");
|
||||
}
|
||||
return {
|
||||
configured: booleanValue(source.configured, "E29 catalog.configured"),
|
||||
candidateTotal: integerValue(
|
||||
source.candidate_total,
|
||||
"E29 catalog.candidate_total",
|
||||
),
|
||||
invalidTotal: integerValue(
|
||||
source.invalid_total,
|
||||
"E29 catalog.invalid_total",
|
||||
),
|
||||
items: source.items.map(parseEvidenceResult),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSemanticObservation(value: unknown): E29SemanticObservation {
|
||||
const source = record(value, "E29 semantic observation");
|
||||
const support = record(source.support, "E29 semantic observation.support");
|
||||
return {
|
||||
trackId: integerValue(source.track_id, "E29 semantic observation.track_id"),
|
||||
label: stringValue(source.label, "E29 semantic observation.label"),
|
||||
geometryStatus: stringValue(
|
||||
source.geometry_status,
|
||||
"E29 semantic observation.geometry_status",
|
||||
),
|
||||
geometryReason: stringValue(
|
||||
source.geometry_reason,
|
||||
"E29 semantic observation.geometry_reason",
|
||||
),
|
||||
rangeM: nullableNumber(source.range_m, "E29 semantic observation.range_m"),
|
||||
score: numberValue(source.score, "E29 semantic observation.score"),
|
||||
support: {
|
||||
classifiedPoints: integerValue(
|
||||
support.classified_points_in_bbox,
|
||||
"E29 support.classified_points_in_bbox",
|
||||
),
|
||||
occupiedPoints: integerValue(
|
||||
support.occupied_points_in_bbox,
|
||||
"E29 support.occupied_points_in_bbox",
|
||||
),
|
||||
surfacePoints: integerValue(
|
||||
support.surface_points_in_bbox,
|
||||
"E29 support.surface_points_in_bbox",
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseGeometryOnly(value: unknown): E29GeometryOnlyCluster {
|
||||
const source = record(value, "E29 geometry-only cluster");
|
||||
return {
|
||||
nearestRangeM: numberValue(
|
||||
source.nearest_range_m,
|
||||
"E29 geometry-only.nearest_range_m",
|
||||
),
|
||||
pointCount: integerValue(
|
||||
source.point_count,
|
||||
"E29 geometry-only.point_count",
|
||||
),
|
||||
voxelCount: integerValue(
|
||||
source.voxel_count,
|
||||
"E29 geometry-only.voxel_count",
|
||||
),
|
||||
heightRangeM: tuple2(
|
||||
source.height_range_m,
|
||||
"E29 geometry-only.height_range_m",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE29EvidenceFrame(value: unknown): E29EvidenceFrame {
|
||||
const source = record(value, "E29 frame response");
|
||||
if (source.schema_version !== "missioncore.laboratory-e29-frame/v1") {
|
||||
throw new E29EvidenceContractError("E29 frame schema не поддерживается.");
|
||||
}
|
||||
const frame = record(source.frame, "E29 frame");
|
||||
if (
|
||||
frame.schema_version !== "missioncore.e29-camera-geometry-frame/v1"
|
||||
|| !Array.isArray(frame.semantic_observations)
|
||||
|| !Array.isArray(frame.geometry_only_occupied)
|
||||
) {
|
||||
throw new E29EvidenceContractError("E29 frame имеет неверный формат.");
|
||||
}
|
||||
return {
|
||||
resultId: stringValue(source.result_id, "E29 frame result_id"),
|
||||
frameIndex: integerValue(frame.frame_index, "E29 frame.frame_index"),
|
||||
sourceFrameIndex: integerValue(
|
||||
frame.source_frame_index,
|
||||
"E29 frame.source_frame_index",
|
||||
),
|
||||
sessionSeconds: numberValue(
|
||||
frame.session_seconds,
|
||||
"E29 frame.session_seconds",
|
||||
),
|
||||
semanticObservations: frame.semantic_observations.map(
|
||||
parseSemanticObservation,
|
||||
),
|
||||
geometryOnlyOccupied: frame.geometry_only_occupied.map(parseGeometryOnly),
|
||||
};
|
||||
}
|
||||
|
||||
async function responseJson(response: Response, fallback: string): Promise<unknown> {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
// Preserve the status-aware fallback below.
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail?: unknown }).detail)
|
||||
: fallback;
|
||||
throw new E29EvidenceContractError(detail);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function fetchE29EvidenceCatalog(
|
||||
options: { signal?: AbortSignal; fetcher?: E29Fetch } = {},
|
||||
): Promise<E29EvidenceCatalog> {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher("/api/v1/laboratory/e29/results?limit=1", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
});
|
||||
return parseE29EvidenceCatalog(
|
||||
await responseJson(response, "Не удалось получить LAB E29."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchE29EvidenceFrame(
|
||||
resultId: string,
|
||||
frameIndex: number,
|
||||
options: { signal?: AbortSignal; fetcher?: E29Fetch } = {},
|
||||
): Promise<E29EvidenceFrame> {
|
||||
if (
|
||||
!/^e29-camera-geometry-[a-f0-9]{64}$/.test(resultId)
|
||||
|| !Number.isSafeInteger(frameIndex)
|
||||
|| frameIndex < 0
|
||||
) {
|
||||
throw new E29EvidenceContractError("Некорректный LAB E29 frame id.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e29/results/${resultId}/frames/${frameIndex}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE29EvidenceFrame(
|
||||
await responseJson(response, "Не удалось получить кадр LAB E29."),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
import {
|
||||
parseE30ReviewItem,
|
||||
type E30ReviewItem,
|
||||
type E30Stratum,
|
||||
} from "./e30Review";
|
||||
|
||||
export type E30EngineeringVerdict =
|
||||
| "confirmed"
|
||||
| "corrected"
|
||||
| "insufficient-evidence";
|
||||
|
||||
export type E30DetectorAssessment =
|
||||
| "valid"
|
||||
| "class-mismatch"
|
||||
| "false-positive"
|
||||
| "missed-object"
|
||||
| "not-applicable"
|
||||
| "insufficient-evidence";
|
||||
|
||||
export type E30ProjectionAssessment =
|
||||
| "aligned"
|
||||
| "misaligned"
|
||||
| "not-assessable";
|
||||
|
||||
export type E30PointOwnership =
|
||||
| "object"
|
||||
| "surface-or-background"
|
||||
| "static-environment"
|
||||
| "self"
|
||||
| "insufficient-support"
|
||||
| "not-applicable"
|
||||
| "mixed"
|
||||
| "insufficient-evidence";
|
||||
|
||||
export interface E30ExceptionReviewPrompt {
|
||||
question: string;
|
||||
focus: string;
|
||||
effects: Readonly<{
|
||||
"object-present": string;
|
||||
"background-or-noise": string;
|
||||
"insufficient-evidence": string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface E30EngineeringGeneration {
|
||||
generationId: string;
|
||||
createdAtUtc: string;
|
||||
materializationId: string;
|
||||
producer: {
|
||||
producerId: string;
|
||||
methodId: string;
|
||||
reviewSheetIdentitySha256: string;
|
||||
claimsHumanGroundTruth: false;
|
||||
};
|
||||
summary: {
|
||||
itemCount: number;
|
||||
reviewedItemCount: number;
|
||||
verdictDistribution: Readonly<Record<string, number>>;
|
||||
detectorDistribution: Readonly<Record<string, number>>;
|
||||
projectionDistribution: Readonly<Record<string, number>>;
|
||||
pointOwnershipDistribution: Readonly<Record<string, number>>;
|
||||
humanExceptionCount: number;
|
||||
meanConfidence: number;
|
||||
};
|
||||
causeDistribution: readonly {
|
||||
reasonCode: string;
|
||||
count: number;
|
||||
}[];
|
||||
humanExceptions: readonly {
|
||||
itemId: string;
|
||||
reviewKey: string;
|
||||
sourceStratum: E30Stratum;
|
||||
confidence: number;
|
||||
reviewPrompt: E30ExceptionReviewPrompt | null;
|
||||
}[];
|
||||
aiReviewComplete: true;
|
||||
humanExceptionComplete: boolean;
|
||||
humanReviewComplete: false;
|
||||
labPublished: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E30EngineeringCatalog {
|
||||
configured: boolean;
|
||||
candidateTotal: number;
|
||||
invalidTotal: number;
|
||||
items: readonly E30EngineeringGeneration[];
|
||||
}
|
||||
|
||||
export interface E30EngineeringDecision {
|
||||
sequence: number;
|
||||
itemId: string;
|
||||
reviewKey: string;
|
||||
sourceStratum: E30Stratum;
|
||||
verdict: E30EngineeringVerdict;
|
||||
effectiveStratum: E30Stratum | null;
|
||||
detectorAssessment: E30DetectorAssessment;
|
||||
projectionAssessment: E30ProjectionAssessment;
|
||||
pointOwnership: E30PointOwnership;
|
||||
causeCode: string | null;
|
||||
confidence: number;
|
||||
humanExceptionRequired: boolean;
|
||||
exceptionReason: "ambiguity" | "high-impact" | null;
|
||||
evidenceNote: string;
|
||||
reviewSheet: {
|
||||
path: string;
|
||||
sha256: string;
|
||||
ordinal: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface E30EngineeringExceptions {
|
||||
resultId: string;
|
||||
generationId: string;
|
||||
items: readonly E30ReviewItem[];
|
||||
total: number;
|
||||
nextCursor: number | null;
|
||||
}
|
||||
|
||||
export class E30EngineeringContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "E30EngineeringContractError";
|
||||
}
|
||||
}
|
||||
|
||||
type E30Fetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
if (
|
||||
typeof value !== "number"
|
||||
|| !Number.isSafeInteger(value)
|
||||
|| value < 0
|
||||
) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new E30EngineeringContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function falseValue(value: unknown, label: string): false {
|
||||
if (value !== false) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалось false.`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trueValue(value: unknown, label: string): true {
|
||||
if (value !== true) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалось true.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function contentId(value: unknown, prefix: string, label: string): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||
throw new E30EngineeringContractError(`${label}: неверный content id.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readOnly(value: unknown, label: string): "read-only" {
|
||||
if (value !== "read-only") {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалось read-only.`);
|
||||
}
|
||||
return "read-only";
|
||||
}
|
||||
|
||||
function authority(value: unknown, label: string): void {
|
||||
const parsed = record(value, label);
|
||||
falseValue(parsed.commands_enabled, `${label}.commands_enabled`);
|
||||
falseValue(
|
||||
parsed.navigation_or_safety_accepted,
|
||||
`${label}.navigation_or_safety_accepted`,
|
||||
);
|
||||
}
|
||||
|
||||
function distribution(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Readonly<Record<string, number>> {
|
||||
const parsed = record(value, label);
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).map(([key, count]) => [
|
||||
key,
|
||||
integerValue(count, `${label}.${key}`),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function stratum(value: unknown, label: string): E30Stratum {
|
||||
if (
|
||||
value !== "conflict"
|
||||
&& value !== "agree"
|
||||
&& value !== "camera-only"
|
||||
&& value !== "unknown"
|
||||
&& value !== "geometry-only"
|
||||
) {
|
||||
throw new E30EngineeringContractError(`${label}: неизвестная страта.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableStratum(value: unknown, label: string): E30Stratum | null {
|
||||
return value === null ? null : stratum(value, label);
|
||||
}
|
||||
|
||||
function enumValue<T extends string>(
|
||||
value: unknown,
|
||||
values: readonly T[],
|
||||
label: string,
|
||||
): T {
|
||||
if (typeof value !== "string" || !values.includes(value as T)) {
|
||||
throw new E30EngineeringContractError(`${label}: неизвестное значение.`);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function exceptionReviewPrompt(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E30ExceptionReviewPrompt | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const source = record(value, label);
|
||||
const effects = record(source.effects, `${label}.effects`);
|
||||
return {
|
||||
question: stringValue(source.question, `${label}.question`),
|
||||
focus: stringValue(source.focus, `${label}.focus`),
|
||||
effects: {
|
||||
"object-present": stringValue(
|
||||
effects["object-present"],
|
||||
`${label}.effects.object-present`,
|
||||
),
|
||||
"background-or-noise": stringValue(
|
||||
effects["background-or-noise"],
|
||||
`${label}.effects.background-or-noise`,
|
||||
),
|
||||
"insufficient-evidence": stringValue(
|
||||
effects["insufficient-evidence"],
|
||||
`${label}.effects.insufficient-evidence`,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseGeneration(value: unknown): E30EngineeringGeneration {
|
||||
const source = record(value, "A3 generation");
|
||||
const producer = record(source.producer, "A3 producer");
|
||||
const summary = record(source.summary, "A3 summary");
|
||||
const causes = record(source.cause_distribution, "A3 causes");
|
||||
const humanExceptions = source.human_exceptions;
|
||||
if (
|
||||
producer.kind !== "ai-assisted-engineering-review"
|
||||
|| !Array.isArray(causes.reasons)
|
||||
|| !Array.isArray(humanExceptions)
|
||||
) {
|
||||
throw new E30EngineeringContractError("A3 provenance не поддерживается.");
|
||||
}
|
||||
falseValue(
|
||||
producer.claims_human_ground_truth,
|
||||
"A3 producer.claims_human_ground_truth",
|
||||
);
|
||||
authority(source.authority, "A3 authority");
|
||||
return {
|
||||
generationId: contentId(
|
||||
source.generation_id,
|
||||
"e30-engineering-generation",
|
||||
"A3 generation_id",
|
||||
),
|
||||
createdAtUtc: stringValue(source.created_at_utc, "A3 created_at_utc"),
|
||||
materializationId: contentId(
|
||||
source.materialization_id,
|
||||
"e30-materialization",
|
||||
"A3 materialization_id",
|
||||
),
|
||||
producer: {
|
||||
producerId: stringValue(producer.producer_id, "A3 producer_id"),
|
||||
methodId: stringValue(producer.method_id, "A3 method_id"),
|
||||
reviewSheetIdentitySha256: stringValue(
|
||||
producer.review_sheet_identity_sha256,
|
||||
"A3 review_sheet_identity_sha256",
|
||||
),
|
||||
claimsHumanGroundTruth: false,
|
||||
},
|
||||
summary: {
|
||||
itemCount: integerValue(summary.item_count, "A3 item_count"),
|
||||
reviewedItemCount: integerValue(
|
||||
summary.reviewed_item_count,
|
||||
"A3 reviewed_item_count",
|
||||
),
|
||||
verdictDistribution: distribution(
|
||||
summary.verdict_distribution,
|
||||
"A3 verdict_distribution",
|
||||
),
|
||||
detectorDistribution: distribution(
|
||||
summary.detector_distribution,
|
||||
"A3 detector_distribution",
|
||||
),
|
||||
projectionDistribution: distribution(
|
||||
summary.projection_distribution,
|
||||
"A3 projection_distribution",
|
||||
),
|
||||
pointOwnershipDistribution: distribution(
|
||||
summary.point_ownership_distribution,
|
||||
"A3 point_ownership_distribution",
|
||||
),
|
||||
humanExceptionCount: integerValue(
|
||||
summary.human_exception_count,
|
||||
"A3 human_exception_count",
|
||||
),
|
||||
meanConfidence: numberValue(
|
||||
summary.mean_confidence,
|
||||
"A3 mean_confidence",
|
||||
),
|
||||
},
|
||||
causeDistribution: causes.reasons.map((value, index) => {
|
||||
const reason = record(value, `A3 causes[${index}]`);
|
||||
return {
|
||||
reasonCode: stringValue(
|
||||
reason.reason_code,
|
||||
`A3 causes[${index}].reason_code`,
|
||||
),
|
||||
count: integerValue(reason.count, `A3 causes[${index}].count`),
|
||||
};
|
||||
}),
|
||||
humanExceptions: humanExceptions.map((value, index) => {
|
||||
const exception = record(value, `A3 exceptions[${index}]`);
|
||||
const confidence = numberValue(
|
||||
exception.confidence,
|
||||
`A3 exceptions[${index}].confidence`,
|
||||
);
|
||||
if (confidence < 0 || confidence > 1) {
|
||||
throw new E30EngineeringContractError(
|
||||
`A3 exceptions[${index}].confidence вне диапазона.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
itemId: contentId(
|
||||
exception.item_id,
|
||||
"e30-review-item",
|
||||
`A3 exceptions[${index}].item_id`,
|
||||
),
|
||||
reviewKey: stringValue(
|
||||
exception.review_key,
|
||||
`A3 exceptions[${index}].review_key`,
|
||||
),
|
||||
sourceStratum: stratum(
|
||||
exception.source_stratum,
|
||||
`A3 exceptions[${index}].source_stratum`,
|
||||
),
|
||||
confidence,
|
||||
reviewPrompt: exceptionReviewPrompt(
|
||||
exception.review_prompt,
|
||||
`A3 exceptions[${index}].review_prompt`,
|
||||
),
|
||||
};
|
||||
}),
|
||||
aiReviewComplete: trueValue(
|
||||
source.ai_review_complete,
|
||||
"A3 ai_review_complete",
|
||||
),
|
||||
humanExceptionComplete: booleanValue(
|
||||
source.human_exception_complete,
|
||||
"A3 human_exception_complete",
|
||||
),
|
||||
humanReviewComplete: falseValue(
|
||||
source.human_review_complete,
|
||||
"A3 human_review_complete",
|
||||
),
|
||||
labPublished: falseValue(source.lab_published, "A3 lab_published"),
|
||||
access: readOnly(source.access, "A3 access"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30EngineeringCatalog(
|
||||
value: unknown,
|
||||
): E30EngineeringCatalog {
|
||||
const source = record(value, "A3 catalog");
|
||||
if (
|
||||
source.schema_version
|
||||
!== "missioncore.laboratory-e30-engineering-generations/v1"
|
||||
|| !Array.isArray(source.items)
|
||||
) {
|
||||
throw new E30EngineeringContractError("A3 catalog schema не поддерживается.");
|
||||
}
|
||||
readOnly(source.access, "A3 catalog access");
|
||||
return {
|
||||
configured: Boolean(source.configured),
|
||||
candidateTotal: integerValue(
|
||||
source.candidate_total,
|
||||
"A3 candidate_total",
|
||||
),
|
||||
invalidTotal: integerValue(source.invalid_total, "A3 invalid_total"),
|
||||
items: source.items.map(parseGeneration),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30EngineeringDecision(
|
||||
value: unknown,
|
||||
): E30EngineeringDecision {
|
||||
const response = record(value, "A3 decision response");
|
||||
if (
|
||||
response.schema_version
|
||||
!== "missioncore.laboratory-e30-engineering-decision/v1"
|
||||
) {
|
||||
throw new E30EngineeringContractError("A3 decision schema не поддерживается.");
|
||||
}
|
||||
readOnly(response.access, "A3 decision access");
|
||||
contentId(
|
||||
response.generation_id,
|
||||
"e30-engineering-generation",
|
||||
"A3 decision generation_id",
|
||||
);
|
||||
const source = record(response.decision, "A3 decision");
|
||||
const sheet = record(source.review_sheet, "A3 review_sheet");
|
||||
const confidence = numberValue(source.confidence, "A3 confidence");
|
||||
if (confidence < 0 || confidence > 1) {
|
||||
throw new E30EngineeringContractError("A3 confidence вне диапазона.");
|
||||
}
|
||||
const exceptionReason = source.exception_reason === null
|
||||
? null
|
||||
: enumValue(
|
||||
source.exception_reason,
|
||||
["ambiguity", "high-impact"] as const,
|
||||
"A3 exception_reason",
|
||||
);
|
||||
return {
|
||||
sequence: integerValue(source.sequence, "A3 sequence"),
|
||||
itemId: contentId(
|
||||
source.item_id,
|
||||
"e30-review-item",
|
||||
"A3 decision item_id",
|
||||
),
|
||||
reviewKey: stringValue(source.review_key, "A3 review_key"),
|
||||
sourceStratum: stratum(source.source_stratum, "A3 source_stratum"),
|
||||
verdict: enumValue(
|
||||
source.verdict,
|
||||
["confirmed", "corrected", "insufficient-evidence"] as const,
|
||||
"A3 verdict",
|
||||
),
|
||||
effectiveStratum: nullableStratum(
|
||||
source.effective_stratum,
|
||||
"A3 effective_stratum",
|
||||
),
|
||||
detectorAssessment: enumValue(
|
||||
source.detector_assessment,
|
||||
[
|
||||
"valid",
|
||||
"class-mismatch",
|
||||
"false-positive",
|
||||
"missed-object",
|
||||
"not-applicable",
|
||||
"insufficient-evidence",
|
||||
] as const,
|
||||
"A3 detector_assessment",
|
||||
),
|
||||
projectionAssessment: enumValue(
|
||||
source.projection_assessment,
|
||||
["aligned", "misaligned", "not-assessable"] as const,
|
||||
"A3 projection_assessment",
|
||||
),
|
||||
pointOwnership: enumValue(
|
||||
source.point_ownership,
|
||||
[
|
||||
"object",
|
||||
"surface-or-background",
|
||||
"static-environment",
|
||||
"self",
|
||||
"insufficient-support",
|
||||
"not-applicable",
|
||||
"mixed",
|
||||
"insufficient-evidence",
|
||||
] as const,
|
||||
"A3 point_ownership",
|
||||
),
|
||||
causeCode: source.cause_code === null
|
||||
? null
|
||||
: stringValue(source.cause_code, "A3 cause_code"),
|
||||
confidence,
|
||||
humanExceptionRequired: booleanValue(
|
||||
source.human_exception_required,
|
||||
"A3 human_exception_required",
|
||||
),
|
||||
exceptionReason,
|
||||
evidenceNote: stringValue(source.evidence_note, "A3 evidence_note"),
|
||||
reviewSheet: {
|
||||
path: stringValue(sheet.path, "A3 review_sheet.path"),
|
||||
sha256: stringValue(sheet.sha256, "A3 review_sheet.sha256"),
|
||||
ordinal: integerValue(sheet.ordinal, "A3 review_sheet.ordinal"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30EngineeringExceptions(
|
||||
value: unknown,
|
||||
): E30EngineeringExceptions {
|
||||
const source = record(value, "A3 exception queue");
|
||||
if (
|
||||
source.schema_version
|
||||
!== "missioncore.laboratory-e30-engineering-exceptions/v1"
|
||||
|| !Array.isArray(source.items)
|
||||
) {
|
||||
throw new E30EngineeringContractError(
|
||||
"A3 exception queue schema не поддерживается.",
|
||||
);
|
||||
}
|
||||
readOnly(source.access, "A3 exception queue access");
|
||||
return {
|
||||
resultId: contentId(
|
||||
source.result_id,
|
||||
"e30-materialization",
|
||||
"A3 exception queue result_id",
|
||||
),
|
||||
generationId: contentId(
|
||||
source.generation_id,
|
||||
"e30-engineering-generation",
|
||||
"A3 exception queue generation_id",
|
||||
),
|
||||
items: source.items.map(parseE30ReviewItem),
|
||||
total: integerValue(source.total, "A3 exception queue total"),
|
||||
nextCursor: source.next_cursor === null
|
||||
? null
|
||||
: integerValue(
|
||||
source.next_cursor,
|
||||
"A3 exception queue next_cursor",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function responseJson(response: Response, fallback: string): Promise<unknown> {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
// Preserve the status-aware fallback below.
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail?: unknown }).detail)
|
||||
: fallback;
|
||||
throw new E30EngineeringContractError(detail);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function validMaterializationId(value: string): boolean {
|
||||
return /^e30-materialization-[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
function validGenerationId(value: string): boolean {
|
||||
return /^e30-engineering-generation-[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
function validItemId(value: string): boolean {
|
||||
return /^e30-review-item-[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
export async function fetchE30EngineeringCatalog(
|
||||
resultId: string,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30EngineeringCatalog> {
|
||||
if (!validMaterializationId(resultId)) {
|
||||
throw new E30EngineeringContractError("Некорректный E30 materialization id.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/engineering-generations?limit=1`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30EngineeringCatalog(
|
||||
await responseJson(response, "Не удалось получить A3 generation."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchE30EngineeringDecision(
|
||||
resultId: string,
|
||||
generationId: string,
|
||||
itemId: string,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30EngineeringDecision> {
|
||||
if (
|
||||
!validMaterializationId(resultId)
|
||||
|| !validGenerationId(generationId)
|
||||
|| !validItemId(itemId)
|
||||
) {
|
||||
throw new E30EngineeringContractError("Некорректный A3 decision id.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/engineering-generations/`
|
||||
+ `${generationId}/items/${itemId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30EngineeringDecision(
|
||||
await responseJson(response, "Не удалось получить A3 decision."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchE30EngineeringExceptions(
|
||||
resultId: string,
|
||||
generationId: string,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30EngineeringExceptions> {
|
||||
if (!validMaterializationId(resultId) || !validGenerationId(generationId)) {
|
||||
throw new E30EngineeringContractError("Некорректная A3 exception queue.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/engineering-generations/`
|
||||
+ `${generationId}/exceptions?limit=128`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30EngineeringExceptions(
|
||||
await responseJson(response, "Не удалось получить A3 exception queue."),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import {
|
||||
E30ReviewContractError,
|
||||
type E30Stratum,
|
||||
} from "./e30Review";
|
||||
|
||||
export const E30_EXCEPTION_DISPOSITIONS = [
|
||||
"object-present",
|
||||
"background-or-noise",
|
||||
"insufficient-evidence",
|
||||
] as const;
|
||||
|
||||
export type E30ExceptionDisposition =
|
||||
(typeof E30_EXCEPTION_DISPOSITIONS)[number];
|
||||
export type E30HumanReviewState = "active" | "finalized";
|
||||
|
||||
export interface E30HumanReviewDecision {
|
||||
itemId: string;
|
||||
sourceStratum: E30Stratum;
|
||||
disposition: E30ExceptionDisposition;
|
||||
notes: string | null;
|
||||
eventId: string;
|
||||
decidedAtUtc: string;
|
||||
}
|
||||
|
||||
export interface E30HumanReviewDraft {
|
||||
draftId: string;
|
||||
materializationId: string;
|
||||
engineeringGenerationId: string;
|
||||
reviewerId: string;
|
||||
createdAtUtc: string;
|
||||
state: E30HumanReviewState;
|
||||
revision: number;
|
||||
itemCount: number;
|
||||
reviewedItemCount: number;
|
||||
remainingItemCount: number;
|
||||
dispositionDistribution: Readonly<Record<string, number>>;
|
||||
generationId: string | null;
|
||||
decisions: readonly E30HumanReviewDecision[];
|
||||
labPublished: false;
|
||||
access: "review-write" | "read-only";
|
||||
}
|
||||
|
||||
export interface E30HumanReviewGeneration {
|
||||
generationId: string;
|
||||
materializationId: string;
|
||||
engineeringGenerationId: string;
|
||||
reviewerId: string;
|
||||
createdAtUtc: string;
|
||||
itemCount: number;
|
||||
dispositionDistribution: Readonly<Record<string, number>>;
|
||||
coverage: {
|
||||
expectedItemCount: number;
|
||||
reviewedItemCount: number;
|
||||
complete: true;
|
||||
};
|
||||
humanReviewComplete: true;
|
||||
labPublished: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E30HumanReviewFinalized {
|
||||
draft: E30HumanReviewDraft;
|
||||
generation: E30HumanReviewGeneration;
|
||||
}
|
||||
|
||||
type E30Fetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
if (
|
||||
typeof value !== "number"
|
||||
|| !Number.isSafeInteger(value)
|
||||
|| value < 0
|
||||
) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function contentId(value: unknown, prefix: string, label: string): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||
throw new E30ReviewContractError(`${label}: некорректный content id.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function falseValue(value: unknown, label: string): false {
|
||||
if (value !== false) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось false.`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trueValue(value: unknown, label: string): true {
|
||||
if (value !== true) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось true.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function nullableString(value: unknown, label: string): string | null {
|
||||
return value === null ? null : stringValue(value, label);
|
||||
}
|
||||
|
||||
function distribution(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Readonly<Record<string, number>> {
|
||||
const source = record(value, label);
|
||||
return Object.fromEntries(
|
||||
Object.entries(source).map(([key, count]) => [
|
||||
key,
|
||||
integerValue(count, `${label}.${key}`),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function disposition(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E30ExceptionDisposition {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| !E30_EXCEPTION_DISPOSITIONS.includes(
|
||||
value as E30ExceptionDisposition,
|
||||
)
|
||||
) {
|
||||
throw new E30ReviewContractError(`${label}: неизвестное решение.`);
|
||||
}
|
||||
return value as E30ExceptionDisposition;
|
||||
}
|
||||
|
||||
function stratum(value: unknown, label: string): E30Stratum {
|
||||
if (
|
||||
value !== "conflict"
|
||||
&& value !== "agree"
|
||||
&& value !== "camera-only"
|
||||
&& value !== "unknown"
|
||||
&& value !== "geometry-only"
|
||||
) {
|
||||
throw new E30ReviewContractError(`${label}: неизвестная страта.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseDecision(value: unknown): E30HumanReviewDecision {
|
||||
const source = record(value, "Решение по исключению");
|
||||
return {
|
||||
itemId: contentId(
|
||||
source.item_id,
|
||||
"e30-review-item",
|
||||
"Решение.item_id",
|
||||
),
|
||||
sourceStratum: stratum(source.source_stratum, "Решение.source_stratum"),
|
||||
disposition: disposition(source.disposition, "Решение.disposition"),
|
||||
notes: nullableString(source.notes, "Решение.notes"),
|
||||
eventId: contentId(
|
||||
source.event_id,
|
||||
"e30-review-event",
|
||||
"Решение.event_id",
|
||||
),
|
||||
decidedAtUtc: stringValue(
|
||||
source.decided_at_utc,
|
||||
"Решение.decided_at_utc",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30HumanReviewDraft(
|
||||
value: unknown,
|
||||
): E30HumanReviewDraft {
|
||||
const source = record(value, "Проверка исключений");
|
||||
if (
|
||||
source.schema_version !== "missioncore.e30-human-review-draft/v2"
|
||||
|| !Array.isArray(source.decisions)
|
||||
) {
|
||||
throw new E30ReviewContractError(
|
||||
"Схема проверки исключений не поддерживается.",
|
||||
);
|
||||
}
|
||||
const state = stringValue(source.state, "Проверка.state");
|
||||
const access = stringValue(source.access, "Проверка.access");
|
||||
if (
|
||||
(state !== "active" && state !== "finalized")
|
||||
|| (state === "active" && access !== "review-write")
|
||||
|| (state === "finalized" && access !== "read-only")
|
||||
) {
|
||||
throw new E30ReviewContractError("Состояние проверки некорректно.");
|
||||
}
|
||||
const itemCount = integerValue(source.item_count, "Проверка.item_count");
|
||||
const reviewedItemCount = integerValue(
|
||||
source.reviewed_item_count,
|
||||
"Проверка.reviewed_item_count",
|
||||
);
|
||||
const remainingItemCount = integerValue(
|
||||
source.remaining_item_count,
|
||||
"Проверка.remaining_item_count",
|
||||
);
|
||||
const decisions = source.decisions.map(parseDecision);
|
||||
if (
|
||||
reviewedItemCount + remainingItemCount !== itemCount
|
||||
|| decisions.length !== reviewedItemCount
|
||||
) {
|
||||
throw new E30ReviewContractError("Покрытие проверки расходится.");
|
||||
}
|
||||
return {
|
||||
draftId: contentId(
|
||||
source.draft_id,
|
||||
"e30-human-draft",
|
||||
"Проверка.draft_id",
|
||||
),
|
||||
materializationId: contentId(
|
||||
source.materialization_id,
|
||||
"e30-materialization",
|
||||
"Проверка.materialization_id",
|
||||
),
|
||||
engineeringGenerationId: contentId(
|
||||
source.engineering_generation_id,
|
||||
"e30-engineering-generation",
|
||||
"Проверка.engineering_generation_id",
|
||||
),
|
||||
reviewerId: stringValue(source.reviewer_id, "Проверка.reviewer_id"),
|
||||
createdAtUtc: stringValue(source.created_at_utc, "Проверка.created_at_utc"),
|
||||
state,
|
||||
revision: integerValue(source.revision, "Проверка.revision"),
|
||||
itemCount,
|
||||
reviewedItemCount,
|
||||
remainingItemCount,
|
||||
dispositionDistribution: distribution(
|
||||
source.disposition_distribution,
|
||||
"Проверка.disposition_distribution",
|
||||
),
|
||||
generationId: source.generation_id === null
|
||||
? null
|
||||
: contentId(
|
||||
source.generation_id,
|
||||
"e30-review-generation",
|
||||
"Проверка.generation_id",
|
||||
),
|
||||
decisions,
|
||||
labPublished: falseValue(source.lab_published, "Проверка.lab_published"),
|
||||
access: access as "review-write" | "read-only",
|
||||
};
|
||||
}
|
||||
|
||||
function parseGeneration(value: unknown): E30HumanReviewGeneration {
|
||||
const source = record(value, "Зафиксированная проверка");
|
||||
const coverage = record(source.coverage, "Проверка.coverage");
|
||||
if (
|
||||
source.schema_version !== "missioncore.e30-human-review-generation/v2"
|
||||
|| source.access !== "read-only"
|
||||
) {
|
||||
throw new E30ReviewContractError(
|
||||
"Схема зафиксированной проверки не поддерживается.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
generationId: contentId(
|
||||
source.generation_id,
|
||||
"e30-review-generation",
|
||||
"Проверка.generation_id",
|
||||
),
|
||||
materializationId: contentId(
|
||||
source.materialization_id,
|
||||
"e30-materialization",
|
||||
"Проверка.materialization_id",
|
||||
),
|
||||
engineeringGenerationId: contentId(
|
||||
source.engineering_generation_id,
|
||||
"e30-engineering-generation",
|
||||
"Проверка.engineering_generation_id",
|
||||
),
|
||||
reviewerId: stringValue(source.reviewer_id, "Проверка.reviewer_id"),
|
||||
createdAtUtc: stringValue(source.created_at_utc, "Проверка.created_at_utc"),
|
||||
itemCount: integerValue(source.item_count, "Проверка.item_count"),
|
||||
dispositionDistribution: distribution(
|
||||
source.disposition_distribution,
|
||||
"Проверка.disposition_distribution",
|
||||
),
|
||||
coverage: {
|
||||
expectedItemCount: integerValue(
|
||||
coverage.expected_item_count,
|
||||
"Проверка.coverage.expected",
|
||||
),
|
||||
reviewedItemCount: integerValue(
|
||||
coverage.reviewed_item_count,
|
||||
"Проверка.coverage.reviewed",
|
||||
),
|
||||
complete: trueValue(coverage.complete, "Проверка.coverage.complete"),
|
||||
},
|
||||
humanReviewComplete: trueValue(
|
||||
source.human_review_complete,
|
||||
"Проверка.human_review_complete",
|
||||
),
|
||||
labPublished: falseValue(source.lab_published, "Проверка.lab_published"),
|
||||
access: "read-only",
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30HumanReviewFinalized(
|
||||
value: unknown,
|
||||
): E30HumanReviewFinalized {
|
||||
const source = record(value, "Фиксация проверки");
|
||||
if (
|
||||
source.schema_version
|
||||
!== "missioncore.laboratory-e30-human-review-finalized/v2"
|
||||
) {
|
||||
throw new E30ReviewContractError("Схема фиксации не поддерживается.");
|
||||
}
|
||||
const draft = parseE30HumanReviewDraft(source.draft);
|
||||
const generation = parseGeneration(source.generation);
|
||||
if (
|
||||
draft.state !== "finalized"
|
||||
|| draft.generationId !== generation.generationId
|
||||
|| draft.engineeringGenerationId !== generation.engineeringGenerationId
|
||||
) {
|
||||
throw new E30ReviewContractError("Связь фиксации расходится.");
|
||||
}
|
||||
return { draft, generation };
|
||||
}
|
||||
|
||||
async function responseJson(response: Response, fallback: string): Promise<unknown> {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
// Preserve the status-aware fallback below.
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail?: unknown }).detail)
|
||||
: fallback;
|
||||
throw new E30ReviewContractError(detail);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function validId(value: string, prefix: string): boolean {
|
||||
return new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(value);
|
||||
}
|
||||
|
||||
function generationQuery(engineeringGenerationId: string): string {
|
||||
return `?engineering_generation_id=${encodeURIComponent(
|
||||
engineeringGenerationId,
|
||||
)}`;
|
||||
}
|
||||
|
||||
export async function createOrResumeE30HumanReview(
|
||||
resultId: string,
|
||||
engineeringGenerationId: string,
|
||||
reviewerId = "DC",
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30HumanReviewDraft> {
|
||||
if (
|
||||
!validId(resultId, "e30-materialization")
|
||||
|| !validId(engineeringGenerationId, "e30-engineering-generation")
|
||||
) {
|
||||
throw new E30ReviewContractError("Некорректная очередь проверки.");
|
||||
}
|
||||
const response = await (options.fetcher ?? fetch)(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/human-review`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
reviewer_id: reviewerId,
|
||||
engineering_generation_id: engineeringGenerationId,
|
||||
}),
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30HumanReviewDraft(
|
||||
await responseJson(response, "Не удалось открыть проверку."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveE30HumanReviewDecision(
|
||||
resultId: string,
|
||||
engineeringGenerationId: string,
|
||||
draftId: string,
|
||||
itemId: string,
|
||||
request: {
|
||||
expectedRevision: number;
|
||||
idempotencyKey: string;
|
||||
disposition: E30ExceptionDisposition;
|
||||
notes: string | null;
|
||||
},
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30HumanReviewDraft> {
|
||||
const response = await (options.fetcher ?? fetch)(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/human-review/${draftId}`
|
||||
+ `/decisions/${itemId}${generationQuery(engineeringGenerationId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
expected_revision: request.expectedRevision,
|
||||
idempotency_key: request.idempotencyKey,
|
||||
disposition: request.disposition,
|
||||
notes: request.notes,
|
||||
}),
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30HumanReviewDraft(
|
||||
await responseJson(response, "Не удалось сохранить решение."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function finalizeE30HumanReview(
|
||||
resultId: string,
|
||||
engineeringGenerationId: string,
|
||||
draftId: string,
|
||||
expectedRevision: number,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30HumanReviewFinalized> {
|
||||
const response = await (options.fetcher ?? fetch)(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/human-review/${draftId}`
|
||||
+ `/finalize${generationQuery(engineeringGenerationId)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
expected_revision: expectedRevision,
|
||||
confirm_generation: true,
|
||||
}),
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30HumanReviewFinalized(
|
||||
await responseJson(response, "Не удалось зафиксировать проверку."),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
export const E30_STRATA = [
|
||||
"conflict",
|
||||
"agree",
|
||||
"camera-only",
|
||||
"unknown",
|
||||
"geometry-only",
|
||||
] as const;
|
||||
|
||||
export type E30Stratum = (typeof E30_STRATA)[number];
|
||||
|
||||
export interface E30ReviewResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
reviewPackId: string;
|
||||
e29ResultId: string;
|
||||
sourceSessionId: string;
|
||||
itemCount: number;
|
||||
stratumCounts: Record<E30Stratum, number>;
|
||||
reasonTaxonomy: readonly string[];
|
||||
projection: {
|
||||
width: number;
|
||||
height: number;
|
||||
sourceId: string;
|
||||
calibrationSlot: string;
|
||||
};
|
||||
cameraEvidenceAvailable: boolean;
|
||||
humanReviewComplete: false;
|
||||
labPublished: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E30ReviewCatalog {
|
||||
configured: boolean;
|
||||
candidateTotal: number;
|
||||
invalidTotal: number;
|
||||
items: readonly E30ReviewResult[];
|
||||
}
|
||||
|
||||
export interface E30ReviewSnapshot {
|
||||
label: string | null;
|
||||
geometryStatus: string;
|
||||
geometryReason: string | null;
|
||||
rangeM: number | null;
|
||||
nearestRangeM: number | null;
|
||||
bboxXyxy: readonly [number, number, number, number] | null;
|
||||
}
|
||||
|
||||
export interface E30ReviewItem {
|
||||
itemId: string;
|
||||
sequence: number;
|
||||
reviewKey: string;
|
||||
stratum: E30Stratum;
|
||||
rangeBucket: string;
|
||||
frameIndex: number;
|
||||
sourceFrameIndex: number;
|
||||
sessionSeconds: number;
|
||||
locatorKind: "semantic-observation" | "geometry-only-cluster";
|
||||
snapshot: E30ReviewSnapshot;
|
||||
materialization: {
|
||||
framePointCount: number;
|
||||
projectedPointCount: number;
|
||||
projectionWidth: number;
|
||||
projectionHeight: number;
|
||||
candidatePointCount: number;
|
||||
selectedPointCount: number;
|
||||
rejectedCandidatePointCount: number;
|
||||
selectedAndCandidateLossless: true;
|
||||
freeSpaceValid: false;
|
||||
humanReviewComplete: false;
|
||||
detectorScore: number | null;
|
||||
};
|
||||
cameraFrameAvailable: boolean;
|
||||
engineeringTriage: E30EngineeringTriage;
|
||||
review: {
|
||||
state: "unreviewed";
|
||||
reasonCode: null;
|
||||
notes: null;
|
||||
};
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E30EngineeringTriage {
|
||||
provenance: "deterministic-evidence-readiness/v1";
|
||||
state: "ready-for-ai-review" | "blocked-camera-frame-unavailable";
|
||||
attention: "standard" | "elevated";
|
||||
signals: readonly string[];
|
||||
semanticVerdict: null;
|
||||
humanExceptionRequired: null;
|
||||
}
|
||||
|
||||
export interface E30ReviewItems {
|
||||
resultId: string;
|
||||
stratum: E30Stratum;
|
||||
items: readonly E30ReviewItem[];
|
||||
total: number;
|
||||
nextCursor: number | null;
|
||||
reasonTaxonomy: readonly string[];
|
||||
}
|
||||
|
||||
export interface E30ReviewItemDetail extends E30ReviewItem {
|
||||
cameraFrame: {
|
||||
available: true;
|
||||
url: string;
|
||||
sha256: string;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceFrameIndex: number;
|
||||
exactSourceFrame: true;
|
||||
} | null;
|
||||
selected: {
|
||||
sourceIndices: readonly number[];
|
||||
pointsMapXyzM: readonly (readonly [number, number, number])[];
|
||||
};
|
||||
candidate: {
|
||||
sourceIndices: readonly number[];
|
||||
pointsMapXyzM: readonly (readonly [number, number, number])[];
|
||||
};
|
||||
projection: {
|
||||
sourceIndices: readonly number[];
|
||||
pointsMapXyzM: readonly (readonly [number, number, number])[];
|
||||
pixelsXy: readonly (readonly [number, number])[];
|
||||
depthM: readonly number[];
|
||||
pointClass: readonly number[];
|
||||
pointHeightM: readonly number[];
|
||||
candidateMask: readonly number[];
|
||||
selectedMask: readonly number[];
|
||||
};
|
||||
pose: {
|
||||
positionMapXyzM: readonly [number, number, number];
|
||||
orientationMapFromLidarXyzw: readonly [number, number, number, number];
|
||||
};
|
||||
}
|
||||
|
||||
export class E30ReviewContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "E30ReviewContractError";
|
||||
}
|
||||
}
|
||||
|
||||
type E30Fetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось конечное число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
const parsed = numberValue(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new E30ReviewContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function falseValue(value: unknown, label: string): false {
|
||||
if (value !== false) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось false.`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trueValue(value: unknown, label: string): true {
|
||||
if (value !== true) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось true.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function nullValue(value: unknown, label: string): null {
|
||||
if (value !== null) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось null.`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readOnly(value: unknown, label: string): "read-only" {
|
||||
if (value !== "read-only") {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось read-only.`);
|
||||
}
|
||||
return "read-only";
|
||||
}
|
||||
|
||||
function stratumValue(value: unknown, label: string): E30Stratum {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| !E30_STRATA.includes(value as E30Stratum)
|
||||
) {
|
||||
throw new E30ReviewContractError(`${label}: неизвестная страта.`);
|
||||
}
|
||||
return value as E30Stratum;
|
||||
}
|
||||
|
||||
function nullableNumber(value: unknown, label: string): number | null {
|
||||
return value === null || value === undefined ? null : numberValue(value, label);
|
||||
}
|
||||
|
||||
function nullableString(value: unknown, label: string): string | null {
|
||||
return value === null || value === undefined ? null : stringValue(value, label);
|
||||
}
|
||||
|
||||
function numberTuple<const N extends number>(
|
||||
value: unknown,
|
||||
length: N,
|
||||
label: string,
|
||||
): readonly number[] {
|
||||
if (!Array.isArray(value) || value.length !== length) {
|
||||
throw new E30ReviewContractError(`${label}: неверная размерность.`);
|
||||
}
|
||||
return value.map((item, index) => numberValue(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function numberArray(value: unknown, label: string): readonly number[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value.map((item, index) => numberValue(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function integerArray(value: unknown, label: string): readonly number[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value.map((item, index) => integerValue(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function points(
|
||||
value: unknown,
|
||||
dimensions: 2 | 3,
|
||||
label: string,
|
||||
): readonly (readonly number[])[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался массив точек.`);
|
||||
}
|
||||
return value.map((item, index) => (
|
||||
numberTuple(item, dimensions, `${label}[${index}]`)
|
||||
));
|
||||
}
|
||||
|
||||
function strings(value: unknown, label: string): readonly string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался массив строк.`);
|
||||
}
|
||||
return value.map((item, index) => stringValue(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function contentId(
|
||||
value: unknown,
|
||||
prefix: string,
|
||||
label: string,
|
||||
): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||
throw new E30ReviewContractError(`${label}: некорректный content id.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): E30ReviewResult {
|
||||
const source = record(value, "E30 result");
|
||||
const counts = record(source.stratum_counts, "E30 stratum_counts");
|
||||
const projection = record(source.projection, "E30 projection");
|
||||
const authority = record(source.authority, "E30 authority");
|
||||
falseValue(authority.commands_enabled, "E30 authority.commands_enabled");
|
||||
falseValue(
|
||||
authority.navigation_or_safety_accepted,
|
||||
"E30 authority.navigation_or_safety_accepted",
|
||||
);
|
||||
return {
|
||||
resultId: contentId(
|
||||
source.result_id,
|
||||
"e30-materialization",
|
||||
"E30 result_id",
|
||||
),
|
||||
createdAtUtc: source.created_at_utc === null
|
||||
? null
|
||||
: stringValue(source.created_at_utc, "E30 created_at_utc"),
|
||||
reviewPackId: contentId(
|
||||
source.review_pack_id,
|
||||
"e30-review-pack",
|
||||
"E30 review_pack_id",
|
||||
),
|
||||
e29ResultId: contentId(
|
||||
source.e29_result_id,
|
||||
"e29-camera-geometry",
|
||||
"E30 e29_result_id",
|
||||
),
|
||||
sourceSessionId: stringValue(
|
||||
source.source_session_id,
|
||||
"E30 source_session_id",
|
||||
),
|
||||
itemCount: integerValue(source.item_count, "E30 item_count"),
|
||||
stratumCounts: Object.fromEntries(E30_STRATA.map((stratum) => [
|
||||
stratum,
|
||||
integerValue(counts[stratum], `E30 stratum_counts.${stratum}`),
|
||||
])) as Record<E30Stratum, number>,
|
||||
reasonTaxonomy: strings(source.reason_taxonomy, "E30 reason_taxonomy"),
|
||||
projection: {
|
||||
width: integerValue(projection.width, "E30 projection.width"),
|
||||
height: integerValue(projection.height, "E30 projection.height"),
|
||||
sourceId: stringValue(projection.source_id, "E30 projection.source_id"),
|
||||
calibrationSlot: stringValue(
|
||||
projection.calibration_slot,
|
||||
"E30 projection.calibration_slot",
|
||||
),
|
||||
},
|
||||
cameraEvidenceAvailable: booleanValue(
|
||||
source.camera_evidence_available,
|
||||
"E30 camera_evidence_available",
|
||||
),
|
||||
humanReviewComplete: falseValue(
|
||||
source.human_review_complete,
|
||||
"E30 human_review_complete",
|
||||
),
|
||||
labPublished: falseValue(source.lab_published, "E30 lab_published"),
|
||||
access: readOnly(source.access, "E30 access"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSnapshot(value: unknown): E30ReviewSnapshot {
|
||||
const source = record(value, "E30 snapshot");
|
||||
const rawBbox = source.bbox_xyxy;
|
||||
return {
|
||||
label: nullableString(source.label ?? source.semantic_class, "E30 snapshot.label"),
|
||||
geometryStatus: stringValue(
|
||||
source.geometry_status,
|
||||
"E30 snapshot.geometry_status",
|
||||
),
|
||||
geometryReason: nullableString(
|
||||
source.geometry_reason,
|
||||
"E30 snapshot.geometry_reason",
|
||||
),
|
||||
rangeM: nullableNumber(source.range_m, "E30 snapshot.range_m"),
|
||||
nearestRangeM: nullableNumber(
|
||||
source.nearest_range_m,
|
||||
"E30 snapshot.nearest_range_m",
|
||||
),
|
||||
bboxXyxy: rawBbox === null || rawBbox === undefined
|
||||
? null
|
||||
: numberTuple(rawBbox, 4, "E30 snapshot.bbox_xyxy") as
|
||||
readonly [number, number, number, number],
|
||||
};
|
||||
}
|
||||
|
||||
function parseItem(value: unknown): E30ReviewItem {
|
||||
const source = record(value, "E30 item");
|
||||
const locator = record(source.locator, "E30 item.locator");
|
||||
const materialized = record(
|
||||
source.materialization,
|
||||
"E30 item.materialization",
|
||||
);
|
||||
const review = record(source.review, "E30 item.review");
|
||||
const triage = record(
|
||||
source.engineering_triage,
|
||||
"E30 item.engineering_triage",
|
||||
);
|
||||
const locatorKind = stringValue(locator.kind, "E30 item.locator.kind");
|
||||
if (
|
||||
locatorKind !== "semantic-observation"
|
||||
&& locatorKind !== "geometry-only-cluster"
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 item.locator.kind не поддерживается.");
|
||||
}
|
||||
if (review.state !== "unreviewed") {
|
||||
throw new E30ReviewContractError("E30 item уже изменён вне review gate.");
|
||||
}
|
||||
if (
|
||||
triage.provenance !== "deterministic-evidence-readiness/v1"
|
||||
|| (
|
||||
triage.state !== "ready-for-ai-review"
|
||||
&& triage.state !== "blocked-camera-frame-unavailable"
|
||||
)
|
||||
|| (triage.attention !== "standard" && triage.attention !== "elevated")
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 engineering triage не поддерживается.");
|
||||
}
|
||||
return {
|
||||
itemId: contentId(
|
||||
source.item_id,
|
||||
"e30-review-item",
|
||||
"E30 item.item_id",
|
||||
),
|
||||
sequence: integerValue(source.sequence, "E30 item.sequence"),
|
||||
reviewKey: stringValue(source.review_key, "E30 item.review_key"),
|
||||
stratum: stratumValue(source.stratum, "E30 item.stratum"),
|
||||
rangeBucket: stringValue(source.range_bucket, "E30 item.range_bucket"),
|
||||
frameIndex: integerValue(source.frame_index, "E30 item.frame_index"),
|
||||
sourceFrameIndex: integerValue(
|
||||
source.source_frame_index,
|
||||
"E30 item.source_frame_index",
|
||||
),
|
||||
sessionSeconds: numberValue(
|
||||
source.session_seconds,
|
||||
"E30 item.session_seconds",
|
||||
),
|
||||
locatorKind,
|
||||
snapshot: parseSnapshot(source.snapshot),
|
||||
materialization: {
|
||||
framePointCount: integerValue(
|
||||
materialized.frame_point_count,
|
||||
"E30 materialization.frame_point_count",
|
||||
),
|
||||
projectedPointCount: integerValue(
|
||||
materialized.projected_point_count,
|
||||
"E30 materialization.projected_point_count",
|
||||
),
|
||||
projectionWidth: integerValue(
|
||||
materialized.projection_width,
|
||||
"E30 materialization.projection_width",
|
||||
),
|
||||
projectionHeight: integerValue(
|
||||
materialized.projection_height,
|
||||
"E30 materialization.projection_height",
|
||||
),
|
||||
candidatePointCount: integerValue(
|
||||
materialized.candidate_point_count,
|
||||
"E30 materialization.candidate_point_count",
|
||||
),
|
||||
selectedPointCount: integerValue(
|
||||
materialized.selected_point_count,
|
||||
"E30 materialization.selected_point_count",
|
||||
),
|
||||
rejectedCandidatePointCount: integerValue(
|
||||
materialized.rejected_candidate_point_count,
|
||||
"E30 materialization.rejected_candidate_point_count",
|
||||
),
|
||||
selectedAndCandidateLossless: trueValue(
|
||||
materialized.selected_and_candidate_lossless,
|
||||
"E30 materialization.selected_and_candidate_lossless",
|
||||
),
|
||||
freeSpaceValid: falseValue(
|
||||
materialized.free_space_valid,
|
||||
"E30 materialization.free_space_valid",
|
||||
),
|
||||
humanReviewComplete: falseValue(
|
||||
materialized.human_review_complete,
|
||||
"E30 materialization.human_review_complete",
|
||||
),
|
||||
detectorScore: nullableNumber(
|
||||
materialized.detector_score,
|
||||
"E30 materialization.detector_score",
|
||||
),
|
||||
},
|
||||
cameraFrameAvailable: booleanValue(
|
||||
source.camera_frame_available,
|
||||
"E30 item.camera_frame_available",
|
||||
),
|
||||
engineeringTriage: {
|
||||
provenance: "deterministic-evidence-readiness/v1",
|
||||
state: triage.state,
|
||||
attention: triage.attention,
|
||||
signals: strings(triage.signals, "E30 engineering_triage.signals"),
|
||||
semanticVerdict: nullValue(
|
||||
triage.semantic_verdict,
|
||||
"E30 engineering_triage.semantic_verdict",
|
||||
),
|
||||
humanExceptionRequired: nullValue(
|
||||
triage.human_exception_required,
|
||||
"E30 engineering_triage.human_exception_required",
|
||||
),
|
||||
},
|
||||
review: {
|
||||
state: "unreviewed",
|
||||
reasonCode: nullValue(review.reason_code, "E30 review.reason_code"),
|
||||
notes: nullValue(review.notes, "E30 review.notes"),
|
||||
},
|
||||
access: readOnly(source.access, "E30 item.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30ReviewItem(value: unknown): E30ReviewItem {
|
||||
return parseItem(value);
|
||||
}
|
||||
|
||||
export function parseE30ReviewCatalog(value: unknown): E30ReviewCatalog {
|
||||
const source = record(value, "E30 catalog");
|
||||
if (source.schema_version !== "missioncore.laboratory-e30-catalog/v1") {
|
||||
throw new E30ReviewContractError("E30 catalog schema не поддерживается.");
|
||||
}
|
||||
if (!Array.isArray(source.items)) {
|
||||
throw new E30ReviewContractError("E30 catalog.items имеет неверный формат.");
|
||||
}
|
||||
readOnly(source.access, "E30 catalog.access");
|
||||
return {
|
||||
configured: booleanValue(source.configured, "E30 catalog.configured"),
|
||||
candidateTotal: integerValue(
|
||||
source.candidate_total,
|
||||
"E30 catalog.candidate_total",
|
||||
),
|
||||
invalidTotal: integerValue(source.invalid_total, "E30 catalog.invalid_total"),
|
||||
items: source.items.map(parseResult),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30ReviewItems(value: unknown): E30ReviewItems {
|
||||
const source = record(value, "E30 items");
|
||||
if (
|
||||
source.schema_version !== "missioncore.laboratory-e30-items/v1"
|
||||
|| !Array.isArray(source.items)
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 items schema не поддерживается.");
|
||||
}
|
||||
readOnly(source.access, "E30 items.access");
|
||||
return {
|
||||
resultId: stringValue(source.result_id, "E30 items.result_id"),
|
||||
stratum: stratumValue(source.stratum, "E30 items.stratum"),
|
||||
items: source.items.map(parseItem),
|
||||
total: integerValue(source.total, "E30 items.total"),
|
||||
nextCursor: source.next_cursor === null
|
||||
? null
|
||||
: integerValue(source.next_cursor, "E30 items.next_cursor"),
|
||||
reasonTaxonomy: strings(source.reason_taxonomy, "E30 items.reason_taxonomy"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30ReviewItemDetail(value: unknown): E30ReviewItemDetail {
|
||||
const response = record(value, "E30 item detail response");
|
||||
if (response.schema_version !== "missioncore.laboratory-e30-item-detail/v1") {
|
||||
throw new E30ReviewContractError("E30 detail schema не поддерживается.");
|
||||
}
|
||||
readOnly(response.access, "E30 detail access");
|
||||
const source = record(response.item, "E30 item detail");
|
||||
const item = parseItem(source);
|
||||
const selected = record(source.selected, "E30 selected");
|
||||
const candidate = record(source.candidate, "E30 candidate");
|
||||
const projection = record(source.projection, "E30 projection detail");
|
||||
const pose = record(source.pose, "E30 pose");
|
||||
const cameraFrame = source.camera_frame === null
|
||||
? null
|
||||
: record(source.camera_frame, "E30 camera_frame");
|
||||
const projectedLength = integerArray(
|
||||
projection.source_indices,
|
||||
"E30 projection.source_indices",
|
||||
).length;
|
||||
const projectionCollections = [
|
||||
projection.points_map_xyz_m,
|
||||
projection.pixels_xy,
|
||||
projection.depth_m,
|
||||
projection.point_class,
|
||||
projection.point_height_m,
|
||||
projection.candidate_mask,
|
||||
projection.selected_mask,
|
||||
];
|
||||
if (
|
||||
projectionCollections.some(
|
||||
(collection) => !Array.isArray(collection) || collection.length !== projectedLength,
|
||||
)
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 projection arrays имеют разную длину.");
|
||||
}
|
||||
const candidateMask = integerArray(
|
||||
projection.candidate_mask,
|
||||
"E30 projection.candidate_mask",
|
||||
);
|
||||
const selectedMask = integerArray(
|
||||
projection.selected_mask,
|
||||
"E30 projection.selected_mask",
|
||||
);
|
||||
if (
|
||||
candidateMask.some((itemValue) => itemValue !== 0 && itemValue !== 1)
|
||||
|| selectedMask.some((itemValue) => itemValue !== 0 && itemValue !== 1)
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 projection mask должна быть бинарной.");
|
||||
}
|
||||
const selectedSourceIndices = integerArray(
|
||||
selected.source_indices,
|
||||
"E30 selected.source_indices",
|
||||
);
|
||||
const selectedPoints = points(
|
||||
selected.points_map_xyz_m,
|
||||
3,
|
||||
"E30 selected.points_map_xyz_m",
|
||||
) as readonly (readonly [number, number, number])[];
|
||||
const candidateSourceIndices = integerArray(
|
||||
candidate.source_indices,
|
||||
"E30 candidate.source_indices",
|
||||
);
|
||||
const candidatePoints = points(
|
||||
candidate.points_map_xyz_m,
|
||||
3,
|
||||
"E30 candidate.points_map_xyz_m",
|
||||
) as readonly (readonly [number, number, number])[];
|
||||
if (
|
||||
selectedSourceIndices.length !== selectedPoints.length
|
||||
|| candidateSourceIndices.length !== candidatePoints.length
|
||||
|| selectedSourceIndices.length !== item.materialization.selectedPointCount
|
||||
|| candidateSourceIndices.length !== item.materialization.candidatePointCount
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 selected/candidate arrays расходятся.");
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
cameraFrame: cameraFrame === null
|
||||
? null
|
||||
: {
|
||||
available: trueValue(
|
||||
cameraFrame.available,
|
||||
"E30 camera_frame.available",
|
||||
),
|
||||
url: stringValue(cameraFrame.url, "E30 camera_frame.url"),
|
||||
sha256: stringValue(cameraFrame.sha256, "E30 camera_frame.sha256"),
|
||||
width: integerValue(cameraFrame.width, "E30 camera_frame.width"),
|
||||
height: integerValue(cameraFrame.height, "E30 camera_frame.height"),
|
||||
sourceFrameIndex: integerValue(
|
||||
cameraFrame.source_frame_index,
|
||||
"E30 camera_frame.source_frame_index",
|
||||
),
|
||||
exactSourceFrame: trueValue(
|
||||
cameraFrame.exact_source_frame,
|
||||
"E30 camera_frame.exact_source_frame",
|
||||
),
|
||||
},
|
||||
selected: {
|
||||
sourceIndices: selectedSourceIndices,
|
||||
pointsMapXyzM: selectedPoints,
|
||||
},
|
||||
candidate: {
|
||||
sourceIndices: candidateSourceIndices,
|
||||
pointsMapXyzM: candidatePoints,
|
||||
},
|
||||
projection: {
|
||||
sourceIndices: integerArray(
|
||||
projection.source_indices,
|
||||
"E30 projection.source_indices",
|
||||
),
|
||||
pointsMapXyzM: points(
|
||||
projection.points_map_xyz_m,
|
||||
3,
|
||||
"E30 projection.points_map_xyz_m",
|
||||
) as readonly (readonly [number, number, number])[],
|
||||
pixelsXy: points(
|
||||
projection.pixels_xy,
|
||||
2,
|
||||
"E30 projection.pixels_xy",
|
||||
) as readonly (readonly [number, number])[],
|
||||
depthM: numberArray(projection.depth_m, "E30 projection.depth_m"),
|
||||
pointClass: integerArray(
|
||||
projection.point_class,
|
||||
"E30 projection.point_class",
|
||||
),
|
||||
pointHeightM: numberArray(
|
||||
projection.point_height_m,
|
||||
"E30 projection.point_height_m",
|
||||
),
|
||||
candidateMask,
|
||||
selectedMask,
|
||||
},
|
||||
pose: {
|
||||
positionMapXyzM: numberTuple(
|
||||
pose.position_map_xyz_m,
|
||||
3,
|
||||
"E30 pose.position_map_xyz_m",
|
||||
) as readonly [number, number, number],
|
||||
orientationMapFromLidarXyzw: numberTuple(
|
||||
pose.orientation_map_from_lidar_xyzw,
|
||||
4,
|
||||
"E30 pose.orientation_map_from_lidar_xyzw",
|
||||
) as readonly [number, number, number, number],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function responseJson(response: Response, fallback: string): Promise<unknown> {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
// Preserve the status-aware fallback below.
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail?: unknown }).detail)
|
||||
: fallback;
|
||||
throw new E30ReviewContractError(detail);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function validResultId(value: string): boolean {
|
||||
return /^e30-materialization-[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
function validItemId(value: string): boolean {
|
||||
return /^e30-review-item-[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
export async function fetchE30ReviewCatalog(
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30ReviewCatalog> {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher("/api/v1/laboratory/e30/reviews?limit=1", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
});
|
||||
return parseE30ReviewCatalog(
|
||||
await responseJson(response, "Не удалось получить LAB E30."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchE30ReviewItems(
|
||||
resultId: string,
|
||||
stratum: E30Stratum,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30ReviewItems> {
|
||||
if (!validResultId(resultId) || !E30_STRATA.includes(stratum)) {
|
||||
throw new E30ReviewContractError("Некорректный E30 review query.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/items?stratum=${stratum}&limit=128&cursor=0`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30ReviewItems(
|
||||
await responseJson(response, "Не удалось получить выборку LAB E30."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchE30ReviewItemDetail(
|
||||
resultId: string,
|
||||
itemId: string,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30ReviewItemDetail> {
|
||||
if (!validResultId(resultId) || !validItemId(itemId)) {
|
||||
throw new E30ReviewContractError("Некорректный E30 review item id.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/items/${itemId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30ReviewItemDetail(
|
||||
await responseJson(response, "Не удалось получить доказательство LAB E30."),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
export type E34TemporalState = "current" | "held" | "expired";
|
||||
export type E34OccupancyState = "occupied" | "unknown";
|
||||
export type E34OwnerKind = "camera-track" | "geometry-cluster";
|
||||
export type E34Point3 = readonly [number, number, number];
|
||||
|
||||
export interface E34TemporalHistoryPoint {
|
||||
frameIndex: number;
|
||||
sessionSeconds: number;
|
||||
centroidMapXyzM: E34Point3;
|
||||
}
|
||||
|
||||
export interface E34TemporalComponent {
|
||||
temporalId: number;
|
||||
state: E34TemporalState;
|
||||
occupancyState: E34OccupancyState;
|
||||
ownerKind: E34OwnerKind;
|
||||
centroidMapXyzM: E34Point3;
|
||||
lastObservedAgeSeconds: number;
|
||||
associationReason: string;
|
||||
history: readonly E34TemporalHistoryPoint[];
|
||||
}
|
||||
|
||||
export interface E34TemporalReviewFrame {
|
||||
frameIndex: number;
|
||||
sessionSeconds: number;
|
||||
sourceAvailable: boolean;
|
||||
layerState: "current" | "held" | "unknown";
|
||||
counts: {
|
||||
current: number;
|
||||
held: number;
|
||||
expired: number;
|
||||
};
|
||||
cellCentersMapXyzM: readonly E34Point3[];
|
||||
components: readonly E34TemporalComponent[];
|
||||
}
|
||||
|
||||
export interface E34TemporalLayerResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
sourceSessionId: string;
|
||||
status: "accepted-bounded-occupied-unknown-temporal-layer";
|
||||
e32ResultId: string;
|
||||
e33ResultId: string;
|
||||
profileId: string;
|
||||
pipelineId: string;
|
||||
coordinateFrame: "map";
|
||||
configuration: {
|
||||
voxelSizeM: number;
|
||||
occupiedTtlSeconds: number;
|
||||
maximumActiveComponents: number;
|
||||
maximumCellsPerComponent: number;
|
||||
geometryMaximumGapSeconds: number;
|
||||
geometryMaximumCentroidDistanceM: number;
|
||||
};
|
||||
metrics: {
|
||||
sourceFrames: number;
|
||||
processedFrames: number;
|
||||
framesWithCurrentLayer: number;
|
||||
heldOnlyFrames: number;
|
||||
unknownEmptyFrames: number;
|
||||
createdComponents: number;
|
||||
cameraCreatedComponents: number;
|
||||
geometryCreatedComponents: number;
|
||||
geometryReassociatedComponents: number;
|
||||
exactCameraAssociations: number;
|
||||
geometrySpatialReassociations: number;
|
||||
heldPublications: number;
|
||||
expiredComponents: number;
|
||||
peakActiveComponents: number;
|
||||
maximumObservedSpanSeconds: number;
|
||||
continuityFraction: number;
|
||||
geometryOnlyReassociationFraction: number;
|
||||
e32CurrentPointRows: number;
|
||||
e34ConsumedCurrentPointRows: number;
|
||||
currentCellRows: number;
|
||||
heldCellRows: number;
|
||||
storedCellRows: number;
|
||||
freeCellRows: number;
|
||||
peakCellsPerComponent: number;
|
||||
maximumHeldAgeSeconds: number;
|
||||
maximumExpiryDelaySeconds: number;
|
||||
maximumReplayMaterializationDelaySeconds: number;
|
||||
mapFrameJumpCandidates: number;
|
||||
frameProcessingP95Ms: number;
|
||||
buildElapsedMs: number;
|
||||
};
|
||||
reviewFrames: readonly E34TemporalReviewFrame[];
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
class E34TemporalLayerContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "E34TemporalLayerContractError";
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E34TemporalLayerContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function list(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E34TemporalLayerContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E34TemporalLayerContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, label: string): string | null {
|
||||
return value === null ? null : stringValue(value, label);
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new E34TemporalLayerContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
const parsed = finiteNumber(value, label);
|
||||
if (parsed < 0) {
|
||||
throw new E34TemporalLayerContractError(`${label}: число отрицательно.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
const parsed = numberValue(value, label);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new E34TemporalLayerContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new E34TemporalLayerContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exactString<T extends string>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) {
|
||||
throw new E34TemporalLayerContractError(`${label}: неверное значение.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function oneOf<T extends string>(
|
||||
value: unknown,
|
||||
expected: readonly T[],
|
||||
label: string,
|
||||
): T {
|
||||
if (typeof value !== "string" || !expected.includes(value as T)) {
|
||||
throw new E34TemporalLayerContractError(`${label}: неверное значение.`);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function contentId(value: unknown, prefix: string, label: string): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||
throw new E34TemporalLayerContractError(`${label}: неверный content id.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function point(value: unknown, label: string): E34Point3 {
|
||||
const coordinates = list(value, label);
|
||||
if (coordinates.length !== 3) {
|
||||
throw new E34TemporalLayerContractError(`${label}: ожидались XYZ.`);
|
||||
}
|
||||
return [
|
||||
finiteNumber(coordinates[0], `${label}[0]`),
|
||||
finiteNumber(coordinates[1], `${label}[1]`),
|
||||
finiteNumber(coordinates[2], `${label}[2]`),
|
||||
];
|
||||
}
|
||||
|
||||
function parseHistory(value: unknown, label: string): E34TemporalHistoryPoint {
|
||||
const item = record(value, label);
|
||||
return {
|
||||
frameIndex: integerValue(item.frame_index, `${label}.frame_index`),
|
||||
sessionSeconds: numberValue(
|
||||
item.session_seconds,
|
||||
`${label}.session_seconds`,
|
||||
),
|
||||
centroidMapXyzM: point(
|
||||
item.centroid_map_xyz_m,
|
||||
`${label}.centroid_map_xyz_m`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseComponent(value: unknown, label: string): E34TemporalComponent {
|
||||
const item = record(value, label);
|
||||
return {
|
||||
temporalId: integerValue(item.temporal_id, `${label}.temporal_id`),
|
||||
state: oneOf(
|
||||
item.state,
|
||||
["current", "held", "expired"] as const,
|
||||
`${label}.state`,
|
||||
),
|
||||
occupancyState: oneOf(
|
||||
item.occupancy_state,
|
||||
["occupied", "unknown"] as const,
|
||||
`${label}.occupancy_state`,
|
||||
),
|
||||
ownerKind: oneOf(
|
||||
item.owner_kind,
|
||||
["camera-track", "geometry-cluster"] as const,
|
||||
`${label}.owner_kind`,
|
||||
),
|
||||
centroidMapXyzM: point(
|
||||
item.centroid_map_xyz_m,
|
||||
`${label}.centroid_map_xyz_m`,
|
||||
),
|
||||
lastObservedAgeSeconds: numberValue(
|
||||
item.last_observed_age_seconds,
|
||||
`${label}.last_observed_age_seconds`,
|
||||
),
|
||||
associationReason: stringValue(
|
||||
item.association_reason,
|
||||
`${label}.association_reason`,
|
||||
),
|
||||
history: list(item.history_tail, `${label}.history_tail`).map(
|
||||
(entry, index) => parseHistory(entry, `${label}.history_tail[${index}]`),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseReviewFrame(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E34TemporalReviewFrame {
|
||||
const item = record(value, label);
|
||||
const counts = record(item.counts, `${label}.counts`);
|
||||
return {
|
||||
frameIndex: integerValue(item.frame_index, `${label}.frame_index`),
|
||||
sessionSeconds: numberValue(
|
||||
item.session_seconds,
|
||||
`${label}.session_seconds`,
|
||||
),
|
||||
sourceAvailable: booleanValue(
|
||||
item.source_available,
|
||||
`${label}.source_available`,
|
||||
),
|
||||
layerState: oneOf(
|
||||
item.layer_state,
|
||||
["current", "held", "unknown"] as const,
|
||||
`${label}.layer_state`,
|
||||
),
|
||||
counts: {
|
||||
current: integerValue(counts.current, `${label}.counts.current`),
|
||||
held: integerValue(counts.held, `${label}.counts.held`),
|
||||
expired: integerValue(counts.expired, `${label}.counts.expired`),
|
||||
},
|
||||
cellCentersMapXyzM: list(
|
||||
item.cell_centers_map_xyz_m,
|
||||
`${label}.cell_centers_map_xyz_m`,
|
||||
).map((entry, index) => point(
|
||||
entry,
|
||||
`${label}.cell_centers_map_xyz_m[${index}]`,
|
||||
)),
|
||||
components: list(item.components, `${label}.components`).map(
|
||||
(entry, index) => parseComponent(
|
||||
entry,
|
||||
`${label}.components[${index}]`,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function diagnosticAuthority(value: unknown, label: string): void {
|
||||
const authority = record(value, label);
|
||||
if (
|
||||
authority.commands_enabled !== false
|
||||
|| authority.navigation_or_safety_accepted !== false
|
||||
) {
|
||||
throw new E34TemporalLayerContractError(
|
||||
`${label}: запрещённые полномочия.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function metricRecord(
|
||||
value: Record<string, unknown>,
|
||||
): E34TemporalLayerResult["metrics"] {
|
||||
const metric = (key: string) => numberValue(
|
||||
value[key],
|
||||
`E34.metrics.${key}`,
|
||||
);
|
||||
const integer = (key: string) => integerValue(
|
||||
value[key],
|
||||
`E34.metrics.${key}`,
|
||||
);
|
||||
return {
|
||||
sourceFrames: integer("source_frames"),
|
||||
processedFrames: integer("processed_frames"),
|
||||
framesWithCurrentLayer: integer("frames_with_current_layer"),
|
||||
heldOnlyFrames: integer("held_only_frames"),
|
||||
unknownEmptyFrames: integer("unknown_empty_frames"),
|
||||
createdComponents: integer("created_components"),
|
||||
cameraCreatedComponents: integer("camera_created_components"),
|
||||
geometryCreatedComponents: integer("geometry_created_components"),
|
||||
geometryReassociatedComponents: integer("geometry_reassociated_components"),
|
||||
exactCameraAssociations: integer("exact_camera_associations"),
|
||||
geometrySpatialReassociations: integer("geometry_spatial_reassociations"),
|
||||
heldPublications: integer("held_publications"),
|
||||
expiredComponents: integer("expired_components"),
|
||||
peakActiveComponents: integer("peak_active_components"),
|
||||
maximumObservedSpanSeconds: metric("maximum_observed_span_seconds"),
|
||||
continuityFraction: metric("continuity_fraction"),
|
||||
geometryOnlyReassociationFraction: metric(
|
||||
"geometry_only_reassociation_fraction",
|
||||
),
|
||||
e32CurrentPointRows: integer("e32_current_point_rows"),
|
||||
e34ConsumedCurrentPointRows: integer("e34_consumed_current_point_rows"),
|
||||
currentCellRows: integer("current_cell_rows"),
|
||||
heldCellRows: integer("held_cell_rows"),
|
||||
storedCellRows: integer("stored_cell_rows"),
|
||||
freeCellRows: integer("free_cell_rows"),
|
||||
peakCellsPerComponent: integer("peak_cells_per_component"),
|
||||
maximumHeldAgeSeconds: metric("maximum_held_age_seconds"),
|
||||
maximumExpiryDelaySeconds: metric("maximum_expiry_delay_seconds"),
|
||||
maximumReplayMaterializationDelaySeconds: metric(
|
||||
"maximum_replay_materialization_delay_seconds",
|
||||
),
|
||||
mapFrameJumpCandidates: integer("map_frame_jump_candidates"),
|
||||
frameProcessingP95Ms: metric("frame_processing_p95_ms"),
|
||||
buildElapsedMs: metric("build_elapsed_ms"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): E34TemporalLayerResult {
|
||||
const item = record(value, "E34");
|
||||
const configuration = record(item.configuration, "E34.configuration");
|
||||
const review = record(item.review, "E34.review");
|
||||
const acceptance = record(item.acceptance, "E34.acceptance");
|
||||
diagnosticAuthority(item.authority, "E34.authority");
|
||||
if (acceptance.accepted !== true) {
|
||||
throw new E34TemporalLayerContractError("E34.acceptance: результат отклонён.");
|
||||
}
|
||||
exactString(
|
||||
review.schema_version,
|
||||
"missioncore.e34-temporal-review-timeline/v1",
|
||||
"E34.review.schema_version",
|
||||
);
|
||||
return {
|
||||
resultId: contentId(
|
||||
item.result_id,
|
||||
"e34-temporal-occupied",
|
||||
"E34.result_id",
|
||||
),
|
||||
createdAtUtc: optionalString(item.created_at_utc, "E34.created_at_utc"),
|
||||
sourceSessionId: stringValue(
|
||||
item.source_session_id,
|
||||
"E34.source_session_id",
|
||||
),
|
||||
status: exactString(
|
||||
item.status,
|
||||
"accepted-bounded-occupied-unknown-temporal-layer",
|
||||
"E34.status",
|
||||
),
|
||||
e32ResultId: contentId(
|
||||
item.e32_result_id,
|
||||
"e32-track-geometry",
|
||||
"E34.e32_result_id",
|
||||
),
|
||||
e33ResultId: contentId(
|
||||
item.e33_result_id,
|
||||
"e33-worker-shadow",
|
||||
"E34.e33_result_id",
|
||||
),
|
||||
profileId: stringValue(item.profile_id, "E34.profile_id"),
|
||||
pipelineId: stringValue(item.pipeline_id, "E34.pipeline_id"),
|
||||
coordinateFrame: exactString(
|
||||
item.coordinate_frame,
|
||||
"map",
|
||||
"E34.coordinate_frame",
|
||||
),
|
||||
configuration: {
|
||||
voxelSizeM: numberValue(
|
||||
configuration.voxel_size_m,
|
||||
"E34.configuration.voxel_size_m",
|
||||
),
|
||||
occupiedTtlSeconds: numberValue(
|
||||
configuration.occupied_ttl_seconds,
|
||||
"E34.configuration.occupied_ttl_seconds",
|
||||
),
|
||||
maximumActiveComponents: integerValue(
|
||||
configuration.maximum_active_components,
|
||||
"E34.configuration.maximum_active_components",
|
||||
),
|
||||
maximumCellsPerComponent: integerValue(
|
||||
configuration.maximum_cells_per_component,
|
||||
"E34.configuration.maximum_cells_per_component",
|
||||
),
|
||||
geometryMaximumGapSeconds: numberValue(
|
||||
configuration.geometry_maximum_gap_seconds,
|
||||
"E34.configuration.geometry_maximum_gap_seconds",
|
||||
),
|
||||
geometryMaximumCentroidDistanceM: numberValue(
|
||||
configuration.geometry_maximum_centroid_distance_m,
|
||||
"E34.configuration.geometry_maximum_centroid_distance_m",
|
||||
),
|
||||
},
|
||||
metrics: metricRecord(record(item.metrics, "E34.metrics")),
|
||||
reviewFrames: list(review.frames, "E34.review.frames").map(
|
||||
(entry, index) => parseReviewFrame(
|
||||
entry,
|
||||
`E34.review.frames[${index}]`,
|
||||
),
|
||||
),
|
||||
access: exactString(item.access, "read-only", "E34.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE34TemporalLayerResult({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E34TemporalLayerResult | null> {
|
||||
const response = await fetcher(
|
||||
"/api/v1/laboratory/e34/results?limit=1",
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new E34TemporalLayerContractError(
|
||||
`Каталог LAB E34 недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const catalog = record(await response.json(), "Каталог LAB E34");
|
||||
exactString(
|
||||
catalog.schema_version,
|
||||
"missioncore.laboratory-advanced-catalog/v1",
|
||||
"Каталог LAB E34.schema_version",
|
||||
);
|
||||
booleanValue(catalog.configured, "Каталог LAB E34.configured");
|
||||
integerValue(catalog.candidate_total, "Каталог LAB E34.candidate_total");
|
||||
integerValue(catalog.invalid_total, "Каталог LAB E34.invalid_total");
|
||||
exactString(
|
||||
catalog.access,
|
||||
"read-only",
|
||||
"Каталог LAB E34.access",
|
||||
);
|
||||
const items = list(catalog.items, "Каталог LAB E34.items");
|
||||
if (items.length > 1) {
|
||||
throw new E34TemporalLayerContractError(
|
||||
"Каталог LAB E34: нарушен limit=1.",
|
||||
);
|
||||
}
|
||||
return items.length ? parseResult(items[0]) : null;
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
import type {
|
||||
E34OccupancyState,
|
||||
E34OwnerKind,
|
||||
E34Point3,
|
||||
E34TemporalState,
|
||||
} from "./e34TemporalLayer";
|
||||
|
||||
export type E35DegradationKind =
|
||||
| "camera-loss"
|
||||
| "lidar-loss"
|
||||
| "pose-staleness"
|
||||
| "delayed-frames"
|
||||
| "bounded-drop"
|
||||
| "timing-offset";
|
||||
|
||||
export type E35FaultPhase = "before" | "during" | "after";
|
||||
|
||||
export interface E35ScenarioMetrics {
|
||||
scenarioId: E35DegradationKind;
|
||||
kind: E35DegradationKind;
|
||||
frameStart: number;
|
||||
frameEnd: number;
|
||||
framesProcessed: number;
|
||||
injectedFrames: number;
|
||||
droppedFrames: number;
|
||||
hiddenSuccessFrames: number;
|
||||
falseFreeRows: number;
|
||||
semanticClaimsDuringCameraLoss: number;
|
||||
metricRowsDuringLidarOrPoseLoss: number;
|
||||
agreeClaimsDuringTimingOffset: number;
|
||||
lateResultsReintroduced: number;
|
||||
maximumCurrentComponentsDuringFault: number;
|
||||
maximumHeldComponentsDuringFault: number;
|
||||
expiredComponentsDuringFault: number;
|
||||
recoveryFrameIndex: number;
|
||||
recoverySeconds: number;
|
||||
}
|
||||
|
||||
export interface E35TemporalComponent {
|
||||
temporalId: number;
|
||||
state: E34TemporalState;
|
||||
occupancyState: E34OccupancyState;
|
||||
ownerKind: E34OwnerKind;
|
||||
semanticLabels: readonly string[];
|
||||
centroidMapXyzM: E34Point3;
|
||||
lastObservedAgeSeconds: number;
|
||||
}
|
||||
|
||||
export interface E35RecoveryReviewFrame {
|
||||
scenarioId: E35DegradationKind;
|
||||
kind: E35DegradationKind;
|
||||
frameIndex: number;
|
||||
sessionSeconds: number;
|
||||
faultPhase: E35FaultPhase;
|
||||
action: string;
|
||||
channels: Readonly<Record<string, string>>;
|
||||
inputPointRows: number;
|
||||
transformedPointRows: number;
|
||||
layerState: "current" | "held" | "unknown";
|
||||
counts: {
|
||||
current: number;
|
||||
held: number;
|
||||
expired: number;
|
||||
};
|
||||
components: readonly E35TemporalComponent[];
|
||||
cellCentersMapXyzM: readonly E34Point3[];
|
||||
}
|
||||
|
||||
export interface E35RecoveryReviewScenario {
|
||||
scenarioId: E35DegradationKind;
|
||||
kind: E35DegradationKind;
|
||||
frameStart: number;
|
||||
frameEnd: number;
|
||||
frames: readonly E35RecoveryReviewFrame[];
|
||||
}
|
||||
|
||||
export interface E35DegradationRecoveryResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
sourceSessionId: string;
|
||||
status: "accepted-deterministic-degradation-recovery";
|
||||
e32ResultId: string;
|
||||
e33ResultId: string;
|
||||
e34ResultId: string;
|
||||
profileId: string;
|
||||
pipelineId: string;
|
||||
coordinateFrame: "map";
|
||||
configuration: {
|
||||
maximumRecoverySeconds: number;
|
||||
scenarioCount: number;
|
||||
};
|
||||
metrics: {
|
||||
sourceFrames: number;
|
||||
variantCount: number;
|
||||
variantFrameOutcomes: number;
|
||||
injectionRecords: number;
|
||||
variantFrameProcessingP95Ms: number;
|
||||
buildElapsedMs: number;
|
||||
};
|
||||
scenarios: readonly E35ScenarioMetrics[];
|
||||
reviewScenarios: readonly E35RecoveryReviewScenario[];
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
class E35DegradationRecoveryContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "E35DegradationRecoveryContractError";
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: ожидался объект.`,
|
||||
);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function list(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: ожидался массив.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: ожидалась строка.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, label: string): string | null {
|
||||
return value === null ? null : stringValue(value, label);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: ожидалось неотрицательное число.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
const parsed = numberValue(value, label);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: ожидалось целое число.`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function exactString<T extends string>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: неверное значение.`,
|
||||
);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function oneOf<T extends string>(
|
||||
value: unknown,
|
||||
expected: readonly T[],
|
||||
label: string,
|
||||
): T {
|
||||
if (typeof value !== "string" || !expected.includes(value as T)) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: неверное значение.`,
|
||||
);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function contentId(value: unknown, prefix: string, label: string): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: неверный content id.`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function point(value: unknown, label: string): E34Point3 {
|
||||
const coordinates = list(value, label);
|
||||
if (coordinates.length !== 3) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: ожидались XYZ.`,
|
||||
);
|
||||
}
|
||||
const result = coordinates.map(
|
||||
(coordinate, index) => {
|
||||
if (typeof coordinate !== "number" || !Number.isFinite(coordinate)) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}[${index}]: ожидалось число.`,
|
||||
);
|
||||
}
|
||||
return coordinate;
|
||||
},
|
||||
);
|
||||
return [result[0]!, result[1]!, result[2]!];
|
||||
}
|
||||
|
||||
const DEGRADATION_KINDS = [
|
||||
"camera-loss",
|
||||
"lidar-loss",
|
||||
"pose-staleness",
|
||||
"delayed-frames",
|
||||
"bounded-drop",
|
||||
"timing-offset",
|
||||
] as const;
|
||||
|
||||
function degradationKind(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E35DegradationKind {
|
||||
return oneOf(value, DEGRADATION_KINDS, label);
|
||||
}
|
||||
|
||||
function parseComponent(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E35TemporalComponent {
|
||||
const item = record(value, label);
|
||||
return {
|
||||
temporalId: integerValue(item.temporal_id, `${label}.temporal_id`),
|
||||
state: oneOf(
|
||||
item.state,
|
||||
["current", "held", "expired"] as const,
|
||||
`${label}.state`,
|
||||
),
|
||||
occupancyState: oneOf(
|
||||
item.occupancy_state,
|
||||
["occupied", "unknown"] as const,
|
||||
`${label}.occupancy_state`,
|
||||
),
|
||||
ownerKind: oneOf(
|
||||
item.owner_kind,
|
||||
["camera-track", "geometry-cluster"] as const,
|
||||
`${label}.owner_kind`,
|
||||
),
|
||||
semanticLabels: list(
|
||||
item.semantic_labels,
|
||||
`${label}.semantic_labels`,
|
||||
).map((entry, index) => stringValue(
|
||||
entry,
|
||||
`${label}.semantic_labels[${index}]`,
|
||||
)),
|
||||
centroidMapXyzM: point(
|
||||
item.centroid_map_xyz_m,
|
||||
`${label}.centroid_map_xyz_m`,
|
||||
),
|
||||
lastObservedAgeSeconds: numberValue(
|
||||
item.last_observed_age_seconds,
|
||||
`${label}.last_observed_age_seconds`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseReviewFrame(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E35RecoveryReviewFrame {
|
||||
const item = record(value, label);
|
||||
const counts = record(item.counts, `${label}.counts`);
|
||||
const channels = record(item.channels, `${label}.channels`);
|
||||
return {
|
||||
scenarioId: degradationKind(
|
||||
item.scenario_id,
|
||||
`${label}.scenario_id`,
|
||||
),
|
||||
kind: degradationKind(item.kind, `${label}.kind`),
|
||||
frameIndex: integerValue(item.frame_index, `${label}.frame_index`),
|
||||
sessionSeconds: numberValue(
|
||||
item.session_seconds,
|
||||
`${label}.session_seconds`,
|
||||
),
|
||||
faultPhase: oneOf(
|
||||
item.fault_phase,
|
||||
["before", "during", "after"] as const,
|
||||
`${label}.fault_phase`,
|
||||
),
|
||||
action: stringValue(item.action, `${label}.action`),
|
||||
channels: Object.fromEntries(
|
||||
Object.entries(channels).map(([key, entry]) => [
|
||||
key,
|
||||
stringValue(entry, `${label}.channels.${key}`),
|
||||
]),
|
||||
),
|
||||
inputPointRows: integerValue(
|
||||
item.input_point_rows,
|
||||
`${label}.input_point_rows`,
|
||||
),
|
||||
transformedPointRows: integerValue(
|
||||
item.transformed_point_rows,
|
||||
`${label}.transformed_point_rows`,
|
||||
),
|
||||
layerState: oneOf(
|
||||
item.layer_state,
|
||||
["current", "held", "unknown"] as const,
|
||||
`${label}.layer_state`,
|
||||
),
|
||||
counts: {
|
||||
current: integerValue(counts.current, `${label}.counts.current`),
|
||||
held: integerValue(counts.held, `${label}.counts.held`),
|
||||
expired: integerValue(counts.expired, `${label}.counts.expired`),
|
||||
},
|
||||
components: list(item.components, `${label}.components`).map(
|
||||
(entry, index) => parseComponent(
|
||||
entry,
|
||||
`${label}.components[${index}]`,
|
||||
),
|
||||
),
|
||||
cellCentersMapXyzM: list(
|
||||
item.cell_centers_map_xyz_m,
|
||||
`${label}.cell_centers_map_xyz_m`,
|
||||
).map((entry, index) => point(
|
||||
entry,
|
||||
`${label}.cell_centers_map_xyz_m[${index}]`,
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
function parseReviewScenario(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E35RecoveryReviewScenario {
|
||||
const item = record(value, label);
|
||||
const scenario = record(item.scenario, `${label}.scenario`);
|
||||
return {
|
||||
scenarioId: degradationKind(
|
||||
scenario.scenario_id,
|
||||
`${label}.scenario.scenario_id`,
|
||||
),
|
||||
kind: degradationKind(
|
||||
scenario.kind,
|
||||
`${label}.scenario.kind`,
|
||||
),
|
||||
frameStart: integerValue(
|
||||
scenario.frame_start,
|
||||
`${label}.scenario.frame_start`,
|
||||
),
|
||||
frameEnd: integerValue(
|
||||
scenario.frame_end,
|
||||
`${label}.scenario.frame_end`,
|
||||
),
|
||||
frames: list(item.frames, `${label}.frames`).map(
|
||||
(entry, index) => parseReviewFrame(
|
||||
entry,
|
||||
`${label}.frames[${index}]`,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseScenarioMetrics(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E35ScenarioMetrics {
|
||||
const item = record(value, label);
|
||||
const integer = (key: string) => integerValue(item[key], `${label}.${key}`);
|
||||
return {
|
||||
scenarioId: degradationKind(item.scenario_id, `${label}.scenario_id`),
|
||||
kind: degradationKind(item.kind, `${label}.kind`),
|
||||
frameStart: integer("frame_start"),
|
||||
frameEnd: integer("frame_end"),
|
||||
framesProcessed: integer("frames_processed"),
|
||||
injectedFrames: integer("injected_frames"),
|
||||
droppedFrames: integer("dropped_frames"),
|
||||
hiddenSuccessFrames: integer("hidden_success_frames"),
|
||||
falseFreeRows: integer("false_free_rows"),
|
||||
semanticClaimsDuringCameraLoss: integer(
|
||||
"semantic_claims_during_camera_loss",
|
||||
),
|
||||
metricRowsDuringLidarOrPoseLoss: integer(
|
||||
"metric_rows_during_lidar_or_pose_loss",
|
||||
),
|
||||
agreeClaimsDuringTimingOffset: integer(
|
||||
"agree_claims_during_timing_offset",
|
||||
),
|
||||
lateResultsReintroduced: integer("late_results_reintroduced"),
|
||||
maximumCurrentComponentsDuringFault: integer(
|
||||
"maximum_current_components_during_fault",
|
||||
),
|
||||
maximumHeldComponentsDuringFault: integer(
|
||||
"maximum_held_components_during_fault",
|
||||
),
|
||||
expiredComponentsDuringFault: integer(
|
||||
"expired_components_during_fault",
|
||||
),
|
||||
recoveryFrameIndex: integer("recovery_frame_index"),
|
||||
recoverySeconds: numberValue(
|
||||
item.recovery_seconds,
|
||||
`${label}.recovery_seconds`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function diagnosticAuthority(value: unknown, label: string): void {
|
||||
const authority = record(value, label);
|
||||
if (
|
||||
authority.commands_enabled !== false
|
||||
|| authority.navigation_or_safety_accepted !== false
|
||||
) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: запрещённые полномочия.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): E35DegradationRecoveryResult {
|
||||
const item = record(value, "E35");
|
||||
const configuration = record(item.configuration, "E35.configuration");
|
||||
const metrics = record(item.metrics, "E35.metrics");
|
||||
const review = record(item.review, "E35.review");
|
||||
const acceptance = record(item.acceptance, "E35.acceptance");
|
||||
diagnosticAuthority(item.authority, "E35.authority");
|
||||
if (acceptance.accepted !== true) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
"E35.acceptance: результат отклонён.",
|
||||
);
|
||||
}
|
||||
exactString(
|
||||
review.schema_version,
|
||||
"missioncore.e35-recovery-review/v1",
|
||||
"E35.review.schema_version",
|
||||
);
|
||||
return {
|
||||
resultId: contentId(
|
||||
item.result_id,
|
||||
"e35-degradation-recovery",
|
||||
"E35.result_id",
|
||||
),
|
||||
createdAtUtc: optionalString(item.created_at_utc, "E35.created_at_utc"),
|
||||
sourceSessionId: stringValue(
|
||||
item.source_session_id,
|
||||
"E35.source_session_id",
|
||||
),
|
||||
status: exactString(
|
||||
item.status,
|
||||
"accepted-deterministic-degradation-recovery",
|
||||
"E35.status",
|
||||
),
|
||||
e32ResultId: contentId(
|
||||
item.e32_result_id,
|
||||
"e32-track-geometry",
|
||||
"E35.e32_result_id",
|
||||
),
|
||||
e33ResultId: contentId(
|
||||
item.e33_result_id,
|
||||
"e33-worker-shadow",
|
||||
"E35.e33_result_id",
|
||||
),
|
||||
e34ResultId: contentId(
|
||||
item.e34_result_id,
|
||||
"e34-temporal-occupied",
|
||||
"E35.e34_result_id",
|
||||
),
|
||||
profileId: stringValue(item.profile_id, "E35.profile_id"),
|
||||
pipelineId: stringValue(item.pipeline_id, "E35.pipeline_id"),
|
||||
coordinateFrame: exactString(
|
||||
item.coordinate_frame,
|
||||
"map",
|
||||
"E35.coordinate_frame",
|
||||
),
|
||||
configuration: {
|
||||
maximumRecoverySeconds: numberValue(
|
||||
configuration.maximum_recovery_seconds,
|
||||
"E35.configuration.maximum_recovery_seconds",
|
||||
),
|
||||
scenarioCount: integerValue(
|
||||
configuration.scenario_count,
|
||||
"E35.configuration.scenario_count",
|
||||
),
|
||||
},
|
||||
metrics: {
|
||||
sourceFrames: integerValue(
|
||||
metrics.source_frames,
|
||||
"E35.metrics.source_frames",
|
||||
),
|
||||
variantCount: integerValue(
|
||||
metrics.variant_count,
|
||||
"E35.metrics.variant_count",
|
||||
),
|
||||
variantFrameOutcomes: integerValue(
|
||||
metrics.variant_frame_outcomes,
|
||||
"E35.metrics.variant_frame_outcomes",
|
||||
),
|
||||
injectionRecords: integerValue(
|
||||
metrics.injection_records,
|
||||
"E35.metrics.injection_records",
|
||||
),
|
||||
variantFrameProcessingP95Ms: numberValue(
|
||||
metrics.variant_frame_processing_p95_ms,
|
||||
"E35.metrics.variant_frame_processing_p95_ms",
|
||||
),
|
||||
buildElapsedMs: numberValue(
|
||||
metrics.build_elapsed_ms,
|
||||
"E35.metrics.build_elapsed_ms",
|
||||
),
|
||||
},
|
||||
scenarios: list(item.scenarios, "E35.scenarios").map(
|
||||
(entry, index) => parseScenarioMetrics(
|
||||
entry,
|
||||
`E35.scenarios[${index}]`,
|
||||
),
|
||||
),
|
||||
reviewScenarios: list(review.scenarios, "E35.review.scenarios").map(
|
||||
(entry, index) => parseReviewScenario(
|
||||
entry,
|
||||
`E35.review.scenarios[${index}]`,
|
||||
),
|
||||
),
|
||||
access: exactString(item.access, "read-only", "E35.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE35DegradationRecoveryResult({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E35DegradationRecoveryResult | null> {
|
||||
const response = await fetcher(
|
||||
"/api/v1/laboratory/e35/results?limit=1",
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`Каталог LAB E35 недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const catalog = record(await response.json(), "Каталог LAB E35");
|
||||
exactString(
|
||||
catalog.schema_version,
|
||||
"missioncore.laboratory-advanced-catalog/v1",
|
||||
"Каталог LAB E35.schema_version",
|
||||
);
|
||||
if (typeof catalog.configured !== "boolean") {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
"Каталог LAB E35.configured: ожидался boolean.",
|
||||
);
|
||||
}
|
||||
integerValue(catalog.candidate_total, "Каталог LAB E35.candidate_total");
|
||||
integerValue(catalog.invalid_total, "Каталог LAB E35.invalid_total");
|
||||
exactString(catalog.access, "read-only", "Каталог LAB E35.access");
|
||||
const items = list(catalog.items, "Каталог LAB E35.items");
|
||||
if (items.length > 1) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
"Каталог LAB E35: нарушен limit=1.",
|
||||
);
|
||||
}
|
||||
return items.length ? parseResult(items[0]) : null;
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
export const E40_CASE_DIMENSIONS = [
|
||||
"presence",
|
||||
"geometry_association",
|
||||
"freshness",
|
||||
] as const;
|
||||
|
||||
export type E40CaseDimension = (typeof E40_CASE_DIMENSIONS)[number];
|
||||
export type E40CaseSeverity = "high" | "medium" | "standard";
|
||||
export type E40CaseStratum =
|
||||
| "agree"
|
||||
| "camera-only"
|
||||
| "conflict"
|
||||
| "geometry-only"
|
||||
| "unknown";
|
||||
export type E40PredictionBasis =
|
||||
| "camera-only-softmax"
|
||||
| "fixed-stratum-policy";
|
||||
|
||||
export interface E40CaseState {
|
||||
presence: "background-or-noise" | "object-present" | "occupied-environment";
|
||||
geometryAssociation:
|
||||
| "independent-occupied"
|
||||
| "insufficient-support"
|
||||
| "object-associated"
|
||||
| "rejected-nonobject"
|
||||
| "unknown";
|
||||
freshness: "current" | "stale" | "unavailable";
|
||||
}
|
||||
|
||||
export interface E40ErrorCase {
|
||||
itemId: string;
|
||||
sequence: number;
|
||||
sourceFrameIndex: number;
|
||||
sourceStratum: E40CaseStratum;
|
||||
severity: E40CaseSeverity;
|
||||
presenceConfidence: number;
|
||||
predictionBasis: E40PredictionBasis;
|
||||
reference: E40CaseState;
|
||||
prediction: E40CaseState;
|
||||
mismatchedDimensions: readonly E40CaseDimension[];
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E40CaseCatalog {
|
||||
resultId: string;
|
||||
materializationId: string;
|
||||
items: readonly E40ErrorCase[];
|
||||
total: number;
|
||||
truncated: boolean;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable v1 storage vocabulary. Product UI interprets these legacy values
|
||||
* only as support/challenge of the E37 engineering label; neither value
|
||||
* establishes physical ground truth or authorizes an operational model.
|
||||
*/
|
||||
export type E40OperatorVerdict = "confirmed-error" | "rejected-error";
|
||||
|
||||
export interface E40OperatorDecision {
|
||||
itemId: string;
|
||||
verdict: E40OperatorVerdict;
|
||||
revision: number;
|
||||
idempotencyKey: string;
|
||||
decidedAtUtc: string;
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
export interface E40OperatorReview {
|
||||
reviewId: string;
|
||||
resultId: string;
|
||||
materializationId: string;
|
||||
caseCatalogSha256: string;
|
||||
itemSetSha256: string;
|
||||
itemCount: number;
|
||||
reviewerId: string;
|
||||
revision: number;
|
||||
decisions: readonly E40OperatorDecision[];
|
||||
reviewedItemCount: number;
|
||||
remainingItemCount: number;
|
||||
updatedAtUtc: string | null;
|
||||
access: "review-write";
|
||||
}
|
||||
|
||||
export class E40CaseReviewContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "E40CaseReviewContractError";
|
||||
}
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E40CaseReviewContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E40CaseReviewContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function contentId(value: unknown, prefix: string, label: string): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||
throw new E40CaseReviewContractError(`${label}: неверный content id.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function sha256Value(value: unknown, label: string): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new E40CaseReviewContractError(`${label}: неверный SHA-256.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
if (
|
||||
typeof value !== "number"
|
||||
|| !Number.isSafeInteger(value)
|
||||
|| value < 0
|
||||
) {
|
||||
throw new E40CaseReviewContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableString(value: unknown, label: string): string | null {
|
||||
if (value === null) return null;
|
||||
return stringValue(value, label);
|
||||
}
|
||||
|
||||
function probability(value: unknown, label: string): number {
|
||||
if (
|
||||
typeof value !== "number"
|
||||
|| !Number.isFinite(value)
|
||||
|| value < 0
|
||||
|| value > 1
|
||||
) {
|
||||
throw new E40CaseReviewContractError(`${label}: ожидалась вероятность.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exactString<T extends string>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) {
|
||||
throw new E40CaseReviewContractError(`${label}: неверное значение.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function enumValue<T extends string>(
|
||||
value: unknown,
|
||||
allowed: readonly T[],
|
||||
label: string,
|
||||
): T {
|
||||
if (typeof value !== "string" || !allowed.includes(value as T)) {
|
||||
throw new E40CaseReviewContractError(`${label}: значение не поддерживается.`);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function parseState(value: unknown, label: string): E40CaseState {
|
||||
const source = record(value, label);
|
||||
return {
|
||||
presence: enumValue(
|
||||
source.presence,
|
||||
[
|
||||
"background-or-noise",
|
||||
"object-present",
|
||||
"occupied-environment",
|
||||
],
|
||||
`${label}.presence`,
|
||||
),
|
||||
geometryAssociation: enumValue(
|
||||
source.geometry_association,
|
||||
[
|
||||
"independent-occupied",
|
||||
"insufficient-support",
|
||||
"object-associated",
|
||||
"rejected-nonobject",
|
||||
"unknown",
|
||||
],
|
||||
`${label}.geometry_association`,
|
||||
),
|
||||
freshness: enumValue(
|
||||
source.freshness,
|
||||
["current", "stale", "unavailable"],
|
||||
`${label}.freshness`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCase(value: unknown, index: number): E40ErrorCase {
|
||||
const label = `E40 case[${index}]`;
|
||||
const source = record(value, label);
|
||||
if (!Array.isArray(source.mismatched_dimensions)) {
|
||||
throw new E40CaseReviewContractError(
|
||||
`${label}.mismatched_dimensions: ожидался массив.`,
|
||||
);
|
||||
}
|
||||
const mismatchedDimensions = source.mismatched_dimensions.map(
|
||||
(dimension, dimensionIndex) => enumValue(
|
||||
dimension,
|
||||
E40_CASE_DIMENSIONS,
|
||||
`${label}.mismatched_dimensions[${dimensionIndex}]`,
|
||||
),
|
||||
);
|
||||
if (!mismatchedDimensions.length) {
|
||||
throw new E40CaseReviewContractError(`${label}: кейс не содержит ошибки.`);
|
||||
}
|
||||
return {
|
||||
itemId: contentId(source.item_id, "e30-review-item", `${label}.item_id`),
|
||||
sequence: integerValue(source.sequence, `${label}.sequence`),
|
||||
sourceFrameIndex: integerValue(
|
||||
source.source_frame_index,
|
||||
`${label}.source_frame_index`,
|
||||
),
|
||||
sourceStratum: enumValue(
|
||||
source.source_stratum,
|
||||
["agree", "camera-only", "conflict", "geometry-only", "unknown"],
|
||||
`${label}.source_stratum`,
|
||||
),
|
||||
severity: enumValue(
|
||||
source.severity,
|
||||
["high", "medium", "standard"],
|
||||
`${label}.severity`,
|
||||
),
|
||||
presenceConfidence: probability(
|
||||
source.presence_confidence,
|
||||
`${label}.presence_confidence`,
|
||||
),
|
||||
predictionBasis: enumValue(
|
||||
source.prediction_basis,
|
||||
["camera-only-softmax", "fixed-stratum-policy"],
|
||||
`${label}.prediction_basis`,
|
||||
),
|
||||
reference: parseState(source.reference, `${label}.reference`),
|
||||
prediction: parseState(source.prediction, `${label}.prediction`),
|
||||
mismatchedDimensions,
|
||||
access: exactString(source.access, "read-only", `${label}.access`),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE40CaseCatalog(payload: unknown): E40CaseCatalog {
|
||||
const source = record(payload, "E40 case catalog");
|
||||
exactString(
|
||||
source.schema_version,
|
||||
"missioncore.laboratory-e40-case-catalog/v1",
|
||||
"E40 case catalog.schema_version",
|
||||
);
|
||||
if (!Array.isArray(source.items) || source.items.length > 64) {
|
||||
throw new E40CaseReviewContractError(
|
||||
"E40 case catalog.items: нарушена граница.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
resultId: contentId(
|
||||
source.result_id,
|
||||
"e40-perception-product-gate",
|
||||
"E40 case catalog.result_id",
|
||||
),
|
||||
materializationId: contentId(
|
||||
source.materialization_id,
|
||||
"e30-materialization",
|
||||
"E40 case catalog.materialization_id",
|
||||
),
|
||||
items: source.items.map(parseCase),
|
||||
total: integerValue(source.total, "E40 case catalog.total"),
|
||||
truncated: typeof source.truncated === "boolean"
|
||||
? source.truncated
|
||||
: (() => {
|
||||
throw new E40CaseReviewContractError(
|
||||
"E40 case catalog.truncated: ожидался boolean.",
|
||||
);
|
||||
})(),
|
||||
access: exactString(
|
||||
source.access,
|
||||
"read-only",
|
||||
"E40 case catalog.access",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseOperatorDecision(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): E40OperatorDecision {
|
||||
const label = `E40 operator decision[${index}]`;
|
||||
const source = record(value, label);
|
||||
return {
|
||||
itemId: contentId(source.item_id, "e30-review-item", `${label}.item_id`),
|
||||
verdict: enumValue(
|
||||
source.verdict,
|
||||
["confirmed-error", "rejected-error"],
|
||||
`${label}.verdict`,
|
||||
),
|
||||
revision: integerValue(source.revision, `${label}.revision`),
|
||||
idempotencyKey: stringValue(
|
||||
source.idempotency_key,
|
||||
`${label}.idempotency_key`,
|
||||
),
|
||||
decidedAtUtc: stringValue(
|
||||
source.decided_at_utc,
|
||||
`${label}.decided_at_utc`,
|
||||
),
|
||||
eventId: contentId(
|
||||
source.event_id,
|
||||
"e40-operator-verdict",
|
||||
`${label}.event_id`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE40OperatorReview(
|
||||
payload: unknown,
|
||||
): E40OperatorReview {
|
||||
const source = record(payload, "E40 operator review");
|
||||
exactString(
|
||||
source.schema_version,
|
||||
"missioncore.e40-operator-review/v1",
|
||||
"E40 operator review.schema_version",
|
||||
);
|
||||
exactString(
|
||||
source.protocol,
|
||||
"sealed-error-adjudication/v1",
|
||||
"E40 operator review.protocol",
|
||||
);
|
||||
const binding = record(source.source, "E40 operator review.source");
|
||||
if (!Array.isArray(source.decisions) || source.decisions.length > 64) {
|
||||
throw new E40CaseReviewContractError(
|
||||
"E40 operator review.decisions: нарушена граница.",
|
||||
);
|
||||
}
|
||||
const decisions = source.decisions.map(parseOperatorDecision);
|
||||
const reviewedItemCount = integerValue(
|
||||
source.reviewed_item_count,
|
||||
"E40 operator review.reviewed_item_count",
|
||||
);
|
||||
const remainingItemCount = integerValue(
|
||||
source.remaining_item_count,
|
||||
"E40 operator review.remaining_item_count",
|
||||
);
|
||||
const itemCount = integerValue(
|
||||
binding.item_count,
|
||||
"E40 operator review.source.item_count",
|
||||
);
|
||||
if (
|
||||
reviewedItemCount !== decisions.length
|
||||
|| reviewedItemCount + remainingItemCount !== itemCount
|
||||
|| new Set(decisions.map((decision) => decision.itemId)).size
|
||||
!== decisions.length
|
||||
) {
|
||||
throw new E40CaseReviewContractError(
|
||||
"E40 operator review: счётчики решений несовместимы.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
reviewId: contentId(
|
||||
source.review_id,
|
||||
"e40-operator-review",
|
||||
"E40 operator review.review_id",
|
||||
),
|
||||
resultId: contentId(
|
||||
binding.result_id,
|
||||
"e40-perception-product-gate",
|
||||
"E40 operator review.source.result_id",
|
||||
),
|
||||
materializationId: contentId(
|
||||
binding.materialization_id,
|
||||
"e30-materialization",
|
||||
"E40 operator review.source.materialization_id",
|
||||
),
|
||||
caseCatalogSha256: sha256Value(
|
||||
binding.case_catalog_sha256,
|
||||
"E40 operator review.source.case_catalog_sha256",
|
||||
),
|
||||
itemSetSha256: sha256Value(
|
||||
binding.item_set_sha256,
|
||||
"E40 operator review.source.item_set_sha256",
|
||||
),
|
||||
itemCount,
|
||||
reviewerId: stringValue(
|
||||
source.reviewer_id,
|
||||
"E40 operator review.reviewer_id",
|
||||
),
|
||||
revision: integerValue(
|
||||
source.revision,
|
||||
"E40 operator review.revision",
|
||||
),
|
||||
decisions,
|
||||
reviewedItemCount,
|
||||
remainingItemCount,
|
||||
updatedAtUtc: nullableString(
|
||||
source.updated_at_utc,
|
||||
"E40 operator review.updated_at_utc",
|
||||
),
|
||||
access: exactString(
|
||||
source.access,
|
||||
"review-write",
|
||||
"E40 operator review.access",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE40CaseCatalog(
|
||||
resultId: string,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<E40CaseCatalog> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e40/results/${encodeURIComponent(resultId)}/cases?limit=48`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new E40CaseReviewContractError(
|
||||
`Case-review E40 недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const catalog = parseE40CaseCatalog(await response.json());
|
||||
if (catalog.resultId !== resultId) {
|
||||
throw new E40CaseReviewContractError(
|
||||
"Case-review E40 вернул другой результат.",
|
||||
);
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
export async function fetchE40OperatorReview(
|
||||
resultId: string,
|
||||
{
|
||||
reviewerId = "DC",
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
reviewerId?: string;
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<E40OperatorReview> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e40/results/${encodeURIComponent(resultId)}`
|
||||
+ `/operator-review?reviewer_id=${encodeURIComponent(reviewerId)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new E40CaseReviewContractError(
|
||||
`Операторская проверка E40 недоступна: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const review = parseE40OperatorReview(await response.json());
|
||||
if (review.resultId !== resultId || review.reviewerId !== reviewerId) {
|
||||
throw new E40CaseReviewContractError(
|
||||
"Операторская проверка E40 вернула другой источник.",
|
||||
);
|
||||
}
|
||||
return review;
|
||||
}
|
||||
|
||||
export async function saveE40OperatorVerdict(
|
||||
resultId: string,
|
||||
itemId: string,
|
||||
{
|
||||
reviewerId = "DC",
|
||||
expectedRevision,
|
||||
idempotencyKey,
|
||||
verdict,
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
reviewerId?: string;
|
||||
expectedRevision: number;
|
||||
idempotencyKey: string;
|
||||
verdict: E40OperatorVerdict;
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<E40OperatorReview> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e40/results/${encodeURIComponent(resultId)}`
|
||||
+ `/operator-review/decisions/${encodeURIComponent(itemId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
reviewer_id: reviewerId,
|
||||
expected_revision: expectedRevision,
|
||||
idempotency_key: idempotencyKey,
|
||||
verdict,
|
||||
}),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new E40CaseReviewContractError(
|
||||
`Решение E40 не сохранено: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const review = parseE40OperatorReview(await response.json());
|
||||
if (
|
||||
review.resultId !== resultId
|
||||
|| review.reviewerId !== reviewerId
|
||||
|| !review.decisions.some(
|
||||
(decision) => decision.itemId === itemId
|
||||
&& decision.verdict === verdict,
|
||||
)
|
||||
) {
|
||||
throw new E40CaseReviewContractError(
|
||||
"Решение E40 не подтверждено сервером.",
|
||||
);
|
||||
}
|
||||
return review;
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
export interface E40DevelopmentDimensionMetric {
|
||||
correct: number;
|
||||
incorrect: number;
|
||||
total: number;
|
||||
accuracy: number;
|
||||
target: number;
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
export interface E40DimensionMetric extends E40DevelopmentDimensionMetric {
|
||||
byStratum: Readonly<Record<string, {
|
||||
correct: number;
|
||||
incorrect: number;
|
||||
total: number;
|
||||
accuracy: number;
|
||||
}>>;
|
||||
confusion: readonly {
|
||||
reference: string;
|
||||
prediction: string;
|
||||
count: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface E40DevelopmentProtocol {
|
||||
items: number;
|
||||
foldSizes: Readonly<Record<string, number>>;
|
||||
passed: boolean;
|
||||
dimensions: {
|
||||
presence: E40DevelopmentDimensionMetric;
|
||||
geometryAssociation: E40DevelopmentDimensionMetric;
|
||||
freshness: E40DevelopmentDimensionMetric;
|
||||
};
|
||||
}
|
||||
|
||||
export interface E40PerceptionProductGateResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
sourceSessionId: string;
|
||||
sourceDisplayName: string;
|
||||
status: "measured-leakage-resistant-product-gate";
|
||||
profileId: string;
|
||||
workerNode: string;
|
||||
qualityGatePassed: boolean;
|
||||
developmentCrossValidation: {
|
||||
strategy: string;
|
||||
seed: string;
|
||||
folds: number;
|
||||
items: number;
|
||||
validationLabelsUsed: false;
|
||||
passed: boolean;
|
||||
protocols: {
|
||||
contiguousSourceTime: E40DevelopmentProtocol;
|
||||
wholeTrackOrSceneWindow: E40DevelopmentProtocol;
|
||||
};
|
||||
};
|
||||
metrics: {
|
||||
developmentItems: number;
|
||||
validationItems: number;
|
||||
terminalOutcomes: number;
|
||||
accountingFraction: number;
|
||||
falseFreeClaims: number;
|
||||
highSeverityFailures: number;
|
||||
dimensions: {
|
||||
presence: E40DimensionMetric;
|
||||
geometryAssociation: E40DimensionMetric;
|
||||
freshness: E40DimensionMetric;
|
||||
};
|
||||
};
|
||||
blockingChecks: readonly string[];
|
||||
method: {
|
||||
summary: string;
|
||||
selection: string;
|
||||
dimensionProjection: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export class E40ProductGateContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "E40ProductGateContractError";
|
||||
}
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E40ProductGateContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E40ProductGateContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, label: string): string | null {
|
||||
return value === null ? null : stringValue(value, label);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new E40ProductGateContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
const parsed = numberValue(value, label);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new E40ProductGateContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new E40ProductGateContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function falseValue(value: unknown, label: string): false {
|
||||
if (value !== false) {
|
||||
throw new E40ProductGateContractError(`${label}: ожидалось false.`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function exactString<T extends string>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) {
|
||||
throw new E40ProductGateContractError(`${label}: неверное значение.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function strings(value: unknown, label: string): readonly string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E40ProductGateContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value.map((item, index) => stringValue(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function numberRecord(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Readonly<Record<string, number>> {
|
||||
const source = record(value, label);
|
||||
return Object.fromEntries(
|
||||
Object.entries(source).map(([key, item]) => [
|
||||
key,
|
||||
integerValue(item, `${label}.${key}`),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function contentId(value: unknown, prefix: string, label: string): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||
throw new E40ProductGateContractError(`${label}: неверный content id.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function diagnosticAuthority(value: unknown, label: string): void {
|
||||
const authority = record(value, label);
|
||||
if (
|
||||
authority.commands_enabled !== false
|
||||
|| authority.navigation_or_safety_accepted !== false
|
||||
) {
|
||||
throw new E40ProductGateContractError(`${label}: запрещённые полномочия.`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseDevelopmentDimension(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E40DevelopmentDimensionMetric {
|
||||
const dimension = record(value, label);
|
||||
return {
|
||||
correct: integerValue(dimension.correct, `${label}.correct`),
|
||||
incorrect: integerValue(dimension.incorrect, `${label}.incorrect`),
|
||||
total: integerValue(dimension.total, `${label}.total`),
|
||||
accuracy: numberValue(dimension.accuracy, `${label}.accuracy`),
|
||||
target: numberValue(dimension.target, `${label}.target`),
|
||||
passed: booleanValue(dimension.passed, `${label}.passed`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDimension(value: unknown, label: string): E40DimensionMetric {
|
||||
const dimension = record(value, label);
|
||||
const byStratum = record(dimension.by_stratum, `${label}.by_stratum`);
|
||||
const confusion = dimension.confusion;
|
||||
if (!Array.isArray(confusion)) {
|
||||
throw new E40ProductGateContractError(
|
||||
`${label}.confusion: ожидался массив.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
...parseDevelopmentDimension(value, label),
|
||||
byStratum: Object.fromEntries(
|
||||
Object.entries(byStratum).map(([stratum, raw]) => {
|
||||
const item = record(raw, `${label}.by_stratum.${stratum}`);
|
||||
return [stratum, {
|
||||
correct: integerValue(
|
||||
item.correct,
|
||||
`${label}.by_stratum.${stratum}.correct`,
|
||||
),
|
||||
incorrect: integerValue(
|
||||
item.incorrect,
|
||||
`${label}.by_stratum.${stratum}.incorrect`,
|
||||
),
|
||||
total: integerValue(
|
||||
item.total,
|
||||
`${label}.by_stratum.${stratum}.total`,
|
||||
),
|
||||
accuracy: numberValue(
|
||||
item.accuracy,
|
||||
`${label}.by_stratum.${stratum}.accuracy`,
|
||||
),
|
||||
}];
|
||||
}),
|
||||
),
|
||||
confusion: confusion.map((raw, index) => {
|
||||
const item = record(raw, `${label}.confusion[${index}]`);
|
||||
return {
|
||||
reference: stringValue(
|
||||
item.reference,
|
||||
`${label}.confusion[${index}].reference`,
|
||||
),
|
||||
prediction: stringValue(
|
||||
item.prediction,
|
||||
`${label}.confusion[${index}].prediction`,
|
||||
),
|
||||
count: integerValue(
|
||||
item.count,
|
||||
`${label}.confusion[${index}].count`,
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDevelopmentProtocol(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E40DevelopmentProtocol {
|
||||
const protocol = record(value, label);
|
||||
const dimensions = record(protocol.dimensions, `${label}.dimensions`);
|
||||
return {
|
||||
items: integerValue(protocol.items, `${label}.items`),
|
||||
foldSizes: numberRecord(protocol.fold_sizes, `${label}.fold_sizes`),
|
||||
passed: booleanValue(protocol.passed, `${label}.passed`),
|
||||
dimensions: {
|
||||
presence: parseDevelopmentDimension(
|
||||
dimensions.presence,
|
||||
`${label}.dimensions.presence`,
|
||||
),
|
||||
geometryAssociation: parseDevelopmentDimension(
|
||||
dimensions.geometry_association,
|
||||
`${label}.dimensions.geometry_association`,
|
||||
),
|
||||
freshness: parseDevelopmentDimension(
|
||||
dimensions.freshness,
|
||||
`${label}.dimensions.freshness`,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): E40PerceptionProductGateResult {
|
||||
const item = record(value, "E40");
|
||||
const metrics = record(item.metrics, "E40.metrics");
|
||||
const dimensions = record(metrics.dimensions, "E40.metrics.dimensions");
|
||||
const development = record(
|
||||
item.development_cross_validation,
|
||||
"E40.development_cross_validation",
|
||||
);
|
||||
const protocols = record(
|
||||
development.protocols,
|
||||
"E40.development_cross_validation.protocols",
|
||||
);
|
||||
const method = record(item.method, "E40.method");
|
||||
diagnosticAuthority(item.authority, "E40.authority");
|
||||
return {
|
||||
resultId: contentId(
|
||||
item.result_id,
|
||||
"e40-perception-product-gate",
|
||||
"E40.result_id",
|
||||
),
|
||||
createdAtUtc: optionalString(item.created_at_utc, "E40.created_at_utc"),
|
||||
sourceSessionId: stringValue(
|
||||
item.source_session_id,
|
||||
"E40.source_session_id",
|
||||
),
|
||||
sourceDisplayName: stringValue(
|
||||
item.source_display_name,
|
||||
"E40.source_display_name",
|
||||
),
|
||||
status: exactString(
|
||||
item.status,
|
||||
"measured-leakage-resistant-product-gate",
|
||||
"E40.status",
|
||||
),
|
||||
profileId: stringValue(item.profile_id, "E40.profile_id"),
|
||||
workerNode: stringValue(item.worker_node, "E40.worker_node"),
|
||||
qualityGatePassed: booleanValue(
|
||||
item.quality_gate_passed,
|
||||
"E40.quality_gate_passed",
|
||||
),
|
||||
developmentCrossValidation: {
|
||||
strategy: stringValue(
|
||||
development.strategy,
|
||||
"E40.development_cross_validation.strategy",
|
||||
),
|
||||
seed: stringValue(
|
||||
development.seed,
|
||||
"E40.development_cross_validation.seed",
|
||||
),
|
||||
folds: integerValue(
|
||||
development.folds,
|
||||
"E40.development_cross_validation.folds",
|
||||
),
|
||||
items: integerValue(
|
||||
development.items,
|
||||
"E40.development_cross_validation.items",
|
||||
),
|
||||
validationLabelsUsed: falseValue(
|
||||
development.validation_labels_used,
|
||||
"E40.development_cross_validation.validation_labels_used",
|
||||
),
|
||||
passed: booleanValue(
|
||||
development.passed,
|
||||
"E40.development_cross_validation.passed",
|
||||
),
|
||||
protocols: {
|
||||
contiguousSourceTime: parseDevelopmentProtocol(
|
||||
protocols["contiguous-source-time-five-fold"],
|
||||
"E40.development_cross_validation.protocols.contiguous-source-time-five-fold",
|
||||
),
|
||||
wholeTrackOrSceneWindow: parseDevelopmentProtocol(
|
||||
protocols["whole-track-or-scene-window-five-fold"],
|
||||
"E40.development_cross_validation.protocols.whole-track-or-scene-window-five-fold",
|
||||
),
|
||||
},
|
||||
},
|
||||
metrics: {
|
||||
developmentItems: integerValue(
|
||||
metrics.development_items,
|
||||
"E40.metrics.development_items",
|
||||
),
|
||||
validationItems: integerValue(
|
||||
metrics.validation_items,
|
||||
"E40.metrics.validation_items",
|
||||
),
|
||||
terminalOutcomes: integerValue(
|
||||
metrics.terminal_outcomes,
|
||||
"E40.metrics.terminal_outcomes",
|
||||
),
|
||||
accountingFraction: numberValue(
|
||||
metrics.accounting_fraction,
|
||||
"E40.metrics.accounting_fraction",
|
||||
),
|
||||
falseFreeClaims: integerValue(
|
||||
metrics.false_free_claims,
|
||||
"E40.metrics.false_free_claims",
|
||||
),
|
||||
highSeverityFailures: integerValue(
|
||||
metrics.high_severity_failures,
|
||||
"E40.metrics.high_severity_failures",
|
||||
),
|
||||
dimensions: {
|
||||
presence: parseDimension(
|
||||
dimensions.presence,
|
||||
"E40.metrics.dimensions.presence",
|
||||
),
|
||||
geometryAssociation: parseDimension(
|
||||
dimensions.geometry_association,
|
||||
"E40.metrics.dimensions.geometry_association",
|
||||
),
|
||||
freshness: parseDimension(
|
||||
dimensions.freshness,
|
||||
"E40.metrics.dimensions.freshness",
|
||||
),
|
||||
},
|
||||
},
|
||||
blockingChecks: strings(item.blocking_checks, "E40.blocking_checks"),
|
||||
method: {
|
||||
summary: stringValue(method.summary, "E40.method.summary"),
|
||||
selection: stringValue(method.selection, "E40.method.selection"),
|
||||
dimensionProjection: stringValue(
|
||||
method.dimension_projection,
|
||||
"E40.method.dimension_projection",
|
||||
),
|
||||
},
|
||||
limitations: strings(item.limitations, "E40.limitations"),
|
||||
access: exactString(item.access, "read-only", "E40.access"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCatalog(payload: unknown): E40PerceptionProductGateResult | null {
|
||||
const catalog = record(payload, "Каталог LAB E40");
|
||||
exactString(
|
||||
catalog.schema_version,
|
||||
"missioncore.laboratory-advanced-catalog/v1",
|
||||
"Каталог LAB E40.schema_version",
|
||||
);
|
||||
booleanValue(catalog.configured, "Каталог LAB E40.configured");
|
||||
integerValue(catalog.candidate_total, "Каталог LAB E40.candidate_total");
|
||||
integerValue(catalog.invalid_total, "Каталог LAB E40.invalid_total");
|
||||
exactString(catalog.access, "read-only", "Каталог LAB E40.access");
|
||||
if (!Array.isArray(catalog.items)) {
|
||||
throw new E40ProductGateContractError(
|
||||
"Каталог LAB E40.items: ожидался массив.",
|
||||
);
|
||||
}
|
||||
if (catalog.items.length > 1) {
|
||||
throw new E40ProductGateContractError(
|
||||
"Каталог LAB E40.items: нарушен limit=1.",
|
||||
);
|
||||
}
|
||||
return catalog.items.length ? parseResult(catalog.items[0]) : null;
|
||||
}
|
||||
|
||||
export async function fetchE40ProductGateResult({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E40PerceptionProductGateResult | null> {
|
||||
const response = await fetcher(
|
||||
"/api/v1/laboratory/e40/results?limit=1",
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new E40ProductGateContractError(
|
||||
`Каталог LAB E40 недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
return parseCatalog(await response.json());
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
export interface E46BlindReviewSubmissionSummary {
|
||||
resultId: string;
|
||||
reviewerId: string;
|
||||
annotationSessionId: string;
|
||||
state: "completed-e48-review-input-not-truth";
|
||||
frameCount: number;
|
||||
objectCount: number;
|
||||
}
|
||||
|
||||
export interface E46BlindReviewResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
status: "prepared-awaiting-independent-human-review";
|
||||
sourceSessionId: string;
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
metrics: {
|
||||
frameCount: 32;
|
||||
anchorCount: 16;
|
||||
temporalFrameCount: 16;
|
||||
temporalGroupCount: 4;
|
||||
reviewSessionCount: number;
|
||||
completedSubmissionCount: number;
|
||||
requiredSubmissionCount: 2;
|
||||
};
|
||||
submissions: readonly E46BlindReviewSubmissionSummary[];
|
||||
decision: {
|
||||
collectionReady: true;
|
||||
twoIndependentReviewsComplete: boolean;
|
||||
adjudicationReady: boolean;
|
||||
e48Sealed: false;
|
||||
l35Open: false;
|
||||
nextAction: string;
|
||||
};
|
||||
access: "review-collection-read-only-summary";
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
class E46BlindReviewContractError extends Error {}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E46BlindReviewContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E46BlindReviewContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E46BlindReviewContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
if (!Number.isInteger(value) || Number(value) < 0) {
|
||||
throw new E46BlindReviewContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new E46BlindReviewContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact<T extends string | boolean | number>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) {
|
||||
throw new E46BlindReviewContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function parseSubmission(value: unknown): E46BlindReviewSubmissionSummary {
|
||||
const item = object(value, "E46 submission");
|
||||
const resultId = string(item.result_id, "E46 submission id");
|
||||
if (!/^e46-lab-review-submission-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new E46BlindReviewContractError("E46 submission: нарушена identity.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
reviewerId: string(item.reviewer_id, "E46 reviewer"),
|
||||
annotationSessionId: string(item.annotation_session_id, "E46 session"),
|
||||
state: exact(
|
||||
item.state,
|
||||
"completed-e48-review-input-not-truth",
|
||||
"E46 submission state",
|
||||
),
|
||||
frameCount: integer(item.frame_count, "E46 submitted frames"),
|
||||
objectCount: integer(item.object_count, "E46 submitted objects"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46BlindReviewResult({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E46BlindReviewResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46/results?limit=1", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new E46BlindReviewContractError(`E46 LAB недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
const catalog = object(await response.json(), "E46 catalog");
|
||||
exact(
|
||||
catalog.schema_version,
|
||||
"missioncore.e46-detector-truth-island-catalog/v1",
|
||||
"E46 catalog schema",
|
||||
);
|
||||
const items = array(catalog.items, "E46 results");
|
||||
if (!items.length) return null;
|
||||
if (items.length !== 1) {
|
||||
throw new E46BlindReviewContractError("E46 catalog: нарушен размер.");
|
||||
}
|
||||
const item = object(items[0], "E46 result");
|
||||
const resultId = string(item.result_id, "E46 result id");
|
||||
if (!/^e46-detector-truth-island-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new E46BlindReviewContractError("E46 result: нарушена identity.");
|
||||
}
|
||||
const metrics = object(item.metrics, "E46 metrics");
|
||||
const decision = object(item.decision, "E46 decision");
|
||||
const blindness = object(item.blindness, "E46 blindness");
|
||||
const authority = object(item.authority, "E46 authority");
|
||||
const submissions = array(item.submissions, "E46 submissions").map(parseSubmission);
|
||||
const reviewSessions = array(item.review_sessions, "E46 review sessions");
|
||||
const completedSubmissionCount = integer(
|
||||
metrics.completed_submission_count,
|
||||
"E46 completed submissions",
|
||||
);
|
||||
if (
|
||||
completedSubmissionCount !== submissions.length
|
||||
|| completedSubmissionCount > 2
|
||||
|| integer(metrics.review_session_count, "E46 session count") !== reviewSessions.length
|
||||
|| reviewSessions.length > 2
|
||||
|| submissions.some((submission) => submission.frameCount !== 32)
|
||||
) {
|
||||
throw new E46BlindReviewContractError("E46 review collection: нарушен размер.");
|
||||
}
|
||||
for (const key of (
|
||||
[
|
||||
"candidate_identity_included",
|
||||
"model_prelabels_included",
|
||||
"model_predictions_included",
|
||||
"model_scores_included",
|
||||
] as const
|
||||
)) exact(blindness[key], false, `E46 blindness ${key}`);
|
||||
for (const key of (
|
||||
[
|
||||
"ground_truth",
|
||||
"independent_truth",
|
||||
"metric_grade_reference",
|
||||
"candidate_accepted",
|
||||
"commands_enabled",
|
||||
"navigation_or_safety_accepted",
|
||||
] as const
|
||||
)) exact(authority[key], false, `E46 authority ${key}`);
|
||||
const twoComplete = boolean(
|
||||
decision.two_independent_reviews_complete,
|
||||
"E46 two review state",
|
||||
);
|
||||
const adjudicationReady = boolean(
|
||||
decision.adjudication_ready,
|
||||
"E46 adjudication state",
|
||||
);
|
||||
if (twoComplete !== (completedSubmissionCount === 2) || adjudicationReady !== twoComplete) {
|
||||
throw new E46BlindReviewContractError("E46 decision: нарушен review gate.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "E46 created time"),
|
||||
status: exact(
|
||||
item.status,
|
||||
"prepared-awaiting-independent-human-review",
|
||||
"E46 status",
|
||||
),
|
||||
sourceSessionId: string(item.source_session_id, "E46 source session"),
|
||||
cameraSourceId: exact(
|
||||
item.camera_source_id,
|
||||
"sensor.camera.right",
|
||||
"E46 camera source",
|
||||
),
|
||||
metrics: {
|
||||
frameCount: exact(metrics.frame_count, 32, "E46 frame count"),
|
||||
anchorCount: exact(metrics.anchor_count, 16, "E46 anchors"),
|
||||
temporalFrameCount: exact(
|
||||
metrics.temporal_frame_count,
|
||||
16,
|
||||
"E46 temporal frames",
|
||||
),
|
||||
temporalGroupCount: exact(
|
||||
metrics.temporal_group_count,
|
||||
4,
|
||||
"E46 temporal groups",
|
||||
),
|
||||
reviewSessionCount: integer(metrics.review_session_count, "E46 sessions"),
|
||||
completedSubmissionCount,
|
||||
requiredSubmissionCount: exact(
|
||||
metrics.required_submission_count,
|
||||
2,
|
||||
"E46 required submissions",
|
||||
),
|
||||
},
|
||||
submissions,
|
||||
decision: {
|
||||
collectionReady: exact(
|
||||
decision.collection_ready,
|
||||
true,
|
||||
"E46 collection ready",
|
||||
),
|
||||
twoIndependentReviewsComplete: twoComplete,
|
||||
adjudicationReady,
|
||||
e48Sealed: exact(decision.e48_sealed, false, "E48 seal state"),
|
||||
l35Open: exact(decision.l35_open, false, "L3.5 state"),
|
||||
nextAction: string(decision.next_action, "E46 next action"),
|
||||
},
|
||||
access: exact(
|
||||
item.access,
|
||||
"review-collection-read-only-summary",
|
||||
"E46 access",
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
export interface E46AObject {
|
||||
objectId: string;
|
||||
category: string;
|
||||
displayCategory: string;
|
||||
boxXyxy: readonly [number, number, number, number];
|
||||
occluded: boolean;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface E46ACase {
|
||||
resultId: string;
|
||||
truthIslandSequence: number;
|
||||
imageId: number;
|
||||
frameIndex: number;
|
||||
groupId: string;
|
||||
sessionSeconds: number;
|
||||
sourceImageSha256: string;
|
||||
objects: readonly E46AObject[];
|
||||
objectCount: number;
|
||||
cameraUrl: string;
|
||||
groundTruth: false;
|
||||
independentReview: false;
|
||||
}
|
||||
|
||||
export interface E46AAiEngineeringPreannotationResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
sourceSessionId: "RAVNOVES00";
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
sourceE46ResultId: string;
|
||||
metrics: {
|
||||
frameCount: 32;
|
||||
reviewedFrameCount: 32;
|
||||
sourceObjectCount: number;
|
||||
objectCount: number;
|
||||
customClassRelabelCount: number;
|
||||
deletedFalseBoxCount: number;
|
||||
geometrySnappedObjectCount: number;
|
||||
sourceGeometryRetainedObjectCount: number;
|
||||
categoryCorrectedObjectCount: number;
|
||||
hardNegativeFrameCount: number;
|
||||
independentReviewSubmissionCount: 0;
|
||||
classCounts: Readonly<Record<string, number>>;
|
||||
};
|
||||
taxonomy: {
|
||||
classes: readonly string[];
|
||||
customClasses: readonly ["laptop", "stroller"];
|
||||
unresolvedClassCount: 0;
|
||||
};
|
||||
decision: {
|
||||
preannotationAvailable: true;
|
||||
visualReviewComplete: true;
|
||||
independentReviewCountAffected: false;
|
||||
e48TruthSealOpen: false;
|
||||
l35AcceptanceOpen: false;
|
||||
nextAction: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
groundTruth: false;
|
||||
access: "read-only-ai-engineering-preannotation-not-truth";
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
class E46AContractError extends Error {}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E46AContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) throw new E46AContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E46AContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new E46AContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new E46AContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function optionalInteger(value: unknown, fallback: number, label: string): number {
|
||||
return value === undefined ? fallback : integer(value, label);
|
||||
}
|
||||
|
||||
function exact<T extends string | boolean | number>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) throw new E46AContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new E46AContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sha256(value: unknown, label: string): string {
|
||||
const parsed = string(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) throw new E46AContractError(`${label}: invalid hash.`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseClassCounts(value: unknown): Readonly<Record<string, number>> {
|
||||
const raw = object(value, "E46A class counts");
|
||||
const parsed: Record<string, number> = {};
|
||||
for (const [key, count] of Object.entries(raw)) {
|
||||
if (!key.trim()) throw new E46AContractError("E46A class name: пустое значение.");
|
||||
parsed[key] = integer(count, `E46A ${key} count`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseObject(value: unknown): E46AObject {
|
||||
const item = object(value, "E46A object");
|
||||
const box = array(item.box_xyxy, "E46A box").map((coordinate) => number(coordinate, "E46A coordinate"));
|
||||
if (box.length !== 4 || box[2] <= box[0] || box[3] <= box[1]) {
|
||||
throw new E46AContractError("E46A box: invalid geometry.");
|
||||
}
|
||||
const category = string(item.category, "E46A category");
|
||||
if (category === "unmapped") throw new E46AContractError("E46A category unresolved.");
|
||||
return {
|
||||
objectId: string(item.object_id, "E46A object id"),
|
||||
category,
|
||||
displayCategory: category === "stroller" ? "Коляска"
|
||||
: category === "laptop" ? "Ноутбук"
|
||||
: category,
|
||||
boxXyxy: box as [number, number, number, number],
|
||||
occluded: boolean(item.occluded, "E46A occluded"),
|
||||
truncated: boolean(item.truncated, "E46A truncated"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46AAiEngineeringPreannotation({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E46AAiEngineeringPreannotationResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46a/results?limit=1", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new E46AContractError(`E46A LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "E46A catalog");
|
||||
exact(catalog.schema_version, "missioncore.e46a-ai-engineering-preannotation-catalog/v1", "E46A catalog schema");
|
||||
const items = array(catalog.items, "E46A results");
|
||||
if (!items.length) return null;
|
||||
if (items.length !== 1) throw new E46AContractError("E46A catalog: нарушен размер.");
|
||||
const item = object(items[0], "E46A result");
|
||||
exact(item.schema_version, "missioncore.e46a-ai-engineering-preannotation-view/v1", "E46A result schema");
|
||||
const resultId = string(item.result_id, "E46A result id");
|
||||
const sourceE46ResultId = string(item.source_e46_result_id, "E46A source id");
|
||||
if (!/^e46a-ai-engineering-preannotation-[a-f0-9]{64}$/.test(resultId)
|
||||
|| !/^e46-detector-truth-island-[a-f0-9]{64}$/.test(sourceE46ResultId)) {
|
||||
throw new E46AContractError("E46A result: нарушена identity.");
|
||||
}
|
||||
const metrics = object(item.metrics, "E46A metrics");
|
||||
const taxonomy = object(item.taxonomy, "E46A taxonomy");
|
||||
const decision = object(item.decision, "E46A decision");
|
||||
const classes = array(taxonomy.classes, "E46A classes").map((value) => string(value, "E46A class"));
|
||||
const custom = array(taxonomy.custom_classes, "E46A custom classes").map((value) => string(value, "E46A custom class"));
|
||||
const objectCount = integer(metrics.object_count, "E46A object count");
|
||||
if (custom.length !== 2 || custom[0] !== "laptop" || custom[1] !== "stroller") {
|
||||
throw new E46AContractError("E46A custom taxonomy changed.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "E46A created time"),
|
||||
sourceSessionId: exact(item.source_session_id, "RAVNOVES00", "E46A source session"),
|
||||
cameraSourceId: exact(item.camera_source_id, "sensor.camera.right", "E46A camera source"),
|
||||
sourceE46ResultId,
|
||||
metrics: {
|
||||
frameCount: exact(metrics.frame_count, 32, "E46A frame count"),
|
||||
reviewedFrameCount: exact(metrics.reviewed_frame_count, 32, "E46A reviewed frames"),
|
||||
sourceObjectCount: optionalInteger(metrics.source_object_count, objectCount, "E46A source object count"),
|
||||
objectCount,
|
||||
customClassRelabelCount: integer(metrics.custom_class_relabel_count, "E46A relabel count"),
|
||||
deletedFalseBoxCount: optionalInteger(metrics.deleted_false_box_count, 0, "E46A deleted false boxes"),
|
||||
geometrySnappedObjectCount: optionalInteger(metrics.geometry_snapped_object_count, 0, "E46A snapped geometry"),
|
||||
sourceGeometryRetainedObjectCount: optionalInteger(metrics.source_geometry_retained_object_count, objectCount, "E46A retained geometry"),
|
||||
categoryCorrectedObjectCount: optionalInteger(metrics.category_corrected_object_count, 0, "E46A category corrections"),
|
||||
hardNegativeFrameCount: integer(metrics.hard_negative_frame_count, "E46A hard negatives"),
|
||||
independentReviewSubmissionCount: exact(metrics.independent_review_submission_count, 0, "E46A independent reviews"),
|
||||
classCounts: parseClassCounts(metrics.class_counts),
|
||||
},
|
||||
taxonomy: {
|
||||
classes,
|
||||
customClasses: ["laptop", "stroller"],
|
||||
unresolvedClassCount: exact(taxonomy.unresolved_class_count, 0, "E46A unresolved classes"),
|
||||
},
|
||||
decision: {
|
||||
preannotationAvailable: exact(decision.preannotation_available, true, "E46A available"),
|
||||
visualReviewComplete: exact(decision.visual_review_complete, true, "E46A visual review"),
|
||||
independentReviewCountAffected: exact(decision.independent_review_count_affected, false, "E46A reviewer isolation"),
|
||||
e48TruthSealOpen: exact(decision.e48_truth_seal_open, false, "E46A E48 state"),
|
||||
l35AcceptanceOpen: exact(decision.l35_acceptance_open, false, "E46A L3.5 state"),
|
||||
nextAction: string(decision.next_action, "E46A next action"),
|
||||
},
|
||||
limitations: array(item.limitations, "E46A limitations").map((value) => string(value, "E46A limitation")),
|
||||
groundTruth: exact(item.ground_truth, false, "E46A truth state"),
|
||||
access: exact(item.access, "read-only-ai-engineering-preannotation-not-truth", "E46A access"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46ACase(
|
||||
resultId: string,
|
||||
sequence: number,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<E46ACase> {
|
||||
if (!/^e46a-ai-engineering-preannotation-[a-f0-9]{64}$/.test(resultId)
|
||||
|| !Number.isInteger(sequence) || sequence < 1 || sequence > 32) {
|
||||
throw new E46AContractError("E46A case request invalid.");
|
||||
}
|
||||
const response = await fetcher(`/api/v1/laboratory/e46a/results/${resultId}/cases/${sequence}`, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new E46AContractError(`E46A frame недоступен: HTTP ${response.status}.`);
|
||||
const item = object(await response.json(), "E46A case");
|
||||
exact(item.schema_version, "missioncore.e46a-ai-engineering-preannotation-case/v1", "E46A case schema");
|
||||
const objects = array(item.objects, "E46A objects").map(parseObject);
|
||||
if (integer(item.object_count, "E46A object count") !== objects.length) {
|
||||
throw new E46AContractError("E46A case object count mismatch.");
|
||||
}
|
||||
return {
|
||||
resultId: exact(item.result_id, resultId, "E46A case result"),
|
||||
truthIslandSequence: exact(item.truth_island_sequence, sequence, "E46A case sequence"),
|
||||
imageId: integer(item.image_id, "E46A image id"),
|
||||
frameIndex: integer(item.frame_index, "E46A frame index"),
|
||||
groupId: string(item.group_id, "E46A group"),
|
||||
sessionSeconds: number(item.session_seconds, "E46A session seconds"),
|
||||
sourceImageSha256: sha256(item.source_image_sha256, "E46A source hash"),
|
||||
objects,
|
||||
objectCount: objects.length,
|
||||
cameraUrl: string(item.camera_url, "E46A camera URL"),
|
||||
groundTruth: exact(item.ground_truth, false, "E46A case truth"),
|
||||
independentReview: exact(item.independent_review, false, "E46A independent review"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
export type E46BMotionState = "dynamic" | "static" | "unknown";
|
||||
|
||||
export interface E46BObject {
|
||||
objectId: string;
|
||||
trackId: string;
|
||||
trackIndex: number;
|
||||
category: string;
|
||||
displayCategory: string;
|
||||
boxXyxy: readonly [number, number, number, number];
|
||||
motionState: E46BMotionState;
|
||||
motionConfidence: number;
|
||||
motionEvidenceObservationCount: number;
|
||||
trailCentersXy: readonly (readonly [number, number])[];
|
||||
}
|
||||
|
||||
export interface E46BCase {
|
||||
resultId: string;
|
||||
truthIslandSequence: number;
|
||||
frameIndex: number;
|
||||
groupId: string;
|
||||
sessionSeconds: number;
|
||||
sourceImageSha256: string;
|
||||
objects: readonly E46BObject[];
|
||||
objectCount: number;
|
||||
motionCounts: Readonly<Record<E46BMotionState, number>>;
|
||||
cameraUrl: string;
|
||||
}
|
||||
|
||||
export interface E46BTemporalMotionResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
sourceE46AResultId: string;
|
||||
sourceE26ResultId: string;
|
||||
metrics: {
|
||||
frameCount: 16;
|
||||
temporalGroupCount: 4;
|
||||
objectObservationCount: 136;
|
||||
trackCount: 34;
|
||||
matchedMotionObservationCount: number;
|
||||
unmatchedMotionObservationCount: number;
|
||||
dynamicTrackCount: number;
|
||||
staticTrackCount: number;
|
||||
unknownTrackCount: number;
|
||||
visuallyReviewedFrameCount: 16;
|
||||
};
|
||||
decision: { stableIdsAvailable: true; motionStateAvailable: true; metricVelocityAvailable: false; nextAction: string };
|
||||
limitations: readonly string[];
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
class ContractError extends Error {}
|
||||
const object = (value: unknown, label: string): Record<string, unknown> => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new ContractError(`${label}: объект.`);
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
const array = (value: unknown, label: string): readonly unknown[] => {
|
||||
if (!Array.isArray(value)) throw new ContractError(`${label}: массив.`);
|
||||
return value;
|
||||
};
|
||||
const string = (value: unknown, label: string): string => {
|
||||
if (typeof value !== "string" || !value.trim()) throw new ContractError(`${label}: строка.`);
|
||||
return value;
|
||||
};
|
||||
const number = (value: unknown, label: string): number => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) throw new ContractError(`${label}: число.`);
|
||||
return value;
|
||||
};
|
||||
const integer = (value: unknown, label: string): number => {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) throw new ContractError(`${label}: целое.`);
|
||||
return parsed;
|
||||
};
|
||||
const exact = <T extends string | boolean | number>(value: unknown, expected: T, label: string): T => {
|
||||
if (value !== expected) throw new ContractError(`${label}: контракт.`);
|
||||
return expected;
|
||||
};
|
||||
const motionState = (value: unknown): E46BMotionState => {
|
||||
if (value !== "dynamic" && value !== "static" && value !== "unknown") throw new ContractError("E46B motion state.");
|
||||
return value;
|
||||
};
|
||||
const displayCategory = (category: string): string => category === "stroller" ? "Коляска"
|
||||
: category === "laptop" ? "Ноутбук"
|
||||
: category === "heavy_vehicle" ? "Тяжёлый транспорт"
|
||||
: category === "person" ? "Человек"
|
||||
: category === "car" ? "Авто" : category;
|
||||
|
||||
export async function fetchE46BTemporalMotion({ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise<E46BTemporalMotionResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46b/results?limit=1", { headers: { Accept: "application/json" }, signal });
|
||||
if (!response.ok) throw new ContractError(`E46B LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "E46B catalog");
|
||||
exact(catalog.schema_version, "missioncore.e46b-temporal-motion-catalog/v1", "E46B catalog schema");
|
||||
const items = array(catalog.items, "E46B results");
|
||||
if (!items.length) return null;
|
||||
if (items.length !== 1) throw new ContractError("E46B catalog size.");
|
||||
const item = object(items[0], "E46B result");
|
||||
exact(item.schema_version, "missioncore.e46b-temporal-motion-view/v1", "E46B view schema");
|
||||
const resultId = string(item.result_id, "E46B result id");
|
||||
if (!/^e46b-temporal-motion-[a-f0-9]{64}$/.test(resultId)) throw new ContractError("E46B identity.");
|
||||
const metrics = object(item.metrics, "E46B metrics");
|
||||
const decision = object(item.decision, "E46B decision");
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "E46B created"),
|
||||
sourceE46AResultId: string(item.source_e46a_result_id, "E46B E46A source"),
|
||||
sourceE26ResultId: string(item.source_e26_result_id, "E46B E26 source"),
|
||||
metrics: {
|
||||
frameCount: exact(metrics.frame_count, 16, "E46B frames"),
|
||||
temporalGroupCount: exact(metrics.temporal_group_count, 4, "E46B groups"),
|
||||
objectObservationCount: exact(metrics.object_observation_count, 136, "E46B observations"),
|
||||
trackCount: exact(metrics.track_count, 34, "E46B tracks"),
|
||||
matchedMotionObservationCount: integer(metrics.matched_motion_observation_count, "E46B matched"),
|
||||
unmatchedMotionObservationCount: integer(metrics.unmatched_motion_observation_count, "E46B unmatched"),
|
||||
dynamicTrackCount: integer(metrics.dynamic_track_count, "E46B dynamic"),
|
||||
staticTrackCount: integer(metrics.static_track_count, "E46B static"),
|
||||
unknownTrackCount: integer(metrics.unknown_track_count, "E46B unknown"),
|
||||
visuallyReviewedFrameCount: exact(metrics.visually_reviewed_frame_count, 16, "E46B reviewed"),
|
||||
},
|
||||
decision: {
|
||||
stableIdsAvailable: exact(decision.stable_ids_available, true, "E46B stable IDs"),
|
||||
motionStateAvailable: exact(decision.motion_state_available, true, "E46B motion"),
|
||||
metricVelocityAvailable: exact(decision.metric_velocity_available, false, "E46B velocity"),
|
||||
nextAction: string(decision.next_action, "E46B next action"),
|
||||
},
|
||||
limitations: array(item.limitations, "E46B limitations").map((value) => string(value, "E46B limitation")),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46BCase(resultId: string, sequence: number, { fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise<E46BCase> {
|
||||
if (!/^e46b-temporal-motion-[a-f0-9]{64}$/.test(resultId)) throw new ContractError("E46B result id.");
|
||||
const response = await fetcher(`/api/v1/laboratory/e46b/results/${resultId}/cases/${sequence}`, { headers: { Accept: "application/json" }, signal });
|
||||
if (!response.ok) throw new ContractError(`E46B frame недоступен: HTTP ${response.status}.`);
|
||||
const item = object(await response.json(), "E46B case");
|
||||
exact(item.schema_version, "missioncore.e46b-temporal-motion-case/v1", "E46B case schema");
|
||||
const objects = array(item.objects, "E46B objects").map((raw): E46BObject => {
|
||||
const value = object(raw, "E46B object");
|
||||
const box = array(value.box_xyxy, "E46B box").map((coordinate) => number(coordinate, "E46B coordinate"));
|
||||
const trail = array(value.trail_centers_xy, "E46B trail").map((rawPoint) => {
|
||||
const point = array(rawPoint, "E46B point").map((coordinate) => number(coordinate, "E46B point coordinate"));
|
||||
if (point.length !== 2) throw new ContractError("E46B point geometry.");
|
||||
return point as [number, number];
|
||||
});
|
||||
if (box.length !== 4 || box[2] <= box[0] || box[3] <= box[1]) throw new ContractError("E46B box geometry.");
|
||||
const category = string(value.category, "E46B category");
|
||||
return {
|
||||
objectId: string(value.object_id, "E46B object id"),
|
||||
trackId: string(value.track_id, "E46B track id"),
|
||||
trackIndex: integer(value.track_index, "E46B track index"),
|
||||
category,
|
||||
displayCategory: displayCategory(category),
|
||||
boxXyxy: box as [number, number, number, number],
|
||||
motionState: motionState(value.motion_state),
|
||||
motionConfidence: number(value.motion_confidence, "E46B motion confidence"),
|
||||
motionEvidenceObservationCount: integer(value.motion_evidence_observation_count, "E46B evidence count"),
|
||||
trailCentersXy: trail,
|
||||
};
|
||||
});
|
||||
const rawCounts = object(item.motion_counts, "E46B motion counts");
|
||||
return {
|
||||
resultId: exact(item.result_id, resultId, "E46B case result"),
|
||||
truthIslandSequence: integer(item.truth_island_sequence, "E46B sequence"),
|
||||
frameIndex: integer(item.frame_index, "E46B frame"),
|
||||
groupId: string(item.group_id, "E46B group"),
|
||||
sessionSeconds: number(item.session_seconds, "E46B time"),
|
||||
sourceImageSha256: string(item.source_image_sha256, "E46B image hash"),
|
||||
objects,
|
||||
objectCount: integer(item.object_count, "E46B object count"),
|
||||
motionCounts: { dynamic: integer(rawCounts.dynamic, "E46B dynamic count"), static: integer(rawCounts.static, "E46B static count"), unknown: integer(rawCounts.unknown, "E46B unknown count") },
|
||||
cameraUrl: string(item.camera_url, "E46B camera URL"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
export type E46CMotionState = "dynamic" | "static" | "unknown";
|
||||
|
||||
export interface E46CCameraObject {
|
||||
objectId: string;
|
||||
category: string;
|
||||
displayCategory: string;
|
||||
boxXyxy: readonly [number, number, number, number];
|
||||
routeTrackId: number | null;
|
||||
worldTrackId: number | null;
|
||||
motionState: E46CMotionState;
|
||||
}
|
||||
export interface E46CWorldObject {
|
||||
worldTrackId: number;
|
||||
routeTrackId: number;
|
||||
category: string;
|
||||
motionState: E46CMotionState;
|
||||
positionMapM: readonly [number, number, number];
|
||||
occupancyFootprintMapXy: readonly (readonly [number, number])[];
|
||||
occupancyEvidenceCurrent: boolean;
|
||||
occupancyCellCount: number;
|
||||
}
|
||||
export interface E46CCase {
|
||||
resultId: string;
|
||||
truthIslandSequence: number;
|
||||
frameIndex: number;
|
||||
groupId: string;
|
||||
sessionSeconds: number;
|
||||
sourceImageSha256: string;
|
||||
fusionState: string;
|
||||
objects: readonly E46CCameraObject[];
|
||||
objectCount: number;
|
||||
matchedRouteObjectCount: number;
|
||||
worldObjects: readonly E46CWorldObject[];
|
||||
worldObjectCount: number;
|
||||
cameraUrl: string;
|
||||
}
|
||||
export interface E46CFullReplayWorldTracksResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
sourceE46AResultId: string;
|
||||
sourceE26ResultId: string;
|
||||
metrics: {
|
||||
routeFrameCount: 4489;
|
||||
routeSpanSeconds: number;
|
||||
fusionObservationCount: number;
|
||||
sourceTrackCount: number;
|
||||
worldTrackCandidateCount: number;
|
||||
worldTrackCount: number;
|
||||
sourceTrackWorldBoundCount: number;
|
||||
worldFrameCount: number;
|
||||
worldObservationCount: number;
|
||||
worldCurrentObservationCount: number;
|
||||
worldHeldObservationCount: number;
|
||||
visualSampleFrameCount: 32;
|
||||
sampleObjectCount: number;
|
||||
sampleMatchedRouteObjectCount: number;
|
||||
sampleUnmatchedObjectCount: number;
|
||||
sampleWorldFrameCount: number;
|
||||
motionObservationCounts: Readonly<Record<E46CMotionState, number>>;
|
||||
};
|
||||
acceptance: { benchmarkPassedEvents: 11; benchmarkTotalEvents: 11 };
|
||||
decision: { routeTrackLayerAvailable: true; worldOccupiedLayerAvailable: true; unknownRemainsOccupied: true; freeSpaceAvailable: false; nextAction: string };
|
||||
limitations: readonly string[];
|
||||
}
|
||||
|
||||
export interface E46CVideoObject {
|
||||
boxXyxy: readonly [number, number, number, number];
|
||||
category: string;
|
||||
displayCategory: string;
|
||||
score: number;
|
||||
routeTrackId: number;
|
||||
worldTrackId: number | null;
|
||||
motionState: E46CMotionState;
|
||||
motionConfidence: number;
|
||||
trackHits: number;
|
||||
trackAgeSeconds: number;
|
||||
cameraEvidenceCurrent: boolean;
|
||||
worldEvidenceCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface E46CVideoFrame {
|
||||
frameIndex: number;
|
||||
sessionSeconds: number;
|
||||
fusionState: string;
|
||||
objects: readonly E46CVideoObject[];
|
||||
}
|
||||
|
||||
export interface E46CVideoReviewWindow {
|
||||
id: string;
|
||||
kind: string;
|
||||
classGroup: string;
|
||||
startSeconds: number;
|
||||
endSeconds: number;
|
||||
targetSourceTrackIds: readonly number[];
|
||||
}
|
||||
|
||||
export interface E46CVideoOverlay {
|
||||
resultId: string;
|
||||
recordedSourceSessionId: string;
|
||||
recordedSourceId: "sensor.camera.right";
|
||||
inputSha256: string;
|
||||
imageWidth: 800;
|
||||
imageHeight: 600;
|
||||
timelineStartSeconds: number;
|
||||
timelineEndSeconds: number;
|
||||
frameCount: 4489;
|
||||
frames: readonly E46CVideoFrame[];
|
||||
reviewWindows: readonly E46CVideoReviewWindow[];
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
class ContractError extends Error {}
|
||||
const object = (value: unknown, label: string): Record<string, unknown> => { if (!value || typeof value !== "object" || Array.isArray(value)) throw new ContractError(`${label}: объект.`); return value as Record<string, unknown>; };
|
||||
const array = (value: unknown, label: string): readonly unknown[] => { if (!Array.isArray(value)) throw new ContractError(`${label}: массив.`); return value; };
|
||||
const string = (value: unknown, label: string): string => { if (typeof value !== "string" || !value.trim()) throw new ContractError(`${label}: строка.`); return value; };
|
||||
const number = (value: unknown, label: string): number => { if (typeof value !== "number" || !Number.isFinite(value)) throw new ContractError(`${label}: число.`); return value; };
|
||||
const integer = (value: unknown, label: string): number => { const parsed = number(value, label); if (!Number.isInteger(parsed) || parsed < 0) throw new ContractError(`${label}: целое.`); return parsed; };
|
||||
const optionalInteger = (value: unknown, label: string): number | null => value === null ? null : integer(value, label);
|
||||
const exact = <T extends string | boolean | number>(value: unknown, expected: T, label: string): T => { if (value !== expected) throw new ContractError(`${label}: контракт.`); return expected; };
|
||||
const motionState = (value: unknown): E46CMotionState => { if (value !== "dynamic" && value !== "static" && value !== "unknown") throw new ContractError("E46C motion state."); return value; };
|
||||
const displayCategory = (category: string): string => category === "stroller" ? "Коляска" : category === "laptop" ? "Ноутбук" : category === "heavy_vehicle" || category === "truck" || category === "bus" ? "Тяжёлый транспорт" : category === "person" ? "Человек" : category === "car" ? "Авто" : category;
|
||||
const vector = (value: unknown, length: number, label: string): number[] => { const parsed = array(value, label).map((item) => number(item, label)); if (parsed.length !== length) throw new ContractError(`${label}: geometry.`); return parsed; };
|
||||
|
||||
export async function fetchE46CFullReplayWorldTracks({ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise<E46CFullReplayWorldTracksResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46c/results?limit=1", { headers: { Accept: "application/json" }, signal });
|
||||
if (!response.ok) throw new ContractError(`E46C LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "E46C catalog"); exact(catalog.schema_version, "missioncore.e46c-full-replay-world-tracks-catalog/v1", "E46C catalog schema");
|
||||
const items = array(catalog.items, "E46C results"); if (!items.length) return null; if (items.length !== 1) throw new ContractError("E46C catalog size.");
|
||||
const item = object(items[0], "E46C result"); exact(item.schema_version, "missioncore.e46c-full-replay-world-tracks-view/v1", "E46C view schema");
|
||||
const resultId = string(item.result_id, "E46C result id"); if (!/^e46c-full-replay-world-tracks-[a-f0-9]{64}$/.test(resultId)) throw new ContractError("E46C identity.");
|
||||
const metrics = object(item.metrics, "E46C metrics"); const counts = object(metrics.motion_observation_counts, "E46C motion counts"); const acceptance = object(item.acceptance, "E46C acceptance"); const decision = object(item.decision, "E46C decision");
|
||||
return { resultId, createdAtUtc: string(item.created_at_utc, "E46C created"), sourceE46AResultId: string(item.source_e46a_result_id, "E46C E46A"), sourceE26ResultId: string(item.source_e26_result_id, "E46C E26"), metrics: { routeFrameCount: exact(metrics.route_frame_count, 4489, "E46C route frames"), routeSpanSeconds: number(metrics.route_span_seconds, "E46C span"), fusionObservationCount: integer(metrics.fusion_observation_count, "E46C observations"), sourceTrackCount: integer(metrics.source_track_count, "E46C source tracks"), worldTrackCandidateCount: integer(metrics.world_track_candidate_count, "E46C world candidates"), worldTrackCount: integer(metrics.world_track_count, "E46C world tracks"), sourceTrackWorldBoundCount: integer(metrics.source_track_world_bound_count, "E46C bound tracks"), worldFrameCount: integer(metrics.world_frame_count, "E46C world frames"), worldObservationCount: integer(metrics.world_observation_count, "E46C world observations"), worldCurrentObservationCount: integer(metrics.world_current_observation_count, "E46C current world"), worldHeldObservationCount: integer(metrics.world_held_observation_count, "E46C held world"), visualSampleFrameCount: exact(metrics.visual_sample_frame_count, 32, "E46C samples"), sampleObjectCount: integer(metrics.sample_object_count, "E46C sample objects"), sampleMatchedRouteObjectCount: integer(metrics.sample_matched_route_object_count, "E46C matched"), sampleUnmatchedObjectCount: integer(metrics.sample_unmatched_object_count, "E46C unmatched"), sampleWorldFrameCount: integer(metrics.sample_world_frame_count, "E46C sample world frames"), motionObservationCounts: { dynamic: integer(counts.dynamic, "E46C dynamic"), static: integer(counts.static, "E46C static"), unknown: integer(counts.unknown, "E46C unknown") } }, acceptance: { benchmarkPassedEvents: exact(acceptance.benchmark_passed_events, 11, "E46C passed events"), benchmarkTotalEvents: exact(acceptance.benchmark_total_events, 11, "E46C total events") }, decision: { routeTrackLayerAvailable: exact(decision.route_track_layer_available, true, "E46C route layer"), worldOccupiedLayerAvailable: exact(decision.world_occupied_layer_available, true, "E46C world layer"), unknownRemainsOccupied: exact(decision.unknown_remains_occupied, true, "E46C unknown"), freeSpaceAvailable: exact(decision.free_space_available, false, "E46C free space"), nextAction: string(decision.next_action, "E46C next") }, limitations: array(item.limitations, "E46C limitations").map((value) => string(value, "E46C limitation")) };
|
||||
}
|
||||
|
||||
export async function fetchE46CCase(resultId: string, sequence: number, { fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise<E46CCase> {
|
||||
const response = await fetcher(`/api/v1/laboratory/e46c/results/${resultId}/cases/${sequence}`, { headers: { Accept: "application/json" }, signal }); if (!response.ok) throw new ContractError(`E46C frame недоступен: HTTP ${response.status}.`);
|
||||
const item = object(await response.json(), "E46C case"); exact(item.schema_version, "missioncore.e46c-full-replay-world-track-case/v1", "E46C case schema");
|
||||
const objects = array(item.objects, "E46C objects").map((raw): E46CCameraObject => { const value = object(raw, "E46C object"); const category = string(value.category, "E46C category"); const box = vector(value.box_xyxy, 4, "E46C box") as [number, number, number, number]; return { objectId: string(value.object_id, "E46C object id"), category, displayCategory: displayCategory(category), boxXyxy: box, routeTrackId: optionalInteger(value.route_track_id, "E46C route track"), worldTrackId: optionalInteger(value.world_track_id, "E46C world track"), motionState: motionState(value.motion_state) }; });
|
||||
const worldObjects = array(item.world_objects, "E46C world objects").map((raw): E46CWorldObject => { const value = object(raw, "E46C world object"); return { worldTrackId: integer(value.world_track_id, "E46C world id"), routeTrackId: integer(value.route_track_id, "E46C route id"), category: string(value.category, "E46C world category"), motionState: motionState(value.motion_state), positionMapM: vector(value.position_map_m, 3, "E46C position") as [number, number, number], occupancyFootprintMapXy: array(value.occupancy_footprint_map_xy, "E46C footprint").map((point) => vector(point, 2, "E46C footprint point") as [number, number]), occupancyEvidenceCurrent: exact(value.occupancy_evidence_current, Boolean(value.occupancy_evidence_current), "E46C current") as boolean, occupancyCellCount: integer(value.occupancy_cell_count, "E46C cells") }; });
|
||||
return { resultId: exact(item.result_id, resultId, "E46C case result"), truthIslandSequence: integer(item.truth_island_sequence, "E46C sequence"), frameIndex: integer(item.frame_index, "E46C frame"), groupId: string(item.group_id, "E46C group"), sessionSeconds: number(item.session_seconds, "E46C time"), sourceImageSha256: string(item.source_image_sha256, "E46C image hash"), fusionState: string(item.fusion_state, "E46C fusion"), objects, objectCount: integer(item.object_count, "E46C object count"), matchedRouteObjectCount: integer(item.matched_route_object_count, "E46C matched count"), worldObjects, worldObjectCount: integer(item.world_object_count, "E46C world count"), cameraUrl: string(item.camera_url, "E46C camera URL") };
|
||||
}
|
||||
|
||||
export async function fetchE46CVideoOverlay(
|
||||
resultId: string,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<E46CVideoOverlay> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e46c/results/${resultId}/video-overlay`,
|
||||
{ headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new ContractError(`E46C video overlay недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
const payload = object(await response.json(), "E46C video overlay");
|
||||
exact(
|
||||
payload.schema_version,
|
||||
"missioncore.e46c-recorded-video-overlay/v1",
|
||||
"E46C video overlay schema",
|
||||
);
|
||||
exact(payload.result_id, resultId, "E46C video result");
|
||||
exact(payload.ground_truth, false, "E46C video truth");
|
||||
const recordedSource = object(payload.recorded_source, "E46C recorded source");
|
||||
exact(recordedSource.source_id, "sensor.camera.right", "E46C video source");
|
||||
exact(
|
||||
recordedSource.synchronization,
|
||||
"host-arrival-best-effort",
|
||||
"E46C video synchronization",
|
||||
);
|
||||
const inputSha256 = string(recordedSource.input_sha256, "E46C video input hash");
|
||||
if (!/^[a-f0-9]{64}$/.test(inputSha256)) throw new ContractError("E46C video input hash.");
|
||||
const frames = array(payload.frames, "E46C video frames").map(
|
||||
(raw, frameSequence): E46CVideoFrame => {
|
||||
const frame = object(raw, "E46C video frame");
|
||||
const frameIndex = integer(frame.frame_index, "E46C video frame index");
|
||||
if (frameIndex !== frameSequence) throw new ContractError("E46C video frame order.");
|
||||
return {
|
||||
frameIndex,
|
||||
sessionSeconds: number(frame.session_seconds, "E46C video frame time"),
|
||||
fusionState: string(frame.fusion_state, "E46C video fusion state"),
|
||||
objects: array(frame.objects, "E46C video objects").map((rawObject) => {
|
||||
const item = object(rawObject, "E46C video object");
|
||||
const category = string(item.category, "E46C video category");
|
||||
return {
|
||||
boxXyxy: vector(item.bbox_xyxy, 4, "E46C video box") as [number, number, number, number],
|
||||
category,
|
||||
displayCategory: displayCategory(category),
|
||||
score: number(item.score, "E46C video score"),
|
||||
routeTrackId: integer(item.route_track_id, "E46C video route track"),
|
||||
worldTrackId: optionalInteger(item.world_track_id, "E46C video world track"),
|
||||
motionState: motionState(item.motion_state),
|
||||
motionConfidence: number(item.motion_confidence, "E46C video motion confidence"),
|
||||
trackHits: integer(item.track_hits, "E46C video track hits"),
|
||||
trackAgeSeconds: number(item.track_age_seconds, "E46C video track age"),
|
||||
cameraEvidenceCurrent: exact(
|
||||
item.camera_evidence_current,
|
||||
Boolean(item.camera_evidence_current),
|
||||
"E46C camera evidence",
|
||||
) as boolean,
|
||||
worldEvidenceCurrent: exact(
|
||||
item.world_evidence_current,
|
||||
Boolean(item.world_evidence_current),
|
||||
"E46C world evidence",
|
||||
) as boolean,
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
const reviewWindows = array(payload.review_windows, "E46C video review windows").map(
|
||||
(raw): E46CVideoReviewWindow => {
|
||||
const window = object(raw, "E46C video review window");
|
||||
return {
|
||||
id: string(window.id, "E46C video review id"),
|
||||
kind: string(window.kind, "E46C video review kind"),
|
||||
classGroup: string(window.class_group, "E46C video review class"),
|
||||
startSeconds: number(window.start_seconds, "E46C video review start"),
|
||||
endSeconds: number(window.end_seconds, "E46C video review end"),
|
||||
targetSourceTrackIds: array(
|
||||
window.target_source_track_ids,
|
||||
"E46C video review targets",
|
||||
).map((value) => integer(value, "E46C video review target")),
|
||||
};
|
||||
},
|
||||
);
|
||||
const timelineStartSeconds = number(
|
||||
payload.timeline_start_seconds,
|
||||
"E46C video timeline start",
|
||||
);
|
||||
const timelineEndSeconds = number(
|
||||
payload.timeline_end_seconds,
|
||||
"E46C video timeline end",
|
||||
);
|
||||
if (
|
||||
frames.length !== 4489 ||
|
||||
exact(payload.frame_count, 4489, "E46C video frame count") !== frames.length ||
|
||||
frames[0]?.sessionSeconds !== timelineStartSeconds ||
|
||||
frames.at(-1)?.sessionSeconds !== timelineEndSeconds
|
||||
) {
|
||||
throw new ContractError("E46C video timeline coverage.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
recordedSourceSessionId: string(
|
||||
recordedSource.session_id,
|
||||
"E46C recorded source session",
|
||||
),
|
||||
recordedSourceId: "sensor.camera.right",
|
||||
inputSha256,
|
||||
imageWidth: exact(payload.image_width, 800, "E46C video width"),
|
||||
imageHeight: exact(payload.image_height, 600, "E46C video height"),
|
||||
timelineStartSeconds,
|
||||
timelineEndSeconds,
|
||||
frameCount: 4489,
|
||||
frames,
|
||||
reviewWindows,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectE46CVideoFrame(
|
||||
frames: readonly E46CVideoFrame[],
|
||||
sessionSeconds: number,
|
||||
): E46CVideoFrame | null {
|
||||
if (!frames.length || !Number.isFinite(sessionSeconds)) return null;
|
||||
let low = 0;
|
||||
let high = frames.length - 1;
|
||||
while (low <= high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
const frame = frames[middle];
|
||||
if (!frame) break;
|
||||
if (frame.sessionSeconds <= sessionSeconds) low = middle + 1;
|
||||
else high = middle - 1;
|
||||
}
|
||||
const before = frames[Math.max(0, high)] ?? null;
|
||||
const after = frames[Math.min(frames.length - 1, low)] ?? null;
|
||||
if (!before) return after;
|
||||
if (!after) return before;
|
||||
return Math.abs(before.sessionSeconds - sessionSeconds) <=
|
||||
Math.abs(after.sessionSeconds - sessionSeconds)
|
||||
? before
|
||||
: after;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
export type E46DTemporalSignalKind =
|
||||
| "layer-blackout"
|
||||
| "camera-evidence-hold"
|
||||
| "route-layer-gap"
|
||||
| "route-id-rebirth-candidate"
|
||||
| "bbox-jump"
|
||||
| "motion-state-flap"
|
||||
| "world-binding-flap"
|
||||
| "short-track-burst";
|
||||
|
||||
export type E46DTemporalPriority = "critical" | "high" | "medium";
|
||||
|
||||
export interface E46DReviewClip {
|
||||
clipId: string;
|
||||
rank: number;
|
||||
priority: E46DTemporalPriority;
|
||||
kind: E46DTemporalSignalKind;
|
||||
signalId: string;
|
||||
startSeconds: number;
|
||||
eventStartSeconds: number;
|
||||
eventEndSeconds: number;
|
||||
endSeconds: number;
|
||||
startFrame: number;
|
||||
endFrame: number;
|
||||
routeTrackIds: readonly number[];
|
||||
worldTrackIds: readonly number[];
|
||||
evidence: Readonly<Record<string, string | number | boolean>>;
|
||||
}
|
||||
|
||||
export interface E46DTemporalFailureAuditResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
sourceE46CResultId: string;
|
||||
sourceE26ResultId: string;
|
||||
metrics: {
|
||||
routeFrameCount: 4489;
|
||||
routeSpanSeconds: number;
|
||||
objectObservationCount: number;
|
||||
routeTrackCount: number;
|
||||
zeroObjectFrameCount: number;
|
||||
zeroObjectFrameFraction: number;
|
||||
cameraHeldObservationCount: number;
|
||||
cameraHeldObservationFraction: number;
|
||||
detectorHoldEpisodeCount: number;
|
||||
layerBlackoutEpisodeCount: number;
|
||||
routeLayerGapEpisodeCount: number;
|
||||
routeIdRebirthCandidateCount: number;
|
||||
bboxJumpEpisodeCount: number;
|
||||
motionStateFlapEpisodeCount: number;
|
||||
worldBindingFlapEpisodeCount: number;
|
||||
shortRouteTrackCount: number;
|
||||
shortRouteTrackFraction: number;
|
||||
shortTrackBurstEpisodeCount: number;
|
||||
failureSignalCount: number;
|
||||
reviewClipCount: number;
|
||||
temporalContinuityPassed: boolean;
|
||||
};
|
||||
acceptance: {
|
||||
fullRouteAccounted: true;
|
||||
temporalContinuityPassed: boolean;
|
||||
independentTruthAvailable: false;
|
||||
navigationOrSafetyAccepted: false;
|
||||
};
|
||||
decision: {
|
||||
temporalRegressionConfirmed: boolean;
|
||||
detectorGapVisible: boolean;
|
||||
routeFragmentationVisible: boolean;
|
||||
worldBindingInstabilityVisible: boolean;
|
||||
nextAction: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
reviewClips: readonly E46DReviewClip[];
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
class ContractError extends Error {}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) throw new ContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new ContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new ContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new ContractError(`${label}: ожидалось неотрицательное целое.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function exact<T extends string | number | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) throw new ContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") throw new ContractError(`${label}: ожидался boolean.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function priority(value: unknown): E46DTemporalPriority {
|
||||
if (value !== "critical" && value !== "high" && value !== "medium") {
|
||||
throw new ContractError("E46D priority: неизвестное значение.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function kind(value: unknown): E46DTemporalSignalKind {
|
||||
if (
|
||||
value !== "layer-blackout"
|
||||
&& value !== "camera-evidence-hold"
|
||||
&& value !== "route-layer-gap"
|
||||
&& value !== "route-id-rebirth-candidate"
|
||||
&& value !== "bbox-jump"
|
||||
&& value !== "motion-state-flap"
|
||||
&& value !== "world-binding-flap"
|
||||
&& value !== "short-track-burst"
|
||||
) {
|
||||
throw new ContractError("E46D signal kind: неизвестное значение.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function shaResult(value: unknown, prefix: string, label: string): string {
|
||||
const parsed = string(value, label);
|
||||
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||
throw new ContractError(`${label}: нарушена идентичность.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function evidence(value: unknown): Readonly<Record<string, string | number | boolean>> {
|
||||
const raw = object(value, "E46D clip evidence");
|
||||
const parsed: Record<string, string | number | boolean> = {};
|
||||
for (const [key, item] of Object.entries(raw)) {
|
||||
if (
|
||||
typeof item !== "string"
|
||||
&& typeof item !== "number"
|
||||
&& typeof item !== "boolean"
|
||||
) {
|
||||
throw new ContractError("E46D clip evidence: неизвестное значение.");
|
||||
}
|
||||
parsed[key] = item;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseClip(value: unknown, expectedRank: number): E46DReviewClip {
|
||||
const item = object(value, "E46D review clip");
|
||||
exact(item.schema_version, "missioncore.e46d-temporal-review-clip/v1", "E46D clip schema");
|
||||
const rank = integer(item.rank, "E46D clip rank");
|
||||
if (rank !== expectedRank) throw new ContractError("E46D clip order: нарушен контракт.");
|
||||
const clipId = string(item.clip_id, "E46D clip id");
|
||||
const signalId = string(item.signal_id, "E46D signal id");
|
||||
if (!/^e46d-clip-\d{2}-[a-f0-9]{20}$/.test(clipId) || !/^e46d-signal-[a-f0-9]{20}$/.test(signalId)) {
|
||||
throw new ContractError("E46D clip identity: нарушен контракт.");
|
||||
}
|
||||
const startSeconds = number(item.start_seconds, "E46D clip start");
|
||||
const eventStartSeconds = number(item.event_start_seconds, "E46D event start");
|
||||
const eventEndSeconds = number(item.event_end_seconds, "E46D event end");
|
||||
const endSeconds = number(item.end_seconds, "E46D clip end");
|
||||
if (!(startSeconds <= eventStartSeconds && eventStartSeconds <= eventEndSeconds && eventEndSeconds <= endSeconds)) {
|
||||
throw new ContractError("E46D clip timeline: нарушен контракт.");
|
||||
}
|
||||
return {
|
||||
clipId,
|
||||
rank,
|
||||
priority: priority(item.priority),
|
||||
kind: kind(item.kind),
|
||||
signalId,
|
||||
startSeconds,
|
||||
eventStartSeconds,
|
||||
eventEndSeconds,
|
||||
endSeconds,
|
||||
startFrame: integer(item.start_frame, "E46D clip start frame"),
|
||||
endFrame: integer(item.end_frame, "E46D clip end frame"),
|
||||
routeTrackIds: array(item.route_track_ids, "E46D clip route tracks").map((entry) => integer(entry, "E46D route track")),
|
||||
worldTrackIds: array(item.world_track_ids, "E46D clip world tracks").map((entry) => integer(entry, "E46D world track")),
|
||||
evidence: evidence(item.evidence),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46DTemporalFailureAudit({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E46DTemporalFailureAuditResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46d/results?limit=1", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new ContractError(`E46D LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "E46D catalog");
|
||||
exact(
|
||||
catalog.schema_version,
|
||||
"missioncore.e46d-temporal-failure-audit-catalog/v1",
|
||||
"E46D catalog schema",
|
||||
);
|
||||
const items = array(catalog.items, "E46D results");
|
||||
if (!items.length) return null;
|
||||
if (items.length !== 1) throw new ContractError("E46D catalog size: нарушен контракт.");
|
||||
const item = object(items[0], "E46D result");
|
||||
exact(
|
||||
item.schema_version,
|
||||
"missioncore.e46d-temporal-failure-audit-view/v1",
|
||||
"E46D view schema",
|
||||
);
|
||||
exact(item.ground_truth, false, "E46D truth authority");
|
||||
const metrics = object(item.metrics, "E46D metrics");
|
||||
const acceptance = object(item.acceptance, "E46D acceptance");
|
||||
const decision = object(item.decision, "E46D decision");
|
||||
const clips = array(item.review_clips, "E46D review clips").map((clip, index) => (
|
||||
parseClip(clip, index + 1)
|
||||
));
|
||||
const result: E46DTemporalFailureAuditResult = {
|
||||
resultId: shaResult(item.result_id, "e46d-temporal-failure-audit", "E46D result id"),
|
||||
createdAtUtc: string(item.created_at_utc, "E46D created"),
|
||||
sourceE46CResultId: shaResult(item.source_e46c_result_id, "e46c-full-replay-world-tracks", "E46D E46C source"),
|
||||
sourceE26ResultId: shaResult(item.source_e26_result_id, "e10-integrated-perception", "E46D E26 source"),
|
||||
metrics: {
|
||||
routeFrameCount: exact(metrics.route_frame_count, 4489, "E46D route frames"),
|
||||
routeSpanSeconds: number(metrics.route_span_seconds, "E46D route span"),
|
||||
objectObservationCount: integer(metrics.object_observation_count, "E46D observations"),
|
||||
routeTrackCount: integer(metrics.route_track_count, "E46D route tracks"),
|
||||
zeroObjectFrameCount: integer(metrics.zero_object_frame_count, "E46D zero frames"),
|
||||
zeroObjectFrameFraction: number(metrics.zero_object_frame_fraction, "E46D zero fraction"),
|
||||
cameraHeldObservationCount: integer(metrics.camera_held_observation_count, "E46D held observations"),
|
||||
cameraHeldObservationFraction: number(metrics.camera_held_observation_fraction, "E46D held fraction"),
|
||||
detectorHoldEpisodeCount: integer(metrics.detector_hold_episode_count, "E46D detector holds"),
|
||||
layerBlackoutEpisodeCount: integer(metrics.layer_blackout_episode_count, "E46D blackouts"),
|
||||
routeLayerGapEpisodeCount: integer(metrics.route_layer_gap_episode_count, "E46D route gaps"),
|
||||
routeIdRebirthCandidateCount: integer(metrics.route_id_rebirth_candidate_count, "E46D ID candidates"),
|
||||
bboxJumpEpisodeCount: integer(metrics.bbox_jump_episode_count, "E46D bbox jumps"),
|
||||
motionStateFlapEpisodeCount: integer(metrics.motion_state_flap_episode_count, "E46D motion flaps"),
|
||||
worldBindingFlapEpisodeCount: integer(metrics.world_binding_flap_episode_count, "E46D world flaps"),
|
||||
shortRouteTrackCount: integer(metrics.short_route_track_count, "E46D short tracks"),
|
||||
shortRouteTrackFraction: number(metrics.short_route_track_fraction, "E46D short fraction"),
|
||||
shortTrackBurstEpisodeCount: integer(metrics.short_track_burst_episode_count, "E46D short bursts"),
|
||||
failureSignalCount: integer(metrics.failure_signal_count, "E46D failure signals"),
|
||||
reviewClipCount: integer(metrics.review_clip_count, "E46D review clips"),
|
||||
temporalContinuityPassed: boolean(metrics.temporal_continuity_passed, "E46D continuity"),
|
||||
},
|
||||
acceptance: {
|
||||
fullRouteAccounted: exact(acceptance.full_route_accounted, true, "E46D full route"),
|
||||
temporalContinuityPassed: boolean(acceptance.temporal_continuity_passed, "E46D acceptance continuity"),
|
||||
independentTruthAvailable: exact(acceptance.independent_truth_available, false, "E46D truth"),
|
||||
navigationOrSafetyAccepted: exact(acceptance.navigation_or_safety_accepted, false, "E46D safety"),
|
||||
},
|
||||
decision: {
|
||||
temporalRegressionConfirmed: boolean(decision.temporal_regression_confirmed, "E46D regression"),
|
||||
detectorGapVisible: boolean(decision.detector_gap_visible, "E46D detector gap"),
|
||||
routeFragmentationVisible: boolean(decision.route_fragmentation_visible, "E46D fragmentation"),
|
||||
worldBindingInstabilityVisible: boolean(decision.world_binding_instability_visible, "E46D world binding"),
|
||||
nextAction: string(decision.next_action, "E46D next action"),
|
||||
},
|
||||
limitations: array(item.limitations, "E46D limitations").map((value) => string(value, "E46D limitation")),
|
||||
reviewClips: clips,
|
||||
};
|
||||
if (result.metrics.reviewClipCount !== clips.length) {
|
||||
throw new ContractError("E46D review clip accounting: нарушен контракт.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
export interface E46ELaboratoryMethod {
|
||||
completeness: "complete";
|
||||
executionClass: "hybrid";
|
||||
pipelineId: string;
|
||||
components: readonly {
|
||||
kind: "source" | "tool" | "model" | "algorithm" | "runtime";
|
||||
name: string;
|
||||
version: string;
|
||||
role: string;
|
||||
identitySha256: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface E46EReadyStackResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
sourceSessionId: string;
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
metrics: {
|
||||
frameCount: 4489;
|
||||
routeDurationSeconds: number;
|
||||
detectionObservationCount: number;
|
||||
trackObservationCount: number;
|
||||
detectionBoxClippedCount: number;
|
||||
trackBoxClippedCount: number;
|
||||
uniqueTrackCount: number;
|
||||
meanTrackedObjectsPerFrame: number;
|
||||
zeroDetectionFrameCount: number;
|
||||
zeroTrackFrameCount: number;
|
||||
trackerRecoveredFrameCount: number;
|
||||
fullLayerBlackoutEventCount: number;
|
||||
routeIdGapEventCount: number;
|
||||
shortTrackCount: number;
|
||||
shortTrackFraction: number;
|
||||
trackClassSwitchCount: number;
|
||||
};
|
||||
acceptance: {
|
||||
fullRouteAccounted: true;
|
||||
stockDetectorTrackerExecuted: true;
|
||||
visualOverlayAvailable: true;
|
||||
independentTruthAvailable: false;
|
||||
navigationOrSafetyAccepted: false;
|
||||
};
|
||||
decision: {
|
||||
readyStackBaselineAvailable: true;
|
||||
customTemporalLogicUsed: false;
|
||||
nextAction: string;
|
||||
};
|
||||
method: E46ELaboratoryMethod;
|
||||
limitations: readonly string[];
|
||||
video: {
|
||||
url: string;
|
||||
mediaType: "video/mp4";
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
width: 800;
|
||||
height: 600;
|
||||
};
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
class ContractError extends Error {}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) throw new ContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new ContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new ContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new ContractError(`${label}: ожидалось неотрицательное целое.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function exact<T extends string | number | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) throw new ContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function sha(value: unknown, label: string): string {
|
||||
const parsed = string(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new ContractError(`${label}: нарушена SHA-256 идентичность.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function method(value: unknown): E46ELaboratoryMethod {
|
||||
const raw = object(value, "E46E method");
|
||||
exact(raw.schema_version, "missioncore.laboratory-method/v1", "E46E method schema");
|
||||
exact(raw.completeness, "complete", "E46E method completeness");
|
||||
exact(raw.execution_class, "hybrid", "E46E execution class");
|
||||
const components = array(raw.components, "E46E method components").map((entry) => {
|
||||
const component = object(entry, "E46E method component");
|
||||
const kind = string(component.kind, "E46E component kind");
|
||||
if (!(["source", "tool", "model", "algorithm", "runtime"] as const).includes(
|
||||
kind as "source",
|
||||
)) {
|
||||
throw new ContractError("E46E component kind: неизвестное значение.");
|
||||
}
|
||||
return {
|
||||
kind: kind as "source" | "tool" | "model" | "algorithm" | "runtime",
|
||||
name: string(component.name, "E46E component name"),
|
||||
version: string(component.version, "E46E component version"),
|
||||
role: string(component.role, "E46E component role"),
|
||||
identitySha256: sha(component.identity_sha256, "E46E component identity"),
|
||||
};
|
||||
});
|
||||
return {
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: string(raw.pipeline_id, "E46E pipeline"),
|
||||
components,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46EReadyStack({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E46EReadyStackResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46e/results?limit=1", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new ContractError(`E46E LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "E46E catalog");
|
||||
exact(catalog.schema_version, "missioncore.e46e-ready-stack-catalog/v1", "E46E catalog schema");
|
||||
const items = array(catalog.items, "E46E results");
|
||||
if (!items.length) return null;
|
||||
if (items.length !== 1) throw new ContractError("E46E catalog size: нарушен контракт.");
|
||||
const item = object(items[0], "E46E result");
|
||||
exact(item.schema_version, "missioncore.e46e-ready-stack-view/v1", "E46E view schema");
|
||||
exact(item.ground_truth, false, "E46E truth authority");
|
||||
const metrics = object(item.metrics, "E46E metrics");
|
||||
const acceptance = object(item.acceptance, "E46E acceptance");
|
||||
const decision = object(item.decision, "E46E decision");
|
||||
const video = object(item.video, "E46E video");
|
||||
const resultId = string(item.result_id, "E46E result id");
|
||||
if (!/^e46e-ready-stack-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new ContractError("E46E result identity: нарушен контракт.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "E46E created"),
|
||||
sourceSessionId: string(item.source_session_id, "E46E source session"),
|
||||
cameraSourceId: exact(item.camera_source_id, "sensor.camera.right", "E46E camera"),
|
||||
metrics: {
|
||||
frameCount: exact(metrics.frame_count, 4489, "E46E frames"),
|
||||
routeDurationSeconds: number(metrics.route_duration_seconds, "E46E duration"),
|
||||
detectionObservationCount: integer(metrics.detection_observation_count, "E46E detections"),
|
||||
trackObservationCount: integer(metrics.track_observation_count, "E46E track observations"),
|
||||
detectionBoxClippedCount: integer(metrics.detection_box_clipped_count, "E46E clipped detections"),
|
||||
trackBoxClippedCount: integer(metrics.track_box_clipped_count, "E46E clipped tracks"),
|
||||
uniqueTrackCount: integer(metrics.unique_track_count, "E46E tracks"),
|
||||
meanTrackedObjectsPerFrame: number(metrics.mean_tracked_objects_per_frame, "E46E mean objects"),
|
||||
zeroDetectionFrameCount: integer(metrics.zero_detection_frame_count, "E46E zero detections"),
|
||||
zeroTrackFrameCount: integer(metrics.zero_track_frame_count, "E46E zero tracks"),
|
||||
trackerRecoveredFrameCount: integer(metrics.tracker_recovered_frame_count, "E46E recovered frames"),
|
||||
fullLayerBlackoutEventCount: integer(metrics.full_layer_blackout_event_count, "E46E blackouts"),
|
||||
routeIdGapEventCount: integer(metrics.route_id_gap_event_count, "E46E ID gaps"),
|
||||
shortTrackCount: integer(metrics.short_track_count, "E46E short tracks"),
|
||||
shortTrackFraction: number(metrics.short_track_fraction, "E46E short fraction"),
|
||||
trackClassSwitchCount: integer(metrics.track_class_switch_count, "E46E class switches"),
|
||||
},
|
||||
acceptance: {
|
||||
fullRouteAccounted: exact(acceptance.full_route_accounted, true, "E46E full route"),
|
||||
stockDetectorTrackerExecuted: exact(acceptance.stock_detector_tracker_executed, true, "E46E ready stack"),
|
||||
visualOverlayAvailable: exact(acceptance.visual_overlay_available, true, "E46E visual"),
|
||||
independentTruthAvailable: exact(acceptance.independent_truth_available, false, "E46E truth"),
|
||||
navigationOrSafetyAccepted: exact(acceptance.navigation_or_safety_accepted, false, "E46E safety"),
|
||||
},
|
||||
decision: {
|
||||
readyStackBaselineAvailable: exact(decision.ready_stack_baseline_available, true, "E46E baseline"),
|
||||
customTemporalLogicUsed: exact(decision.custom_temporal_logic_used, false, "E46E custom logic"),
|
||||
nextAction: string(decision.next_action, "E46E next action"),
|
||||
},
|
||||
method: method(item.method),
|
||||
limitations: array(item.limitations, "E46E limitations").map((entry) => string(entry, "E46E limitation")),
|
||||
video: {
|
||||
url: string(video.url, "E46E video URL"),
|
||||
mediaType: exact(video.media_type, "video/mp4", "E46E media type"),
|
||||
byteLength: integer(video.byte_length, "E46E video bytes"),
|
||||
sha256: sha(video.sha256, "E46E video SHA"),
|
||||
width: exact(video.width, 800, "E46E video width"),
|
||||
height: exact(video.height, 600, "E46E video height"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import type { E46ELaboratoryMethod } from "./e46eReadyStack";
|
||||
|
||||
export interface E46FMetrics {
|
||||
frameCount: 4489;
|
||||
routeDurationSeconds: number;
|
||||
detectionObservationCount: number;
|
||||
trackObservationCount: number;
|
||||
detectionBoxClippedCount: number;
|
||||
trackBoxClippedCount: number;
|
||||
uniqueTrackCount: number;
|
||||
meanTrackedObjectsPerFrame: number;
|
||||
zeroDetectionFrameCount: number;
|
||||
zeroTrackFrameCount: number;
|
||||
trackerRecoveredFrameCount: number;
|
||||
fullLayerBlackoutEventCount: number;
|
||||
routeIdGapEventCount: number;
|
||||
shortTrackCount: number;
|
||||
shortTrackFraction: number;
|
||||
trackClassSwitchCount: number;
|
||||
}
|
||||
|
||||
export interface E46FLargeBoxTriage {
|
||||
observationCount: number;
|
||||
frameCount: number;
|
||||
trackIdCount: number;
|
||||
classObservations: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
export interface E46FDashCamBakeoffResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
sourceSessionId: string;
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
metrics: E46FMetrics;
|
||||
acceptance: {
|
||||
fullRouteAccounted: true;
|
||||
stockDetectorTrackerExecuted: true;
|
||||
controlledDetectorOnlyChange: true;
|
||||
visualOverlayAvailable: true;
|
||||
independentTruthAvailable: false;
|
||||
navigationOrSafetyAccepted: false;
|
||||
};
|
||||
method: E46ELaboratoryMethod;
|
||||
limitations: readonly string[];
|
||||
comparison: {
|
||||
baselineResultId: string;
|
||||
baselineMetrics: E46FMetrics;
|
||||
delta: {
|
||||
zeroTrackFrameCount: number;
|
||||
fullLayerBlackoutEventCount: number;
|
||||
uniqueTrackCount: number;
|
||||
shortTrackFraction: number;
|
||||
};
|
||||
largeBoxVisualTriage: {
|
||||
areaRatioThreshold: 0.2;
|
||||
candidate: E46FLargeBoxTriage;
|
||||
baseline: E46FLargeBoxTriage;
|
||||
interpretation: string;
|
||||
};
|
||||
visualReview: {
|
||||
status: "rejected-semantic-regression";
|
||||
sampleVideoSeconds: readonly number[];
|
||||
finding: string;
|
||||
nextAction: string;
|
||||
};
|
||||
verdict: "reject-dashcamnet-on-unrectified-fisheye";
|
||||
};
|
||||
video: {
|
||||
url: string;
|
||||
mediaType: "video/mp4";
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
width: 800;
|
||||
height: 600;
|
||||
};
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
class ContractError extends Error {}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) throw new ContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new ContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new ContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new ContractError(`${label}: ожидалось неотрицательное целое.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function exact<T extends string | number | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) throw new ContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function sha(value: unknown, label: string): string {
|
||||
const parsed = string(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new ContractError(`${label}: нарушена SHA-256 идентичность.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function signedNumber(value: unknown, label: string): number {
|
||||
return number(value, label);
|
||||
}
|
||||
|
||||
function metrics(value: unknown, label: string): E46FMetrics {
|
||||
const raw = object(value, label);
|
||||
return {
|
||||
frameCount: exact(raw.frame_count, 4489, `${label} frames`),
|
||||
routeDurationSeconds: number(raw.route_duration_seconds, `${label} duration`),
|
||||
detectionObservationCount: integer(raw.detection_observation_count, `${label} detections`),
|
||||
trackObservationCount: integer(raw.track_observation_count, `${label} track observations`),
|
||||
detectionBoxClippedCount: integer(raw.detection_box_clipped_count, `${label} clipped detections`),
|
||||
trackBoxClippedCount: integer(raw.track_box_clipped_count, `${label} clipped tracks`),
|
||||
uniqueTrackCount: integer(raw.unique_track_count, `${label} tracks`),
|
||||
meanTrackedObjectsPerFrame: number(raw.mean_tracked_objects_per_frame, `${label} mean objects`),
|
||||
zeroDetectionFrameCount: integer(raw.zero_detection_frame_count, `${label} zero detections`),
|
||||
zeroTrackFrameCount: integer(raw.zero_track_frame_count, `${label} zero tracks`),
|
||||
trackerRecoveredFrameCount: integer(raw.tracker_recovered_frame_count, `${label} recovered`),
|
||||
fullLayerBlackoutEventCount: integer(raw.full_layer_blackout_event_count, `${label} blackouts`),
|
||||
routeIdGapEventCount: integer(raw.route_id_gap_event_count, `${label} ID gaps`),
|
||||
shortTrackCount: integer(raw.short_track_count, `${label} short tracks`),
|
||||
shortTrackFraction: number(raw.short_track_fraction, `${label} short fraction`),
|
||||
trackClassSwitchCount: integer(raw.track_class_switch_count, `${label} class switches`),
|
||||
};
|
||||
}
|
||||
|
||||
function method(value: unknown): E46ELaboratoryMethod {
|
||||
const raw = object(value, "E46F method");
|
||||
exact(raw.schema_version, "missioncore.laboratory-method/v1", "E46F method schema");
|
||||
exact(raw.completeness, "complete", "E46F method completeness");
|
||||
exact(raw.execution_class, "hybrid", "E46F execution class");
|
||||
const components = array(raw.components, "E46F method components").map((entry) => {
|
||||
const component = object(entry, "E46F method component");
|
||||
const kind = string(component.kind, "E46F component kind");
|
||||
if (!(["source", "tool", "model", "algorithm", "runtime"] as const).includes(
|
||||
kind as "source",
|
||||
)) {
|
||||
throw new ContractError("E46F component kind: неизвестное значение.");
|
||||
}
|
||||
return {
|
||||
kind: kind as "source" | "tool" | "model" | "algorithm" | "runtime",
|
||||
name: string(component.name, "E46F component name"),
|
||||
version: string(component.version, "E46F component version"),
|
||||
role: string(component.role, "E46F component role"),
|
||||
identitySha256: sha(component.identity_sha256, "E46F component identity"),
|
||||
};
|
||||
});
|
||||
return {
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: string(raw.pipeline_id, "E46F pipeline"),
|
||||
components,
|
||||
};
|
||||
}
|
||||
|
||||
function classObservations(value: unknown, label: string): Readonly<Record<string, number>> {
|
||||
const raw = object(value, label);
|
||||
return Object.fromEntries(
|
||||
Object.entries(raw).map(([key, entry]) => [key, integer(entry, `${label}.${key}`)]),
|
||||
);
|
||||
}
|
||||
|
||||
function triage(value: unknown, label: string): E46FLargeBoxTriage {
|
||||
const raw = object(value, label);
|
||||
return {
|
||||
observationCount: integer(raw.observation_count, `${label} observations`),
|
||||
frameCount: integer(raw.frame_count, `${label} frames`),
|
||||
trackIdCount: integer(raw.track_id_count, `${label} tracks`),
|
||||
classObservations: classObservations(raw.class_observations, `${label} classes`),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46FDashCamBakeoff({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E46FDashCamBakeoffResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46f/results?limit=1", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new ContractError(`E46F LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "E46F catalog");
|
||||
exact(catalog.schema_version, "missioncore.e46f-dashcam-bakeoff-catalog/v1", "E46F catalog schema");
|
||||
const items = array(catalog.items, "E46F results");
|
||||
if (!items.length) return null;
|
||||
if (items.length !== 1) throw new ContractError("E46F catalog size: нарушен контракт.");
|
||||
const item = object(items[0], "E46F result");
|
||||
exact(item.schema_version, "missioncore.e46f-dashcam-bakeoff-view/v1", "E46F view schema");
|
||||
exact(item.ground_truth, false, "E46F truth authority");
|
||||
const resultId = string(item.result_id, "E46F result id");
|
||||
if (!/^e46f-dashcam-bakeoff-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new ContractError("E46F result identity: нарушен контракт.");
|
||||
}
|
||||
const acceptance = object(item.acceptance, "E46F acceptance");
|
||||
const comparison = object(item.comparison, "E46F comparison");
|
||||
exact(comparison.controlled_change, "detector-only", "E46F controlled change");
|
||||
const delta = object(comparison.delta, "E46F delta");
|
||||
const largeBox = object(comparison.large_box_visual_triage, "E46F large-box triage");
|
||||
const visualReview = object(comparison.visual_review, "E46F visual review");
|
||||
const video = object(item.video, "E46F video");
|
||||
const baselineResultId = string(comparison.baseline_result_id, "E46F baseline id");
|
||||
if (!/^e46e-ready-stack-[a-f0-9]{64}$/.test(baselineResultId)) {
|
||||
throw new ContractError("E46F baseline identity: нарушен контракт.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "E46F created"),
|
||||
sourceSessionId: string(item.source_session_id, "E46F source session"),
|
||||
cameraSourceId: exact(item.camera_source_id, "sensor.camera.right", "E46F camera"),
|
||||
metrics: metrics(item.metrics, "E46F metrics"),
|
||||
acceptance: {
|
||||
fullRouteAccounted: exact(acceptance.full_route_accounted, true, "E46F full route"),
|
||||
stockDetectorTrackerExecuted: exact(acceptance.stock_detector_tracker_executed, true, "E46F stack"),
|
||||
controlledDetectorOnlyChange: exact(acceptance.controlled_detector_only_change, true, "E46F controlled change"),
|
||||
visualOverlayAvailable: exact(acceptance.visual_overlay_available, true, "E46F visual"),
|
||||
independentTruthAvailable: exact(acceptance.independent_truth_available, false, "E46F truth"),
|
||||
navigationOrSafetyAccepted: exact(acceptance.navigation_or_safety_accepted, false, "E46F safety"),
|
||||
},
|
||||
method: method(item.method),
|
||||
limitations: array(item.limitations, "E46F limitations").map((entry) => string(entry, "E46F limitation")),
|
||||
comparison: {
|
||||
baselineResultId,
|
||||
baselineMetrics: metrics(comparison.baseline_metrics, "E46E baseline metrics"),
|
||||
delta: {
|
||||
zeroTrackFrameCount: signedNumber(delta.zero_track_frame_count, "E46F zero-track delta"),
|
||||
fullLayerBlackoutEventCount: signedNumber(delta.full_layer_blackout_event_count, "E46F blackout delta"),
|
||||
uniqueTrackCount: signedNumber(delta.unique_track_count, "E46F track delta"),
|
||||
shortTrackFraction: signedNumber(delta.short_track_fraction, "E46F short-track delta"),
|
||||
},
|
||||
largeBoxVisualTriage: {
|
||||
areaRatioThreshold: exact(largeBox.area_ratio_threshold, 0.2, "E46F triage threshold"),
|
||||
candidate: triage(largeBox.candidate, "E46F candidate triage"),
|
||||
baseline: triage(largeBox.baseline, "E46E baseline triage"),
|
||||
interpretation: string(largeBox.interpretation, "E46F triage interpretation"),
|
||||
},
|
||||
visualReview: {
|
||||
status: exact(visualReview.status, "rejected-semantic-regression", "E46F visual status"),
|
||||
sampleVideoSeconds: array(visualReview.sample_video_seconds, "E46F review timestamps")
|
||||
.map((entry) => number(entry, "E46F review timestamp")),
|
||||
finding: string(visualReview.finding, "E46F visual finding"),
|
||||
nextAction: string(visualReview.next_action, "E46F next action"),
|
||||
},
|
||||
verdict: exact(comparison.verdict, "reject-dashcamnet-on-unrectified-fisheye", "E46F verdict"),
|
||||
},
|
||||
video: {
|
||||
url: string(video.url, "E46F video URL"),
|
||||
mediaType: exact(video.media_type, "video/mp4", "E46F media type"),
|
||||
byteLength: integer(video.byte_length, "E46F video bytes"),
|
||||
sha256: sha(video.sha256, "E46F video SHA"),
|
||||
width: exact(video.width, 800, "E46F video width"),
|
||||
height: exact(video.height, 600, "E46F video height"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import type { E46ELaboratoryMethod } from "./e46eReadyStack";
|
||||
|
||||
export type E46GCandidate = "trafficcamnet" | "dashcamnet";
|
||||
export type E46GView = "left" | "front" | "right";
|
||||
|
||||
export interface E46GViewMetrics {
|
||||
frameCount: 600;
|
||||
detectionObservationCount: number;
|
||||
trackObservationCount: number;
|
||||
uniqueTrackCount: number;
|
||||
meanTrackedObjectsPerFrame: number;
|
||||
zeroDetectionFrameCount: number;
|
||||
zeroTrackFrameCount: number;
|
||||
fullLayerBlackoutEventCount: number;
|
||||
shortTrackFraction: number;
|
||||
largeTrackObservationCount: number;
|
||||
largeTrackFraction: number;
|
||||
}
|
||||
|
||||
export interface E46GCandidateMetrics {
|
||||
sourceFrameCount: 600;
|
||||
viewFrameCount: 1800;
|
||||
detectionObservationCount: number;
|
||||
trackObservationCount: number;
|
||||
uniqueTrackCount: number;
|
||||
largeTrackObservationCount: number;
|
||||
largeTrackFraction: number;
|
||||
views: Readonly<Record<E46GView, E46GViewMetrics>>;
|
||||
}
|
||||
|
||||
export interface E46GVideo {
|
||||
url: string;
|
||||
mediaType: "video/mp4";
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
width: 2880;
|
||||
height: 544;
|
||||
durationSeconds: 60;
|
||||
viewOrder: readonly ["left", "front", "right"];
|
||||
}
|
||||
|
||||
export interface E46GRectifiedDetectorBakeoffResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
sourceSessionId: string;
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
selection: {
|
||||
firstSourceFrameIndex: 1000;
|
||||
lastSourceFrameIndex: 1599;
|
||||
frameCount: 600;
|
||||
};
|
||||
rectification: {
|
||||
provider: "NVIDIA Gst-nvdewarper";
|
||||
providerVersion: string;
|
||||
outputResolution: readonly [960, 544];
|
||||
horizontalFovDegrees: 100;
|
||||
retainedSourceFrameIndexRange: readonly [0, 4487];
|
||||
excludedSourceTailFrameCount: 1;
|
||||
viewOrder: readonly ["left", "front", "right"];
|
||||
};
|
||||
metrics: Readonly<Record<E46GCandidate, E46GCandidateMetrics>>;
|
||||
acceptance: {
|
||||
exactRecordedRightSourceBound: true;
|
||||
factoryCalibrationBound: true;
|
||||
officialNvidiaDewarperExecuted: true;
|
||||
stockDetectorTrackerExecuted: true;
|
||||
sameViewsAndFramesForBothCandidates: true;
|
||||
visualComparisonVideosAvailable: true;
|
||||
independentTruthAvailable: false;
|
||||
candidateAccepted: false;
|
||||
navigationOrSafetyAccepted: false;
|
||||
};
|
||||
method: E46ELaboratoryMethod;
|
||||
limitations: readonly string[];
|
||||
comparison: {
|
||||
visualReview: {
|
||||
status: "selected-for-next-diagnostic";
|
||||
reviewedVideoSeconds: readonly number[];
|
||||
selectedCandidate: "trafficcamnet";
|
||||
selectedView: "front";
|
||||
excludedViews: readonly ["left", "right"];
|
||||
finding: string;
|
||||
risk: string;
|
||||
nextAction: string;
|
||||
};
|
||||
verdict: "select-trafficcamnet-front-only-for-e46h";
|
||||
};
|
||||
videos: Readonly<Record<E46GCandidate, E46GVideo>>;
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
class ContractError extends Error {}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) throw new ContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new ContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new ContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new ContractError(`${label}: ожидалось неотрицательное целое.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function exact<T extends string | number | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) throw new ContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function sha(value: unknown, label: string): string {
|
||||
const parsed = string(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new ContractError(`${label}: нарушена SHA-256 идентичность.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function tuple<T extends readonly unknown[]>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
const parsed = array(value, label);
|
||||
if (parsed.length !== expected.length || parsed.some((entry, index) => entry !== expected[index])) {
|
||||
throw new ContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function viewMetrics(value: unknown, label: string): E46GViewMetrics {
|
||||
const raw = object(value, label);
|
||||
return {
|
||||
frameCount: exact(raw.frame_count, 600, `${label} frames`),
|
||||
detectionObservationCount: integer(raw.detection_observation_count, `${label} detections`),
|
||||
trackObservationCount: integer(raw.track_observation_count, `${label} tracks`),
|
||||
uniqueTrackCount: integer(raw.unique_track_count, `${label} identities`),
|
||||
meanTrackedObjectsPerFrame: number(raw.mean_tracked_objects_per_frame, `${label} mean`),
|
||||
zeroDetectionFrameCount: integer(raw.zero_detection_frame_count, `${label} zero detections`),
|
||||
zeroTrackFrameCount: integer(raw.zero_track_frame_count, `${label} zero tracks`),
|
||||
fullLayerBlackoutEventCount: integer(raw.full_layer_blackout_event_count, `${label} blackouts`),
|
||||
shortTrackFraction: number(raw.short_track_fraction, `${label} short fraction`),
|
||||
largeTrackObservationCount: integer(raw.large_track_observation_count, `${label} large tracks`),
|
||||
largeTrackFraction: number(raw.large_track_fraction, `${label} large fraction`),
|
||||
};
|
||||
}
|
||||
|
||||
function candidateMetrics(value: unknown, label: string): E46GCandidateMetrics {
|
||||
const raw = object(value, label);
|
||||
const views = object(raw.views, `${label} views`);
|
||||
return {
|
||||
sourceFrameCount: exact(raw.source_frame_count, 600, `${label} source frames`),
|
||||
viewFrameCount: exact(raw.view_frame_count, 1800, `${label} view frames`),
|
||||
detectionObservationCount: integer(raw.detection_observation_count, `${label} detections`),
|
||||
trackObservationCount: integer(raw.track_observation_count, `${label} tracks`),
|
||||
uniqueTrackCount: integer(raw.unique_track_count, `${label} identities`),
|
||||
largeTrackObservationCount: integer(raw.large_track_observation_count, `${label} large tracks`),
|
||||
largeTrackFraction: number(raw.large_track_fraction, `${label} large fraction`),
|
||||
views: {
|
||||
left: viewMetrics(views.left, `${label} left`),
|
||||
front: viewMetrics(views.front, `${label} front`),
|
||||
right: viewMetrics(views.right, `${label} right`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function method(value: unknown): E46ELaboratoryMethod {
|
||||
const raw = object(value, "E46G method");
|
||||
exact(raw.schema_version, "missioncore.laboratory-method/v1", "E46G method schema");
|
||||
exact(raw.completeness, "complete", "E46G method completeness");
|
||||
exact(raw.execution_class, "hybrid", "E46G execution class");
|
||||
const components = array(raw.components, "E46G method components").map((entry) => {
|
||||
const component = object(entry, "E46G method component");
|
||||
const kind = string(component.kind, "E46G component kind");
|
||||
if (!("source tool model algorithm runtime".split(" ")).includes(kind)) {
|
||||
throw new ContractError("E46G component kind: неизвестное значение.");
|
||||
}
|
||||
return {
|
||||
kind: kind as "source" | "tool" | "model" | "algorithm" | "runtime",
|
||||
name: string(component.name, "E46G component name"),
|
||||
version: string(component.version, "E46G component version"),
|
||||
role: string(component.role, "E46G component role"),
|
||||
identitySha256: sha(component.identity_sha256, "E46G component identity"),
|
||||
};
|
||||
});
|
||||
return {
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: string(raw.pipeline_id, "E46G pipeline"),
|
||||
components,
|
||||
};
|
||||
}
|
||||
|
||||
function video(value: unknown, label: string): E46GVideo {
|
||||
const raw = object(value, label);
|
||||
return {
|
||||
url: string(raw.url, `${label} URL`),
|
||||
mediaType: exact(raw.media_type, "video/mp4", `${label} media`),
|
||||
byteLength: integer(raw.byte_length, `${label} bytes`),
|
||||
sha256: sha(raw.sha256, `${label} SHA`),
|
||||
width: exact(raw.width, 2880, `${label} width`),
|
||||
height: exact(raw.height, 544, `${label} height`),
|
||||
durationSeconds: exact(raw.duration_seconds, 60, `${label} duration`),
|
||||
viewOrder: tuple(raw.view_order, ["left", "front", "right"] as const, `${label} views`),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46GRectifiedDetectorBakeoff({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E46GRectifiedDetectorBakeoffResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46g/results?limit=1", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new ContractError(`E46G LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "E46G catalog");
|
||||
exact(catalog.schema_version, "missioncore.e46g-rectified-detector-bakeoff-catalog/v1", "E46G catalog schema");
|
||||
const items = array(catalog.items, "E46G results");
|
||||
if (!items.length) return null;
|
||||
if (items.length !== 1) throw new ContractError("E46G catalog size: нарушен контракт.");
|
||||
const item = object(items[0], "E46G result");
|
||||
exact(item.schema_version, "missioncore.e46g-rectified-detector-bakeoff-view/v1", "E46G view schema");
|
||||
exact(item.status, "selected-for-next-diagnostic-full-route", "E46G status");
|
||||
exact(item.ground_truth, false, "E46G truth authority");
|
||||
const resultId = string(item.result_id, "E46G result id");
|
||||
if (!/^e46g-rectified-detector-bakeoff-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new ContractError("E46G result identity: нарушен контракт.");
|
||||
}
|
||||
const selection = object(item.selection, "E46G selection");
|
||||
const rectification = object(item.rectification, "E46G rectification");
|
||||
const metrics = object(item.metrics, "E46G metrics");
|
||||
const acceptance = object(item.acceptance, "E46G acceptance");
|
||||
const comparison = object(item.comparison, "E46G comparison");
|
||||
const review = object(comparison.visual_review, "E46G visual review");
|
||||
const videos = object(item.videos, "E46G videos");
|
||||
const authority = object(item.authority, "E46G authority");
|
||||
exact(authority.ground_truth, false, "E46G authority ground truth");
|
||||
exact(authority.independent_truth, false, "E46G authority independent truth");
|
||||
exact(authority.candidate_accepted, false, "E46G authority candidate");
|
||||
exact(authority.commands_enabled, false, "E46G authority commands");
|
||||
exact(authority.navigation_or_safety_accepted, false, "E46G authority safety");
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "E46G created"),
|
||||
sourceSessionId: string(item.source_session_id, "E46G source session"),
|
||||
cameraSourceId: exact(item.camera_source_id, "sensor.camera.right", "E46G camera"),
|
||||
selection: {
|
||||
firstSourceFrameIndex: exact(selection.first_source_frame_index, 1000, "E46G first frame"),
|
||||
lastSourceFrameIndex: exact(selection.last_source_frame_index, 1599, "E46G last frame"),
|
||||
frameCount: exact(selection.frame_count, 600, "E46G frame count"),
|
||||
},
|
||||
rectification: {
|
||||
provider: exact(rectification.provider, "NVIDIA Gst-nvdewarper", "E46G dewarper"),
|
||||
providerVersion: string(rectification.provider_version, "E46G dewarper version"),
|
||||
outputResolution: tuple(rectification.output_resolution, [960, 544] as const, "E46G output"),
|
||||
horizontalFovDegrees: exact(rectification.horizontal_fov_degrees, 100, "E46G FOV"),
|
||||
retainedSourceFrameIndexRange: tuple(rectification.retained_source_frame_index_range, [0, 4487] as const, "E46G retained source"),
|
||||
excludedSourceTailFrameCount: exact(rectification.excluded_source_tail_frame_count, 1, "E46G excluded tail"),
|
||||
viewOrder: tuple(rectification.view_order, ["left", "front", "right"] as const, "E46G views"),
|
||||
},
|
||||
metrics: {
|
||||
trafficcamnet: candidateMetrics(metrics.trafficcamnet, "TrafficCamNet"),
|
||||
dashcamnet: candidateMetrics(metrics.dashcamnet, "DashCamNet"),
|
||||
},
|
||||
acceptance: {
|
||||
exactRecordedRightSourceBound: exact(acceptance.exact_recorded_right_source_bound, true, "E46G source"),
|
||||
factoryCalibrationBound: exact(acceptance.factory_calibration_bound, true, "E46G calibration"),
|
||||
officialNvidiaDewarperExecuted: exact(acceptance.official_nvidia_dewarper_executed, true, "E46G dewarper execution"),
|
||||
stockDetectorTrackerExecuted: exact(acceptance.stock_detector_tracker_executed, true, "E46G stock stack"),
|
||||
sameViewsAndFramesForBothCandidates: exact(acceptance.same_views_and_frames_for_both_candidates, true, "E46G controlled A/B"),
|
||||
visualComparisonVideosAvailable: exact(acceptance.visual_comparison_videos_available, true, "E46G videos"),
|
||||
independentTruthAvailable: exact(acceptance.independent_truth_available, false, "E46G truth"),
|
||||
candidateAccepted: exact(acceptance.candidate_accepted, false, "E46G acceptance authority"),
|
||||
navigationOrSafetyAccepted: exact(acceptance.navigation_or_safety_accepted, false, "E46G safety"),
|
||||
},
|
||||
method: method(item.method),
|
||||
limitations: array(item.limitations, "E46G limitations").map((entry) => string(entry, "E46G limitation")),
|
||||
comparison: {
|
||||
visualReview: {
|
||||
status: exact(review.status, "selected-for-next-diagnostic", "E46G review status"),
|
||||
reviewedVideoSeconds: array(review.reviewed_video_seconds, "E46G review seconds").map((entry) => number(entry, "E46G review second")),
|
||||
selectedCandidate: exact(review.selected_candidate, "trafficcamnet", "E46G candidate"),
|
||||
selectedView: exact(review.selected_view, "front", "E46G selected view"),
|
||||
excludedViews: tuple(review.excluded_views, ["left", "right"] as const, "E46G excluded views"),
|
||||
finding: string(review.finding, "E46G finding"),
|
||||
risk: string(review.risk, "E46G risk"),
|
||||
nextAction: string(review.next_action, "E46G next action"),
|
||||
},
|
||||
verdict: exact(comparison.verdict, "select-trafficcamnet-front-only-for-e46h", "E46G verdict"),
|
||||
},
|
||||
videos: {
|
||||
trafficcamnet: video(videos.trafficcamnet, "TrafficCamNet video"),
|
||||
dashcamnet: video(videos.dashcamnet, "DashCamNet video"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import type { E46ELaboratoryMethod } from "./e46eReadyStack";
|
||||
|
||||
export interface E46HReviewWindow {
|
||||
id: string;
|
||||
label: string;
|
||||
startSeconds: number;
|
||||
endSeconds: number;
|
||||
sourceTrackId: number | null;
|
||||
verdict: "semantic-false-positive" | "empty-scene-expected";
|
||||
}
|
||||
|
||||
export interface E46HFullRectifiedFrontReplayResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
sourceSessionId: string;
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
baselineResultId: string;
|
||||
selection: {
|
||||
firstSourceFrameIndex: 0;
|
||||
lastSourceFrameIndex: 4487;
|
||||
frameCount: 4488;
|
||||
excludedSourceTailFrameCount: 1;
|
||||
};
|
||||
rectification: {
|
||||
provider: "NVIDIA Gst-nvdewarper";
|
||||
providerVersion: string;
|
||||
projection: "fisheye-to-perspective";
|
||||
view: "front";
|
||||
outputResolution: readonly [960, 544];
|
||||
horizontalFovDegrees: 100;
|
||||
};
|
||||
metrics: {
|
||||
frameCount: 4488;
|
||||
routeDurationSeconds: number;
|
||||
detectionObservationCount: number;
|
||||
trackObservationCount: number;
|
||||
uniqueTrackCount: number;
|
||||
meanTrackedObjectsPerFrame: number;
|
||||
zeroDetectionFrameCount: number;
|
||||
zeroTrackFrameCount: number;
|
||||
trackerRecoveredFrameCount: number;
|
||||
fullLayerBlackoutEventCount: number;
|
||||
routeIdGapEventCount: number;
|
||||
shortTrackCount: number;
|
||||
shortTrackFraction: number;
|
||||
trackClassSwitchCount: number;
|
||||
largeTrackObservationCount: number;
|
||||
largeTrackFraction: number;
|
||||
};
|
||||
acceptance: {
|
||||
exactRecordedRightSourceBound: true;
|
||||
factoryCalibrationBound: true;
|
||||
officialNvidiaDewarperExecuted: true;
|
||||
selectedStockDetectorTrackerExecuted: true;
|
||||
retainedRouteAccounted: true;
|
||||
terminalSourceFrameExcluded: true;
|
||||
fullVisualReviewCompleted: false;
|
||||
independentTruthAvailable: false;
|
||||
candidateAccepted: false;
|
||||
navigationOrSafetyAccepted: false;
|
||||
};
|
||||
decision: {
|
||||
selectedProvider: "front-trafficcamnet-stock-nvdcf";
|
||||
customDetectorOrTrackerLogicUsed: false;
|
||||
providerPromoted: false;
|
||||
nextAction: string;
|
||||
};
|
||||
method: E46ELaboratoryMethod;
|
||||
limitations: readonly string[];
|
||||
visualReview: {
|
||||
status: "full-continuous-and-targeted-review-completed";
|
||||
completeVideoReviewed: true;
|
||||
reviewedVideoRangeSeconds: readonly [0, 448.8];
|
||||
verdict: "useful-front-continuity-but-semantic-regression-blocks-promotion";
|
||||
reviewWindows: readonly E46HReviewWindow[];
|
||||
finding: string;
|
||||
blackoutInterpretation: string;
|
||||
nextAction: string;
|
||||
};
|
||||
video: {
|
||||
url: string;
|
||||
mediaType: "video/mp4";
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
width: 960;
|
||||
height: 544;
|
||||
durationSeconds: 448.8;
|
||||
};
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
class ContractError extends Error {}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) throw new ContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new ContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new ContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new ContractError(`${label}: ожидалось неотрицательное целое.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function exact<T extends string | number | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) throw new ContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function sha(value: unknown, label: string): string {
|
||||
const parsed = string(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new ContractError(`${label}: нарушена SHA-256 идентичность.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function method(value: unknown): E46ELaboratoryMethod {
|
||||
const raw = object(value, "E46H method");
|
||||
exact(raw.schema_version, "missioncore.laboratory-method/v1", "E46H method schema");
|
||||
exact(raw.completeness, "complete", "E46H method completeness");
|
||||
exact(raw.execution_class, "hybrid", "E46H execution class");
|
||||
const components = array(raw.components, "E46H method components").map((entry) => {
|
||||
const component = object(entry, "E46H method component");
|
||||
const kind = string(component.kind, "E46H component kind");
|
||||
if (!("source tool model algorithm runtime".split(" ")).includes(kind)) {
|
||||
throw new ContractError("E46H component kind: неизвестное значение.");
|
||||
}
|
||||
return {
|
||||
kind: kind as "source" | "tool" | "model" | "algorithm" | "runtime",
|
||||
name: string(component.name, "E46H component name"),
|
||||
version: string(component.version, "E46H component version"),
|
||||
role: string(component.role, "E46H component role"),
|
||||
identitySha256: sha(component.identity_sha256, "E46H component identity"),
|
||||
};
|
||||
});
|
||||
return {
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: string(raw.pipeline_id, "E46H pipeline"),
|
||||
components,
|
||||
};
|
||||
}
|
||||
|
||||
function reviewWindow(value: unknown): E46HReviewWindow {
|
||||
const raw = object(value, "E46H review window");
|
||||
const sourceTrackId = raw.source_track_id === null
|
||||
? null
|
||||
: integer(raw.source_track_id, "E46H review track");
|
||||
const verdict = raw.verdict;
|
||||
if (verdict !== "semantic-false-positive" && verdict !== "empty-scene-expected") {
|
||||
throw new ContractError("E46H review verdict: неизвестное значение.");
|
||||
}
|
||||
const startSeconds = number(raw.start_seconds, "E46H review start");
|
||||
const endSeconds = number(raw.end_seconds, "E46H review end");
|
||||
if (startSeconds < 0 || endSeconds <= startSeconds || endSeconds > 448.8) {
|
||||
throw new ContractError("E46H review window: нарушен диапазон.");
|
||||
}
|
||||
return {
|
||||
id: string(raw.id, "E46H review id"),
|
||||
label: string(raw.label, "E46H review label"),
|
||||
startSeconds,
|
||||
endSeconds,
|
||||
sourceTrackId,
|
||||
verdict,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46HFullRectifiedFrontReplay({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E46HFullRectifiedFrontReplayResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46h/results?limit=1", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new ContractError(`E46H LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "E46H catalog");
|
||||
exact(catalog.schema_version, "missioncore.e46h-full-rectified-front-replay-catalog/v1", "E46H catalog schema");
|
||||
const items = array(catalog.items, "E46H results");
|
||||
if (!items.length) return null;
|
||||
if (items.length !== 1) throw new ContractError("E46H catalog size: нарушен контракт.");
|
||||
const item = object(items[0], "E46H result");
|
||||
exact(item.schema_version, "missioncore.e46h-full-rectified-front-replay-view/v1", "E46H view schema");
|
||||
exact(item.status, "diagnostic-regression-large-semantic-false-tracks", "E46H status");
|
||||
exact(item.ground_truth, false, "E46H truth authority");
|
||||
const resultId = string(item.result_id, "E46H result id");
|
||||
if (!/^e46h-full-rectified-front-replay-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new ContractError("E46H result identity: нарушен контракт.");
|
||||
}
|
||||
const selection = object(item.selection, "E46H selection");
|
||||
const rectification = object(item.rectification, "E46H rectification");
|
||||
const metrics = object(item.metrics, "E46H metrics");
|
||||
const acceptance = object(item.acceptance, "E46H acceptance");
|
||||
const decision = object(item.decision, "E46H decision");
|
||||
const visualReview = object(item.visual_review, "E46H visual review");
|
||||
const video = object(item.video, "E46H video");
|
||||
const authority = object(item.authority, "E46H authority");
|
||||
exact(authority.ground_truth, false, "E46H authority ground truth");
|
||||
exact(authority.independent_truth, false, "E46H authority independent truth");
|
||||
exact(authority.candidate_accepted, false, "E46H authority candidate");
|
||||
exact(authority.commands_enabled, false, "E46H authority commands");
|
||||
exact(authority.navigation_or_safety_accepted, false, "E46H authority safety");
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "E46H created"),
|
||||
sourceSessionId: string(item.source_session_id, "E46H source session"),
|
||||
cameraSourceId: exact(item.camera_source_id, "sensor.camera.right", "E46H camera"),
|
||||
baselineResultId: string(item.baseline_result_id, "E46H baseline"),
|
||||
selection: {
|
||||
firstSourceFrameIndex: exact(selection.first_source_frame_index, 0, "E46H first frame"),
|
||||
lastSourceFrameIndex: exact(selection.last_source_frame_index, 4487, "E46H last frame"),
|
||||
frameCount: exact(selection.frame_count, 4488, "E46H frame count"),
|
||||
excludedSourceTailFrameCount: exact(selection.excluded_source_tail_frame_count, 1, "E46H excluded tail"),
|
||||
},
|
||||
rectification: {
|
||||
provider: exact(rectification.provider, "NVIDIA Gst-nvdewarper", "E46H dewarper"),
|
||||
providerVersion: string(rectification.provider_version, "E46H dewarper version"),
|
||||
projection: exact(rectification.projection, "fisheye-to-perspective", "E46H projection"),
|
||||
view: exact(rectification.view, "front", "E46H view"),
|
||||
outputResolution: (() => {
|
||||
const parsed = array(rectification.output_resolution, "E46H output");
|
||||
if (parsed.length !== 2 || parsed[0] !== 960 || parsed[1] !== 544) {
|
||||
throw new ContractError("E46H output: нарушен контракт.");
|
||||
}
|
||||
return [960, 544] as const;
|
||||
})(),
|
||||
horizontalFovDegrees: exact(rectification.horizontal_fov_degrees, 100, "E46H FOV"),
|
||||
},
|
||||
metrics: {
|
||||
frameCount: exact(metrics.frame_count, 4488, "E46H metric frames"),
|
||||
routeDurationSeconds: number(metrics.route_duration_seconds, "E46H source duration"),
|
||||
detectionObservationCount: integer(metrics.detection_observation_count, "E46H detections"),
|
||||
trackObservationCount: integer(metrics.track_observation_count, "E46H tracks"),
|
||||
uniqueTrackCount: integer(metrics.unique_track_count, "E46H identities"),
|
||||
meanTrackedObjectsPerFrame: number(metrics.mean_tracked_objects_per_frame, "E46H mean"),
|
||||
zeroDetectionFrameCount: integer(metrics.zero_detection_frame_count, "E46H zero detections"),
|
||||
zeroTrackFrameCount: integer(metrics.zero_track_frame_count, "E46H zero tracks"),
|
||||
trackerRecoveredFrameCount: integer(metrics.tracker_recovered_frame_count, "E46H recovered"),
|
||||
fullLayerBlackoutEventCount: integer(metrics.full_layer_blackout_event_count, "E46H blackouts"),
|
||||
routeIdGapEventCount: integer(metrics.route_id_gap_event_count, "E46H id gaps"),
|
||||
shortTrackCount: integer(metrics.short_track_count, "E46H short tracks"),
|
||||
shortTrackFraction: number(metrics.short_track_fraction, "E46H short fraction"),
|
||||
trackClassSwitchCount: integer(metrics.track_class_switch_count, "E46H class switches"),
|
||||
largeTrackObservationCount: integer(metrics.large_track_observation_count, "E46H large tracks"),
|
||||
largeTrackFraction: number(metrics.large_track_fraction, "E46H large fraction"),
|
||||
},
|
||||
acceptance: {
|
||||
exactRecordedRightSourceBound: exact(acceptance.exact_recorded_right_source_bound, true, "E46H source"),
|
||||
factoryCalibrationBound: exact(acceptance.factory_calibration_bound, true, "E46H calibration"),
|
||||
officialNvidiaDewarperExecuted: exact(acceptance.official_nvidia_dewarper_executed, true, "E46H dewarper execution"),
|
||||
selectedStockDetectorTrackerExecuted: exact(acceptance.selected_stock_detector_tracker_executed, true, "E46H stock stack"),
|
||||
retainedRouteAccounted: exact(acceptance.retained_route_accounted, true, "E46H retained route"),
|
||||
terminalSourceFrameExcluded: exact(acceptance.terminal_source_frame_excluded, true, "E46H tail"),
|
||||
fullVisualReviewCompleted: exact(acceptance.full_visual_review_completed, false, "E46H visual review authority"),
|
||||
independentTruthAvailable: exact(acceptance.independent_truth_available, false, "E46H truth"),
|
||||
candidateAccepted: exact(acceptance.candidate_accepted, false, "E46H candidate"),
|
||||
navigationOrSafetyAccepted: exact(acceptance.navigation_or_safety_accepted, false, "E46H safety"),
|
||||
},
|
||||
decision: {
|
||||
selectedProvider: exact(decision.selected_provider, "front-trafficcamnet-stock-nvdcf", "E46H provider"),
|
||||
customDetectorOrTrackerLogicUsed: exact(decision.custom_detector_or_tracker_logic_used, false, "E46H custom logic"),
|
||||
providerPromoted: exact(decision.provider_promoted, false, "E46H promotion"),
|
||||
nextAction: string(decision.next_action, "E46H decision"),
|
||||
},
|
||||
method: method(item.method),
|
||||
limitations: array(item.limitations, "E46H limitations").map((entry) => string(entry, "E46H limitation")),
|
||||
visualReview: {
|
||||
status: exact(visualReview.status, "full-continuous-and-targeted-review-completed", "E46H review status"),
|
||||
completeVideoReviewed: exact(visualReview.complete_video_reviewed, true, "E46H complete video review"),
|
||||
reviewedVideoRangeSeconds: (() => {
|
||||
const parsed = array(visualReview.reviewed_video_range_seconds, "E46H reviewed video range");
|
||||
if (parsed.length !== 2 || parsed[0] !== 0 || parsed[1] !== 448.8) {
|
||||
throw new ContractError("E46H reviewed video range: нарушен контракт.");
|
||||
}
|
||||
return [0, 448.8] as const;
|
||||
})(),
|
||||
verdict: exact(visualReview.verdict, "useful-front-continuity-but-semantic-regression-blocks-promotion", "E46H review verdict"),
|
||||
reviewWindows: array(visualReview.review_windows, "E46H review windows").map(reviewWindow),
|
||||
finding: string(visualReview.finding, "E46H finding"),
|
||||
blackoutInterpretation: string(visualReview.blackout_interpretation, "E46H blackout interpretation"),
|
||||
nextAction: string(visualReview.next_action, "E46H next action"),
|
||||
},
|
||||
video: {
|
||||
url: string(video.url, "E46H video URL"),
|
||||
mediaType: exact(video.media_type, "video/mp4", "E46H video media"),
|
||||
byteLength: integer(video.byte_length, "E46H video bytes"),
|
||||
sha256: sha(video.sha256, "E46H video SHA"),
|
||||
width: exact(video.width, 960, "E46H video width"),
|
||||
height: exact(video.height, 544, "E46H video height"),
|
||||
durationSeconds: exact(video.duration_seconds, 448.8, "E46H video duration"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import type { E46ELaboratoryMethod } from "./e46eReadyStack";
|
||||
|
||||
export interface E46IReviewWindow {
|
||||
id: string;
|
||||
label: string;
|
||||
startSeconds: number;
|
||||
endSeconds: number;
|
||||
verdict:
|
||||
| "legacy-background-false-positive-suppressed"
|
||||
| "empty-scene-mostly-preserved";
|
||||
}
|
||||
|
||||
export interface E46IGroundingDinoFullReplayResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
baselineResultId: string;
|
||||
source: {
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
sessionId: string;
|
||||
view: "front";
|
||||
resolution: readonly [960, 544];
|
||||
frameCount: 4488;
|
||||
frameRate: 10;
|
||||
durationSeconds: 448.8;
|
||||
};
|
||||
provider: {
|
||||
name: "NVIDIA TAO Grounding DINO Swin-Tiny Commercial";
|
||||
version: "1.0";
|
||||
toolkit: "NVIDIA TAO Toolkit Deploy 7.0.1";
|
||||
precision: "FP16";
|
||||
};
|
||||
inference: {
|
||||
captions: readonly ["car", "person", "bicycle", "road sign"];
|
||||
confidenceThreshold: 0.5;
|
||||
meanFramesPerSecond: number;
|
||||
};
|
||||
metrics: {
|
||||
frameCount: 4488;
|
||||
detectionObservationCount: number;
|
||||
classObservationCounts: Readonly<Record<string, number>>;
|
||||
meanDetectionsPerFrame: number;
|
||||
maxDetectionsPerFrame: number;
|
||||
zeroDetectionFrameCount: number;
|
||||
longestZeroDetectionRunFrames: number;
|
||||
confidenceMean: number;
|
||||
largeBoxObservationCount: number;
|
||||
largeBoxObservationFraction: number;
|
||||
workerElapsedSeconds: number;
|
||||
workerMeanFramesPerSecond: number;
|
||||
};
|
||||
shadowGate: {
|
||||
legacyCases: 5;
|
||||
suppressedCases: 5;
|
||||
positiveCases: 5;
|
||||
positiveCasesWithRelevantDetection: 5;
|
||||
limitations: readonly string[];
|
||||
};
|
||||
visualReview: {
|
||||
completeVideoPlaybackCompleted: true;
|
||||
reviewedRangeSeconds: readonly [0, 448.8];
|
||||
verdict: "material-semantic-progress-not-yet-complete-perception";
|
||||
reviewWindows: readonly E46IReviewWindow[];
|
||||
finding: string;
|
||||
knownError: string;
|
||||
};
|
||||
decision: {
|
||||
selectedProvider: "nvidia-grounding-dino-swin-tiny-commercial-v1.0";
|
||||
providerSemanticProgress: true;
|
||||
providerPromoted: false;
|
||||
nextAction: string;
|
||||
};
|
||||
method: E46ELaboratoryMethod;
|
||||
limitations: readonly string[];
|
||||
video: {
|
||||
url: string;
|
||||
mediaType: "video/mp4";
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
width: 960;
|
||||
height: 544;
|
||||
frameRate: 10;
|
||||
frameCount: 4488;
|
||||
durationSeconds: 448.8;
|
||||
};
|
||||
visuals: {
|
||||
shadowGate: VisualArtifact;
|
||||
fullRoute: VisualArtifact;
|
||||
targetedWindows: VisualArtifact;
|
||||
};
|
||||
}
|
||||
|
||||
interface VisualArtifact {
|
||||
url: string;
|
||||
mediaType: "image/png";
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
class ContractError extends Error {}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) throw new ContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new ContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new ContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new ContractError(`${label}: ожидалось неотрицательное целое.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function exact<T extends string | number | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) throw new ContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function sha(value: unknown, label: string): string {
|
||||
const parsed = text(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new ContractError(`${label}: нарушена SHA-256 идентичность.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function pair<const T extends readonly [number, number]>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
const parsed = array(value, label);
|
||||
if (parsed.length !== 2 || parsed[0] !== expected[0] || parsed[1] !== expected[1]) {
|
||||
throw new ContractError(`${label}: нарушен диапазон.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function method(value: unknown): E46ELaboratoryMethod {
|
||||
const raw = object(value, "E46I method");
|
||||
exact(raw.schema_version, "missioncore.laboratory-method/v1", "E46I method schema");
|
||||
exact(raw.completeness, "complete", "E46I method completeness");
|
||||
exact(raw.execution_class, "hybrid", "E46I method execution");
|
||||
return {
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: text(raw.pipeline_id, "E46I pipeline"),
|
||||
components: array(raw.components, "E46I components").map((entry) => {
|
||||
const component = object(entry, "E46I component");
|
||||
const kind = text(component.kind, "E46I component kind");
|
||||
if (!("source tool model algorithm runtime".split(" ")).includes(kind)) {
|
||||
throw new ContractError("E46I component kind: неизвестное значение.");
|
||||
}
|
||||
return {
|
||||
kind: kind as "source" | "tool" | "model" | "algorithm" | "runtime",
|
||||
name: text(component.name, "E46I component name"),
|
||||
version: text(component.version, "E46I component version"),
|
||||
role: text(component.role, "E46I component role"),
|
||||
identitySha256: sha(component.identity_sha256, "E46I component identity"),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function visual(value: unknown, label: string): VisualArtifact {
|
||||
const raw = object(value, label);
|
||||
return {
|
||||
url: text(raw.url, `${label} URL`),
|
||||
mediaType: exact(raw.media_type, "image/png", `${label} media`),
|
||||
byteLength: integer(raw.byte_length, `${label} bytes`),
|
||||
sha256: sha(raw.sha256, `${label} SHA`),
|
||||
};
|
||||
}
|
||||
|
||||
function reviewWindow(value: unknown): E46IReviewWindow {
|
||||
const raw = object(value, "E46I review window");
|
||||
const verdict = raw.verdict;
|
||||
if (
|
||||
verdict !== "legacy-background-false-positive-suppressed"
|
||||
&& verdict !== "empty-scene-mostly-preserved"
|
||||
) {
|
||||
throw new ContractError("E46I review verdict: неизвестное значение.");
|
||||
}
|
||||
const startSeconds = number(raw.start_seconds, "E46I review start");
|
||||
const endSeconds = number(raw.end_seconds, "E46I review end");
|
||||
if (startSeconds < 0 || endSeconds <= startSeconds || endSeconds > 448.8) {
|
||||
throw new ContractError("E46I review window: нарушен диапазон.");
|
||||
}
|
||||
return {
|
||||
id: text(raw.id, "E46I review id"),
|
||||
label: text(raw.label, "E46I review label"),
|
||||
startSeconds,
|
||||
endSeconds,
|
||||
verdict,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46IGroundingDinoFullReplay({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E46IGroundingDinoFullReplayResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46i/results?limit=1", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new ContractError(`E46I LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "E46I catalog");
|
||||
exact(catalog.schema_version, "missioncore.e46i-grounding-dino-full-replay-catalog/v1", "E46I catalog schema");
|
||||
const items = array(catalog.items, "E46I results");
|
||||
if (!items.length) return null;
|
||||
if (items.length !== 1) throw new ContractError("E46I catalog size: нарушен контракт.");
|
||||
const item = object(items[0], "E46I result");
|
||||
exact(item.schema_version, "missioncore.e46i-grounding-dino-full-replay-view/v1", "E46I view schema");
|
||||
exact(item.status, "semantic-regression-suppressed-awaiting-temporal-layer", "E46I status");
|
||||
exact(item.ground_truth, false, "E46I truth authority");
|
||||
const authority = object(item.authority, "E46I authority");
|
||||
exact(authority.ground_truth, false, "E46I authority truth");
|
||||
exact(authority.independent_truth, false, "E46I independent truth");
|
||||
exact(authority.candidate_accepted, false, "E46I acceptance");
|
||||
exact(authority.commands_enabled, false, "E46I commands");
|
||||
exact(authority.navigation_or_safety_accepted, false, "E46I safety");
|
||||
const resultId = text(item.result_id, "E46I result id");
|
||||
if (!/^e46i-grounding-dino-full-replay-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new ContractError("E46I result identity: нарушен контракт.");
|
||||
}
|
||||
const source = object(item.source, "E46I source");
|
||||
const provider = object(item.provider, "E46I provider");
|
||||
const inference = object(item.inference, "E46I inference");
|
||||
const metrics = object(item.metrics, "E46I metrics");
|
||||
const shadow = object(item.shadow_gate, "E46I shadow gate");
|
||||
const review = object(item.visual_review, "E46I visual review");
|
||||
const decision = object(item.decision, "E46I decision");
|
||||
const video = object(item.video, "E46I video");
|
||||
const visuals = object(item.visuals, "E46I visuals");
|
||||
const captions = array(inference.captions, "E46I captions");
|
||||
if (captions.join("|") !== "car|person|bicycle|road sign") {
|
||||
throw new ContractError("E46I captions: нарушен контракт.");
|
||||
}
|
||||
const classCounts = object(metrics.class_observation_counts, "E46I class counts");
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: text(item.created_at_utc, "E46I created"),
|
||||
baselineResultId: text(item.baseline_result_id, "E46I baseline"),
|
||||
source: {
|
||||
cameraSourceId: exact(source.camera_source_id, "sensor.camera.right", "E46I camera"),
|
||||
sessionId: text(source.session_id, "E46I session"),
|
||||
view: exact(source.view, "front", "E46I view"),
|
||||
resolution: pair(source.projection_resolution, [960, 544], "E46I resolution"),
|
||||
frameCount: exact(source.video_frame_count, 4488, "E46I frames"),
|
||||
frameRate: exact(source.video_frame_rate, 10, "E46I fps"),
|
||||
durationSeconds: exact(source.video_duration_seconds, 448.8, "E46I duration"),
|
||||
},
|
||||
provider: {
|
||||
name: exact(provider.name, "NVIDIA TAO Grounding DINO Swin-Tiny Commercial", "E46I provider"),
|
||||
version: exact(provider.version, "1.0", "E46I provider version"),
|
||||
toolkit: exact(provider.deployment_toolkit, "NVIDIA TAO Toolkit Deploy 7.0.1", "E46I toolkit"),
|
||||
precision: exact(provider.engine_precision, "FP16", "E46I precision"),
|
||||
},
|
||||
inference: {
|
||||
captions: ["car", "person", "bicycle", "road sign"],
|
||||
confidenceThreshold: exact(inference.confidence_threshold, 0.5, "E46I threshold"),
|
||||
meanFramesPerSecond: number(inference.mean_frames_per_second, "E46I inference fps"),
|
||||
},
|
||||
metrics: {
|
||||
frameCount: exact(metrics.frame_count, 4488, "E46I metric frames"),
|
||||
detectionObservationCount: integer(metrics.detection_observation_count, "E46I detections"),
|
||||
classObservationCounts: Object.fromEntries(
|
||||
Object.entries(classCounts).map(([key, value]) => [key, integer(value, `E46I ${key}`)]),
|
||||
),
|
||||
meanDetectionsPerFrame: number(metrics.mean_detections_per_frame, "E46I mean"),
|
||||
maxDetectionsPerFrame: integer(metrics.max_detections_per_frame, "E46I max"),
|
||||
zeroDetectionFrameCount: integer(metrics.zero_detection_frame_count, "E46I zero"),
|
||||
longestZeroDetectionRunFrames: integer(metrics.longest_zero_detection_run_frames, "E46I zero run"),
|
||||
confidenceMean: number(metrics.confidence_mean, "E46I confidence"),
|
||||
largeBoxObservationCount: integer(metrics.large_box_observation_count, "E46I large boxes"),
|
||||
largeBoxObservationFraction: number(metrics.large_box_observation_fraction, "E46I large fraction"),
|
||||
workerElapsedSeconds: number(metrics.worker_elapsed_seconds, "E46I elapsed"),
|
||||
workerMeanFramesPerSecond: number(metrics.worker_mean_frames_per_second, "E46I worker fps"),
|
||||
},
|
||||
shadowGate: {
|
||||
legacyCases: exact(shadow.legacy_large_false_background_cases, 5, "E46I legacy cases"),
|
||||
suppressedCases: exact(shadow.legacy_large_false_background_cases_suppressed, 5, "E46I suppressed"),
|
||||
positiveCases: exact(shadow.positive_anchor_cases, 5, "E46I positives"),
|
||||
positiveCasesWithRelevantDetection: exact(shadow.positive_anchor_cases_with_relevant_detection, 5, "E46I retained positives"),
|
||||
limitations: array(shadow.known_semantic_limitations, "E46I gate limitations").map((entry) => text(entry, "E46I gate limitation")),
|
||||
},
|
||||
visualReview: {
|
||||
completeVideoPlaybackCompleted: exact(review.complete_video_playback_completed, true, "E46I playback"),
|
||||
reviewedRangeSeconds: pair(review.reviewed_video_range_seconds, [0, 448.8], "E46I reviewed range"),
|
||||
verdict: exact(review.verdict, "material-semantic-progress-not-yet-complete-perception", "E46I verdict"),
|
||||
reviewWindows: array(review.review_windows, "E46I review windows").map(reviewWindow),
|
||||
finding: text(review.finding, "E46I finding"),
|
||||
knownError: text(review.known_error, "E46I known error"),
|
||||
},
|
||||
decision: {
|
||||
selectedProvider: exact(decision.selected_provider, "nvidia-grounding-dino-swin-tiny-commercial-v1.0", "E46I selected provider"),
|
||||
providerSemanticProgress: exact(decision.provider_semantic_progress, true, "E46I progress"),
|
||||
providerPromoted: exact(decision.provider_promoted, false, "E46I promotion"),
|
||||
nextAction: text(decision.next_action, "E46I next action"),
|
||||
},
|
||||
method: method(item.method),
|
||||
limitations: array(item.limitations, "E46I limitations").map((entry) => text(entry, "E46I limitation")),
|
||||
video: {
|
||||
url: text(video.url, "E46I video URL"),
|
||||
mediaType: exact(video.media_type, "video/mp4", "E46I video media"),
|
||||
byteLength: integer(video.byte_length, "E46I video bytes"),
|
||||
sha256: sha(video.sha256, "E46I video SHA"),
|
||||
width: exact(video.width, 960, "E46I video width"),
|
||||
height: exact(video.height, 544, "E46I video height"),
|
||||
frameRate: exact(video.frame_rate, 10, "E46I video fps"),
|
||||
frameCount: exact(video.frame_count, 4488, "E46I video frames"),
|
||||
durationSeconds: exact(video.duration_seconds, 448.8, "E46I video duration"),
|
||||
},
|
||||
visuals: {
|
||||
shadowGate: visual(visuals.shadow_gate, "E46I shadow visual"),
|
||||
fullRoute: visual(visuals.full_route, "E46I route visual"),
|
||||
targetedWindows: visual(visuals.targeted_windows, "E46I targeted visual"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import type { E46ELaboratoryMethod } from "./e46eReadyStack";
|
||||
|
||||
export interface E46JReviewWindow {
|
||||
id: string;
|
||||
label: string;
|
||||
startSeconds: number;
|
||||
endSeconds: number;
|
||||
verdict:
|
||||
| "legacy-background-false-positive-suppressed"
|
||||
| "operator-shadow-person-false-positive-observed";
|
||||
}
|
||||
|
||||
interface VisualArtifact {
|
||||
url: string;
|
||||
mediaType: "image/png";
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface E46JRawFisheyeRealtimeResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
source: {
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
sessionId: string;
|
||||
resolution: readonly [800, 600];
|
||||
frameCount: 4489;
|
||||
frameRate: number;
|
||||
durationSeconds: 448.723;
|
||||
calibrationModel: "KB4";
|
||||
};
|
||||
detector: {
|
||||
architecture: "YOLOX-S";
|
||||
source: string;
|
||||
license: "Apache-2.0";
|
||||
runtime: string;
|
||||
};
|
||||
detection: {
|
||||
minimumScore: 0.5;
|
||||
nmsIouThreshold: 0.45;
|
||||
};
|
||||
metrics: {
|
||||
frameCount: 4489;
|
||||
failedFrameCount: 0;
|
||||
detectionObservationCount: number;
|
||||
classObservationCounts: Readonly<Record<string, number>>;
|
||||
meanDetectionsPerFrame: number;
|
||||
maxDetectionsPerFrame: number;
|
||||
zeroDetectionFrameCount: number;
|
||||
longestZeroDetectionRunFrames: number;
|
||||
coreCapacityFps: number;
|
||||
corePathMeanMs: number;
|
||||
corePathP95Ms: number;
|
||||
inferenceRequestMeanMs: number;
|
||||
inferenceRequestP95Ms: number;
|
||||
gpuUtilizationMeanPercent: number;
|
||||
operatorShadowWindowFrameCount: number;
|
||||
operatorShadowPersonFrameCount: number;
|
||||
};
|
||||
visualReview: {
|
||||
reviewedRangeSeconds: readonly [0, 448.723];
|
||||
verdict: "realtime-detector-progress-with-known-shadow-exception";
|
||||
reviewWindows: readonly E46JReviewWindow[];
|
||||
finding: string;
|
||||
knownError: string;
|
||||
};
|
||||
decision: {
|
||||
selectedProvider: "megvii-yolox-s-0.1.1rc0";
|
||||
realtimeCapacityPassed: true;
|
||||
readyForTemporalBakeoff: true;
|
||||
providerPromoted: false;
|
||||
nextAction: string;
|
||||
};
|
||||
method: E46ELaboratoryMethod;
|
||||
limitations: readonly string[];
|
||||
video: {
|
||||
url: string;
|
||||
mediaType: "video/mp4";
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
width: 800;
|
||||
height: 600;
|
||||
frameRate: number;
|
||||
frameCount: 4489;
|
||||
durationSeconds: 448.723;
|
||||
};
|
||||
visuals: {
|
||||
fullRoute: VisualArtifact;
|
||||
targetedWindows: VisualArtifact;
|
||||
operatorShadow: VisualArtifact;
|
||||
};
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
class ContractError extends Error {}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) throw new ContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new ContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new ContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new ContractError(`${label}: ожидалось неотрицательное целое.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function exact<T extends string | number | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) throw new ContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function sha(value: unknown, label: string): string {
|
||||
const parsed = text(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new ContractError(`${label}: нарушена SHA-256 идентичность.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function pair<const T extends readonly [number, number]>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
const parsed = array(value, label);
|
||||
if (parsed.length !== 2 || parsed[0] !== expected[0] || parsed[1] !== expected[1]) {
|
||||
throw new ContractError(`${label}: нарушен диапазон.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function visual(value: unknown, label: string): VisualArtifact {
|
||||
const raw = object(value, label);
|
||||
return {
|
||||
url: text(raw.url, `${label} URL`),
|
||||
mediaType: exact(raw.media_type, "image/png", `${label} media`),
|
||||
byteLength: integer(raw.byte_length, `${label} bytes`),
|
||||
sha256: sha(raw.sha256, `${label} SHA`),
|
||||
};
|
||||
}
|
||||
|
||||
function method(value: unknown): E46ELaboratoryMethod {
|
||||
const raw = object(value, "E46J method");
|
||||
exact(raw.schema_version, "missioncore.laboratory-method/v1", "E46J method schema");
|
||||
exact(raw.completeness, "complete", "E46J method completeness");
|
||||
exact(raw.execution_class, "hybrid", "E46J method execution");
|
||||
return {
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: text(raw.pipeline_id, "E46J pipeline"),
|
||||
components: array(raw.components, "E46J components").map((value) => {
|
||||
const component = object(value, "E46J component");
|
||||
const kind = text(component.kind, "E46J component kind");
|
||||
if (!("source tool model algorithm runtime".split(" ")).includes(kind)) {
|
||||
throw new ContractError("E46J component kind: неизвестное значение.");
|
||||
}
|
||||
return {
|
||||
kind: kind as "source" | "tool" | "model" | "algorithm" | "runtime",
|
||||
name: text(component.name, "E46J component name"),
|
||||
version: text(component.version, "E46J component version"),
|
||||
role: text(component.role, "E46J component role"),
|
||||
identitySha256: sha(component.identity_sha256, "E46J component identity"),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function reviewWindow(value: unknown): E46JReviewWindow {
|
||||
const raw = object(value, "E46J review window");
|
||||
const verdict = raw.verdict;
|
||||
if (
|
||||
verdict !== "legacy-background-false-positive-suppressed"
|
||||
&& verdict !== "operator-shadow-person-false-positive-observed"
|
||||
) {
|
||||
throw new ContractError("E46J review verdict: неизвестное значение.");
|
||||
}
|
||||
const startSeconds = number(raw.start_seconds, "E46J review start");
|
||||
const endSeconds = number(raw.end_seconds, "E46J review end");
|
||||
if (startSeconds < 0 || endSeconds <= startSeconds || endSeconds > 448.723) {
|
||||
throw new ContractError("E46J review window: нарушен диапазон.");
|
||||
}
|
||||
return {
|
||||
id: text(raw.id, "E46J review id"),
|
||||
label: text(raw.label, "E46J review label"),
|
||||
startSeconds,
|
||||
endSeconds,
|
||||
verdict,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46JRawFisheyeRealtime({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E46JRawFisheyeRealtimeResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46j/results?limit=1", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new ContractError(`E46J LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "E46J catalog");
|
||||
exact(catalog.schema_version, "missioncore.e46j-raw-fisheye-realtime-catalog/v1", "E46J catalog schema");
|
||||
const items = array(catalog.items, "E46J results");
|
||||
if (!items.length) return null;
|
||||
if (items.length !== 1) throw new ContractError("E46J catalog size: нарушен контракт.");
|
||||
const item = object(items[0], "E46J result");
|
||||
exact(item.schema_version, "missioncore.e46j-raw-fisheye-realtime-view/v1", "E46J view schema");
|
||||
exact(item.status, "realtime-capacity-passed-awaiting-temporal-layer", "E46J status");
|
||||
exact(item.ground_truth, false, "E46J truth authority");
|
||||
const authority = object(item.authority, "E46J authority");
|
||||
exact(authority.ground_truth, false, "E46J authority truth");
|
||||
exact(authority.provider_promoted, false, "E46J promotion");
|
||||
exact(authority.commands_enabled, false, "E46J commands");
|
||||
exact(authority.navigation_or_safety_accepted, false, "E46J safety");
|
||||
const resultId = text(item.result_id, "E46J result id");
|
||||
if (!/^e46j-raw-fisheye-realtime-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new ContractError("E46J result identity: нарушен контракт.");
|
||||
}
|
||||
const source = object(item.source, "E46J source");
|
||||
const detector = object(item.detector, "E46J detector");
|
||||
const detection = object(item.detection, "E46J detection");
|
||||
const metrics = object(item.metrics, "E46J metrics");
|
||||
const review = object(item.visual_review, "E46J visual review");
|
||||
const decision = object(item.decision, "E46J decision");
|
||||
const video = object(item.video, "E46J video");
|
||||
const visuals = object(item.visuals, "E46J visuals");
|
||||
const classCounts = object(metrics.class_observation_counts, "E46J class counts");
|
||||
const acceptance = object(item.acceptance, "E46J acceptance");
|
||||
exact(acceptance.ten_hz_capacity_gate_passed, true, "E46J capacity gate");
|
||||
exact(acceptance.latency_gate_passed, true, "E46J latency gate");
|
||||
exact(acceptance.full_raw_fisheye_retained, true, "E46J FOV gate");
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: text(item.created_at_utc, "E46J created"),
|
||||
source: {
|
||||
cameraSourceId: exact(source.camera_source_id, "sensor.camera.right", "E46J camera"),
|
||||
sessionId: text(source.session_id, "E46J session"),
|
||||
resolution: pair(source.resolution, [800, 600], "E46J resolution"),
|
||||
frameCount: exact(source.frame_count, 4489, "E46J frames"),
|
||||
frameRate: number(source.frame_rate, "E46J fps"),
|
||||
durationSeconds: 448.723,
|
||||
calibrationModel: exact(source.calibration_model, "KB4", "E46J calibration"),
|
||||
},
|
||||
detector: {
|
||||
architecture: exact(detector.architecture, "YOLOX-S", "E46J architecture"),
|
||||
source: text(detector.source, "E46J detector source"),
|
||||
license: exact(detector.license, "Apache-2.0", "E46J license"),
|
||||
runtime: text(detector.runtime, "E46J runtime"),
|
||||
},
|
||||
detection: {
|
||||
minimumScore: exact(detection.minimum_score, 0.5, "E46J score"),
|
||||
nmsIouThreshold: exact(detection.nms_iou_threshold, 0.45, "E46J NMS"),
|
||||
},
|
||||
metrics: {
|
||||
frameCount: exact(metrics.frame_count, 4489, "E46J metric frames"),
|
||||
failedFrameCount: exact(metrics.failed_frame_count, 0, "E46J failures"),
|
||||
detectionObservationCount: integer(metrics.detection_observation_count, "E46J detections"),
|
||||
classObservationCounts: Object.fromEntries(
|
||||
Object.entries(classCounts).map(([key, value]) => [key, integer(value, `E46J ${key}`)]),
|
||||
),
|
||||
meanDetectionsPerFrame: number(metrics.mean_detections_per_frame, "E46J mean"),
|
||||
maxDetectionsPerFrame: integer(metrics.max_detections_per_frame, "E46J max"),
|
||||
zeroDetectionFrameCount: integer(metrics.zero_detection_frame_count, "E46J zero"),
|
||||
longestZeroDetectionRunFrames: integer(metrics.longest_zero_detection_run_frames, "E46J zero run"),
|
||||
coreCapacityFps: number(metrics.core_capacity_fps, "E46J capacity"),
|
||||
corePathMeanMs: number(metrics.core_path_mean_ms, "E46J core mean"),
|
||||
corePathP95Ms: number(metrics.core_path_p95_ms, "E46J core p95"),
|
||||
inferenceRequestMeanMs: number(metrics.inference_request_mean_ms, "E46J infer mean"),
|
||||
inferenceRequestP95Ms: number(metrics.inference_request_p95_ms, "E46J infer p95"),
|
||||
gpuUtilizationMeanPercent: number(metrics.gpu_utilization_mean_percent, "E46J GPU"),
|
||||
operatorShadowWindowFrameCount: integer(metrics.operator_shadow_window_frame_count, "E46J shadow window"),
|
||||
operatorShadowPersonFrameCount: integer(metrics.operator_shadow_person_frame_count, "E46J shadow false frames"),
|
||||
},
|
||||
visualReview: {
|
||||
reviewedRangeSeconds: pair(review.reviewed_video_range_seconds, [0, 448.723], "E46J reviewed range"),
|
||||
verdict: exact(review.verdict, "realtime-detector-progress-with-known-shadow-exception", "E46J verdict"),
|
||||
reviewWindows: array(review.review_windows, "E46J review windows").map(reviewWindow),
|
||||
finding: text(review.finding, "E46J finding"),
|
||||
knownError: text(review.known_error, "E46J known error"),
|
||||
},
|
||||
decision: {
|
||||
selectedProvider: exact(decision.selected_provider, "megvii-yolox-s-0.1.1rc0", "E46J provider"),
|
||||
realtimeCapacityPassed: exact(decision.realtime_capacity_passed, true, "E46J realtime"),
|
||||
readyForTemporalBakeoff: exact(decision.ready_for_temporal_bakeoff, true, "E46J temporal readiness"),
|
||||
providerPromoted: exact(decision.provider_promoted, false, "E46J provider promotion"),
|
||||
nextAction: text(decision.next_action, "E46J next action"),
|
||||
},
|
||||
method: method(item.method),
|
||||
limitations: array(item.limitations, "E46J limitations").map((value) => text(value, "E46J limitation")),
|
||||
video: {
|
||||
url: text(video.url, "E46J video URL"),
|
||||
mediaType: exact(video.media_type, "video/mp4", "E46J video media"),
|
||||
byteLength: integer(video.byte_length, "E46J video bytes"),
|
||||
sha256: sha(video.sha256, "E46J video SHA"),
|
||||
width: exact(video.width, 800, "E46J video width"),
|
||||
height: exact(video.height, 600, "E46J video height"),
|
||||
frameRate: number(video.frame_rate, "E46J video fps"),
|
||||
frameCount: exact(video.frame_count, 4489, "E46J video frames"),
|
||||
durationSeconds: exact(video.duration_seconds, 448.723, "E46J video duration"),
|
||||
},
|
||||
visuals: {
|
||||
fullRoute: visual(visuals.full_route, "E46J route visual"),
|
||||
targetedWindows: visual(visuals.targeted_windows, "E46J targeted visual"),
|
||||
operatorShadow: visual(visuals.operator_shadow, "E46J shadow visual"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
export type E47SemanticDisposition = "labeled" | "ambiguous";
|
||||
|
||||
export interface E47SemanticClass {
|
||||
classId: number;
|
||||
label: string;
|
||||
disposition: E47SemanticDisposition;
|
||||
colorRgb: readonly [number, number, number];
|
||||
}
|
||||
|
||||
export interface E47SemanticSlamResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
status: "diagnostic-semantic-slam-shadow";
|
||||
profileId: string;
|
||||
baseM4ResultId: string;
|
||||
semanticResultId: string;
|
||||
geometryResultId: string;
|
||||
sourcePackId: string;
|
||||
calibrationContentSha256: string;
|
||||
provider: {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
modelRevision: string;
|
||||
modelWeightsSha256: string;
|
||||
preprocessId: string;
|
||||
};
|
||||
temporalBinding: {
|
||||
semanticToCamera: "exact-sequence-and-session-time";
|
||||
cameraToLidar: "accepted-e6-nearest-host-arrival-best-effort";
|
||||
clockBasis: "recorded-host-monotonic-arrival";
|
||||
maximumLidarCameraDeltaMs: number;
|
||||
maximumPosePointDeltaMs: number;
|
||||
physicalSynchronizationProven: false;
|
||||
};
|
||||
taxonomy: readonly E47SemanticClass[];
|
||||
metrics: {
|
||||
frames: {
|
||||
total: number;
|
||||
maskAvailable: number;
|
||||
sourceAvailable: number;
|
||||
};
|
||||
points: {
|
||||
total: number;
|
||||
projected: number;
|
||||
labeled: number;
|
||||
ambiguous: number;
|
||||
unprojected: number;
|
||||
absent: number;
|
||||
};
|
||||
observations: {
|
||||
total: number;
|
||||
labeled: number;
|
||||
ambiguous: number;
|
||||
unprojected: number;
|
||||
absent: number;
|
||||
};
|
||||
runtime: {
|
||||
elapsedMs: number;
|
||||
framesPerSecond: number;
|
||||
};
|
||||
};
|
||||
acceptance: {
|
||||
artifactContractPassed: boolean;
|
||||
frameAccountingPassed: boolean;
|
||||
pointAccountingPassed: boolean;
|
||||
observationBindingPassed: boolean;
|
||||
temporalBindingPassed: boolean;
|
||||
independentSemanticTruthPassed: false;
|
||||
providerPromoted: false;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
}
|
||||
|
||||
export interface E47SemanticTimelineFrame {
|
||||
sequence: number;
|
||||
sourcePointCount: number;
|
||||
classIds: readonly number[];
|
||||
statusCodes: readonly number[];
|
||||
counts: {
|
||||
labeled: number;
|
||||
ambiguous: number;
|
||||
unprojected: number;
|
||||
absent: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface E47SemanticTimelineChunk {
|
||||
resultId: string;
|
||||
startSequence: number;
|
||||
frameCount: number;
|
||||
nextSequence: number | null;
|
||||
frames: readonly E47SemanticTimelineFrame[];
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export class E47SemanticSlamContractError extends Error {}
|
||||
|
||||
const object = (value: unknown, label: string): Record<string, unknown> => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E47SemanticSlamContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const array = (value: unknown, label: string): readonly unknown[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E47SemanticSlamContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const text = (value: unknown, label: string): string => {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E47SemanticSlamContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const finite = (value: unknown, label: string): number => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new E47SemanticSlamContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const integer = (value: unknown, label: string): number => {
|
||||
const parsed = finite(value, label);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new E47SemanticSlamContractError(`${label}: ожидалось неотрицательное целое.`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const exact = <T extends string | number | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T => {
|
||||
if (value !== expected) {
|
||||
throw new E47SemanticSlamContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
};
|
||||
|
||||
function resultId(value: unknown): string {
|
||||
const parsed = text(value, "E47 result id");
|
||||
if (!/^e47-semantic-slam-[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new E47SemanticSlamContractError("E47 result id: нарушена идентичность.");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function m4ResultId(value: unknown): string {
|
||||
const parsed = text(value, "E47 base M4 result id");
|
||||
if (!/^m4-threat-replay-[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new E47SemanticSlamContractError("E47 base M4 result id: нарушена идентичность.");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function sha256(value: unknown, label: string): string {
|
||||
const parsed = text(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new E47SemanticSlamContractError(`${label}: ожидался SHA-256.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function taxonomy(value: unknown): readonly E47SemanticClass[] {
|
||||
const classes = array(value, "E47 taxonomy").map((raw) => {
|
||||
const item = object(raw, "E47 semantic class");
|
||||
const classId = integer(item.class_id, "E47 class id");
|
||||
if (classId > 255) {
|
||||
throw new E47SemanticSlamContractError("E47 class id: вышел за uint8.");
|
||||
}
|
||||
const disposition = text(item.disposition, "E47 class disposition");
|
||||
if (disposition !== "labeled" && disposition !== "ambiguous") {
|
||||
throw new E47SemanticSlamContractError("E47 class disposition: неизвестное значение.");
|
||||
}
|
||||
const rgb = array(item.color_rgb, "E47 class color").map(
|
||||
(channel) => integer(channel, "E47 color channel"),
|
||||
);
|
||||
if (rgb.length !== 3 || rgb.some((channel) => channel > 255)) {
|
||||
throw new E47SemanticSlamContractError("E47 class color: нарушен RGB-контракт.");
|
||||
}
|
||||
return {
|
||||
classId,
|
||||
label: text(item.label, "E47 class label"),
|
||||
disposition: disposition as E47SemanticDisposition,
|
||||
colorRgb: [rgb[0]!, rgb[1]!, rgb[2]!] as const,
|
||||
};
|
||||
});
|
||||
if (!classes.length || new Set(classes.map((item) => item.classId)).size !== classes.length) {
|
||||
throw new E47SemanticSlamContractError("E47 taxonomy: классы отсутствуют или дублируются.");
|
||||
}
|
||||
return classes;
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): E47SemanticSlamResult {
|
||||
const item = object(value, "E47 result");
|
||||
exact(item.schema_version, "missioncore.e47-semantic-slam-view/v1", "E47 view schema");
|
||||
exact(item.status, "diagnostic-semantic-slam-shadow", "E47 status");
|
||||
exact(item.ground_truth, false, "E47 ground truth");
|
||||
exact(item.semantic_authority, "diagnostic-only", "E47 semantic authority");
|
||||
exact(item.navigation_or_safety_accepted, false, "E47 safety authority");
|
||||
exact(item.actuation_allowed, false, "E47 actuation authority");
|
||||
const provider = object(item.provider, "E47 provider");
|
||||
const temporalBinding = object(item.temporal_binding, "E47 temporal binding");
|
||||
const metrics = object(item.metrics, "E47 metrics");
|
||||
const frames = object(metrics.frames, "E47 frame metrics");
|
||||
const points = object(metrics.points, "E47 point metrics");
|
||||
const observations = object(metrics.observations, "E47 observation metrics");
|
||||
const runtime = object(metrics.runtime, "E47 runtime metrics");
|
||||
const acceptance = object(item.acceptance, "E47 acceptance");
|
||||
const frameMetrics = {
|
||||
total: exact(frames.total, 4489, "E47 frame total"),
|
||||
maskAvailable: integer(frames.mask_available, "E47 mask frames"),
|
||||
sourceAvailable: integer(frames.source_available, "E47 source frames"),
|
||||
};
|
||||
if (
|
||||
frameMetrics.maskAvailable > frameMetrics.total
|
||||
|| frameMetrics.sourceAvailable > frameMetrics.total
|
||||
) {
|
||||
throw new E47SemanticSlamContractError("E47 frame accounting: нарушен контракт.");
|
||||
}
|
||||
const pointMetrics = {
|
||||
total: integer(points.total, "E47 total points"),
|
||||
projected: integer(points.projected, "E47 projected points"),
|
||||
labeled: integer(points.labeled, "E47 labeled points"),
|
||||
ambiguous: integer(points.ambiguous, "E47 ambiguous points"),
|
||||
unprojected: integer(points.unprojected, "E47 unprojected points"),
|
||||
absent: integer(points.absent, "E47 absent points"),
|
||||
};
|
||||
if (
|
||||
pointMetrics.projected !== pointMetrics.labeled + pointMetrics.ambiguous
|
||||
|| pointMetrics.total !== pointMetrics.projected
|
||||
+ pointMetrics.unprojected
|
||||
+ pointMetrics.absent
|
||||
) {
|
||||
throw new E47SemanticSlamContractError("E47 point accounting: нарушен контракт.");
|
||||
}
|
||||
const observationMetrics = {
|
||||
total: integer(observations.total, "E47 total observations"),
|
||||
labeled: integer(observations.labeled, "E47 labeled observations"),
|
||||
ambiguous: integer(observations.ambiguous, "E47 ambiguous observations"),
|
||||
unprojected: integer(observations.unprojected, "E47 unprojected observations"),
|
||||
absent: integer(observations.absent, "E47 absent observations"),
|
||||
};
|
||||
if (
|
||||
observationMetrics.total !== observationMetrics.labeled
|
||||
+ observationMetrics.ambiguous
|
||||
+ observationMetrics.unprojected
|
||||
+ observationMetrics.absent
|
||||
) {
|
||||
throw new E47SemanticSlamContractError("E47 observation accounting: нарушен контракт.");
|
||||
}
|
||||
const runtimeMetrics = {
|
||||
elapsedMs: finite(runtime.elapsed_ms, "E47 elapsed"),
|
||||
framesPerSecond: finite(runtime.frames_per_second, "E47 FPS"),
|
||||
};
|
||||
if (runtimeMetrics.elapsedMs <= 0 || runtimeMetrics.framesPerSecond <= 0) {
|
||||
throw new E47SemanticSlamContractError("E47 runtime accounting: нарушен контракт.");
|
||||
}
|
||||
return {
|
||||
resultId: resultId(item.result_id),
|
||||
createdAtUtc: text(item.created_at_utc, "E47 created at"),
|
||||
status: "diagnostic-semantic-slam-shadow",
|
||||
profileId: text(item.profile_id, "E47 profile"),
|
||||
baseM4ResultId: m4ResultId(item.base_m4_result_id),
|
||||
semanticResultId: text(item.semantic_result_id, "E47 semantic source"),
|
||||
geometryResultId: text(item.geometry_result_id, "E47 geometry source"),
|
||||
sourcePackId: text(item.source_pack_id, "E47 source pack"),
|
||||
calibrationContentSha256: sha256(item.calibration_content_sha256, "E47 calibration"),
|
||||
provider: {
|
||||
providerId: text(provider.provider_id, "E47 provider id"),
|
||||
modelId: text(provider.model_id, "E47 model id"),
|
||||
modelRevision: text(provider.model_revision, "E47 model revision"),
|
||||
modelWeightsSha256: sha256(provider.model_weights_sha256, "E47 model weights"),
|
||||
preprocessId: text(provider.preprocess_id, "E47 preprocess id"),
|
||||
},
|
||||
temporalBinding: {
|
||||
semanticToCamera: exact(
|
||||
temporalBinding.semantic_to_camera,
|
||||
"exact-sequence-and-session-time",
|
||||
"E47 semantic/camera binding",
|
||||
),
|
||||
cameraToLidar: exact(
|
||||
temporalBinding.camera_to_lidar,
|
||||
"accepted-e6-nearest-host-arrival-best-effort",
|
||||
"E47 camera/LiDAR binding",
|
||||
),
|
||||
clockBasis: exact(
|
||||
temporalBinding.clock_basis,
|
||||
"recorded-host-monotonic-arrival",
|
||||
"E47 clock basis",
|
||||
),
|
||||
maximumLidarCameraDeltaMs: finite(
|
||||
temporalBinding.maximum_lidar_camera_delta_ms,
|
||||
"E47 maximum camera/LiDAR delta",
|
||||
),
|
||||
maximumPosePointDeltaMs: finite(
|
||||
temporalBinding.maximum_pose_point_delta_ms,
|
||||
"E47 maximum pose/point delta",
|
||||
),
|
||||
physicalSynchronizationProven: exact(
|
||||
temporalBinding.physical_synchronization_proven,
|
||||
false,
|
||||
"E47 physical synchronization",
|
||||
),
|
||||
},
|
||||
taxonomy: taxonomy(item.taxonomy),
|
||||
metrics: {
|
||||
frames: frameMetrics,
|
||||
points: pointMetrics,
|
||||
observations: observationMetrics,
|
||||
runtime: runtimeMetrics,
|
||||
},
|
||||
acceptance: {
|
||||
artifactContractPassed: exact(
|
||||
acceptance.artifact_contract_passed,
|
||||
true,
|
||||
"E47 artifact contract",
|
||||
),
|
||||
frameAccountingPassed: exact(
|
||||
acceptance.frame_accounting_passed,
|
||||
true,
|
||||
"E47 frame accounting",
|
||||
),
|
||||
pointAccountingPassed: exact(
|
||||
acceptance.point_accounting_passed,
|
||||
true,
|
||||
"E47 point accounting",
|
||||
),
|
||||
observationBindingPassed: exact(
|
||||
acceptance.observation_binding_passed,
|
||||
true,
|
||||
"E47 observation binding",
|
||||
),
|
||||
temporalBindingPassed: exact(
|
||||
acceptance.temporal_binding_passed,
|
||||
true,
|
||||
"E47 temporal binding",
|
||||
),
|
||||
independentSemanticTruthPassed: exact(
|
||||
acceptance.independent_semantic_truth_passed,
|
||||
false,
|
||||
"E47 independent truth",
|
||||
),
|
||||
providerPromoted: exact(
|
||||
acceptance.provider_promoted,
|
||||
false,
|
||||
"E47 provider promotion",
|
||||
),
|
||||
},
|
||||
limitations: array(item.limitations, "E47 limitations").map(
|
||||
(entry) => text(entry, "E47 limitation"),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE47SemanticSlamResult({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E47SemanticSlamResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e47-semantic-slam/results?limit=1", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new E47SemanticSlamContractError(`E47 LAB недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
const payload = object(await response.json(), "E47 catalog");
|
||||
exact(
|
||||
payload.schema_version,
|
||||
"missioncore.e47-semantic-slam-catalog/v1",
|
||||
"E47 catalog schema",
|
||||
);
|
||||
const items = array(payload.items, "E47 catalog items");
|
||||
return items.length ? parseResult(items[0]) : null;
|
||||
}
|
||||
|
||||
function parseFrame(
|
||||
value: unknown,
|
||||
expectedSequence: number,
|
||||
declaredTaxonomy: readonly E47SemanticClass[] | undefined,
|
||||
): E47SemanticTimelineFrame {
|
||||
const item = object(value, "E47 semantic frame");
|
||||
exact(item.schema_version, "missioncore.e47-semantic-slam-frame/v1", "E47 frame schema");
|
||||
const sequence = integer(item.sequence, "E47 frame sequence");
|
||||
if (sequence !== expectedSequence) {
|
||||
throw new E47SemanticSlamContractError("E47 frame sequence: нарушен порядок.");
|
||||
}
|
||||
const sourcePointCount = integer(item.source_point_count, "E47 frame source points");
|
||||
const classIds = array(item.class_ids, "E47 frame classes").map((entry) => {
|
||||
const parsed = finite(entry, "E47 frame class");
|
||||
if (!Number.isInteger(parsed) || parsed < -1 || parsed > 255) {
|
||||
throw new E47SemanticSlamContractError("E47 frame class: вышел за контракт.");
|
||||
}
|
||||
return parsed;
|
||||
});
|
||||
const statusCodes = array(item.status_codes, "E47 frame statuses").map((entry) => {
|
||||
const parsed = integer(entry, "E47 frame status");
|
||||
if (parsed > 3) {
|
||||
throw new E47SemanticSlamContractError("E47 frame status: неизвестное значение.");
|
||||
}
|
||||
return parsed;
|
||||
});
|
||||
if (classIds.length !== sourcePointCount || statusCodes.length !== sourcePointCount) {
|
||||
throw new E47SemanticSlamContractError("E47 frame point accounting: нарушен контракт.");
|
||||
}
|
||||
if (classIds.some((classId, index) => {
|
||||
const status = statusCodes[index];
|
||||
return status === 0 || status === 1 ? classId !== -1 : classId < 0;
|
||||
})) {
|
||||
throw new E47SemanticSlamContractError("E47 frame class/status binding: нарушен контракт.");
|
||||
}
|
||||
if (declaredTaxonomy) {
|
||||
const classesById = new Map(declaredTaxonomy.map((item) => [item.classId, item]));
|
||||
if (classIds.some((classId, index) => {
|
||||
const status = statusCodes[index];
|
||||
if (status !== 2 && status !== 3) return false;
|
||||
const semanticClass = classesById.get(classId);
|
||||
return !semanticClass
|
||||
|| (status === 2 && semanticClass.disposition !== "ambiguous")
|
||||
|| (status === 3 && semanticClass.disposition !== "labeled");
|
||||
})) {
|
||||
throw new E47SemanticSlamContractError("E47 frame taxonomy binding: нарушен контракт.");
|
||||
}
|
||||
}
|
||||
const counts = object(item.counts, "E47 frame counts");
|
||||
const parsedCounts = {
|
||||
labeled: integer(counts.labeled, "E47 frame labeled"),
|
||||
ambiguous: integer(counts.ambiguous, "E47 frame ambiguous"),
|
||||
unprojected: integer(counts.unprojected, "E47 frame unprojected"),
|
||||
absent: integer(counts.absent, "E47 frame absent"),
|
||||
};
|
||||
if (Object.values(parsedCounts).reduce((sum, count) => sum + count, 0) !== sourcePointCount) {
|
||||
throw new E47SemanticSlamContractError("E47 frame status accounting: нарушен контракт.");
|
||||
}
|
||||
const actualCounts = {
|
||||
labeled: statusCodes.filter((status) => status === 3).length,
|
||||
ambiguous: statusCodes.filter((status) => status === 2).length,
|
||||
unprojected: statusCodes.filter((status) => status === 1).length,
|
||||
absent: statusCodes.filter((status) => status === 0).length,
|
||||
};
|
||||
if (Object.keys(actualCounts).some(
|
||||
(key) => actualCounts[key as keyof typeof actualCounts]
|
||||
!== parsedCounts[key as keyof typeof parsedCounts],
|
||||
)) {
|
||||
throw new E47SemanticSlamContractError("E47 frame status histogram: нарушен контракт.");
|
||||
}
|
||||
return { sequence, sourcePointCount, classIds, statusCodes, counts: parsedCounts };
|
||||
}
|
||||
|
||||
export async function fetchE47SemanticTimelineChunk(
|
||||
result: string,
|
||||
startSequence: number,
|
||||
frameCount: number,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
taxonomy,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
taxonomy?: readonly E47SemanticClass[];
|
||||
} = {},
|
||||
): Promise<E47SemanticTimelineChunk> {
|
||||
resultId(result);
|
||||
const parameters = new URLSearchParams({
|
||||
start: String(startSequence),
|
||||
count: String(frameCount),
|
||||
});
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e47-semantic-slam/results/${result}/timeline/chunk?${parameters}`,
|
||||
{ headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new E47SemanticSlamContractError(`E47 timeline chunk: HTTP ${response.status}.`);
|
||||
}
|
||||
const payload = object(await response.json(), "E47 semantic chunk");
|
||||
exact(payload.schema_version, "missioncore.e47-semantic-slam-chunk/v1", "E47 chunk schema");
|
||||
exact(payload.result_id, result, "E47 chunk result");
|
||||
const parsedStart = integer(payload.start_sequence, "E47 chunk start");
|
||||
if (parsedStart !== startSequence) {
|
||||
throw new E47SemanticSlamContractError("E47 chunk start: нарушен контракт.");
|
||||
}
|
||||
const frames = array(payload.frames, "E47 chunk frames").map(
|
||||
(frame, offset) => parseFrame(frame, parsedStart + offset, taxonomy),
|
||||
);
|
||||
const parsedCount = integer(payload.frame_count, "E47 chunk count");
|
||||
if (parsedCount !== frames.length || parsedCount > frameCount) {
|
||||
throw new E47SemanticSlamContractError("E47 chunk frame count: нарушен контракт.");
|
||||
}
|
||||
return {
|
||||
resultId: result,
|
||||
startSequence: parsedStart,
|
||||
frameCount: parsedCount,
|
||||
nextSequence: payload.next_sequence === null
|
||||
? null
|
||||
: integer(payload.next_sequence, "E47 next sequence"),
|
||||
frames,
|
||||
};
|
||||
}
|
||||
|
||||
export function e47SemanticMaskUrl(result: string, sequence: number): string {
|
||||
resultId(result);
|
||||
if (!Number.isInteger(sequence) || sequence < 0 || sequence >= 4489) {
|
||||
throw new E47SemanticSlamContractError("E47 mask sequence: вне recorded replay.");
|
||||
}
|
||||
return `/api/v1/laboratory/e47-semantic-slam/results/${result}/masks/${sequence}`;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
export type LaboratoryEvidenceCompleteness = "recorded" | "not-recorded";
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
export interface LaboratoryEvidenceArtifact {
|
||||
kind: string | null;
|
||||
path: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
schemaVersion: string | null;
|
||||
mediaType: string | null;
|
||||
verified: true;
|
||||
}
|
||||
|
||||
export interface LaboratoryEvidenceReport {
|
||||
schemaVersion: "missioncore.laboratory-evidence-report/v1";
|
||||
workId: string;
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
access: "read-only";
|
||||
proof: {
|
||||
documentSchemaVersion: string;
|
||||
documentSha256: string;
|
||||
identitySha256: string;
|
||||
reportSchemaVersion: string | null;
|
||||
reportSha256: string | null;
|
||||
artifactCount: number;
|
||||
verifiedArtifactCount: number;
|
||||
};
|
||||
completeness: Readonly<Record<string, LaboratoryEvidenceCompleteness>>;
|
||||
identity: Record<string, JsonValue>;
|
||||
source: Record<string, JsonValue> | null;
|
||||
configuration: Record<string, JsonValue> | null;
|
||||
method: Record<string, JsonValue> | null;
|
||||
execution: Record<string, JsonValue> | null;
|
||||
resources: Record<string, JsonValue> | null;
|
||||
metrics: Record<string, JsonValue> | null;
|
||||
gates: Record<string, JsonValue> | null;
|
||||
decision: JsonValue | undefined;
|
||||
limitations: JsonValue | undefined;
|
||||
authority: Record<string, JsonValue> | null;
|
||||
artifacts: readonly LaboratoryEvidenceArtifact[];
|
||||
visualEvidence: Record<string, JsonValue>;
|
||||
rawReport: Record<string, JsonValue>;
|
||||
canonicalJson: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
export class LaboratoryEvidenceReportContractError extends Error {}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new LaboratoryEvidenceReportContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function nullableRecord(value: unknown, label: string): Record<string, JsonValue> | null {
|
||||
return value === null ? null : jsonRecord(value, label);
|
||||
}
|
||||
|
||||
function jsonRecord(value: unknown, label: string): Record<string, JsonValue> {
|
||||
const document = record(value, label);
|
||||
for (const [key, item] of Object.entries(document)) validateJson(item, `${label}.${key}`);
|
||||
return document as Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
function validateJson(value: unknown, label: string): asserts value is JsonValue {
|
||||
if (value === null || ["string", "number", "boolean"].includes(typeof value)) {
|
||||
if (typeof value === "number" && !Number.isFinite(value)) {
|
||||
throw new LaboratoryEvidenceReportContractError(`${label}: число не конечно.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => validateJson(item, `${label}[${index}]`));
|
||||
return;
|
||||
}
|
||||
const document = record(value, label);
|
||||
Object.entries(document).forEach(([key, item]) => validateJson(item, `${label}.${key}`));
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new LaboratoryEvidenceReportContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableText(value: unknown, label: string): string | null {
|
||||
return value === null ? null : text(value, label);
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new LaboratoryEvidenceReportContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseArtifact(value: unknown): LaboratoryEvidenceArtifact {
|
||||
const artifact = record(value, "LAB artifact");
|
||||
if (artifact.verified !== true) {
|
||||
throw new LaboratoryEvidenceReportContractError("LAB artifact: хэш не подтверждён.");
|
||||
}
|
||||
return {
|
||||
kind: nullableText(artifact.kind, "LAB artifact kind"),
|
||||
path: text(artifact.path, "LAB artifact path"),
|
||||
byteLength: integer(artifact.byte_length, "LAB artifact byte_length"),
|
||||
sha256: text(artifact.sha256, "LAB artifact sha256"),
|
||||
schemaVersion: nullableText(artifact.schema_version, "LAB artifact schema_version"),
|
||||
mediaType: nullableText(artifact.media_type, "LAB artifact media_type"),
|
||||
verified: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseLaboratoryEvidenceReport(value: unknown): LaboratoryEvidenceReport {
|
||||
const payload = jsonRecord(value, "LAB evidence report") as Record<string, unknown>;
|
||||
if (payload.schema_version !== "missioncore.laboratory-evidence-report/v1") {
|
||||
throw new LaboratoryEvidenceReportContractError("LAB evidence report: неизвестная схема.");
|
||||
}
|
||||
if (payload.access !== "read-only") {
|
||||
throw new LaboratoryEvidenceReportContractError("LAB evidence report: доступ не read-only.");
|
||||
}
|
||||
const proof = record(payload.proof, "LAB evidence proof");
|
||||
const completenessPayload = record(payload.completeness, "LAB evidence completeness");
|
||||
const completeness: Record<string, LaboratoryEvidenceCompleteness> = {};
|
||||
for (const [key, state] of Object.entries(completenessPayload)) {
|
||||
if (state !== "recorded" && state !== "not-recorded") {
|
||||
throw new LaboratoryEvidenceReportContractError(`LAB completeness ${key}: неизвестное значение.`);
|
||||
}
|
||||
completeness[key] = state;
|
||||
}
|
||||
if (!Array.isArray(payload.artifacts)) {
|
||||
throw new LaboratoryEvidenceReportContractError("LAB evidence artifacts: ожидался список.");
|
||||
}
|
||||
return {
|
||||
schemaVersion: "missioncore.laboratory-evidence-report/v1",
|
||||
workId: text(payload.work_id, "LAB work_id"),
|
||||
resultId: text(payload.result_id, "LAB result_id"),
|
||||
createdAtUtc: nullableText(payload.created_at_utc, "LAB created_at_utc"),
|
||||
access: "read-only",
|
||||
proof: {
|
||||
documentSchemaVersion: text(proof.document_schema_version, "document schema"),
|
||||
documentSha256: text(proof.document_sha256, "document sha256"),
|
||||
identitySha256: text(proof.identity_sha256, "identity sha256"),
|
||||
reportSchemaVersion: nullableText(proof.report_schema_version, "report schema"),
|
||||
reportSha256: nullableText(proof.report_sha256, "report sha256"),
|
||||
artifactCount: integer(proof.artifact_count, "artifact_count"),
|
||||
verifiedArtifactCount: integer(proof.verified_artifact_count, "verified_artifact_count"),
|
||||
},
|
||||
completeness,
|
||||
identity: jsonRecord(payload.identity, "LAB identity"),
|
||||
source: nullableRecord(payload.source, "LAB source"),
|
||||
configuration: nullableRecord(payload.configuration, "LAB configuration"),
|
||||
method: nullableRecord(payload.method, "LAB method"),
|
||||
execution: nullableRecord(payload.execution, "LAB execution"),
|
||||
resources: nullableRecord(payload.resources, "LAB resources"),
|
||||
metrics: nullableRecord(payload.metrics, "LAB metrics"),
|
||||
gates: nullableRecord(payload.gates, "LAB gates"),
|
||||
decision: payload.decision as JsonValue | undefined,
|
||||
limitations: payload.limitations as JsonValue | undefined,
|
||||
authority: nullableRecord(payload.authority, "LAB authority"),
|
||||
artifacts: payload.artifacts.map(parseArtifact),
|
||||
visualEvidence: jsonRecord(payload.visual_evidence, "LAB visual evidence"),
|
||||
rawReport: jsonRecord(payload.raw_report, "LAB raw report"),
|
||||
canonicalJson: payload as Record<string, JsonValue>,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLaboratoryEvidenceReport({
|
||||
workId,
|
||||
resultId,
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
workId: string;
|
||||
resultId: string;
|
||||
fetcher?: typeof fetch;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<LaboratoryEvidenceReport> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/evidence-reports/${encodeURIComponent(workId)}/${encodeURIComponent(resultId)}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new LaboratoryEvidenceReportContractError(
|
||||
response.status === 404
|
||||
? "Для этой LAB канонический evidence-report пока не опубликован."
|
||||
: `Evidence-report LAB не прошёл серверную проверку: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const report = parseLaboratoryEvidenceReport(await response.json());
|
||||
if (report.workId !== workId || report.resultId !== resultId) {
|
||||
throw new LaboratoryEvidenceReportContractError("Evidence-report не совпадает с выбранной LAB identity.");
|
||||
}
|
||||
return report;
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import {
|
||||
AdvancedLaboratoryContractError,
|
||||
type LaboratoryFetch,
|
||||
} from "./advancedResults";
|
||||
import type { L3VisualBox } from "./l3PointPillarsVisualAudit";
|
||||
|
||||
export interface L31ClassCounts {
|
||||
Vehicle: number;
|
||||
Pedestrian: number;
|
||||
Cyclist: number;
|
||||
}
|
||||
|
||||
export interface L31VisualFrameSummary {
|
||||
frameId: string;
|
||||
frameIndex: number;
|
||||
sessionSeconds: number;
|
||||
sourcePointCount: number;
|
||||
predictionCount: number;
|
||||
classCounts: L31ClassCounts;
|
||||
inferenceMs: number;
|
||||
}
|
||||
|
||||
export interface L31PointPillarsRavnovesResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
status: "cross-domain-transfer-measured-visual-review-required";
|
||||
sourceSessionId: string;
|
||||
sourcePackId: string;
|
||||
sourceLogicalContentSha256: string;
|
||||
model: {
|
||||
name: "pointpillars";
|
||||
sourceModelSha256: string;
|
||||
engineSha256: string;
|
||||
embeddedScoreThreshold: number;
|
||||
};
|
||||
execution: {
|
||||
workerHostId: "worker-006";
|
||||
sequential: true;
|
||||
parallelWorkers: 1;
|
||||
sourcePaced: false;
|
||||
existingTritonOnly: true;
|
||||
};
|
||||
metrics: {
|
||||
frameCount: number;
|
||||
inputAdmissionFraction: number;
|
||||
outputSchemaValidFraction: number;
|
||||
framesWithPredictions: number;
|
||||
framesWithVehiclePredictions: number;
|
||||
predictionCount: number;
|
||||
classCounts: L31ClassCounts;
|
||||
inferenceLatencyMs: { p50: number; p95: number; maximum: number };
|
||||
poseBindingAgeMs: { p50: number; p95: number; maximum: number };
|
||||
deterministicReplayFraction: number;
|
||||
deterministicReplayFrames: number;
|
||||
};
|
||||
frames: readonly L31VisualFrameSummary[];
|
||||
limitations: readonly string[];
|
||||
}
|
||||
|
||||
export interface L31VisualFrame {
|
||||
frameId: string;
|
||||
summary: L31VisualFrameSummary & {
|
||||
deterministicReplay: boolean;
|
||||
replayInferenceMs: number;
|
||||
visualBoxCount: number;
|
||||
visualBoxTruncated: boolean;
|
||||
};
|
||||
sourcePointCount: number;
|
||||
modelRangePointCount: number;
|
||||
sampledPointCount: number;
|
||||
pointsXyzi: readonly number[];
|
||||
truthBoxes: readonly L3VisualBox[];
|
||||
predictionBoxes: readonly L3VisualBox[];
|
||||
}
|
||||
|
||||
const RESULT_ID = /^l31-pointpillars-ravnoves-[a-f0-9]{64}$/;
|
||||
const PACK_ID = /^lidar-replay-pack-[a-f0-9]{64}$/;
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const FRAME_ID = /^[0-9]{6}$/;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact<T extends string>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string, minimum = 0): number {
|
||||
if (
|
||||
typeof value !== "number"
|
||||
|| !Number.isFinite(value)
|
||||
|| value < minimum
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: неверное число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалось целое.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exactBoolean(
|
||||
value: unknown,
|
||||
expected: boolean,
|
||||
label: string,
|
||||
): boolean {
|
||||
const parsed = boolean(value, label);
|
||||
if (parsed !== expected) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function classCounts(value: unknown, label: string): L31ClassCounts {
|
||||
const counts = record(value, label);
|
||||
if (
|
||||
Object.keys(counts).sort().join(",") !== "Cyclist,Pedestrian,Vehicle"
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: классы изменились.`);
|
||||
}
|
||||
return {
|
||||
Vehicle: integer(counts.Vehicle, `${label}.Vehicle`),
|
||||
Pedestrian: integer(counts.Pedestrian, `${label}.Pedestrian`),
|
||||
Cyclist: integer(counts.Cyclist, `${label}.Cyclist`),
|
||||
};
|
||||
}
|
||||
|
||||
function distribution(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): { p50: number; p95: number; maximum: number } {
|
||||
const item = record(value, label);
|
||||
return {
|
||||
p50: number(item.p50, `${label}.p50`),
|
||||
p95: number(item.p95, `${label}.p95`),
|
||||
maximum: number(item.maximum, `${label}.maximum`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSummary(value: unknown): L31VisualFrameSummary {
|
||||
const item = record(value, "L3.1 frame");
|
||||
const frameId = string(item.frame_id, "L3.1 frame_id");
|
||||
if (!FRAME_ID.test(frameId)) {
|
||||
throw new AdvancedLaboratoryContractError("L3.1 frame_id: неверный формат.");
|
||||
}
|
||||
return {
|
||||
frameId,
|
||||
frameIndex: integer(item.frame_index, "L3.1 frame_index"),
|
||||
sessionSeconds: number(item.session_seconds, "L3.1 session_seconds"),
|
||||
sourcePointCount: integer(
|
||||
item.source_point_count,
|
||||
"L3.1 source_point_count",
|
||||
),
|
||||
predictionCount: integer(
|
||||
item.prediction_count,
|
||||
"L3.1 prediction_count",
|
||||
),
|
||||
classCounts: classCounts(item.class_counts, "L3.1 class_counts"),
|
||||
inferenceMs: number(
|
||||
item.inference_ms,
|
||||
"L3.1 inference_ms",
|
||||
Number.MIN_VALUE,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): L31PointPillarsRavnovesResult {
|
||||
const item = record(value, "L3.1 result");
|
||||
exact(
|
||||
item.schema_version,
|
||||
"missioncore.l31-pointpillars-ravnoves-result/v1",
|
||||
"L3.1 schema_version",
|
||||
);
|
||||
exact(item.access, "read-only", "L3.1 access");
|
||||
const resultId = string(item.result_id, "L3.1 result_id");
|
||||
const sourcePackId = string(item.source_pack_id, "L3.1 source_pack_id");
|
||||
const logicalSha = string(
|
||||
item.source_logical_content_sha256,
|
||||
"L3.1 logical content",
|
||||
);
|
||||
if (
|
||||
!RESULT_ID.test(resultId)
|
||||
|| !PACK_ID.test(sourcePackId)
|
||||
|| !SHA256.test(logicalSha)
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"L3.1: нарушена идентичность результата.",
|
||||
);
|
||||
}
|
||||
const model = record(item.model, "L3.1 model");
|
||||
const execution = record(item.execution, "L3.1 execution");
|
||||
const metrics = record(item.metrics, "L3.1 metrics");
|
||||
const frames = array(item.frames, "L3.1 frames").map(parseSummary);
|
||||
if (
|
||||
!frames.length
|
||||
|| frames.length > 18
|
||||
|| new Set(frames.map(({ frameId }) => frameId)).size !== frames.length
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError("L3.1 frames: неверный каталог.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "L3.1 created_at_utc"),
|
||||
status: exact(
|
||||
item.status,
|
||||
"cross-domain-transfer-measured-visual-review-required",
|
||||
"L3.1 status",
|
||||
),
|
||||
sourceSessionId: exact(
|
||||
item.source_session_id,
|
||||
"20260720T065719Z_viewer_live",
|
||||
"L3.1 source_session_id",
|
||||
),
|
||||
sourcePackId,
|
||||
sourceLogicalContentSha256: logicalSha,
|
||||
model: {
|
||||
name: exact(model.name, "pointpillars", "L3.1 model.name"),
|
||||
sourceModelSha256: string(
|
||||
model.source_model_sha256,
|
||||
"L3.1 model sha",
|
||||
),
|
||||
engineSha256: string(model.engine_sha256, "L3.1 engine sha"),
|
||||
embeddedScoreThreshold: number(
|
||||
model.embedded_score_threshold,
|
||||
"L3.1 score threshold",
|
||||
),
|
||||
},
|
||||
execution: {
|
||||
workerHostId: exact(
|
||||
execution.worker_host_id,
|
||||
"worker-006",
|
||||
"L3.1 worker",
|
||||
),
|
||||
sequential: exactBoolean(
|
||||
execution.sequential,
|
||||
true,
|
||||
"L3.1 sequential",
|
||||
) as true,
|
||||
parallelWorkers: (
|
||||
integer(execution.parallel_workers, "L3.1 parallel workers") === 1
|
||||
? 1
|
||||
: (() => {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"L3.1 parallel workers: нарушен контракт.",
|
||||
);
|
||||
})()
|
||||
),
|
||||
sourcePaced: exactBoolean(
|
||||
execution.source_paced,
|
||||
false,
|
||||
"L3.1 source paced",
|
||||
) as false,
|
||||
existingTritonOnly: exactBoolean(
|
||||
execution.existing_triton_only,
|
||||
true,
|
||||
"L3.1 Triton",
|
||||
) as true,
|
||||
},
|
||||
metrics: {
|
||||
frameCount: integer(metrics.frame_count, "L3.1 frame count"),
|
||||
inputAdmissionFraction: number(
|
||||
metrics.input_admission_fraction,
|
||||
"L3.1 admission",
|
||||
),
|
||||
outputSchemaValidFraction: number(
|
||||
metrics.output_schema_valid_fraction,
|
||||
"L3.1 schema valid",
|
||||
),
|
||||
framesWithPredictions: integer(
|
||||
metrics.frames_with_predictions,
|
||||
"L3.1 frames with predictions",
|
||||
),
|
||||
framesWithVehiclePredictions: integer(
|
||||
metrics.frames_with_vehicle_predictions,
|
||||
"L3.1 frames with vehicles",
|
||||
),
|
||||
predictionCount: integer(
|
||||
metrics.prediction_count,
|
||||
"L3.1 predictions",
|
||||
),
|
||||
classCounts: classCounts(metrics.class_counts, "L3.1 metric classes"),
|
||||
inferenceLatencyMs: distribution(
|
||||
metrics.inference_latency_ms,
|
||||
"L3.1 latency",
|
||||
),
|
||||
poseBindingAgeMs: distribution(
|
||||
metrics.pose_binding_age_ms,
|
||||
"L3.1 pose age",
|
||||
),
|
||||
deterministicReplayFraction: number(
|
||||
metrics.deterministic_replay_fraction,
|
||||
"L3.1 determinism",
|
||||
),
|
||||
deterministicReplayFrames: integer(
|
||||
metrics.deterministic_replay_frames,
|
||||
"L3.1 determinism frames",
|
||||
),
|
||||
},
|
||||
frames,
|
||||
limitations: array(item.limitations, "L3.1 limitations").map(
|
||||
(entry) => string(entry, "L3.1 limitation"),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseBox(value: unknown): L3VisualBox {
|
||||
const box = record(value, "L3.1 box");
|
||||
const center = [
|
||||
number(box.x_m, "L3.1 box.x", -Infinity),
|
||||
number(box.y_m, "L3.1 box.y", -Infinity),
|
||||
number(box.z_m, "L3.1 box.z", -Infinity),
|
||||
] as const;
|
||||
const size = [
|
||||
number(box.length_m, "L3.1 box.length", Number.MIN_VALUE),
|
||||
number(box.width_m, "L3.1 box.width", Number.MIN_VALUE),
|
||||
number(box.height_m, "L3.1 box.height", Number.MIN_VALUE),
|
||||
] as const;
|
||||
const modelClass = string(box.model_class, "L3.1 box.class");
|
||||
if (!["Vehicle", "Pedestrian", "Cyclist"].includes(modelClass)) {
|
||||
throw new AdvancedLaboratoryContractError("L3.1 box.class: неизвестен.");
|
||||
}
|
||||
return {
|
||||
benchmarkClass: modelClass,
|
||||
centerXyzM: center,
|
||||
sizeLwhM: size,
|
||||
yawRad: number(box.yaw_rad, "L3.1 box.yaw", -Infinity),
|
||||
status: "model-prediction",
|
||||
score: number(box.score, "L3.1 box.score"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchL31PointPillarsRavnoves({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<L31PointPillarsRavnovesResult | null> {
|
||||
const response = await fetcher(
|
||||
"/api/v1/laboratory/l31/pointpillars-ravnoves/results?limit=1",
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
`L3.1 RAVNOVES недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const catalog = record(await response.json(), "L3.1 catalog");
|
||||
exact(
|
||||
catalog.schema_version,
|
||||
"missioncore.l31-pointpillars-ravnoves-catalog-results/v1",
|
||||
"L3.1 catalog schema",
|
||||
);
|
||||
const items = array(catalog.items, "L3.1 catalog.items");
|
||||
if (items.length > 1) {
|
||||
throw new AdvancedLaboratoryContractError("L3.1 catalog: лишние результаты.");
|
||||
}
|
||||
return items.length ? parseResult(items[0]) : null;
|
||||
}
|
||||
|
||||
export async function fetchL31PointPillarsRavnovesFrame(
|
||||
resultId: string,
|
||||
frameId: string,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<L31VisualFrame> {
|
||||
if (!RESULT_ID.test(resultId) || !FRAME_ID.test(frameId)) {
|
||||
throw new AdvancedLaboratoryContractError("L3.1 frame: неверная identity.");
|
||||
}
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/l31/pointpillars-ravnoves/${resultId}/frames/${frameId}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
`L3.1 frame недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const payload = record(await response.json(), "L3.1 visual frame");
|
||||
exact(
|
||||
payload.schema_version,
|
||||
"missioncore.l31-pointpillars-ravnoves-visual-frame/v1",
|
||||
"L3.1 visual schema",
|
||||
);
|
||||
exact(payload.frame_id, frameId, "L3.1 visual frame_id");
|
||||
const summaryRaw = record(payload.summary, "L3.1 visual summary");
|
||||
const summary = parseSummary(summaryRaw);
|
||||
const points = record(payload.points, "L3.1 points");
|
||||
exact(points.layout, "flat-xyzi", "L3.1 points.layout");
|
||||
const pointValues = array(points.values, "L3.1 points.values").map(
|
||||
(entry, index) => number(entry, `L3.1 points[${index}]`, -Infinity),
|
||||
);
|
||||
const sampledPointCount = integer(
|
||||
points.sampled_point_count,
|
||||
"L3.1 sampled points",
|
||||
);
|
||||
if (sampledPointCount > 12_000 || pointValues.length !== sampledPointCount * 4) {
|
||||
throw new AdvancedLaboratoryContractError("L3.1 points: нарушен bound.");
|
||||
}
|
||||
return {
|
||||
frameId,
|
||||
summary: {
|
||||
...summary,
|
||||
deterministicReplay: boolean(
|
||||
summaryRaw.deterministic_replay,
|
||||
"L3.1 deterministic replay",
|
||||
),
|
||||
replayInferenceMs: number(
|
||||
summaryRaw.replay_inference_ms,
|
||||
"L3.1 replay latency",
|
||||
),
|
||||
visualBoxCount: integer(
|
||||
summaryRaw.visual_box_count,
|
||||
"L3.1 visual box count",
|
||||
),
|
||||
visualBoxTruncated: boolean(
|
||||
summaryRaw.visual_box_truncated,
|
||||
"L3.1 visual box truncated",
|
||||
),
|
||||
},
|
||||
sourcePointCount: integer(
|
||||
points.source_point_count,
|
||||
"L3.1 source points",
|
||||
),
|
||||
modelRangePointCount: integer(
|
||||
points.model_range_point_count,
|
||||
"L3.1 model range points",
|
||||
),
|
||||
sampledPointCount,
|
||||
pointsXyzi: pointValues,
|
||||
truthBoxes: [],
|
||||
predictionBoxes: array(
|
||||
payload.prediction_boxes,
|
||||
"L3.1 prediction boxes",
|
||||
).map(parseBox),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import {
|
||||
AdvancedLaboratoryContractError,
|
||||
type LaboratoryFetch,
|
||||
} from "./advancedResults";
|
||||
import type { L3VisualBox } from "./l3PointPillarsVisualAudit";
|
||||
import type { L31ClassCounts } from "./l31PointPillarsRavnoves";
|
||||
|
||||
export interface L32FrameSummary {
|
||||
frameId: string;
|
||||
frameIndex: number;
|
||||
sessionSeconds: number;
|
||||
sourcePointCount: number;
|
||||
predictionCount: number;
|
||||
classCounts: L31ClassCounts;
|
||||
inferenceMs: number;
|
||||
cameraSourceFrameIndex: number;
|
||||
cameraSessionSeconds: number;
|
||||
cameraDeltaMs: number;
|
||||
}
|
||||
|
||||
export interface L32PointPillarsCameraReviewResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
status: "camera-bound-review-rejects-current-candidate";
|
||||
sourceSessionId: string;
|
||||
sourceL31ResultId: string;
|
||||
sourceE10PackId: string;
|
||||
metrics: {
|
||||
frameCount: number;
|
||||
predictionCount: number;
|
||||
classCounts: L31ClassCounts;
|
||||
inferenceLatencyMs: { p50: number; p95: number; maximum: number };
|
||||
reviewFrameCount: number;
|
||||
reviewPredictionCount: number;
|
||||
reviewVisibleProjectedBoxCount: number;
|
||||
reviewProjectedPointCount: number;
|
||||
scoreBelow025Fraction: number;
|
||||
scoreBelow050Fraction: number;
|
||||
cameraBindingAbsoluteDeltaMs: { p95: number; maximum: number };
|
||||
};
|
||||
frames: readonly L32FrameSummary[];
|
||||
limitations: readonly string[];
|
||||
}
|
||||
|
||||
export interface L32ProjectedBox {
|
||||
modelClass: string;
|
||||
score: number;
|
||||
segmentsXyxy: readonly number[];
|
||||
}
|
||||
|
||||
export interface L32VisualFrame {
|
||||
frameId: string;
|
||||
summary: L32FrameSummary;
|
||||
cameraUrl: string;
|
||||
cameraWidth: number;
|
||||
cameraHeight: number;
|
||||
modelRangePointCount: number;
|
||||
pointsXyzi: readonly number[];
|
||||
predictionBoxes: readonly L3VisualBox[];
|
||||
projectedPointsXyd: readonly number[];
|
||||
projectedBoxes: readonly L32ProjectedBox[];
|
||||
}
|
||||
|
||||
export const L32_REVIEW_SCORE_THRESHOLD = 0.25;
|
||||
|
||||
const RESULT_ID = /^l32-pointpillars-camera-review-[a-f0-9]{64}$/;
|
||||
const L31_RESULT_ID = /^l31-pointpillars-ravnoves-[a-f0-9]{64}$/;
|
||||
const PACK_ID = /^e10-lidar-pack-[a-f0-9]{64}$/;
|
||||
const FRAME_ID = /^[0-9]{6}$/;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact<T extends string>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string, minimum = 0): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: неверное число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалось целое.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function counts(value: unknown, label: string): L31ClassCounts {
|
||||
const item = record(value, label);
|
||||
if (Object.keys(item).sort().join(",") !== "Cyclist,Pedestrian,Vehicle") {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: классы изменились.`);
|
||||
}
|
||||
return {
|
||||
Vehicle: integer(item.Vehicle, `${label}.Vehicle`),
|
||||
Pedestrian: integer(item.Pedestrian, `${label}.Pedestrian`),
|
||||
Cyclist: integer(item.Cyclist, `${label}.Cyclist`),
|
||||
};
|
||||
}
|
||||
|
||||
function distribution(value: unknown, label: string) {
|
||||
const item = record(value, label);
|
||||
return {
|
||||
p50: number(item.p50, `${label}.p50`),
|
||||
p95: number(item.p95, `${label}.p95`),
|
||||
maximum: number(item.maximum, `${label}.maximum`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSummary(value: unknown): L32FrameSummary {
|
||||
const item = record(value, "L3.2 frame");
|
||||
const frameId = string(item.frame_id, "L3.2 frame_id");
|
||||
if (!FRAME_ID.test(frameId)) {
|
||||
throw new AdvancedLaboratoryContractError("L3.2 frame_id: неверный формат.");
|
||||
}
|
||||
return {
|
||||
frameId,
|
||||
frameIndex: integer(item.frame_index, "L3.2 frame index"),
|
||||
sessionSeconds: number(item.session_seconds, "L3.2 session seconds"),
|
||||
sourcePointCount: integer(item.source_point_count, "L3.2 source points"),
|
||||
predictionCount: integer(item.prediction_count, "L3.2 predictions"),
|
||||
classCounts: counts(item.class_counts, "L3.2 classes"),
|
||||
inferenceMs: number(item.inference_ms, "L3.2 inference", Number.MIN_VALUE),
|
||||
cameraSourceFrameIndex: integer(
|
||||
item.camera_source_frame_index,
|
||||
"L3.2 camera frame",
|
||||
),
|
||||
cameraSessionSeconds: number(
|
||||
item.camera_session_seconds,
|
||||
"L3.2 camera seconds",
|
||||
),
|
||||
cameraDeltaMs: number(item.camera_delta_ms, "L3.2 camera delta", -100),
|
||||
};
|
||||
}
|
||||
|
||||
function parseBox(value: unknown): L3VisualBox {
|
||||
const box = record(value, "L3.2 box");
|
||||
const modelClass = string(box.model_class, "L3.2 box class");
|
||||
return {
|
||||
benchmarkClass: modelClass,
|
||||
centerXyzM: [
|
||||
number(box.x_m, "L3.2 box x", -Infinity),
|
||||
number(box.y_m, "L3.2 box y", -Infinity),
|
||||
number(box.z_m, "L3.2 box z", -Infinity),
|
||||
],
|
||||
sizeLwhM: [
|
||||
number(box.length_m, "L3.2 box length", Number.MIN_VALUE),
|
||||
number(box.width_m, "L3.2 box width", Number.MIN_VALUE),
|
||||
number(box.height_m, "L3.2 box height", Number.MIN_VALUE),
|
||||
],
|
||||
yawRad: number(box.yaw_rad, "L3.2 box yaw", -Infinity),
|
||||
status: "model-prediction",
|
||||
score: number(box.score, "L3.2 box score"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): L32PointPillarsCameraReviewResult {
|
||||
const item = record(value, "L3.2 result");
|
||||
exact(
|
||||
item.schema_version,
|
||||
"missioncore.l32-pointpillars-camera-review-result/v1",
|
||||
"L3.2 schema",
|
||||
);
|
||||
exact(item.access, "read-only", "L3.2 access");
|
||||
const resultId = string(item.result_id, "L3.2 result id");
|
||||
const sourceL31ResultId = string(item.source_l31_result_id, "L3.2 L3.1 id");
|
||||
const sourceE10PackId = string(item.source_e10_pack_id, "L3.2 E10 id");
|
||||
if (
|
||||
!RESULT_ID.test(resultId)
|
||||
|| !L31_RESULT_ID.test(sourceL31ResultId)
|
||||
|| !PACK_ID.test(sourceE10PackId)
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError("L3.2: неверная identity.");
|
||||
}
|
||||
const metrics = record(item.metrics, "L3.2 metrics");
|
||||
const cameraDelta = distribution(
|
||||
metrics.camera_binding_absolute_delta_ms,
|
||||
"L3.2 camera delta",
|
||||
);
|
||||
const frames = array(item.frames, "L3.2 frames").map(parseSummary);
|
||||
if (!frames.length || frames.length > 18) {
|
||||
throw new AdvancedLaboratoryContractError("L3.2 frames: нарушен bound.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "L3.2 created at"),
|
||||
status: exact(
|
||||
item.status,
|
||||
"camera-bound-review-rejects-current-candidate",
|
||||
"L3.2 status",
|
||||
),
|
||||
sourceSessionId: exact(
|
||||
item.source_session_id,
|
||||
"20260720T065719Z_viewer_live",
|
||||
"L3.2 session",
|
||||
),
|
||||
sourceL31ResultId,
|
||||
sourceE10PackId,
|
||||
metrics: {
|
||||
frameCount: integer(metrics.frame_count, "L3.2 frame count"),
|
||||
predictionCount: integer(metrics.prediction_count, "L3.2 predictions"),
|
||||
classCounts: counts(metrics.class_counts, "L3.2 metric classes"),
|
||||
inferenceLatencyMs: distribution(
|
||||
metrics.inference_latency_ms,
|
||||
"L3.2 inference latency",
|
||||
),
|
||||
reviewFrameCount: integer(metrics.review_frame_count, "L3.2 review frames"),
|
||||
reviewPredictionCount: integer(
|
||||
metrics.review_prediction_count,
|
||||
"L3.2 review predictions",
|
||||
),
|
||||
reviewVisibleProjectedBoxCount: integer(
|
||||
metrics.review_visible_projected_box_count,
|
||||
"L3.2 visible boxes",
|
||||
),
|
||||
reviewProjectedPointCount: integer(
|
||||
metrics.review_projected_point_count,
|
||||
"L3.2 projected points",
|
||||
),
|
||||
scoreBelow025Fraction: number(
|
||||
metrics.score_below_0_25_fraction,
|
||||
"L3.2 low score 025",
|
||||
),
|
||||
scoreBelow050Fraction: number(
|
||||
metrics.score_below_0_50_fraction,
|
||||
"L3.2 low score 050",
|
||||
),
|
||||
cameraBindingAbsoluteDeltaMs: cameraDelta,
|
||||
},
|
||||
frames,
|
||||
limitations: array(item.limitations, "L3.2 limitations").map(
|
||||
(entry) => string(entry, "L3.2 limitation"),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchL32PointPillarsCameraReview({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<L32PointPillarsCameraReviewResult | null> {
|
||||
const response = await fetcher(
|
||||
"/api/v1/laboratory/l32/pointpillars-camera-review/results?limit=1",
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
`L3.2 RAVNOVES недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const catalog = record(await response.json(), "L3.2 catalog");
|
||||
exact(
|
||||
catalog.schema_version,
|
||||
"missioncore.l32-pointpillars-camera-review-catalog-results/v1",
|
||||
"L3.2 catalog schema",
|
||||
);
|
||||
const items = array(catalog.items, "L3.2 catalog items");
|
||||
return items.length ? parseResult(items[0]) : null;
|
||||
}
|
||||
|
||||
export async function fetchL32PointPillarsCameraReviewFrame(
|
||||
resultId: string,
|
||||
frameId: string,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<L32VisualFrame> {
|
||||
if (!RESULT_ID.test(resultId) || !FRAME_ID.test(frameId)) {
|
||||
throw new AdvancedLaboratoryContractError("L3.2 frame: неверная identity.");
|
||||
}
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/l32/pointpillars-camera-review/${resultId}/frames/${frameId}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
`L3.2 frame недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const item = record(await response.json(), "L3.2 visual frame");
|
||||
exact(
|
||||
item.schema_version,
|
||||
"missioncore.l32-pointpillars-camera-review-frame/v1",
|
||||
"L3.2 frame schema",
|
||||
);
|
||||
const summary = parseSummary(item.summary);
|
||||
const camera = record(item.camera, "L3.2 camera");
|
||||
const points = record(item.points, "L3.2 points");
|
||||
exact(points.layout, "flat-xyzi", "L3.2 point layout");
|
||||
const values = array(points.values, "L3.2 point values").map(
|
||||
(entry, index) => number(entry, `L3.2 points[${index}]`, -Infinity),
|
||||
);
|
||||
const projection = record(item.camera_projection, "L3.2 projection");
|
||||
exact(projection.point_layout, "flat-xy-depth-m", "L3.2 projection layout");
|
||||
const projected = array(projection.point_values, "L3.2 projected points").map(
|
||||
(entry, index) => number(entry, `L3.2 projected[${index}]`, -Infinity),
|
||||
);
|
||||
if (projected.length !== integer(projection.point_count, "L3.2 point count") * 3) {
|
||||
throw new AdvancedLaboratoryContractError("L3.2 projection: нарушен размер.");
|
||||
}
|
||||
return {
|
||||
frameId,
|
||||
summary,
|
||||
cameraUrl: `/api/v1/laboratory/l32/pointpillars-camera-review/${resultId}/frames/${frameId}/camera`,
|
||||
cameraWidth: integer(camera.width, "L3.2 camera width"),
|
||||
cameraHeight: integer(camera.height, "L3.2 camera height"),
|
||||
modelRangePointCount: integer(
|
||||
points.model_range_point_count,
|
||||
"L3.2 model range points",
|
||||
),
|
||||
pointsXyzi: values,
|
||||
predictionBoxes: array(item.prediction_boxes, "L3.2 boxes").map(parseBox),
|
||||
projectedPointsXyd: projected,
|
||||
projectedBoxes: array(projection.boxes, "L3.2 projected boxes").map(
|
||||
(entry) => {
|
||||
const box = record(entry, "L3.2 projected box");
|
||||
return {
|
||||
modelClass: string(box.model_class, "L3.2 projected class"),
|
||||
score: number(box.score, "L3.2 projected score"),
|
||||
segmentsXyxy: array(box.segments_xyxy, "L3.2 projected segments").map(
|
||||
(value, index) => number(value, `L3.2 segment[${index}]`, -Infinity),
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import {
|
||||
AdvancedLaboratoryContractError,
|
||||
type LaboratoryFetch,
|
||||
} from "./advancedResults";
|
||||
import type { L3VisualBox } from "./l3PointPillarsVisualAudit";
|
||||
|
||||
export type L33SemanticProvenance =
|
||||
| "recorded-current"
|
||||
| "bounded-track-interpolation"
|
||||
| "rectified-detector-current"
|
||||
| "e23-temporal-hold";
|
||||
|
||||
export interface L33FrameSummary {
|
||||
frameId: string;
|
||||
frameIndex: number;
|
||||
sessionSeconds: number;
|
||||
cameraSourceFrameIndex: number;
|
||||
cameraSessionSeconds: number;
|
||||
cameraDeltaMs: number;
|
||||
sourcePointCount: number;
|
||||
detectionCount: number;
|
||||
rangedDetectionCount: number;
|
||||
cameraOnlyDetectionCount: number;
|
||||
cuboidCount: number;
|
||||
semanticProvenance: L33SemanticProvenance;
|
||||
}
|
||||
|
||||
export interface L33CameraFirstDetectorReviewResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
status: "camera-first-boundary-restored-shadow-only";
|
||||
sourceSessionId: string;
|
||||
sourceL32ResultId: string;
|
||||
sourceE26ResultId: string;
|
||||
sourceE29ResultId: string;
|
||||
sourceE10PackId: string;
|
||||
sourceWorldStateResultId: string;
|
||||
detector: {
|
||||
id: string;
|
||||
architecture: string;
|
||||
classes: string;
|
||||
modelSha256: string;
|
||||
inputShape: readonly number[];
|
||||
};
|
||||
metrics: {
|
||||
reviewFrameCount: number;
|
||||
recordedCurrentFrameCount: number;
|
||||
recoveredCameraFrameCount: number;
|
||||
semanticFrameCoverageFraction: number;
|
||||
detectionCount: number;
|
||||
rangedDetectionCount: number;
|
||||
cameraOnlyDetectionCount: number;
|
||||
rangeCoverageFraction: number;
|
||||
minimumDetectorScore: number;
|
||||
cuboidCount: number;
|
||||
routeDetectionCount: number;
|
||||
routeDetectionFrameCount: number;
|
||||
framesProcessed: number;
|
||||
effectiveComposedFps: number;
|
||||
composedP95Ms: number;
|
||||
semanticEffectiveFps: number;
|
||||
lidarFusedFrames: number;
|
||||
rawAcceptedCuboids: number;
|
||||
duplicateSupportRejections: number;
|
||||
temporalAcceptedCuboids: number;
|
||||
worldObjectObservations: number;
|
||||
classCounts: Readonly<Record<string, number>>;
|
||||
};
|
||||
frames: readonly L33FrameSummary[];
|
||||
limitations: readonly string[];
|
||||
}
|
||||
|
||||
export interface L33Detection {
|
||||
sourceTrackId: number;
|
||||
label: string;
|
||||
associationGroup: string;
|
||||
score: number;
|
||||
bboxXyxy: readonly [number, number, number, number];
|
||||
semanticProvenance: L33SemanticProvenance;
|
||||
geometryStatus: string;
|
||||
geometryReason: string;
|
||||
rangeM: number | null;
|
||||
sourceE29OpticalDepthM: number | null;
|
||||
support: {
|
||||
projectedPoints: number;
|
||||
occupiedPoints: number;
|
||||
connectedOccupiedPoints: number;
|
||||
};
|
||||
geometryAnchorXyzM: readonly [number, number, number] | null;
|
||||
cameraRayLidar: {
|
||||
originXyzM: readonly [number, number, number];
|
||||
directionXyz: readonly [number, number, number];
|
||||
};
|
||||
}
|
||||
|
||||
export interface L33VisualFrame {
|
||||
frameId: string;
|
||||
summary: L33FrameSummary;
|
||||
cameraUrl: string;
|
||||
cameraWidth: number;
|
||||
cameraHeight: number;
|
||||
pointsXyzi: readonly number[];
|
||||
projectedPointsXyd: readonly number[];
|
||||
detections: readonly L33Detection[];
|
||||
predictionBoxes: readonly L3VisualBox[];
|
||||
}
|
||||
|
||||
const RESULT_ID = /^l33-camera-first-detector-review-[a-f0-9]{64}$/;
|
||||
const SOURCE_ID = /^(?:l32-pointpillars-camera-review|e10-integrated-perception|e29-camera-geometry|e10-lidar-pack|rectified-camera-world-state)-[a-f0-9]{64}$/;
|
||||
const FRAME_ID = /^[0-9]{6}$/;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact<T extends string>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string, minimum = 0): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: неверное число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалось целое.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function provenance(value: unknown, label: string): L33SemanticProvenance {
|
||||
if (
|
||||
value !== "recorded-current"
|
||||
&& value !== "bounded-track-interpolation"
|
||||
&& value !== "rectified-detector-current"
|
||||
&& value !== "e23-temporal-hold"
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: неизвестный источник.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function tuple(
|
||||
value: unknown,
|
||||
length: number,
|
||||
label: string,
|
||||
minimum = -Infinity,
|
||||
): readonly number[] {
|
||||
const values = array(value, label).map((item, index) => (
|
||||
number(item, `${label}[${index}]`, minimum)
|
||||
));
|
||||
if (values.length !== length) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: неверная размерность.`);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function sourceId(value: unknown, label: string): string {
|
||||
const parsed = string(value, label);
|
||||
if (!SOURCE_ID.test(parsed)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: неверная identity.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseSummary(value: unknown): L33FrameSummary {
|
||||
const item = record(value, "L3.3 frame");
|
||||
const frameId = string(item.frame_id, "L3.3 frame id");
|
||||
if (!FRAME_ID.test(frameId)) {
|
||||
throw new AdvancedLaboratoryContractError("L3.3 frame id: неверный формат.");
|
||||
}
|
||||
return {
|
||||
frameId,
|
||||
frameIndex: integer(item.frame_index, "L3.3 frame index"),
|
||||
sessionSeconds: number(item.session_seconds, "L3.3 session seconds"),
|
||||
cameraSourceFrameIndex: integer(item.camera_source_frame_index, "L3.3 camera frame"),
|
||||
cameraSessionSeconds: number(item.camera_session_seconds, "L3.3 camera seconds"),
|
||||
cameraDeltaMs: number(item.camera_delta_ms, "L3.3 camera delta", -100),
|
||||
sourcePointCount: integer(item.source_point_count, "L3.3 source points"),
|
||||
detectionCount: integer(item.detection_count, "L3.3 detections"),
|
||||
rangedDetectionCount: integer(item.ranged_detection_count, "L3.3 ranged detections"),
|
||||
cameraOnlyDetectionCount: integer(item.camera_only_detection_count, "L3.3 camera-only detections"),
|
||||
cuboidCount: item.cuboid_count === undefined
|
||||
? 0
|
||||
: integer(item.cuboid_count, "L3.3 cuboids"),
|
||||
semanticProvenance: provenance(item.semantic_provenance, "L3.3 provenance"),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePredictionBox(value: unknown): L3VisualBox {
|
||||
const item = record(value, "L3.3 cuboid");
|
||||
exact(item.status, "model-prediction", "L3.3 cuboid status");
|
||||
return {
|
||||
benchmarkClass: string(item.benchmark_class, "L3.3 cuboid class"),
|
||||
centerXyzM: tuple(item.center_xyz_m, 3, "L3.3 cuboid center", -Infinity) as [number, number, number],
|
||||
sizeLwhM: tuple(item.size_lwh_m, 3, "L3.3 cuboid size", Number.MIN_VALUE) as [number, number, number],
|
||||
yawRad: number(item.yaw_rad, "L3.3 cuboid yaw", -Infinity),
|
||||
status: "model-prediction",
|
||||
score: number(item.score, "L3.3 cuboid score"),
|
||||
temporalStatus: string(item.temporal_status, "L3.3 cuboid temporal status"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDetection(value: unknown): L33Detection {
|
||||
const item = record(value, "L3.3 detection");
|
||||
const support = record(item.support, "L3.3 support");
|
||||
const cameraRay = record(item.camera_ray_lidar, "L3.3 camera ray");
|
||||
return {
|
||||
sourceTrackId: integer(item.source_track_id, "L3.3 track"),
|
||||
label: string(item.label, "L3.3 label"),
|
||||
associationGroup: string(item.association_group, "L3.3 group"),
|
||||
score: number(item.score, "L3.3 score", 0.25),
|
||||
bboxXyxy: tuple(item.bbox_xyxy, 4, "L3.3 bbox", -Infinity) as [number, number, number, number],
|
||||
semanticProvenance: provenance(item.semantic_provenance, "L3.3 detection provenance"),
|
||||
geometryStatus: string(item.geometry_status, "L3.3 geometry status"),
|
||||
geometryReason: string(item.geometry_reason, "L3.3 geometry reason"),
|
||||
rangeM: item.range_m === null ? null : number(item.range_m, "L3.3 range"),
|
||||
sourceE29OpticalDepthM: item.source_e29_optical_depth_m === null
|
||||
? null
|
||||
: number(item.source_e29_optical_depth_m, "L3.3 E29 optical depth"),
|
||||
support: {
|
||||
projectedPoints: integer(support.projected_points, "L3.3 projected support"),
|
||||
occupiedPoints: integer(support.occupied_points, "L3.3 occupied support"),
|
||||
connectedOccupiedPoints: integer(support.connected_occupied_points, "L3.3 connected support"),
|
||||
},
|
||||
geometryAnchorXyzM: item.geometry_anchor_xyz_m === null
|
||||
? null
|
||||
: tuple(item.geometry_anchor_xyz_m, 3, "L3.3 geometry anchor", -Infinity) as [number, number, number],
|
||||
cameraRayLidar: {
|
||||
originXyzM: tuple(cameraRay.origin_xyz_m, 3, "L3.3 ray origin", -Infinity) as [number, number, number],
|
||||
directionXyz: tuple(cameraRay.direction_xyz, 3, "L3.3 ray direction", -Infinity) as [number, number, number],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): L33CameraFirstDetectorReviewResult {
|
||||
const item = record(value, "L3.3 result");
|
||||
exact(item.schema_version, "missioncore.l33-camera-first-detector-review-result/v1", "L3.3 schema");
|
||||
exact(item.access, "read-only", "L3.3 access");
|
||||
const resultId = string(item.result_id, "L3.3 result id");
|
||||
if (!RESULT_ID.test(resultId)) {
|
||||
throw new AdvancedLaboratoryContractError("L3.3 result: неверная identity.");
|
||||
}
|
||||
const detector = record(item.detector, "L3.3 detector");
|
||||
const metrics = record(item.metrics, "L3.3 metrics");
|
||||
const classCounts = record(metrics.class_counts, "L3.3 class counts");
|
||||
const frames = array(item.frames, "L3.3 frames").map(parseSummary);
|
||||
if (!frames.length || frames.length > 18) {
|
||||
throw new AdvancedLaboratoryContractError("L3.3 frames: нарушен bound.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "L3.3 created at"),
|
||||
status: exact(item.status, "camera-first-boundary-restored-shadow-only", "L3.3 status"),
|
||||
sourceSessionId: exact(item.source_session_id, "20260720T065719Z_viewer_live", "L3.3 session"),
|
||||
sourceL32ResultId: sourceId(item.source_l32_result_id, "L3.3 L3.2 id"),
|
||||
sourceE26ResultId: sourceId(item.source_e26_result_id, "L3.3 E26 id"),
|
||||
sourceE29ResultId: sourceId(item.source_e29_result_id, "L3.3 E29 id"),
|
||||
sourceE10PackId: sourceId(item.source_e10_pack_id, "L3.3 E10 id"),
|
||||
sourceWorldStateResultId: sourceId(item.source_world_state_result_id, "L3.3 world-state id"),
|
||||
detector: {
|
||||
id: string(detector.id, "L3.3 detector id"),
|
||||
architecture: string(detector.architecture, "L3.3 detector architecture"),
|
||||
classes: string(detector.classes, "L3.3 detector classes"),
|
||||
modelSha256: string(detector.model_sha256, "L3.3 detector sha"),
|
||||
inputShape: tuple(detector.input_shape, 4, "L3.3 detector shape"),
|
||||
},
|
||||
metrics: {
|
||||
reviewFrameCount: integer(metrics.review_frame_count, "L3.3 review frames"),
|
||||
recordedCurrentFrameCount: integer(metrics.recorded_current_frame_count, "L3.3 current frames"),
|
||||
recoveredCameraFrameCount: integer(metrics.recovered_camera_frame_count, "L3.3 recovered frames"),
|
||||
semanticFrameCoverageFraction: number(metrics.semantic_frame_coverage_fraction, "L3.3 semantic coverage"),
|
||||
detectionCount: integer(metrics.detection_count, "L3.3 detections"),
|
||||
rangedDetectionCount: integer(metrics.ranged_detection_count, "L3.3 ranged"),
|
||||
cameraOnlyDetectionCount: integer(metrics.camera_only_detection_count, "L3.3 camera only"),
|
||||
rangeCoverageFraction: number(metrics.range_coverage_fraction, "L3.3 range coverage"),
|
||||
minimumDetectorScore: number(metrics.minimum_detector_score, "L3.3 threshold"),
|
||||
cuboidCount: integer(metrics.cuboid_count, "L3.3 selected cuboids"),
|
||||
routeDetectionCount: integer(metrics.route_detection_count, "L3.3 route detections"),
|
||||
routeDetectionFrameCount: integer(metrics.route_detection_frame_count, "L3.3 route detection frames"),
|
||||
framesProcessed: integer(metrics.frames_processed, "L3.3 processed frames"),
|
||||
effectiveComposedFps: number(metrics.effective_composed_fps, "L3.3 composed FPS", Number.MIN_VALUE),
|
||||
composedP95Ms: number(metrics.composed_p95_ms, "L3.3 composed p95", Number.MIN_VALUE),
|
||||
semanticEffectiveFps: number(metrics.semantic_effective_fps, "L3.3 semantic FPS", Number.MIN_VALUE),
|
||||
lidarFusedFrames: integer(metrics.lidar_fused_frames, "L3.3 fused frames"),
|
||||
rawAcceptedCuboids: integer(metrics.raw_accepted_cuboids, "L3.3 raw cuboids"),
|
||||
duplicateSupportRejections: integer(
|
||||
metrics.duplicate_support_rejections,
|
||||
"L3.3 duplicate support rejections",
|
||||
),
|
||||
temporalAcceptedCuboids: integer(metrics.temporal_accepted_cuboids, "L3.3 temporal cuboids"),
|
||||
worldObjectObservations: integer(metrics.world_object_observations, "L3.3 world objects"),
|
||||
classCounts: Object.fromEntries(
|
||||
Object.entries(classCounts).map(([key, entry]) => [key, integer(entry, `L3.3 class ${key}`)]),
|
||||
),
|
||||
},
|
||||
frames,
|
||||
limitations: array(item.limitations, "L3.3 limitations").map((entry) => string(entry, "L3.3 limitation")),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchL33CameraFirstDetectorReview({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<L33CameraFirstDetectorReviewResult | null> {
|
||||
const response = await fetcher(
|
||||
"/api/v1/laboratory/l33/camera-first-detector-review/results?limit=1",
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new AdvancedLaboratoryContractError(`L3.3 недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
const catalog = record(await response.json(), "L3.3 catalog");
|
||||
exact(catalog.schema_version, "missioncore.l33-camera-first-detector-review-catalog-results/v1", "L3.3 catalog schema");
|
||||
const items = array(catalog.items, "L3.3 catalog items");
|
||||
return items.length ? parseResult(items[0]) : null;
|
||||
}
|
||||
|
||||
export async function fetchL33CameraFirstDetectorReviewFrame(
|
||||
resultId: string,
|
||||
frameId: string,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<L33VisualFrame> {
|
||||
if (!RESULT_ID.test(resultId) || !FRAME_ID.test(frameId)) {
|
||||
throw new AdvancedLaboratoryContractError("L3.3 frame: неверная identity.");
|
||||
}
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/l33/camera-first-detector-review/${resultId}/frames/${frameId}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new AdvancedLaboratoryContractError(`L3.3 frame недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
const item = record(await response.json(), "L3.3 visual frame");
|
||||
exact(item.schema_version, "missioncore.l33-camera-first-detector-review-frame/v1", "L3.3 frame schema");
|
||||
const summary = parseSummary(item.summary);
|
||||
const camera = record(item.camera, "L3.3 camera");
|
||||
const points = record(item.points, "L3.3 points");
|
||||
exact(points.layout, "flat-xyzi", "L3.3 point layout");
|
||||
const projection = record(item.camera_projection, "L3.3 projection");
|
||||
exact(projection.point_layout, "flat-xy-depth-m", "L3.3 projection layout");
|
||||
const projected = array(projection.point_values, "L3.3 projected points").map(
|
||||
(entry, index) => number(entry, `L3.3 projected[${index}]`, -Infinity),
|
||||
);
|
||||
if (projected.length !== integer(projection.point_count, "L3.3 projected count") * 3) {
|
||||
throw new AdvancedLaboratoryContractError("L3.3 projection: нарушен размер.");
|
||||
}
|
||||
const detections = array(item.detections, "L3.3 detections").map(parseDetection);
|
||||
const predictionBoxes = array(item.prediction_boxes, "L3.3 cuboids").map(parsePredictionBox);
|
||||
return {
|
||||
frameId,
|
||||
summary,
|
||||
cameraUrl: `/api/v1/laboratory/l33/camera-first-detector-review/${resultId}/frames/${frameId}/camera`,
|
||||
cameraWidth: integer(camera.width, "L3.3 camera width"),
|
||||
cameraHeight: integer(camera.height, "L3.3 camera height"),
|
||||
pointsXyzi: array(points.values, "L3.3 point values").map(
|
||||
(entry, index) => number(entry, `L3.3 points[${index}]`, -Infinity),
|
||||
),
|
||||
projectedPointsXyd: projected,
|
||||
detections,
|
||||
predictionBoxes,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,436 @@
|
||||
export interface L34FrameSummary {
|
||||
truthIslandSequence: number;
|
||||
imageId: number;
|
||||
frameIndex: number;
|
||||
groupId: string;
|
||||
predictionCount: number;
|
||||
maximumScore: number;
|
||||
}
|
||||
|
||||
export type L34PredictionLabel =
|
||||
| "person"
|
||||
| "bicycle"
|
||||
| "motorcycle"
|
||||
| "car"
|
||||
| "heavy_vehicle"
|
||||
| "static_obstacle"
|
||||
| "animal";
|
||||
|
||||
export interface L34Prediction {
|
||||
label: L34PredictionLabel;
|
||||
score: number;
|
||||
bboxXyxy: readonly [number, number, number, number];
|
||||
}
|
||||
|
||||
export interface L34VisualFrame {
|
||||
resultId: string;
|
||||
truthIslandSequence: number;
|
||||
imageId: number;
|
||||
frameIndex: number;
|
||||
groupId: string;
|
||||
role: "anchor" | "temporal";
|
||||
sessionSeconds: number;
|
||||
cameraUrl: string;
|
||||
cameraWidth: number;
|
||||
cameraHeight: number;
|
||||
cameraSha256: string;
|
||||
predictionRowsSha256: string;
|
||||
predictions: readonly L34Prediction[];
|
||||
truthLabelsRead: false;
|
||||
}
|
||||
|
||||
export interface L34RightYoloxTruthIslandResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
status: "predictions-frozen-awaiting-independent-truth";
|
||||
profileId: "RAVNOVES00_RIGHT_YOLOX_TRUTH_ISLAND_V1";
|
||||
pipelineId: string;
|
||||
sourceSessionId: string;
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
candidate: {
|
||||
architecture: "YOLOX-S";
|
||||
modelSha256: string;
|
||||
minimumScore: number;
|
||||
l33ResultId: string;
|
||||
};
|
||||
truthIsland: {
|
||||
resultId: string;
|
||||
truthState: "labels-unavailable";
|
||||
};
|
||||
metrics: {
|
||||
frameCount: number;
|
||||
temporalGroupCount: number;
|
||||
predictionCount: number;
|
||||
framesWithPredictions: number;
|
||||
classCounts: Readonly<Record<string, number>>;
|
||||
accuracyMetricsAvailable: false;
|
||||
};
|
||||
frames: readonly L34FrameSummary[];
|
||||
decision: {
|
||||
candidatePredictionsFrozen: true;
|
||||
truthLabelsRead: false;
|
||||
candidateAccepted: false;
|
||||
modelRetrainingAuthorized: false;
|
||||
nextGate: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
class L34ContractError extends Error {}
|
||||
|
||||
type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new L34ContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new L34ContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new L34ContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
if (!Number.isInteger(value) || Number(value) < 0) {
|
||||
throw new L34ContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function finiteNumber(
|
||||
value: unknown,
|
||||
label: string,
|
||||
minimum = 0,
|
||||
): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) {
|
||||
throw new L34ContractError(`${label}: ожидалось конечное число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact<T extends string | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) throw new L34ContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function predictionLabel(value: unknown, label: string): L34PredictionLabel {
|
||||
if (
|
||||
value !== "person"
|
||||
&& value !== "bicycle"
|
||||
&& value !== "motorcycle"
|
||||
&& value !== "car"
|
||||
&& value !== "heavy_vehicle"
|
||||
&& value !== "static_obstacle"
|
||||
&& value !== "animal"
|
||||
) {
|
||||
throw new L34ContractError(`${label}: неизвестный класс.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseFrameSummary(value: unknown): L34FrameSummary {
|
||||
const item = object(value, "L3.4 frame summary");
|
||||
const maximumScore = finiteNumber(
|
||||
item.maximum_score,
|
||||
"L3.4 frame maximum score",
|
||||
);
|
||||
if (maximumScore > 1) {
|
||||
throw new L34ContractError("L3.4 frame maximum score: нарушен диапазон.");
|
||||
}
|
||||
return {
|
||||
truthIslandSequence: integer(
|
||||
item.truth_island_sequence,
|
||||
"L3.4 frame sequence",
|
||||
),
|
||||
imageId: integer(item.image_id, "L3.4 frame image"),
|
||||
frameIndex: integer(item.frame_index, "L3.4 frame index"),
|
||||
groupId: string(item.group_id, "L3.4 frame group"),
|
||||
predictionCount: integer(
|
||||
item.prediction_count,
|
||||
"L3.4 frame predictions",
|
||||
),
|
||||
maximumScore,
|
||||
};
|
||||
}
|
||||
|
||||
function parse(itemValue: unknown): L34RightYoloxTruthIslandResult {
|
||||
const item = object(itemValue, "L3.4");
|
||||
const candidate = object(item.candidate, "L3.4.candidate");
|
||||
const truthIsland = object(item.truth_island, "L3.4.truth_island");
|
||||
const metrics = object(item.metrics, "L3.4.metrics");
|
||||
const classCounts = object(metrics.class_counts, "L3.4.metrics.class_counts");
|
||||
const decision = object(item.decision, "L3.4.decision");
|
||||
const frames = array(item.frames, "L3.4.frames").map(parseFrameSummary);
|
||||
const resultId = string(item.result_id, "L3.4.result_id");
|
||||
const modelSha256 = string(candidate.model_sha256, "L3.4.candidate.model_sha256");
|
||||
if (
|
||||
!/^l34-right-yolox-truth-island-freeze-[a-f0-9]{64}$/.test(resultId)
|
||||
|| !/^[a-f0-9]{64}$/.test(modelSha256)
|
||||
) {
|
||||
throw new L34ContractError("L3.4: нарушена content identity.");
|
||||
}
|
||||
const frameCount = integer(metrics.frame_count, "L3.4.metrics.frame_count");
|
||||
const predictionCount = integer(
|
||||
metrics.prediction_count,
|
||||
"L3.4.metrics.prediction_count",
|
||||
);
|
||||
const framesWithPredictions = integer(
|
||||
metrics.frames_with_predictions,
|
||||
"L3.4.metrics.frames_with_predictions",
|
||||
);
|
||||
if (
|
||||
!frames.length
|
||||
|| frames.length > 32
|
||||
|| frames.length !== frameCount
|
||||
|| new Set(frames.map(({ truthIslandSequence }) => truthIslandSequence)).size
|
||||
!== frames.length
|
||||
|| frames.reduce((total, frame) => total + frame.predictionCount, 0)
|
||||
!== predictionCount
|
||||
|| frames.filter(({ predictionCount: count }) => count > 0).length
|
||||
!== framesWithPredictions
|
||||
) {
|
||||
throw new L34ContractError("L3.4.frames: нарушена визуальная связность.");
|
||||
}
|
||||
const counts = Object.fromEntries(Object.entries(classCounts).map(([key, value]) => (
|
||||
[key, integer(value, `L3.4.metrics.class_counts.${key}`)]
|
||||
)));
|
||||
const limitations = item.limitations;
|
||||
if (!Array.isArray(limitations) || limitations.some((value) => typeof value !== "string")) {
|
||||
throw new L34ContractError("L3.4.limitations: ожидался список строк.");
|
||||
}
|
||||
const minimumScore = candidate.minimum_score;
|
||||
if (typeof minimumScore !== "number" || minimumScore !== 0.25) {
|
||||
throw new L34ContractError("L3.4.candidate.minimum_score: нарушен контракт.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "L3.4.created_at_utc"),
|
||||
status: exact(
|
||||
item.status,
|
||||
"predictions-frozen-awaiting-independent-truth",
|
||||
"L3.4.status",
|
||||
),
|
||||
profileId: exact(
|
||||
item.profile_id,
|
||||
"RAVNOVES00_RIGHT_YOLOX_TRUTH_ISLAND_V1",
|
||||
"L3.4.profile_id",
|
||||
),
|
||||
pipelineId: string(item.pipeline_id, "L3.4.pipeline_id"),
|
||||
sourceSessionId: string(item.source_session_id, "L3.4.source_session_id"),
|
||||
cameraSourceId: exact(
|
||||
item.camera_source_id,
|
||||
"sensor.camera.right",
|
||||
"L3.4.camera_source_id",
|
||||
),
|
||||
candidate: {
|
||||
architecture: exact(candidate.architecture, "YOLOX-S", "L3.4.architecture"),
|
||||
modelSha256,
|
||||
minimumScore,
|
||||
l33ResultId: string(candidate.l33_result_id, "L3.4.candidate.l33_result_id"),
|
||||
},
|
||||
truthIsland: {
|
||||
resultId: string(truthIsland.result_id, "L3.4.truth_island.result_id"),
|
||||
truthState: exact(
|
||||
truthIsland.truth_state,
|
||||
"labels-unavailable",
|
||||
"L3.4.truth_island.truth_state",
|
||||
),
|
||||
},
|
||||
metrics: {
|
||||
frameCount,
|
||||
temporalGroupCount: integer(
|
||||
metrics.temporal_group_count,
|
||||
"L3.4.metrics.temporal_group_count",
|
||||
),
|
||||
predictionCount,
|
||||
framesWithPredictions,
|
||||
classCounts: counts,
|
||||
accuracyMetricsAvailable: exact(
|
||||
metrics.accuracy_metrics_available,
|
||||
false,
|
||||
"L3.4.metrics.accuracy_metrics_available",
|
||||
),
|
||||
},
|
||||
frames,
|
||||
decision: {
|
||||
candidatePredictionsFrozen: exact(
|
||||
decision.candidate_predictions_frozen,
|
||||
true,
|
||||
"L3.4.decision.candidate_predictions_frozen",
|
||||
),
|
||||
truthLabelsRead: exact(
|
||||
decision.truth_labels_read,
|
||||
false,
|
||||
"L3.4.decision.truth_labels_read",
|
||||
),
|
||||
candidateAccepted: exact(
|
||||
decision.candidate_accepted,
|
||||
false,
|
||||
"L3.4.decision.candidate_accepted",
|
||||
),
|
||||
modelRetrainingAuthorized: exact(
|
||||
decision.model_retraining_authorized,
|
||||
false,
|
||||
"L3.4.decision.model_retraining_authorized",
|
||||
),
|
||||
nextGate: string(decision.next_gate, "L3.4.decision.next_gate"),
|
||||
},
|
||||
limitations: limitations as string[],
|
||||
access: exact(item.access, "read-only", "L3.4.access"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseVisualFrame(
|
||||
value: unknown,
|
||||
resultId: string,
|
||||
sequence: number,
|
||||
): L34VisualFrame {
|
||||
const item = object(value, "L3.4 visual frame");
|
||||
exact(
|
||||
item.schema_version,
|
||||
"missioncore.l34-right-yolox-truth-island-frame/v1",
|
||||
"L3.4 frame schema",
|
||||
);
|
||||
exact(item.access, "read-only", "L3.4 frame access");
|
||||
exact(item.truth_labels_read, false, "L3.4 frame truth state");
|
||||
if (
|
||||
item.result_id !== resultId
|
||||
|| item.truth_island_sequence !== sequence
|
||||
|| !Number.isInteger(sequence)
|
||||
|| sequence < 1
|
||||
|| sequence > 32
|
||||
) {
|
||||
throw new L34ContractError("L3.4 visual frame: нарушена identity.");
|
||||
}
|
||||
const camera = object(item.camera, "L3.4 frame camera");
|
||||
const cameraWidth = integer(camera.width, "L3.4 camera width");
|
||||
const cameraHeight = integer(camera.height, "L3.4 camera height");
|
||||
const cameraSha256 = string(camera.sha256, "L3.4 camera sha256");
|
||||
const predictionRowsSha256 = string(
|
||||
item.prediction_rows_sha256,
|
||||
"L3.4 prediction rows sha256",
|
||||
);
|
||||
if (
|
||||
!/^[a-f0-9]{64}$/.test(cameraSha256)
|
||||
|| !/^[a-f0-9]{64}$/.test(predictionRowsSha256)
|
||||
) {
|
||||
throw new L34ContractError("L3.4 visual frame: нарушена content identity.");
|
||||
}
|
||||
const predictions = array(item.predictions, "L3.4 frame predictions").map(
|
||||
(value, index): L34Prediction => {
|
||||
const prediction = object(value, `L3.4 prediction ${index}`);
|
||||
const score = finiteNumber(prediction.score, `L3.4 prediction ${index} score`, 0.25);
|
||||
const bbox = array(prediction.bbox_xyxy, `L3.4 prediction ${index} bbox`).map(
|
||||
(entry, coordinate) => finiteNumber(
|
||||
entry,
|
||||
`L3.4 prediction ${index} bbox ${coordinate}`,
|
||||
),
|
||||
);
|
||||
if (
|
||||
score > 1
|
||||
|| bbox.length !== 4
|
||||
|| !(bbox[0] < bbox[2] && bbox[2] <= cameraWidth)
|
||||
|| !(bbox[1] < bbox[3] && bbox[3] <= cameraHeight)
|
||||
) {
|
||||
throw new L34ContractError(`L3.4 prediction ${index}: нарушена геометрия.`);
|
||||
}
|
||||
return {
|
||||
label: predictionLabel(prediction.label, `L3.4 prediction ${index} label`),
|
||||
score,
|
||||
bboxXyxy: bbox as [number, number, number, number],
|
||||
};
|
||||
},
|
||||
);
|
||||
const role = item.role;
|
||||
if (role !== "anchor" && role !== "temporal") {
|
||||
throw new L34ContractError("L3.4 frame role: нарушен контракт.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
truthIslandSequence: sequence,
|
||||
imageId: integer(item.image_id, "L3.4 frame image"),
|
||||
frameIndex: integer(item.frame_index, "L3.4 frame index"),
|
||||
groupId: string(item.group_id, "L3.4 frame group"),
|
||||
role,
|
||||
sessionSeconds: finiteNumber(item.session_seconds, "L3.4 frame time"),
|
||||
cameraUrl: `/api/v1/laboratory/l34/results/${resultId}/frames/${sequence}/camera`,
|
||||
cameraWidth,
|
||||
cameraHeight,
|
||||
cameraSha256,
|
||||
predictionRowsSha256,
|
||||
predictions,
|
||||
truthLabelsRead: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchL34RightYoloxTruthIsland({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<L34RightYoloxTruthIslandResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/l34/results?limit=1", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new L34ContractError(`L3.4 недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "L3.4 catalog");
|
||||
exact(
|
||||
catalog.schema_version,
|
||||
"missioncore.l34-right-yolox-truth-island-catalog/v1",
|
||||
"L3.4 catalog.schema_version",
|
||||
);
|
||||
exact(catalog.access, "read-only", "L3.4 catalog.access");
|
||||
if (!Array.isArray(catalog.items) || catalog.items.length > 1) {
|
||||
throw new L34ContractError("L3.4 catalog.items: нарушен размер.");
|
||||
}
|
||||
return catalog.items.length ? parse(catalog.items[0]) : null;
|
||||
}
|
||||
|
||||
export async function fetchL34RightYoloxTruthIslandFrame(
|
||||
resultId: string,
|
||||
sequence: number,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<L34VisualFrame> {
|
||||
if (!/^l34-right-yolox-truth-island-freeze-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new L34ContractError("L3.4 visual frame: неверный result id.");
|
||||
}
|
||||
if (!Number.isInteger(sequence) || sequence < 1 || sequence > 32) {
|
||||
throw new L34ContractError("L3.4 visual frame: неверный sequence.");
|
||||
}
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/l34/results/${resultId}/frames/${sequence}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new L34ContractError(`L3.4 visual frame недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
return parseVisualFrame(await response.json(), resultId, sequence);
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
export type L34APredictionVerdict =
|
||||
| "true_positive"
|
||||
| "class_mismatch"
|
||||
| "duplicate_false_positive"
|
||||
| "false_positive";
|
||||
|
||||
export type L34AAnnotationVerdict =
|
||||
| "true_positive"
|
||||
| "class_mismatch"
|
||||
| "false_negative";
|
||||
|
||||
export interface L34ACaseSummaryMetrics {
|
||||
predictionCount: number;
|
||||
referenceCount: number;
|
||||
truePositive: number;
|
||||
falsePositive: number;
|
||||
falseNegative: number;
|
||||
classMismatch: number;
|
||||
duplicateFalsePositive: number;
|
||||
unmatchedFalsePositive: number;
|
||||
unmatchedFalseNegative: number;
|
||||
severityScore: number;
|
||||
}
|
||||
|
||||
export interface L34ACaseSummary {
|
||||
truthIslandSequence: number;
|
||||
imageId: number;
|
||||
frameIndex: number;
|
||||
groupId: string;
|
||||
sessionSeconds: number;
|
||||
sourceImageSha256: string;
|
||||
summary: L34ACaseSummaryMetrics;
|
||||
}
|
||||
|
||||
export interface L34AClassMetric {
|
||||
referenceCount: number;
|
||||
truePositive: number;
|
||||
falsePositive: number;
|
||||
falseNegative: number;
|
||||
precisionIou50: number;
|
||||
recallIou50: number;
|
||||
}
|
||||
|
||||
export interface L34AAssistedYoloxErrorAuditResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
status: "completed-assisted-candidate-error-audit-not-truth";
|
||||
profileId: string;
|
||||
pipelineId: string;
|
||||
sourceSessionId: string;
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
assistedAnnotation: {
|
||||
sessionId: string;
|
||||
sessionSha256: string;
|
||||
revision: number;
|
||||
updatedAtUtc: string;
|
||||
independentTruthEligible: false;
|
||||
};
|
||||
metrics: {
|
||||
frameCount: number;
|
||||
predictionCount: number;
|
||||
referenceCount: number;
|
||||
truePositive: number;
|
||||
falsePositive: number;
|
||||
falseNegative: number;
|
||||
classMismatch: number;
|
||||
duplicateFalsePositive: number;
|
||||
unmatchedFalsePositive: number;
|
||||
unmatchedFalseNegative: number;
|
||||
precisionIou50: number;
|
||||
recallIou50: number;
|
||||
f1Iou50: number;
|
||||
errorCaseCount: number;
|
||||
customReferenceCount: number;
|
||||
perClass: Readonly<Record<string, L34AClassMetric>>;
|
||||
};
|
||||
cases: readonly L34ACaseSummary[];
|
||||
caseOrder: readonly number[];
|
||||
decision: {
|
||||
assistedAlignmentAvailable: true;
|
||||
blindAccuracyAvailable: false;
|
||||
postprocessingIssueConfirmed: boolean;
|
||||
ontologyGapConfirmed: boolean;
|
||||
candidateAccepted: false;
|
||||
modelRetrainingAuthorized: false;
|
||||
l35BlindGateOpen: false;
|
||||
nextAction: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
groundTruth: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface L34AAuditPrediction {
|
||||
predictionIndex: number;
|
||||
category: string;
|
||||
score: number;
|
||||
boxXyxy: readonly [number, number, number, number];
|
||||
verdict: L34APredictionVerdict;
|
||||
matchedObjectId: string | null;
|
||||
matchIou: number | null;
|
||||
}
|
||||
|
||||
export interface L34AAuditAnnotation {
|
||||
objectId: string;
|
||||
category: string;
|
||||
proposedLabel: string | null;
|
||||
displayCategory: string;
|
||||
origin: "manual" | "frozen_candidate_seed";
|
||||
boxXyxy: readonly [number, number, number, number];
|
||||
occluded: boolean;
|
||||
truncated: boolean;
|
||||
verdict: L34AAnnotationVerdict;
|
||||
matchedPredictionIndex: number | null;
|
||||
matchIou: number | null;
|
||||
}
|
||||
|
||||
export interface L34AAuditCase {
|
||||
resultId: string;
|
||||
truthIslandSequence: number;
|
||||
imageId: number;
|
||||
frameIndex: number;
|
||||
groupId: string;
|
||||
sessionSeconds: number;
|
||||
sourceImageSha256: string;
|
||||
cameraWidth: number;
|
||||
cameraHeight: number;
|
||||
cameraUrl: string;
|
||||
predictions: readonly L34AAuditPrediction[];
|
||||
annotations: readonly L34AAuditAnnotation[];
|
||||
summary: L34ACaseSummaryMetrics;
|
||||
groundTruth: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export type L34ALaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export class L34AContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "L34AContractError";
|
||||
}
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new L34AContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new L34AContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact<T>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) {
|
||||
throw new L34AContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
if (!Number.isInteger(value) || Number(value) < 0) {
|
||||
throw new L34AContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function finite(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new L34AContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new L34AContractError(`${label}: ожидалось логическое значение.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function ratio(value: unknown, label: string): number {
|
||||
const parsed = finite(value, label);
|
||||
if (parsed < 0 || parsed > 1) {
|
||||
throw new L34AContractError(`${label}: нарушен диапазон.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function sha(value: unknown, label: string): string {
|
||||
const parsed = string(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new L34AContractError(`${label}: нарушен SHA-256.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function box(value: unknown, label: string): readonly [number, number, number, number] {
|
||||
if (!Array.isArray(value) || value.length !== 4) {
|
||||
throw new L34AContractError(`${label}: ожидалась рамка xyxy.`);
|
||||
}
|
||||
const parsed = value.map((coordinate) => finite(coordinate, label));
|
||||
if (!(0 <= parsed[0] && parsed[0] < parsed[2] && parsed[2] <= 800
|
||||
&& 0 <= parsed[1] && parsed[1] < parsed[3] && parsed[3] <= 600)) {
|
||||
throw new L34AContractError(`${label}: нарушена геометрия.`);
|
||||
}
|
||||
return parsed as [number, number, number, number];
|
||||
}
|
||||
|
||||
function summary(value: unknown, label: string): L34ACaseSummaryMetrics {
|
||||
const item = object(value, label);
|
||||
return {
|
||||
predictionCount: integer(item.prediction_count, `${label}.prediction_count`),
|
||||
referenceCount: integer(item.reference_count, `${label}.reference_count`),
|
||||
truePositive: integer(item.true_positive, `${label}.true_positive`),
|
||||
falsePositive: integer(item.false_positive, `${label}.false_positive`),
|
||||
falseNegative: integer(item.false_negative, `${label}.false_negative`),
|
||||
classMismatch: integer(item.class_mismatch, `${label}.class_mismatch`),
|
||||
duplicateFalsePositive: integer(item.duplicate_false_positive, `${label}.duplicate_false_positive`),
|
||||
unmatchedFalsePositive: integer(item.unmatched_false_positive, `${label}.unmatched_false_positive`),
|
||||
unmatchedFalseNegative: integer(item.unmatched_false_negative, `${label}.unmatched_false_negative`),
|
||||
severityScore: integer(item.severity_score, `${label}.severity_score`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCaseSummary(value: unknown): L34ACaseSummary {
|
||||
const item = object(value, "L3.4A case summary");
|
||||
const sequence = integer(item.truth_island_sequence, "L3.4A case sequence");
|
||||
if (sequence < 1 || sequence > 32) {
|
||||
throw new L34AContractError("L3.4A case sequence: нарушен диапазон.");
|
||||
}
|
||||
return {
|
||||
truthIslandSequence: sequence,
|
||||
imageId: integer(item.image_id, "L3.4A case image"),
|
||||
frameIndex: integer(item.frame_index, "L3.4A case frame"),
|
||||
groupId: string(item.group_id, "L3.4A case group"),
|
||||
sessionSeconds: finite(item.session_seconds, "L3.4A case time"),
|
||||
sourceImageSha256: sha(item.source_image_sha256, "L3.4A case source"),
|
||||
summary: summary(item.summary, "L3.4A case summary.metrics"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseClassMetrics(value: unknown): Readonly<Record<string, L34AClassMetric>> {
|
||||
const metrics = object(value, "L3.4A per-class metrics");
|
||||
return Object.fromEntries(Object.entries(metrics).map(([category, raw]) => {
|
||||
const item = object(raw, `L3.4A class ${category}`);
|
||||
return [category, {
|
||||
referenceCount: integer(item.reference_count, `${category}.reference_count`),
|
||||
truePositive: integer(item.true_positive, `${category}.true_positive`),
|
||||
falsePositive: integer(item.false_positive, `${category}.false_positive`),
|
||||
falseNegative: integer(item.false_negative, `${category}.false_negative`),
|
||||
precisionIou50: ratio(item.precision_iou50, `${category}.precision_iou50`),
|
||||
recallIou50: ratio(item.recall_iou50, `${category}.recall_iou50`),
|
||||
} satisfies L34AClassMetric];
|
||||
}));
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): L34AAssistedYoloxErrorAuditResult {
|
||||
const item = object(value, "L3.4A");
|
||||
const resultId = string(item.result_id, "L3.4A.result_id");
|
||||
if (!/^l34a-assisted-yolox-error-audit-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new L34AContractError("L3.4A.result_id: нарушена идентичность.");
|
||||
}
|
||||
const assisted = object(item.assisted_annotation, "L3.4A assisted annotation");
|
||||
const metrics = object(item.metrics, "L3.4A metrics");
|
||||
const decision = object(item.decision, "L3.4A decision");
|
||||
const cases = Array.isArray(item.cases) ? item.cases.map(parseCaseSummary) : [];
|
||||
if (cases.length !== 32 || new Set(cases.map((entry) => entry.truthIslandSequence)).size !== 32) {
|
||||
throw new L34AContractError("L3.4A cases: требуется 32 уникальных кадра.");
|
||||
}
|
||||
if (!Array.isArray(item.case_order) || item.case_order.length !== 32) {
|
||||
throw new L34AContractError("L3.4A case order: нарушен размер.");
|
||||
}
|
||||
const caseOrder = item.case_order.map((entry) => integer(entry, "L3.4A case order"));
|
||||
if (new Set(caseOrder).size !== 32 || caseOrder.some((entry) => entry < 1 || entry > 32)) {
|
||||
throw new L34AContractError("L3.4A case order: нарушено покрытие.");
|
||||
}
|
||||
if (!Array.isArray(item.limitations) || item.limitations.some((entry) => typeof entry !== "string")) {
|
||||
throw new L34AContractError("L3.4A limitations: ожидался список строк.");
|
||||
}
|
||||
const sessionId = string(assisted.session_id, "L3.4A annotation session");
|
||||
if (!/^l34-annotation-session-[a-f0-9]{64}$/.test(sessionId)) {
|
||||
throw new L34AContractError("L3.4A annotation session: нарушена идентичность.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "L3.4A.created_at_utc"),
|
||||
status: exact(item.status, "completed-assisted-candidate-error-audit-not-truth", "L3.4A.status"),
|
||||
profileId: string(item.profile_id, "L3.4A.profile_id"),
|
||||
pipelineId: string(item.pipeline_id, "L3.4A.pipeline_id"),
|
||||
sourceSessionId: string(item.source_session_id, "L3.4A.source_session_id"),
|
||||
cameraSourceId: exact(item.camera_source_id, "sensor.camera.right", "L3.4A.camera_source_id"),
|
||||
assistedAnnotation: {
|
||||
sessionId,
|
||||
sessionSha256: sha(assisted.session_sha256, "L3.4A annotation sha"),
|
||||
revision: integer(assisted.revision, "L3.4A annotation revision"),
|
||||
updatedAtUtc: string(assisted.updated_at_utc, "L3.4A annotation time"),
|
||||
independentTruthEligible: exact(assisted.independent_truth_eligible, false, "L3.4A independent truth"),
|
||||
},
|
||||
metrics: {
|
||||
frameCount: integer(metrics.frame_count, "L3.4A frame count"),
|
||||
predictionCount: integer(metrics.prediction_count, "L3.4A prediction count"),
|
||||
referenceCount: integer(metrics.reference_count, "L3.4A reference count"),
|
||||
truePositive: integer(metrics.true_positive, "L3.4A TP"),
|
||||
falsePositive: integer(metrics.false_positive, "L3.4A FP"),
|
||||
falseNegative: integer(metrics.false_negative, "L3.4A FN"),
|
||||
classMismatch: integer(metrics.class_mismatch, "L3.4A mismatch"),
|
||||
duplicateFalsePositive: integer(metrics.duplicate_false_positive, "L3.4A duplicates"),
|
||||
unmatchedFalsePositive: integer(metrics.unmatched_false_positive, "L3.4A unmatched FP"),
|
||||
unmatchedFalseNegative: integer(metrics.unmatched_false_negative, "L3.4A unmatched FN"),
|
||||
precisionIou50: ratio(metrics.precision_iou50, "L3.4A precision"),
|
||||
recallIou50: ratio(metrics.recall_iou50, "L3.4A recall"),
|
||||
f1Iou50: ratio(metrics.f1_iou50, "L3.4A F1"),
|
||||
errorCaseCount: integer(metrics.error_case_count, "L3.4A error cases"),
|
||||
customReferenceCount: integer(metrics.custom_reference_count, "L3.4A custom references"),
|
||||
perClass: parseClassMetrics(metrics.per_class),
|
||||
},
|
||||
cases,
|
||||
caseOrder,
|
||||
decision: {
|
||||
assistedAlignmentAvailable: exact(decision.assisted_alignment_available, true, "L3.4A assisted alignment"),
|
||||
blindAccuracyAvailable: exact(decision.blind_accuracy_available, false, "L3.4A blind accuracy"),
|
||||
postprocessingIssueConfirmed: boolean(decision.postprocessing_issue_confirmed, "L3.4A postprocessing issue"),
|
||||
ontologyGapConfirmed: boolean(decision.ontology_gap_confirmed, "L3.4A ontology gap"),
|
||||
candidateAccepted: exact(decision.candidate_accepted, false, "L3.4A candidate acceptance"),
|
||||
modelRetrainingAuthorized: exact(decision.model_retraining_authorized, false, "L3.4A retraining authority"),
|
||||
l35BlindGateOpen: exact(decision.l35_blind_gate_open, false, "L3.5 gate"),
|
||||
nextAction: string(decision.next_action, "L3.4A next action"),
|
||||
},
|
||||
limitations: item.limitations as string[],
|
||||
groundTruth: exact(item.ground_truth, false, "L3.4A ground truth"),
|
||||
access: exact(item.access, "read-only", "L3.4A access"),
|
||||
};
|
||||
}
|
||||
|
||||
const PREDICTION_VERDICTS: readonly L34APredictionVerdict[] = [
|
||||
"true_positive",
|
||||
"class_mismatch",
|
||||
"duplicate_false_positive",
|
||||
"false_positive",
|
||||
];
|
||||
const ANNOTATION_VERDICTS: readonly L34AAnnotationVerdict[] = [
|
||||
"true_positive",
|
||||
"class_mismatch",
|
||||
"false_negative",
|
||||
];
|
||||
|
||||
function nullableRatio(value: unknown, label: string): number | null {
|
||||
return value === null ? null : ratio(value, label);
|
||||
}
|
||||
|
||||
function parseCase(value: unknown, resultId: string, sequence: number): L34AAuditCase {
|
||||
const item = object(value, "L3.4A visual case");
|
||||
exact(item.schema_version, "missioncore.l34a-assisted-yolox-error-case/v1", "L3.4A case schema");
|
||||
exact(item.result_id, resultId, "L3.4A case result");
|
||||
exact(item.truth_island_sequence, sequence, "L3.4A case sequence");
|
||||
const camera = object(item.camera, "L3.4A camera");
|
||||
const rawPredictions = Array.isArray(item.predictions) ? item.predictions : [];
|
||||
const rawAnnotations = Array.isArray(item.annotations) ? item.annotations : [];
|
||||
const predictions = rawPredictions.map((raw, index): L34AAuditPrediction => {
|
||||
const prediction = object(raw, `L3.4A prediction ${index + 1}`);
|
||||
const verdict = string(prediction.verdict, "L3.4A prediction verdict") as L34APredictionVerdict;
|
||||
if (!PREDICTION_VERDICTS.includes(verdict)) throw new L34AContractError("L3.4A prediction verdict: неизвестное значение.");
|
||||
return {
|
||||
predictionIndex: integer(prediction.prediction_index, "L3.4A prediction index"),
|
||||
category: string(prediction.category, "L3.4A prediction category"),
|
||||
score: ratio(prediction.score, "L3.4A prediction score"),
|
||||
boxXyxy: box(prediction.box_xyxy, "L3.4A prediction box"),
|
||||
verdict,
|
||||
matchedObjectId: prediction.matched_object_id === null ? null : string(prediction.matched_object_id, "L3.4A matched object"),
|
||||
matchIou: nullableRatio(prediction.match_iou, "L3.4A prediction IoU"),
|
||||
};
|
||||
});
|
||||
const annotations = rawAnnotations.map((raw, index): L34AAuditAnnotation => {
|
||||
const annotation = object(raw, `L3.4A annotation ${index + 1}`);
|
||||
const verdict = string(annotation.verdict, "L3.4A annotation verdict") as L34AAnnotationVerdict;
|
||||
if (!ANNOTATION_VERDICTS.includes(verdict)) throw new L34AContractError("L3.4A annotation verdict: неизвестное значение.");
|
||||
return {
|
||||
objectId: string(annotation.object_id, "L3.4A annotation id"),
|
||||
category: string(annotation.category, "L3.4A annotation category"),
|
||||
proposedLabel: annotation.proposed_label === null ? null : string(annotation.proposed_label, "L3.4A proposed label"),
|
||||
displayCategory: string(annotation.display_category, "L3.4A display category"),
|
||||
origin: exact(
|
||||
annotation.origin,
|
||||
annotation.origin === "manual" ? "manual" : "frozen_candidate_seed",
|
||||
"L3.4A annotation origin",
|
||||
),
|
||||
boxXyxy: box(annotation.box_xyxy, "L3.4A annotation box"),
|
||||
occluded: boolean(annotation.occluded, "L3.4A annotation occluded"),
|
||||
truncated: boolean(annotation.truncated, "L3.4A annotation truncated"),
|
||||
verdict,
|
||||
matchedPredictionIndex: annotation.matched_prediction_index === null ? null : integer(annotation.matched_prediction_index, "L3.4A matched prediction"),
|
||||
matchIou: nullableRatio(annotation.match_iou, "L3.4A annotation IoU"),
|
||||
};
|
||||
});
|
||||
return {
|
||||
resultId,
|
||||
truthIslandSequence: sequence,
|
||||
imageId: integer(item.image_id, "L3.4A case image"),
|
||||
frameIndex: integer(item.frame_index, "L3.4A case frame"),
|
||||
groupId: string(item.group_id, "L3.4A case group"),
|
||||
sessionSeconds: finite(item.session_seconds, "L3.4A case time"),
|
||||
sourceImageSha256: sha(item.source_image_sha256, "L3.4A case source"),
|
||||
cameraWidth: integer(camera.width, "L3.4A camera width"),
|
||||
cameraHeight: integer(camera.height, "L3.4A camera height"),
|
||||
cameraUrl: `/api/v1/laboratory/l34a/results/${resultId}/cases/${sequence}/camera`,
|
||||
predictions,
|
||||
annotations,
|
||||
summary: summary(item.summary, "L3.4A visual summary"),
|
||||
groundTruth: exact(item.ground_truth, false, "L3.4A visual ground truth"),
|
||||
access: exact(item.access, "read-only", "L3.4A visual access"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchL34AAssistedYoloxErrorAudit({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: L34ALaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<L34AAssistedYoloxErrorAuditResult> {
|
||||
const response = await fetcher("/api/v1/laboratory/l34a/results?limit=1", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new L34AContractError(`L3.4A недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "L3.4A catalog");
|
||||
exact(catalog.schema_version, "missioncore.l34a-assisted-yolox-error-catalog/v1", "L3.4A catalog schema");
|
||||
exact(catalog.access, "read-only", "L3.4A catalog access");
|
||||
if (!Array.isArray(catalog.items) || catalog.items.length !== 1) {
|
||||
throw new L34AContractError("L3.4A catalog: ожидался один результат.");
|
||||
}
|
||||
return parseResult(catalog.items[0]);
|
||||
}
|
||||
|
||||
export async function fetchL34AAuditCase(
|
||||
resultId: string,
|
||||
sequence: number,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: L34ALaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<L34AAuditCase> {
|
||||
if (!/^l34a-assisted-yolox-error-audit-[a-f0-9]{64}$/.test(resultId) || !Number.isInteger(sequence) || sequence < 1 || sequence > 32) {
|
||||
throw new L34AContractError("L3.4A visual case: неверная идентичность.");
|
||||
}
|
||||
const response = await fetcher(`/api/v1/laboratory/l34a/results/${resultId}/cases/${sequence}`, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new L34AContractError(`L3.4A visual case недоступен: HTTP ${response.status}.`);
|
||||
return parseCase(await response.json(), resultId, sequence);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import type {
|
||||
L34AAuditAnnotation,
|
||||
L34AAuditPrediction,
|
||||
L34ACaseSummaryMetrics,
|
||||
L34ALaboratoryFetch,
|
||||
} from "./l34aAssistedYoloxErrorAudit";
|
||||
|
||||
export interface L34BMetricSet {
|
||||
frameCount: number;
|
||||
predictionCount: number;
|
||||
referenceCount: number;
|
||||
truePositive: number;
|
||||
falsePositive: number;
|
||||
falseNegative: number;
|
||||
classMismatch: number;
|
||||
duplicateFalsePositive: number;
|
||||
unmatchedFalsePositive: number;
|
||||
unmatchedFalseNegative: number;
|
||||
precisionIou50: number;
|
||||
recallIou50: number;
|
||||
f1Iou50: number;
|
||||
errorCaseCount: number;
|
||||
customReferenceCount: number;
|
||||
}
|
||||
|
||||
export interface L34BCaseSummary {
|
||||
truthIslandSequence: number;
|
||||
imageId: number;
|
||||
frameIndex: number;
|
||||
groupId: string;
|
||||
sessionSeconds: number;
|
||||
sourceImageSha256: string;
|
||||
beforeSummary: L34ACaseSummaryMetrics;
|
||||
afterSummary: L34ACaseSummaryMetrics;
|
||||
consolidationCount: number;
|
||||
}
|
||||
|
||||
export interface L34BResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
status: "completed-nested-box-consolidation-shadow-not-truth";
|
||||
profile: {
|
||||
profileId: string;
|
||||
categoryPolicy: string;
|
||||
overlapMetric: string;
|
||||
overlapThreshold: number;
|
||||
geometryPolicy: string;
|
||||
scorePolicy: string;
|
||||
scope: string;
|
||||
};
|
||||
pipelineId: string;
|
||||
sourceSessionId: string;
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
metrics: {
|
||||
before: L34BMetricSet;
|
||||
after: L34BMetricSet;
|
||||
delta: Readonly<Record<string, number>>;
|
||||
consolidationCount: number;
|
||||
affectedCaseCount: number;
|
||||
assistedRegressionFree: boolean;
|
||||
};
|
||||
cases: readonly L34BCaseSummary[];
|
||||
caseOrder: readonly number[];
|
||||
decision: {
|
||||
shadowPolicyAccepted: boolean;
|
||||
classicIouNmsFixRejected: boolean;
|
||||
remainingL34aDuplicateSignals: number;
|
||||
nextAction: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
groundTruth: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface L34BPrediction extends L34AAuditPrediction {
|
||||
sourcePredictionIndices: readonly number[];
|
||||
}
|
||||
|
||||
export interface L34BConsolidation {
|
||||
category: string;
|
||||
sourcePredictionIndices: readonly number[];
|
||||
sourceScores: readonly number[];
|
||||
sourceBoxesXyxy: readonly (readonly [number, number, number, number])[];
|
||||
mergedScore: number;
|
||||
mergedBoxXyxy: readonly [number, number, number, number];
|
||||
minimumOverlapOverSmaller: number;
|
||||
outputPredictionIndex: number;
|
||||
}
|
||||
|
||||
export interface L34BCase {
|
||||
resultId: string;
|
||||
truthIslandSequence: number;
|
||||
imageId: number;
|
||||
frameIndex: number;
|
||||
groupId: string;
|
||||
sessionSeconds: number;
|
||||
sourceImageSha256: string;
|
||||
cameraWidth: number;
|
||||
cameraHeight: number;
|
||||
cameraUrl: string;
|
||||
beforePredictions: readonly L34AAuditPrediction[];
|
||||
afterPredictions: readonly L34BPrediction[];
|
||||
annotations: readonly L34AAuditAnnotation[];
|
||||
consolidations: readonly L34BConsolidation[];
|
||||
beforeSummary: L34ACaseSummaryMetrics;
|
||||
afterSummary: L34ACaseSummaryMetrics;
|
||||
groundTruth: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export class L34BContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "L34BContractError";
|
||||
}
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new L34BContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) throw new L34BContractError(`${label}: ожидалась строка.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact<T>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) throw new L34BContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
if (!Number.isInteger(value) || Number(value) < 0) throw new L34BContractError(`${label}: ожидалось целое число.`);
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function finite(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) throw new L34BContractError(`${label}: ожидалось число.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function ratio(value: unknown, label: string): number {
|
||||
const parsed = finite(value, label);
|
||||
if (parsed < 0 || parsed > 1) throw new L34BContractError(`${label}: нарушен диапазон.`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function bool(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") throw new L34BContractError(`${label}: ожидалось логическое значение.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function box(value: unknown, label: string): readonly [number, number, number, number] {
|
||||
if (!Array.isArray(value) || value.length !== 4) throw new L34BContractError(`${label}: ожидалась рамка.`);
|
||||
const parsed = value.map((entry) => finite(entry, label));
|
||||
if (!(0 <= parsed[0] && parsed[0] < parsed[2] && parsed[2] <= 800
|
||||
&& 0 <= parsed[1] && parsed[1] < parsed[3] && parsed[3] <= 600)) {
|
||||
throw new L34BContractError(`${label}: нарушена геометрия.`);
|
||||
}
|
||||
return parsed as [number, number, number, number];
|
||||
}
|
||||
|
||||
function summary(value: unknown, label: string): L34ACaseSummaryMetrics {
|
||||
const item = object(value, label);
|
||||
return {
|
||||
predictionCount: integer(item.prediction_count, `${label}.prediction_count`),
|
||||
referenceCount: integer(item.reference_count, `${label}.reference_count`),
|
||||
truePositive: integer(item.true_positive, `${label}.true_positive`),
|
||||
falsePositive: integer(item.false_positive, `${label}.false_positive`),
|
||||
falseNegative: integer(item.false_negative, `${label}.false_negative`),
|
||||
classMismatch: integer(item.class_mismatch, `${label}.class_mismatch`),
|
||||
duplicateFalsePositive: integer(item.duplicate_false_positive, `${label}.duplicate_false_positive`),
|
||||
unmatchedFalsePositive: integer(item.unmatched_false_positive, `${label}.unmatched_false_positive`),
|
||||
unmatchedFalseNegative: integer(item.unmatched_false_negative, `${label}.unmatched_false_negative`),
|
||||
severityScore: integer(item.severity_score, `${label}.severity_score`),
|
||||
};
|
||||
}
|
||||
|
||||
function metrics(value: unknown, label: string): L34BMetricSet {
|
||||
const item = object(value, label);
|
||||
return {
|
||||
frameCount: integer(item.frame_count, `${label}.frame_count`),
|
||||
predictionCount: integer(item.prediction_count, `${label}.prediction_count`),
|
||||
referenceCount: integer(item.reference_count, `${label}.reference_count`),
|
||||
truePositive: integer(item.true_positive, `${label}.true_positive`),
|
||||
falsePositive: integer(item.false_positive, `${label}.false_positive`),
|
||||
falseNegative: integer(item.false_negative, `${label}.false_negative`),
|
||||
classMismatch: integer(item.class_mismatch, `${label}.class_mismatch`),
|
||||
duplicateFalsePositive: integer(item.duplicate_false_positive, `${label}.duplicate_false_positive`),
|
||||
unmatchedFalsePositive: integer(item.unmatched_false_positive, `${label}.unmatched_false_positive`),
|
||||
unmatchedFalseNegative: integer(item.unmatched_false_negative, `${label}.unmatched_false_negative`),
|
||||
precisionIou50: ratio(item.precision_iou50, `${label}.precision_iou50`),
|
||||
recallIou50: ratio(item.recall_iou50, `${label}.recall_iou50`),
|
||||
f1Iou50: ratio(item.f1_iou50, `${label}.f1_iou50`),
|
||||
errorCaseCount: integer(item.error_case_count, `${label}.error_case_count`),
|
||||
customReferenceCount: integer(item.custom_reference_count, `${label}.custom_reference_count`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): L34BResult {
|
||||
const item = object(value, "L3.4B");
|
||||
const resultId = text(item.result_id, "L3.4B.result_id");
|
||||
if (!/^l34b-nested-box-consolidation-shadow-[a-f0-9]{64}$/.test(resultId)) throw new L34BContractError("L3.4B.result_id: нарушена идентичность.");
|
||||
const profile = object(item.profile, "L3.4B.profile");
|
||||
const rawMetrics = object(item.metrics, "L3.4B.metrics");
|
||||
const rawDecision = object(item.decision, "L3.4B.decision");
|
||||
const cases = Array.isArray(item.cases) ? item.cases.map((raw): L34BCaseSummary => {
|
||||
const current = object(raw, "L3.4B.case summary");
|
||||
return {
|
||||
truthIslandSequence: integer(current.truth_island_sequence, "L3.4B.case.sequence"),
|
||||
imageId: integer(current.image_id, "L3.4B.case.image"),
|
||||
frameIndex: integer(current.frame_index, "L3.4B.case.frame"),
|
||||
groupId: text(current.group_id, "L3.4B.case.group"),
|
||||
sessionSeconds: finite(current.session_seconds, "L3.4B.case.time"),
|
||||
sourceImageSha256: text(current.source_image_sha256, "L3.4B.case.source"),
|
||||
beforeSummary: summary(current.before_summary, "L3.4B.case.before"),
|
||||
afterSummary: summary(current.after_summary, "L3.4B.case.after"),
|
||||
consolidationCount: integer(current.consolidation_count, "L3.4B.case.consolidations"),
|
||||
};
|
||||
}) : [];
|
||||
if (cases.length !== 32 || !Array.isArray(item.case_order) || item.case_order.length !== 32) throw new L34BContractError("L3.4B: требуется 32 кадра.");
|
||||
const delta = object(rawMetrics.delta, "L3.4B.metrics.delta");
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: text(item.created_at_utc, "L3.4B.created_at"),
|
||||
status: exact(item.status, "completed-nested-box-consolidation-shadow-not-truth", "L3.4B.status"),
|
||||
profile: {
|
||||
profileId: text(profile.profile_id, "L3.4B.profile.id"),
|
||||
categoryPolicy: text(profile.category_policy, "L3.4B.profile.category"),
|
||||
overlapMetric: text(profile.overlap_metric, "L3.4B.profile.metric"),
|
||||
overlapThreshold: ratio(profile.overlap_threshold, "L3.4B.profile.threshold"),
|
||||
geometryPolicy: text(profile.geometry_policy, "L3.4B.profile.geometry"),
|
||||
scorePolicy: text(profile.score_policy, "L3.4B.profile.score"),
|
||||
scope: text(profile.scope, "L3.4B.profile.scope"),
|
||||
},
|
||||
pipelineId: text(item.pipeline_id, "L3.4B.pipeline"),
|
||||
sourceSessionId: text(item.source_session_id, "L3.4B.session"),
|
||||
cameraSourceId: exact(item.camera_source_id, "sensor.camera.right", "L3.4B.camera"),
|
||||
metrics: {
|
||||
before: metrics(rawMetrics.before, "L3.4B.before"),
|
||||
after: metrics(rawMetrics.after, "L3.4B.after"),
|
||||
delta: Object.fromEntries(Object.entries(delta).map(([key, raw]) => [key, finite(raw, `L3.4B.delta.${key}`)])),
|
||||
consolidationCount: integer(rawMetrics.consolidation_count, "L3.4B.consolidation_count"),
|
||||
affectedCaseCount: integer(rawMetrics.affected_case_count, "L3.4B.affected_case_count"),
|
||||
assistedRegressionFree: bool(rawMetrics.assisted_regression_free, "L3.4B.regression_free"),
|
||||
},
|
||||
cases,
|
||||
caseOrder: item.case_order.map((entry) => integer(entry, "L3.4B.case_order")),
|
||||
decision: {
|
||||
shadowPolicyAccepted: bool(rawDecision.shadow_policy_accepted, "L3.4B.shadow accepted"),
|
||||
classicIouNmsFixRejected: bool(rawDecision.classic_iou_nms_fix_rejected, "L3.4B.NMS rejected"),
|
||||
remainingL34aDuplicateSignals: integer(rawDecision.remaining_l34a_duplicate_signals, "L3.4B.remaining signals"),
|
||||
nextAction: text(rawDecision.next_action, "L3.4B.next action"),
|
||||
},
|
||||
limitations: Array.isArray(item.limitations) ? item.limitations.map((entry) => text(entry, "L3.4B.limitation")) : [],
|
||||
groundTruth: exact(item.ground_truth, false, "L3.4B.truth"),
|
||||
access: exact(item.access, "read-only", "L3.4B.access"),
|
||||
};
|
||||
}
|
||||
|
||||
const PREDICTION_VERDICTS = new Set(["true_positive", "class_mismatch", "duplicate_false_positive", "false_positive"]);
|
||||
const ANNOTATION_VERDICTS = new Set(["true_positive", "class_mismatch", "false_negative"]);
|
||||
|
||||
function parsePrediction(raw: unknown, label: string, withSources: boolean): L34BPrediction {
|
||||
const item = object(raw, label);
|
||||
const verdict = text(item.verdict, `${label}.verdict`);
|
||||
if (!PREDICTION_VERDICTS.has(verdict)) throw new L34BContractError(`${label}: неизвестный verdict.`);
|
||||
return {
|
||||
predictionIndex: integer(item.prediction_index, `${label}.index`),
|
||||
category: text(item.category, `${label}.category`),
|
||||
score: ratio(item.score, `${label}.score`),
|
||||
boxXyxy: box(item.box_xyxy, `${label}.box`),
|
||||
verdict: verdict as L34AAuditPrediction["verdict"],
|
||||
matchedObjectId: item.matched_object_id === null ? null : text(item.matched_object_id, `${label}.match`),
|
||||
matchIou: item.match_iou === null ? null : ratio(item.match_iou, `${label}.iou`),
|
||||
sourcePredictionIndices: withSources && Array.isArray(item.source_prediction_indices)
|
||||
? item.source_prediction_indices.map((entry) => integer(entry, `${label}.source`))
|
||||
: [integer(item.prediction_index, `${label}.index`)],
|
||||
};
|
||||
}
|
||||
|
||||
function parseAnnotation(raw: unknown, label: string): L34AAuditAnnotation {
|
||||
const item = object(raw, label);
|
||||
const verdict = text(item.verdict, `${label}.verdict`);
|
||||
if (!ANNOTATION_VERDICTS.has(verdict)) throw new L34BContractError(`${label}: неизвестный verdict.`);
|
||||
return {
|
||||
objectId: text(item.object_id, `${label}.id`),
|
||||
category: text(item.category, `${label}.category`),
|
||||
proposedLabel: item.proposed_label === null ? null : text(item.proposed_label, `${label}.proposed`),
|
||||
displayCategory: text(item.display_category, `${label}.display`),
|
||||
origin: item.origin === "manual" ? "manual" : "frozen_candidate_seed",
|
||||
boxXyxy: box(item.box_xyxy, `${label}.box`),
|
||||
occluded: bool(item.occluded, `${label}.occluded`),
|
||||
truncated: bool(item.truncated, `${label}.truncated`),
|
||||
verdict: verdict as L34AAuditAnnotation["verdict"],
|
||||
matchedPredictionIndex: item.matched_prediction_index === null ? null : integer(item.matched_prediction_index, `${label}.match`),
|
||||
matchIou: item.match_iou === null ? null : ratio(item.match_iou, `${label}.iou`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCase(value: unknown, resultId: string, sequence: number): L34BCase {
|
||||
const item = object(value, "L3.4B.case");
|
||||
exact(item.schema_version, "missioncore.l34b-nested-box-consolidation-case/v1", "L3.4B.case.schema");
|
||||
exact(item.result_id, resultId, "L3.4B.case.result");
|
||||
exact(item.truth_island_sequence, sequence, "L3.4B.case.sequence");
|
||||
const camera = object(item.camera, "L3.4B.case.camera");
|
||||
const consolidations = Array.isArray(item.consolidations) ? item.consolidations.map((raw): L34BConsolidation => {
|
||||
const current = object(raw, "L3.4B.consolidation");
|
||||
return {
|
||||
category: text(current.category, "L3.4B.consolidation.category"),
|
||||
sourcePredictionIndices: Array.isArray(current.source_prediction_indices) ? current.source_prediction_indices.map((entry) => integer(entry, "L3.4B.consolidation.source")) : [],
|
||||
sourceScores: Array.isArray(current.source_scores) ? current.source_scores.map((entry) => ratio(entry, "L3.4B.consolidation.score")) : [],
|
||||
sourceBoxesXyxy: Array.isArray(current.source_boxes_xyxy) ? current.source_boxes_xyxy.map((entry) => box(entry, "L3.4B.consolidation.source box")) : [],
|
||||
mergedScore: ratio(current.merged_score, "L3.4B.consolidation.merged score"),
|
||||
mergedBoxXyxy: box(current.merged_box_xyxy, "L3.4B.consolidation.merged box"),
|
||||
minimumOverlapOverSmaller: ratio(current.minimum_overlap_over_smaller, "L3.4B.consolidation.overlap"),
|
||||
outputPredictionIndex: integer(current.output_prediction_index, "L3.4B.consolidation.output"),
|
||||
};
|
||||
}) : [];
|
||||
return {
|
||||
resultId,
|
||||
truthIslandSequence: sequence,
|
||||
imageId: integer(item.image_id, "L3.4B.case.image"),
|
||||
frameIndex: integer(item.frame_index, "L3.4B.case.frame"),
|
||||
groupId: text(item.group_id, "L3.4B.case.group"),
|
||||
sessionSeconds: finite(item.session_seconds, "L3.4B.case.time"),
|
||||
sourceImageSha256: text(item.source_image_sha256, "L3.4B.case.source"),
|
||||
cameraWidth: integer(camera.width, "L3.4B.case.width"),
|
||||
cameraHeight: integer(camera.height, "L3.4B.case.height"),
|
||||
cameraUrl: `/api/v1/laboratory/l34b/results/${resultId}/cases/${sequence}/camera`,
|
||||
beforePredictions: Array.isArray(item.before_predictions) ? item.before_predictions.map((raw, index) => parsePrediction(raw, `L3.4B.before.${index}`, false)) : [],
|
||||
afterPredictions: Array.isArray(item.after_predictions) ? item.after_predictions.map((raw, index) => parsePrediction(raw, `L3.4B.after.${index}`, true)) : [],
|
||||
annotations: Array.isArray(item.annotations) ? item.annotations.map((raw, index) => parseAnnotation(raw, `L3.4B.annotation.${index}`)) : [],
|
||||
consolidations,
|
||||
beforeSummary: summary(item.before_summary, "L3.4B.case.before summary"),
|
||||
afterSummary: summary(item.after_summary, "L3.4B.case.after summary"),
|
||||
groundTruth: exact(item.ground_truth, false, "L3.4B.case.truth"),
|
||||
access: exact(item.access, "read-only", "L3.4B.case.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchL34BNestedBoxConsolidation({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: { fetcher?: L34ALaboratoryFetch; signal?: AbortSignal } = {}): Promise<L34BResult> {
|
||||
const response = await fetcher("/api/v1/laboratory/l34b/results?limit=1", { method: "GET", headers: { Accept: "application/json" }, signal });
|
||||
if (!response.ok) throw new L34BContractError(`L3.4B недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "L3.4B.catalog");
|
||||
exact(catalog.schema_version, "missioncore.l34b-nested-box-consolidation-catalog/v1", "L3.4B.catalog.schema");
|
||||
if (!Array.isArray(catalog.items) || catalog.items.length !== 1) throw new L34BContractError("L3.4B.catalog: ожидался один результат.");
|
||||
return parseResult(catalog.items[0]);
|
||||
}
|
||||
|
||||
export async function fetchL34BCase(
|
||||
resultId: string,
|
||||
sequence: number,
|
||||
{ fetcher = fetch, signal }: { fetcher?: L34ALaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<L34BCase> {
|
||||
if (!/^l34b-nested-box-consolidation-shadow-[a-f0-9]{64}$/.test(resultId) || !Number.isInteger(sequence) || sequence < 1 || sequence > 32) throw new L34BContractError("L3.4B.case: неверная идентичность.");
|
||||
const response = await fetcher(`/api/v1/laboratory/l34b/results/${resultId}/cases/${sequence}`, { method: "GET", headers: { Accept: "application/json" }, signal });
|
||||
if (!response.ok) throw new L34BContractError(`L3.4B.case недоступен: HTTP ${response.status}.`);
|
||||
return parseCase(await response.json(), resultId, sequence);
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
import type {
|
||||
L34AAuditAnnotation,
|
||||
L34AAuditPrediction,
|
||||
L34ACaseSummaryMetrics,
|
||||
L34ALaboratoryFetch,
|
||||
} from "./l34aAssistedYoloxErrorAudit";
|
||||
import type { L34BMetricSet } from "./l34bNestedBoxConsolidation";
|
||||
|
||||
export type L34CRectificationTile = "front" | "left" | "right";
|
||||
|
||||
export interface L34CCaseSummary {
|
||||
truthIslandSequence: number;
|
||||
imageId: number;
|
||||
frameIndex: number;
|
||||
groupId: string;
|
||||
sessionSeconds: number;
|
||||
sourceImageSha256: string;
|
||||
beforeSummary: L34ACaseSummaryMetrics;
|
||||
afterSummary: L34ACaseSummaryMetrics;
|
||||
stitchCount: number;
|
||||
}
|
||||
|
||||
export interface L34CResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
status: "completed-temporal-tile-seam-stitch-shadow-not-truth";
|
||||
profile: {
|
||||
profileId: string;
|
||||
categoryPolicy: string;
|
||||
tilePolicy: string;
|
||||
horizontalOverlapThreshold: number;
|
||||
verticalOverlapThreshold: number;
|
||||
minimumUnionAreaFraction: number;
|
||||
temporalUnionIouThreshold: number;
|
||||
minimumConsecutiveFrames: number;
|
||||
geometryPolicy: string;
|
||||
scorePolicy: string;
|
||||
scope: string;
|
||||
};
|
||||
pipelineId: string;
|
||||
sourceSessionId: string;
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
metrics: {
|
||||
before: L34BMetricSet;
|
||||
after: L34BMetricSet;
|
||||
delta: Readonly<Record<string, number>>;
|
||||
staticCandidateCount: number;
|
||||
temporallyRejectedCandidateCount: number;
|
||||
stitchCount: number;
|
||||
affectedCaseCount: number;
|
||||
temporalRunCount: number;
|
||||
assistedRegressionFree: boolean;
|
||||
};
|
||||
cases: readonly L34CCaseSummary[];
|
||||
caseOrder: readonly number[];
|
||||
decision: {
|
||||
shadowPolicyAccepted: boolean;
|
||||
predictionProvenancePreserved: boolean;
|
||||
globalNmsUnchanged: boolean;
|
||||
remainingL34aDuplicateSignals: number;
|
||||
nextAction: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
groundTruth: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface L34CBeforePrediction extends L34AAuditPrediction {
|
||||
rectificationTile: L34CRectificationTile;
|
||||
rawLabel: string;
|
||||
classId: number;
|
||||
rawCenterXy: readonly [number, number];
|
||||
}
|
||||
|
||||
export interface L34CAfterPrediction extends L34AAuditPrediction {
|
||||
sourcePredictionIndices: readonly number[];
|
||||
sourceRectificationTiles: readonly L34CRectificationTile[];
|
||||
}
|
||||
|
||||
export interface L34CStitch {
|
||||
category: string;
|
||||
sourcePredictionIndices: readonly number[];
|
||||
sourceTiles: readonly L34CRectificationTile[];
|
||||
sourceScores: readonly number[];
|
||||
sourceBoxesXyxy: readonly (readonly [number, number, number, number])[];
|
||||
mergedScore: number;
|
||||
mergedBoxXyxy: readonly [number, number, number, number];
|
||||
horizontalOverlapOverSmaller: number;
|
||||
verticalOverlapOverSmaller: number;
|
||||
sourceIou: number;
|
||||
unionAreaFraction: number;
|
||||
temporalRunId: string;
|
||||
temporalRunLength: number;
|
||||
}
|
||||
|
||||
export interface L34CCase {
|
||||
resultId: string;
|
||||
truthIslandSequence: number;
|
||||
imageId: number;
|
||||
frameIndex: number;
|
||||
groupId: string;
|
||||
sessionSeconds: number;
|
||||
sourceImageSha256: string;
|
||||
cameraWidth: number;
|
||||
cameraHeight: number;
|
||||
cameraUrl: string;
|
||||
beforePredictions: readonly L34CBeforePrediction[];
|
||||
afterPredictions: readonly L34CAfterPrediction[];
|
||||
annotations: readonly L34AAuditAnnotation[];
|
||||
stitches: readonly L34CStitch[];
|
||||
beforeSummary: L34ACaseSummaryMetrics;
|
||||
afterSummary: L34ACaseSummaryMetrics;
|
||||
groundTruth: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export class L34CContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "L34CContractError";
|
||||
}
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new L34CContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new L34CContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact<T>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) {
|
||||
throw new L34CContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
if (!Number.isInteger(value) || Number(value) < 0) {
|
||||
throw new L34CContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function finite(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new L34CContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function ratio(value: unknown, label: string): number {
|
||||
const parsed = finite(value, label);
|
||||
if (parsed < 0 || parsed > 1) {
|
||||
throw new L34CContractError(`${label}: нарушен диапазон.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function bool(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new L34CContractError(`${label}: ожидалось логическое значение.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function box(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): readonly [number, number, number, number] {
|
||||
if (!Array.isArray(value) || value.length !== 4) {
|
||||
throw new L34CContractError(`${label}: ожидалась рамка.`);
|
||||
}
|
||||
const parsed = value.map((entry) => finite(entry, label));
|
||||
if (!(0 <= parsed[0] && parsed[0] < parsed[2] && parsed[2] <= 800
|
||||
&& 0 <= parsed[1] && parsed[1] < parsed[3] && parsed[3] <= 600)) {
|
||||
throw new L34CContractError(`${label}: нарушена геометрия.`);
|
||||
}
|
||||
return parsed as [number, number, number, number];
|
||||
}
|
||||
|
||||
function point(value: unknown, label: string): readonly [number, number] {
|
||||
if (!Array.isArray(value) || value.length !== 2) {
|
||||
throw new L34CContractError(`${label}: ожидалась точка.`);
|
||||
}
|
||||
return [finite(value[0], label), finite(value[1], label)];
|
||||
}
|
||||
|
||||
function tile(value: unknown, label: string): L34CRectificationTile {
|
||||
if (value !== "front" && value !== "left" && value !== "right") {
|
||||
throw new L34CContractError(`${label}: неизвестный rectification tile.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function summary(value: unknown, label: string): L34ACaseSummaryMetrics {
|
||||
const item = object(value, label);
|
||||
return {
|
||||
predictionCount: integer(item.prediction_count, `${label}.prediction_count`),
|
||||
referenceCount: integer(item.reference_count, `${label}.reference_count`),
|
||||
truePositive: integer(item.true_positive, `${label}.true_positive`),
|
||||
falsePositive: integer(item.false_positive, `${label}.false_positive`),
|
||||
falseNegative: integer(item.false_negative, `${label}.false_negative`),
|
||||
classMismatch: integer(item.class_mismatch, `${label}.class_mismatch`),
|
||||
duplicateFalsePositive: integer(item.duplicate_false_positive, `${label}.duplicate_false_positive`),
|
||||
unmatchedFalsePositive: integer(item.unmatched_false_positive, `${label}.unmatched_false_positive`),
|
||||
unmatchedFalseNegative: integer(item.unmatched_false_negative, `${label}.unmatched_false_negative`),
|
||||
severityScore: integer(item.severity_score, `${label}.severity_score`),
|
||||
};
|
||||
}
|
||||
|
||||
function metrics(value: unknown, label: string): L34BMetricSet {
|
||||
const item = object(value, label);
|
||||
return {
|
||||
frameCount: integer(item.frame_count, `${label}.frame_count`),
|
||||
predictionCount: integer(item.prediction_count, `${label}.prediction_count`),
|
||||
referenceCount: integer(item.reference_count, `${label}.reference_count`),
|
||||
truePositive: integer(item.true_positive, `${label}.true_positive`),
|
||||
falsePositive: integer(item.false_positive, `${label}.false_positive`),
|
||||
falseNegative: integer(item.false_negative, `${label}.false_negative`),
|
||||
classMismatch: integer(item.class_mismatch, `${label}.class_mismatch`),
|
||||
duplicateFalsePositive: integer(item.duplicate_false_positive, `${label}.duplicate_false_positive`),
|
||||
unmatchedFalsePositive: integer(item.unmatched_false_positive, `${label}.unmatched_false_positive`),
|
||||
unmatchedFalseNegative: integer(item.unmatched_false_negative, `${label}.unmatched_false_negative`),
|
||||
precisionIou50: ratio(item.precision_iou50, `${label}.precision_iou50`),
|
||||
recallIou50: ratio(item.recall_iou50, `${label}.recall_iou50`),
|
||||
f1Iou50: ratio(item.f1_iou50, `${label}.f1_iou50`),
|
||||
errorCaseCount: integer(item.error_case_count, `${label}.error_case_count`),
|
||||
customReferenceCount: integer(item.custom_reference_count, `${label}.custom_reference_count`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): L34CResult {
|
||||
const item = object(value, "L3.4C");
|
||||
const resultId = text(item.result_id, "L3.4C.result_id");
|
||||
if (!/^l34c-tile-seam-stitch-shadow-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new L34CContractError("L3.4C.result_id: нарушена идентичность.");
|
||||
}
|
||||
const profile = object(item.profile, "L3.4C.profile");
|
||||
const rawMetrics = object(item.metrics, "L3.4C.metrics");
|
||||
const rawDecision = object(item.decision, "L3.4C.decision");
|
||||
const cases = Array.isArray(item.cases) ? item.cases.map((raw): L34CCaseSummary => {
|
||||
const current = object(raw, "L3.4C.case summary");
|
||||
return {
|
||||
truthIslandSequence: integer(current.truth_island_sequence, "L3.4C.case.sequence"),
|
||||
imageId: integer(current.image_id, "L3.4C.case.image"),
|
||||
frameIndex: integer(current.frame_index, "L3.4C.case.frame"),
|
||||
groupId: text(current.group_id, "L3.4C.case.group"),
|
||||
sessionSeconds: finite(current.session_seconds, "L3.4C.case.time"),
|
||||
sourceImageSha256: text(current.source_image_sha256, "L3.4C.case.source"),
|
||||
beforeSummary: summary(current.before_summary, "L3.4C.case.before"),
|
||||
afterSummary: summary(current.after_summary, "L3.4C.case.after"),
|
||||
stitchCount: integer(current.stitch_count, "L3.4C.case.stitches"),
|
||||
};
|
||||
}) : [];
|
||||
if (cases.length !== 32 || !Array.isArray(item.case_order) || item.case_order.length !== 32) {
|
||||
throw new L34CContractError("L3.4C: требуется 32 кадра.");
|
||||
}
|
||||
const delta = object(rawMetrics.delta, "L3.4C.metrics.delta");
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: text(item.created_at_utc, "L3.4C.created_at"),
|
||||
status: exact(item.status, "completed-temporal-tile-seam-stitch-shadow-not-truth", "L3.4C.status"),
|
||||
profile: {
|
||||
profileId: text(profile.profile_id, "L3.4C.profile.id"),
|
||||
categoryPolicy: text(profile.category_policy, "L3.4C.profile.category"),
|
||||
tilePolicy: text(profile.tile_policy, "L3.4C.profile.tile"),
|
||||
horizontalOverlapThreshold: ratio(profile.horizontal_overlap_over_smaller_threshold, "L3.4C.profile.horizontal"),
|
||||
verticalOverlapThreshold: ratio(profile.vertical_overlap_over_smaller_threshold, "L3.4C.profile.vertical"),
|
||||
minimumUnionAreaFraction: ratio(profile.minimum_union_area_fraction, "L3.4C.profile.area"),
|
||||
temporalUnionIouThreshold: ratio(profile.temporal_union_iou_threshold, "L3.4C.profile.temporal"),
|
||||
minimumConsecutiveFrames: integer(profile.minimum_consecutive_frames, "L3.4C.profile.frames"),
|
||||
geometryPolicy: text(profile.geometry_policy, "L3.4C.profile.geometry"),
|
||||
scorePolicy: text(profile.score_policy, "L3.4C.profile.score"),
|
||||
scope: text(profile.scope, "L3.4C.profile.scope"),
|
||||
},
|
||||
pipelineId: text(item.pipeline_id, "L3.4C.pipeline"),
|
||||
sourceSessionId: text(item.source_session_id, "L3.4C.session"),
|
||||
cameraSourceId: exact(item.camera_source_id, "sensor.camera.right", "L3.4C.camera"),
|
||||
metrics: {
|
||||
before: metrics(rawMetrics.before, "L3.4C.before"),
|
||||
after: metrics(rawMetrics.after, "L3.4C.after"),
|
||||
delta: Object.fromEntries(Object.entries(delta).map(([key, raw]) => [key, finite(raw, `L3.4C.delta.${key}`)])),
|
||||
staticCandidateCount: integer(rawMetrics.static_candidate_count, "L3.4C.static candidates"),
|
||||
temporallyRejectedCandidateCount: integer(rawMetrics.temporally_rejected_candidate_count, "L3.4C.rejected candidates"),
|
||||
stitchCount: integer(rawMetrics.stitch_count, "L3.4C.stitch count"),
|
||||
affectedCaseCount: integer(rawMetrics.affected_case_count, "L3.4C.affected cases"),
|
||||
temporalRunCount: integer(rawMetrics.temporal_run_count, "L3.4C.run count"),
|
||||
assistedRegressionFree: bool(rawMetrics.assisted_regression_free, "L3.4C.regression free"),
|
||||
},
|
||||
cases,
|
||||
caseOrder: item.case_order.map((entry) => integer(entry, "L3.4C.case_order")),
|
||||
decision: {
|
||||
shadowPolicyAccepted: bool(rawDecision.shadow_policy_accepted, "L3.4C.shadow accepted"),
|
||||
predictionProvenancePreserved: bool(rawDecision.prediction_provenance_preserved, "L3.4C.provenance"),
|
||||
globalNmsUnchanged: bool(rawDecision.global_nms_unchanged, "L3.4C.NMS"),
|
||||
remainingL34aDuplicateSignals: integer(rawDecision.remaining_l34a_duplicate_signals, "L3.4C.remaining signals"),
|
||||
nextAction: text(rawDecision.next_action, "L3.4C.next action"),
|
||||
},
|
||||
limitations: Array.isArray(item.limitations) ? item.limitations.map((entry) => text(entry, "L3.4C.limitation")) : [],
|
||||
groundTruth: exact(item.ground_truth, false, "L3.4C.truth"),
|
||||
access: exact(item.access, "read-only", "L3.4C.access"),
|
||||
};
|
||||
}
|
||||
|
||||
const PREDICTION_VERDICTS = new Set(["true_positive", "class_mismatch", "duplicate_false_positive", "false_positive"]);
|
||||
const ANNOTATION_VERDICTS = new Set(["true_positive", "class_mismatch", "false_negative"]);
|
||||
|
||||
function predictionBase(raw: unknown, label: string): L34AAuditPrediction {
|
||||
const item = object(raw, label);
|
||||
const verdict = text(item.verdict, `${label}.verdict`);
|
||||
if (!PREDICTION_VERDICTS.has(verdict)) {
|
||||
throw new L34CContractError(`${label}: неизвестный verdict.`);
|
||||
}
|
||||
return {
|
||||
predictionIndex: integer(item.prediction_index, `${label}.index`),
|
||||
category: text(item.category, `${label}.category`),
|
||||
score: ratio(item.score, `${label}.score`),
|
||||
boxXyxy: box(item.box_xyxy, `${label}.box`),
|
||||
verdict: verdict as L34AAuditPrediction["verdict"],
|
||||
matchedObjectId: item.matched_object_id === null ? null : text(item.matched_object_id, `${label}.match`),
|
||||
matchIou: item.match_iou === null ? null : ratio(item.match_iou, `${label}.iou`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseBeforePrediction(raw: unknown, label: string): L34CBeforePrediction {
|
||||
const item = object(raw, label);
|
||||
return {
|
||||
...predictionBase(item, label),
|
||||
rectificationTile: tile(item.rectification_tile, `${label}.tile`),
|
||||
rawLabel: text(item.raw_label, `${label}.raw label`),
|
||||
classId: integer(item.class_id, `${label}.class id`),
|
||||
rawCenterXy: point(item.raw_center_xy, `${label}.raw center`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAfterPrediction(raw: unknown, label: string): L34CAfterPrediction {
|
||||
const item = object(raw, label);
|
||||
return {
|
||||
...predictionBase(item, label),
|
||||
sourcePredictionIndices: Array.isArray(item.source_prediction_indices)
|
||||
? item.source_prediction_indices.map((entry) => integer(entry, `${label}.source`))
|
||||
: [],
|
||||
sourceRectificationTiles: Array.isArray(item.source_rectification_tiles)
|
||||
? item.source_rectification_tiles.map((entry) => tile(entry, `${label}.tile`))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function parseAnnotation(raw: unknown, label: string): L34AAuditAnnotation {
|
||||
const item = object(raw, label);
|
||||
const verdict = text(item.verdict, `${label}.verdict`);
|
||||
if (!ANNOTATION_VERDICTS.has(verdict)) {
|
||||
throw new L34CContractError(`${label}: неизвестный verdict.`);
|
||||
}
|
||||
return {
|
||||
objectId: text(item.object_id, `${label}.id`),
|
||||
category: text(item.category, `${label}.category`),
|
||||
proposedLabel: item.proposed_label === null ? null : text(item.proposed_label, `${label}.proposed`),
|
||||
displayCategory: text(item.display_category, `${label}.display`),
|
||||
origin: item.origin === "manual" ? "manual" : "frozen_candidate_seed",
|
||||
boxXyxy: box(item.box_xyxy, `${label}.box`),
|
||||
occluded: bool(item.occluded, `${label}.occluded`),
|
||||
truncated: bool(item.truncated, `${label}.truncated`),
|
||||
verdict: verdict as L34AAuditAnnotation["verdict"],
|
||||
matchedPredictionIndex: item.matched_prediction_index === null ? null : integer(item.matched_prediction_index, `${label}.match`),
|
||||
matchIou: item.match_iou === null ? null : ratio(item.match_iou, `${label}.iou`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseStitch(raw: unknown): L34CStitch {
|
||||
const item = object(raw, "L3.4C.stitch");
|
||||
return {
|
||||
category: text(item.category, "L3.4C.stitch.category"),
|
||||
sourcePredictionIndices: Array.isArray(item.source_prediction_indices) ? item.source_prediction_indices.map((entry) => integer(entry, "L3.4C.stitch.source")) : [],
|
||||
sourceTiles: Array.isArray(item.source_tiles) ? item.source_tiles.map((entry) => tile(entry, "L3.4C.stitch.tile")) : [],
|
||||
sourceScores: Array.isArray(item.source_scores) ? item.source_scores.map((entry) => ratio(entry, "L3.4C.stitch.score")) : [],
|
||||
sourceBoxesXyxy: Array.isArray(item.source_boxes_xyxy) ? item.source_boxes_xyxy.map((entry) => box(entry, "L3.4C.stitch.source box")) : [],
|
||||
mergedScore: ratio(item.merged_score, "L3.4C.stitch.merged score"),
|
||||
mergedBoxXyxy: box(item.merged_box_xyxy, "L3.4C.stitch.merged box"),
|
||||
horizontalOverlapOverSmaller: ratio(item.horizontal_overlap_over_smaller, "L3.4C.stitch.horizontal"),
|
||||
verticalOverlapOverSmaller: ratio(item.vertical_overlap_over_smaller, "L3.4C.stitch.vertical"),
|
||||
sourceIou: ratio(item.source_iou, "L3.4C.stitch.iou"),
|
||||
unionAreaFraction: ratio(item.union_area_fraction, "L3.4C.stitch.area"),
|
||||
temporalRunId: text(item.temporal_run_id, "L3.4C.stitch.run"),
|
||||
temporalRunLength: integer(item.temporal_run_length, "L3.4C.stitch.run length"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCase(value: unknown, resultId: string, sequence: number): L34CCase {
|
||||
const item = object(value, "L3.4C.case");
|
||||
exact(item.schema_version, "missioncore.l34c-tile-seam-stitch-case/v1", "L3.4C.case.schema");
|
||||
exact(item.result_id, resultId, "L3.4C.case.result");
|
||||
exact(item.truth_island_sequence, sequence, "L3.4C.case.sequence");
|
||||
const camera = object(item.camera, "L3.4C.case.camera");
|
||||
return {
|
||||
resultId,
|
||||
truthIslandSequence: sequence,
|
||||
imageId: integer(item.image_id, "L3.4C.case.image"),
|
||||
frameIndex: integer(item.frame_index, "L3.4C.case.frame"),
|
||||
groupId: text(item.group_id, "L3.4C.case.group"),
|
||||
sessionSeconds: finite(item.session_seconds, "L3.4C.case.time"),
|
||||
sourceImageSha256: text(item.source_image_sha256, "L3.4C.case.source"),
|
||||
cameraWidth: integer(camera.width, "L3.4C.case.width"),
|
||||
cameraHeight: integer(camera.height, "L3.4C.case.height"),
|
||||
cameraUrl: `/api/v1/laboratory/l34c/results/${resultId}/cases/${sequence}/camera`,
|
||||
beforePredictions: Array.isArray(item.before_predictions) ? item.before_predictions.map((raw, index) => parseBeforePrediction(raw, `L3.4C.before.${index}`)) : [],
|
||||
afterPredictions: Array.isArray(item.after_predictions) ? item.after_predictions.map((raw, index) => parseAfterPrediction(raw, `L3.4C.after.${index}`)) : [],
|
||||
annotations: Array.isArray(item.annotations) ? item.annotations.map((raw, index) => parseAnnotation(raw, `L3.4C.annotation.${index}`)) : [],
|
||||
stitches: Array.isArray(item.stitches) ? item.stitches.map(parseStitch) : [],
|
||||
beforeSummary: summary(item.before_summary, "L3.4C.case.before summary"),
|
||||
afterSummary: summary(item.after_summary, "L3.4C.case.after summary"),
|
||||
groundTruth: exact(item.ground_truth, false, "L3.4C.case.truth"),
|
||||
access: exact(item.access, "read-only", "L3.4C.case.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchL34CTileSeamStitch({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: { fetcher?: L34ALaboratoryFetch; signal?: AbortSignal } = {}): Promise<L34CResult> {
|
||||
const response = await fetcher("/api/v1/laboratory/l34c/results?limit=1", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new L34CContractError(`L3.4C недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
const catalog = object(await response.json(), "L3.4C.catalog");
|
||||
exact(catalog.schema_version, "missioncore.l34c-tile-seam-stitch-catalog/v1", "L3.4C.catalog.schema");
|
||||
if (!Array.isArray(catalog.items) || catalog.items.length !== 1) {
|
||||
throw new L34CContractError("L3.4C.catalog: ожидался один результат.");
|
||||
}
|
||||
return parseResult(catalog.items[0]);
|
||||
}
|
||||
|
||||
export async function fetchL34CCase(
|
||||
resultId: string,
|
||||
sequence: number,
|
||||
{ fetcher = fetch, signal }: { fetcher?: L34ALaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<L34CCase> {
|
||||
if (!/^l34c-tile-seam-stitch-shadow-[a-f0-9]{64}$/.test(resultId)
|
||||
|| !Number.isInteger(sequence) || sequence < 1 || sequence > 32) {
|
||||
throw new L34CContractError("L3.4C.case: неверная идентичность.");
|
||||
}
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/l34c/results/${resultId}/cases/${sequence}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new L34CContractError(`L3.4C.case недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
return parseCase(await response.json(), resultId, sequence);
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
import type {
|
||||
L34AAuditAnnotation,
|
||||
L34AAuditPrediction,
|
||||
L34ACaseSummaryMetrics,
|
||||
L34ALaboratoryFetch,
|
||||
} from "./l34aAssistedYoloxErrorAudit";
|
||||
import type { L34BMetricSet } from "./l34bNestedBoxConsolidation";
|
||||
import type {
|
||||
L34CBeforePrediction,
|
||||
L34CRectificationTile,
|
||||
} from "./l34cTileSeamStitch";
|
||||
|
||||
export type L34DOperationType =
|
||||
| "nested-box-consolidation"
|
||||
| "temporal-tile-seam-stitch";
|
||||
|
||||
export interface L34DCaseSummary {
|
||||
truthIslandSequence: number;
|
||||
imageId: number;
|
||||
frameIndex: number;
|
||||
groupId: string;
|
||||
sessionSeconds: number;
|
||||
sourceImageSha256: string;
|
||||
beforeSummary: L34ACaseSummaryMetrics;
|
||||
afterSummary: L34ACaseSummaryMetrics;
|
||||
operationCount: number;
|
||||
operationTypes: readonly L34DOperationType[];
|
||||
}
|
||||
|
||||
export interface L34DResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
status: "completed-cumulative-postprocessing-candidate-freeze-not-truth";
|
||||
profile: {
|
||||
profileId: string;
|
||||
operationOrder: string;
|
||||
conflictPolicy: string;
|
||||
geometryPolicy: string;
|
||||
scorePolicy: string;
|
||||
scope: string;
|
||||
};
|
||||
pipelineId: string;
|
||||
sourceSessionId: string;
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
metrics: {
|
||||
before: L34BMetricSet;
|
||||
after: L34BMetricSet;
|
||||
delta: Readonly<Record<string, number>>;
|
||||
nestedConsolidationCount: number;
|
||||
temporalStitchCount: number;
|
||||
cumulativeOperationCount: number;
|
||||
affectedCaseCount: number;
|
||||
operationConflictCount: number;
|
||||
countEffectsAdditive: boolean;
|
||||
assistedRegressionFree: boolean;
|
||||
};
|
||||
cases: readonly L34DCaseSummary[];
|
||||
caseOrder: readonly number[];
|
||||
decision: {
|
||||
cumulativeShadowAccepted: boolean;
|
||||
candidateFrozen: boolean;
|
||||
candidateAccepted: boolean;
|
||||
predictionProvenancePreserved: boolean;
|
||||
operationSetsDisjoint: boolean;
|
||||
globalNmsUnchanged: boolean;
|
||||
independentTruthAvailable: boolean;
|
||||
l35BlindGateOpen: boolean;
|
||||
nextAction: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
groundTruth: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface L34DAfterPrediction extends L34AAuditPrediction {
|
||||
sourcePredictionIndices: readonly number[];
|
||||
sourceRectificationTiles: readonly L34CRectificationTile[];
|
||||
operationTypes: readonly L34DOperationType[];
|
||||
}
|
||||
|
||||
export interface L34DOperation {
|
||||
operationType: L34DOperationType;
|
||||
category: string;
|
||||
sourcePredictionIndices: readonly number[];
|
||||
sourceTiles: readonly L34CRectificationTile[];
|
||||
sourceScores: readonly number[];
|
||||
sourceBoxesXyxy: readonly (readonly [number, number, number, number])[];
|
||||
mergedScore: number;
|
||||
mergedBoxXyxy: readonly [number, number, number, number];
|
||||
temporalRunId: string | null;
|
||||
temporalRunLength: number | null;
|
||||
}
|
||||
|
||||
export interface L34DCase {
|
||||
resultId: string;
|
||||
truthIslandSequence: number;
|
||||
imageId: number;
|
||||
frameIndex: number;
|
||||
groupId: string;
|
||||
sessionSeconds: number;
|
||||
sourceImageSha256: string;
|
||||
cameraWidth: number;
|
||||
cameraHeight: number;
|
||||
cameraUrl: string;
|
||||
beforePredictions: readonly L34CBeforePrediction[];
|
||||
afterPredictions: readonly L34DAfterPrediction[];
|
||||
annotations: readonly L34AAuditAnnotation[];
|
||||
operations: readonly L34DOperation[];
|
||||
beforeSummary: L34ACaseSummaryMetrics;
|
||||
afterSummary: L34ACaseSummaryMetrics;
|
||||
groundTruth: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export class L34DContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "L34DContractError";
|
||||
}
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new L34DContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new L34DContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact<T>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) {
|
||||
throw new L34DContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
if (!Number.isInteger(value) || Number(value) < 0) {
|
||||
throw new L34DContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function finite(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new L34DContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function ratio(value: unknown, label: string): number {
|
||||
const parsed = finite(value, label);
|
||||
if (parsed < 0 || parsed > 1) {
|
||||
throw new L34DContractError(`${label}: нарушен диапазон.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function bool(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new L34DContractError(`${label}: ожидалось логическое значение.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function box(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): readonly [number, number, number, number] {
|
||||
if (!Array.isArray(value) || value.length !== 4) {
|
||||
throw new L34DContractError(`${label}: ожидалась рамка.`);
|
||||
}
|
||||
const parsed = value.map((entry) => finite(entry, label));
|
||||
if (!(0 <= parsed[0] && parsed[0] < parsed[2] && parsed[2] <= 800
|
||||
&& 0 <= parsed[1] && parsed[1] < parsed[3] && parsed[3] <= 600)) {
|
||||
throw new L34DContractError(`${label}: нарушена геометрия.`);
|
||||
}
|
||||
return parsed as [number, number, number, number];
|
||||
}
|
||||
|
||||
function point(value: unknown, label: string): readonly [number, number] {
|
||||
if (!Array.isArray(value) || value.length !== 2) {
|
||||
throw new L34DContractError(`${label}: ожидалась точка.`);
|
||||
}
|
||||
return [finite(value[0], label), finite(value[1], label)];
|
||||
}
|
||||
|
||||
function tile(value: unknown, label: string): L34CRectificationTile {
|
||||
if (value !== "front" && value !== "left" && value !== "right") {
|
||||
throw new L34DContractError(`${label}: неизвестный rectification tile.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function operationType(value: unknown, label: string): L34DOperationType {
|
||||
if (value !== "nested-box-consolidation"
|
||||
&& value !== "temporal-tile-seam-stitch") {
|
||||
throw new L34DContractError(`${label}: неизвестная операция.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function summary(value: unknown, label: string): L34ACaseSummaryMetrics {
|
||||
const item = object(value, label);
|
||||
return {
|
||||
predictionCount: integer(item.prediction_count, `${label}.prediction_count`),
|
||||
referenceCount: integer(item.reference_count, `${label}.reference_count`),
|
||||
truePositive: integer(item.true_positive, `${label}.true_positive`),
|
||||
falsePositive: integer(item.false_positive, `${label}.false_positive`),
|
||||
falseNegative: integer(item.false_negative, `${label}.false_negative`),
|
||||
classMismatch: integer(item.class_mismatch, `${label}.class_mismatch`),
|
||||
duplicateFalsePositive: integer(item.duplicate_false_positive, `${label}.duplicate_false_positive`),
|
||||
unmatchedFalsePositive: integer(item.unmatched_false_positive, `${label}.unmatched_false_positive`),
|
||||
unmatchedFalseNegative: integer(item.unmatched_false_negative, `${label}.unmatched_false_negative`),
|
||||
severityScore: integer(item.severity_score, `${label}.severity_score`),
|
||||
};
|
||||
}
|
||||
|
||||
function metrics(value: unknown, label: string): L34BMetricSet {
|
||||
const item = object(value, label);
|
||||
return {
|
||||
frameCount: integer(item.frame_count, `${label}.frame_count`),
|
||||
predictionCount: integer(item.prediction_count, `${label}.prediction_count`),
|
||||
referenceCount: integer(item.reference_count, `${label}.reference_count`),
|
||||
truePositive: integer(item.true_positive, `${label}.true_positive`),
|
||||
falsePositive: integer(item.false_positive, `${label}.false_positive`),
|
||||
falseNegative: integer(item.false_negative, `${label}.false_negative`),
|
||||
classMismatch: integer(item.class_mismatch, `${label}.class_mismatch`),
|
||||
duplicateFalsePositive: integer(item.duplicate_false_positive, `${label}.duplicate_false_positive`),
|
||||
unmatchedFalsePositive: integer(item.unmatched_false_positive, `${label}.unmatched_false_positive`),
|
||||
unmatchedFalseNegative: integer(item.unmatched_false_negative, `${label}.unmatched_false_negative`),
|
||||
precisionIou50: ratio(item.precision_iou50, `${label}.precision_iou50`),
|
||||
recallIou50: ratio(item.recall_iou50, `${label}.recall_iou50`),
|
||||
f1Iou50: ratio(item.f1_iou50, `${label}.f1_iou50`),
|
||||
errorCaseCount: integer(item.error_case_count, `${label}.error_case_count`),
|
||||
customReferenceCount: integer(item.custom_reference_count, `${label}.custom_reference_count`),
|
||||
};
|
||||
}
|
||||
|
||||
const PREDICTION_VERDICTS = new Set([
|
||||
"true_positive",
|
||||
"class_mismatch",
|
||||
"duplicate_false_positive",
|
||||
"false_positive",
|
||||
]);
|
||||
const ANNOTATION_VERDICTS = new Set([
|
||||
"true_positive",
|
||||
"class_mismatch",
|
||||
"false_negative",
|
||||
]);
|
||||
|
||||
function predictionBase(raw: unknown, label: string): L34AAuditPrediction {
|
||||
const item = object(raw, label);
|
||||
const verdict = text(item.verdict, `${label}.verdict`);
|
||||
if (!PREDICTION_VERDICTS.has(verdict)) {
|
||||
throw new L34DContractError(`${label}: неизвестный verdict.`);
|
||||
}
|
||||
return {
|
||||
predictionIndex: integer(item.prediction_index, `${label}.index`),
|
||||
category: text(item.category, `${label}.category`),
|
||||
score: ratio(item.score, `${label}.score`),
|
||||
boxXyxy: box(item.box_xyxy, `${label}.box`),
|
||||
verdict: verdict as L34AAuditPrediction["verdict"],
|
||||
matchedObjectId: item.matched_object_id === null
|
||||
? null
|
||||
: text(item.matched_object_id, `${label}.match`),
|
||||
matchIou: item.match_iou === null
|
||||
? null
|
||||
: ratio(item.match_iou, `${label}.iou`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseBeforePrediction(raw: unknown, label: string): L34CBeforePrediction {
|
||||
const item = object(raw, label);
|
||||
return {
|
||||
...predictionBase(item, label),
|
||||
rectificationTile: tile(item.rectification_tile, `${label}.tile`),
|
||||
rawLabel: text(item.raw_label, `${label}.raw label`),
|
||||
classId: integer(item.class_id, `${label}.class id`),
|
||||
rawCenterXy: point(item.raw_center_xy, `${label}.raw center`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAfterPrediction(raw: unknown, label: string): L34DAfterPrediction {
|
||||
const item = object(raw, label);
|
||||
return {
|
||||
...predictionBase(item, label),
|
||||
sourcePredictionIndices: Array.isArray(item.source_prediction_indices)
|
||||
? item.source_prediction_indices.map((entry) => integer(entry, `${label}.source`))
|
||||
: [],
|
||||
sourceRectificationTiles: Array.isArray(item.source_rectification_tiles)
|
||||
? item.source_rectification_tiles.map((entry) => tile(entry, `${label}.tile`))
|
||||
: [],
|
||||
operationTypes: Array.isArray(item.operation_types)
|
||||
? item.operation_types.map((entry) => operationType(entry, `${label}.operation`))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function parseAnnotation(raw: unknown, label: string): L34AAuditAnnotation {
|
||||
const item = object(raw, label);
|
||||
const verdict = text(item.verdict, `${label}.verdict`);
|
||||
if (!ANNOTATION_VERDICTS.has(verdict)) {
|
||||
throw new L34DContractError(`${label}: неизвестный verdict.`);
|
||||
}
|
||||
return {
|
||||
objectId: text(item.object_id, `${label}.id`),
|
||||
category: text(item.category, `${label}.category`),
|
||||
proposedLabel: item.proposed_label === null
|
||||
? null
|
||||
: text(item.proposed_label, `${label}.proposed`),
|
||||
displayCategory: text(item.display_category, `${label}.display`),
|
||||
origin: item.origin === "manual" ? "manual" : "frozen_candidate_seed",
|
||||
boxXyxy: box(item.box_xyxy, `${label}.box`),
|
||||
occluded: bool(item.occluded, `${label}.occluded`),
|
||||
truncated: bool(item.truncated, `${label}.truncated`),
|
||||
verdict: verdict as L34AAuditAnnotation["verdict"],
|
||||
matchedPredictionIndex: item.matched_prediction_index === null
|
||||
? null
|
||||
: integer(item.matched_prediction_index, `${label}.match`),
|
||||
matchIou: item.match_iou === null
|
||||
? null
|
||||
: ratio(item.match_iou, `${label}.iou`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseOperation(raw: unknown): L34DOperation {
|
||||
const item = object(raw, "L3.4D.operation");
|
||||
const type = operationType(item.operation_type, "L3.4D.operation.type");
|
||||
return {
|
||||
operationType: type,
|
||||
category: text(item.category, "L3.4D.operation.category"),
|
||||
sourcePredictionIndices: Array.isArray(item.source_prediction_indices)
|
||||
? item.source_prediction_indices.map((entry) => integer(entry, "L3.4D.operation.source"))
|
||||
: [],
|
||||
sourceTiles: Array.isArray(item.source_tiles)
|
||||
? item.source_tiles.map((entry) => tile(entry, "L3.4D.operation.tile"))
|
||||
: [],
|
||||
sourceScores: Array.isArray(item.source_scores)
|
||||
? item.source_scores.map((entry) => ratio(entry, "L3.4D.operation.score"))
|
||||
: [],
|
||||
sourceBoxesXyxy: Array.isArray(item.source_boxes_xyxy)
|
||||
? item.source_boxes_xyxy.map((entry) => box(entry, "L3.4D.operation.box"))
|
||||
: [],
|
||||
mergedScore: ratio(item.merged_score, "L3.4D.operation.merged score"),
|
||||
mergedBoxXyxy: box(item.merged_box_xyxy, "L3.4D.operation.merged box"),
|
||||
temporalRunId: type === "temporal-tile-seam-stitch"
|
||||
? text(item.temporal_run_id, "L3.4D.operation.run")
|
||||
: null,
|
||||
temporalRunLength: type === "temporal-tile-seam-stitch"
|
||||
? integer(item.temporal_run_length, "L3.4D.operation.run length")
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): L34DResult {
|
||||
const item = object(value, "L3.4D");
|
||||
const resultId = text(item.result_id, "L3.4D.result_id");
|
||||
if (!/^l34d-cumulative-postprocessing-candidate-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new L34DContractError("L3.4D.result_id: нарушена идентичность.");
|
||||
}
|
||||
const profile = object(item.profile, "L3.4D.profile");
|
||||
const rawMetrics = object(item.metrics, "L3.4D.metrics");
|
||||
const rawDecision = object(item.decision, "L3.4D.decision");
|
||||
const cases = Array.isArray(item.cases)
|
||||
? item.cases.map((raw): L34DCaseSummary => {
|
||||
const current = object(raw, "L3.4D.case summary");
|
||||
return {
|
||||
truthIslandSequence: integer(current.truth_island_sequence, "L3.4D.case.sequence"),
|
||||
imageId: integer(current.image_id, "L3.4D.case.image"),
|
||||
frameIndex: integer(current.frame_index, "L3.4D.case.frame"),
|
||||
groupId: text(current.group_id, "L3.4D.case.group"),
|
||||
sessionSeconds: finite(current.session_seconds, "L3.4D.case.time"),
|
||||
sourceImageSha256: text(current.source_image_sha256, "L3.4D.case.source"),
|
||||
beforeSummary: summary(current.before_summary, "L3.4D.case.before"),
|
||||
afterSummary: summary(current.after_summary, "L3.4D.case.after"),
|
||||
operationCount: integer(current.operation_count, "L3.4D.case.operations"),
|
||||
operationTypes: Array.isArray(current.operation_types)
|
||||
? current.operation_types.map((entry) => operationType(entry, "L3.4D.case.operation"))
|
||||
: [],
|
||||
};
|
||||
})
|
||||
: [];
|
||||
if (cases.length !== 32 || !Array.isArray(item.case_order)
|
||||
|| item.case_order.length !== 32) {
|
||||
throw new L34DContractError("L3.4D: требуется 32 кадра.");
|
||||
}
|
||||
const delta = object(rawMetrics.delta, "L3.4D.metrics.delta");
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: text(item.created_at_utc, "L3.4D.created_at"),
|
||||
status: exact(
|
||||
item.status,
|
||||
"completed-cumulative-postprocessing-candidate-freeze-not-truth",
|
||||
"L3.4D.status",
|
||||
),
|
||||
profile: {
|
||||
profileId: text(profile.profile_id, "L3.4D.profile.id"),
|
||||
operationOrder: text(profile.operation_order, "L3.4D.profile.order"),
|
||||
conflictPolicy: text(profile.conflict_policy, "L3.4D.profile.conflict"),
|
||||
geometryPolicy: text(profile.geometry_policy, "L3.4D.profile.geometry"),
|
||||
scorePolicy: text(profile.score_policy, "L3.4D.profile.score"),
|
||||
scope: text(profile.scope, "L3.4D.profile.scope"),
|
||||
},
|
||||
pipelineId: text(item.pipeline_id, "L3.4D.pipeline"),
|
||||
sourceSessionId: text(item.source_session_id, "L3.4D.session"),
|
||||
cameraSourceId: exact(item.camera_source_id, "sensor.camera.right", "L3.4D.camera"),
|
||||
metrics: {
|
||||
before: metrics(rawMetrics.before, "L3.4D.before"),
|
||||
after: metrics(rawMetrics.after, "L3.4D.after"),
|
||||
delta: Object.fromEntries(
|
||||
Object.entries(delta).map(([key, raw]) => [key, finite(raw, `L3.4D.delta.${key}`)]),
|
||||
),
|
||||
nestedConsolidationCount: integer(rawMetrics.nested_consolidation_count, "L3.4D.nested"),
|
||||
temporalStitchCount: integer(rawMetrics.temporal_stitch_count, "L3.4D.stitches"),
|
||||
cumulativeOperationCount: integer(rawMetrics.cumulative_operation_count, "L3.4D.operations"),
|
||||
affectedCaseCount: integer(rawMetrics.affected_case_count, "L3.4D.affected"),
|
||||
operationConflictCount: integer(rawMetrics.operation_conflict_count, "L3.4D.conflicts"),
|
||||
countEffectsAdditive: bool(rawMetrics.count_effects_additive, "L3.4D.additive"),
|
||||
assistedRegressionFree: bool(rawMetrics.assisted_regression_free, "L3.4D.regression"),
|
||||
},
|
||||
cases,
|
||||
caseOrder: item.case_order.map((entry) => integer(entry, "L3.4D.case_order")),
|
||||
decision: {
|
||||
cumulativeShadowAccepted: bool(rawDecision.cumulative_shadow_accepted, "L3.4D.shadow"),
|
||||
candidateFrozen: bool(rawDecision.candidate_frozen, "L3.4D.frozen"),
|
||||
candidateAccepted: bool(rawDecision.candidate_accepted, "L3.4D.accepted"),
|
||||
predictionProvenancePreserved: bool(rawDecision.prediction_provenance_preserved, "L3.4D.provenance"),
|
||||
operationSetsDisjoint: bool(rawDecision.operation_sets_disjoint, "L3.4D.disjoint"),
|
||||
globalNmsUnchanged: bool(rawDecision.global_nms_unchanged, "L3.4D.NMS"),
|
||||
independentTruthAvailable: bool(rawDecision.independent_truth_available, "L3.4D.truth available"),
|
||||
l35BlindGateOpen: bool(rawDecision.l35_blind_gate_open, "L3.4D.gate"),
|
||||
nextAction: text(rawDecision.next_action, "L3.4D.next action"),
|
||||
},
|
||||
limitations: Array.isArray(item.limitations)
|
||||
? item.limitations.map((entry) => text(entry, "L3.4D.limitation"))
|
||||
: [],
|
||||
groundTruth: exact(item.ground_truth, false, "L3.4D.truth"),
|
||||
access: exact(item.access, "read-only", "L3.4D.access"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCase(value: unknown, resultId: string, sequence: number): L34DCase {
|
||||
const item = object(value, "L3.4D.case");
|
||||
exact(item.schema_version, "missioncore.l34d-cumulative-postprocessing-case/v1", "L3.4D.case.schema");
|
||||
exact(item.result_id, resultId, "L3.4D.case.result");
|
||||
exact(item.truth_island_sequence, sequence, "L3.4D.case.sequence");
|
||||
const camera = object(item.camera, "L3.4D.case.camera");
|
||||
return {
|
||||
resultId,
|
||||
truthIslandSequence: sequence,
|
||||
imageId: integer(item.image_id, "L3.4D.case.image"),
|
||||
frameIndex: integer(item.frame_index, "L3.4D.case.frame"),
|
||||
groupId: text(item.group_id, "L3.4D.case.group"),
|
||||
sessionSeconds: finite(item.session_seconds, "L3.4D.case.time"),
|
||||
sourceImageSha256: text(item.source_image_sha256, "L3.4D.case.source"),
|
||||
cameraWidth: integer(camera.width, "L3.4D.case.width"),
|
||||
cameraHeight: integer(camera.height, "L3.4D.case.height"),
|
||||
cameraUrl: `/api/v1/laboratory/l34d/results/${resultId}/cases/${sequence}/camera`,
|
||||
beforePredictions: Array.isArray(item.before_predictions)
|
||||
? item.before_predictions.map((raw, index) => parseBeforePrediction(raw, `L3.4D.before.${index}`))
|
||||
: [],
|
||||
afterPredictions: Array.isArray(item.after_predictions)
|
||||
? item.after_predictions.map((raw, index) => parseAfterPrediction(raw, `L3.4D.after.${index}`))
|
||||
: [],
|
||||
annotations: Array.isArray(item.annotations)
|
||||
? item.annotations.map((raw, index) => parseAnnotation(raw, `L3.4D.annotation.${index}`))
|
||||
: [],
|
||||
operations: Array.isArray(item.operations)
|
||||
? item.operations.map(parseOperation)
|
||||
: [],
|
||||
beforeSummary: summary(item.before_summary, "L3.4D.case.before summary"),
|
||||
afterSummary: summary(item.after_summary, "L3.4D.case.after summary"),
|
||||
groundTruth: exact(item.ground_truth, false, "L3.4D.case.truth"),
|
||||
access: exact(item.access, "read-only", "L3.4D.case.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchL34DCumulativePostprocessing({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: L34ALaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<L34DResult> {
|
||||
const response = await fetcher("/api/v1/laboratory/l34d/results?limit=1", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new L34DContractError(`L3.4D недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
const catalog = object(await response.json(), "L3.4D.catalog");
|
||||
exact(
|
||||
catalog.schema_version,
|
||||
"missioncore.l34d-cumulative-postprocessing-catalog/v1",
|
||||
"L3.4D.catalog.schema",
|
||||
);
|
||||
if (!Array.isArray(catalog.items) || catalog.items.length !== 1) {
|
||||
throw new L34DContractError("L3.4D.catalog: ожидался один результат.");
|
||||
}
|
||||
return parseResult(catalog.items[0]);
|
||||
}
|
||||
|
||||
export async function fetchL34DCase(
|
||||
resultId: string,
|
||||
sequence: number,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: L34ALaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<L34DCase> {
|
||||
if (!/^l34d-cumulative-postprocessing-candidate-[a-f0-9]{64}$/.test(resultId)
|
||||
|| !Number.isInteger(sequence) || sequence < 1 || sequence > 32) {
|
||||
throw new L34DContractError("L3.4D.case: неверная идентичность.");
|
||||
}
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/l34d/results/${resultId}/cases/${sequence}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new L34DContractError(`L3.4D.case недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
return parseCase(await response.json(), resultId, sequence);
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
import type { L34ALaboratoryFetch } from "./l34aAssistedYoloxErrorAudit";
|
||||
|
||||
export type L34EDiagnosticVerdict =
|
||||
| "strict_alignment"
|
||||
| "strict_class_mismatch"
|
||||
| "localization_disagreement"
|
||||
| "class_and_localization_disagreement"
|
||||
| "prediction_only"
|
||||
| "reference_only";
|
||||
|
||||
export interface L34EStrictCaseSummary {
|
||||
predictionCount: number;
|
||||
referenceCount: number;
|
||||
truePositive: number;
|
||||
falsePositive: number;
|
||||
falseNegative: number;
|
||||
classMismatch: number;
|
||||
duplicateFalsePositive: number;
|
||||
unmatchedFalsePositive: number;
|
||||
unmatchedFalseNegative: number;
|
||||
severityScore: number;
|
||||
}
|
||||
|
||||
export interface L34EDiagnosticSummary {
|
||||
predictionCount: number;
|
||||
referenceCount: number;
|
||||
associatedPairCount: number;
|
||||
strictAlignment: number;
|
||||
strictClassMismatch: number;
|
||||
localizationDisagreement: number;
|
||||
classAndLocalizationDisagreement: number;
|
||||
predictionOnly: number;
|
||||
referenceOnly: number;
|
||||
candidateAssociationCoverage: number;
|
||||
referenceAssociationCoverage: number;
|
||||
severityScore: number;
|
||||
}
|
||||
|
||||
export interface L34ECaseSummary {
|
||||
truthIslandSequence: number;
|
||||
imageId: number;
|
||||
frameIndex: number;
|
||||
groupId: string;
|
||||
sessionSeconds: number;
|
||||
sourceImageSha256: string;
|
||||
strictSummary: L34EStrictCaseSummary;
|
||||
diagnosticSummary: L34EDiagnosticSummary;
|
||||
}
|
||||
|
||||
export interface L34EPrediction {
|
||||
predictionIndex: number;
|
||||
category: string;
|
||||
score: number;
|
||||
boxXyxy: readonly [number, number, number, number];
|
||||
strictVerdict: string;
|
||||
diagnosticVerdict: L34EDiagnosticVerdict;
|
||||
associatedObjectId: string | null;
|
||||
associationIou: number | null;
|
||||
associationOverlapOverSmaller: number | null;
|
||||
sourcePredictionIndices: readonly number[];
|
||||
sourceRectificationTiles: readonly string[];
|
||||
operationTypes: readonly string[];
|
||||
}
|
||||
|
||||
export interface L34EReference {
|
||||
objectId: string;
|
||||
category: string;
|
||||
displayCategory: string;
|
||||
proposedLabel: string | null;
|
||||
boxXyxy: readonly [number, number, number, number];
|
||||
occluded: boolean;
|
||||
truncated: boolean;
|
||||
strictVerdict: string;
|
||||
diagnosticVerdict: L34EDiagnosticVerdict;
|
||||
associatedPredictionIndex: number | null;
|
||||
associationIou: number | null;
|
||||
associationOverlapOverSmaller: number | null;
|
||||
}
|
||||
|
||||
export interface L34EAssociation {
|
||||
predictionIndex: number;
|
||||
objectId: string;
|
||||
predictionCategory: string;
|
||||
referenceCategory: string;
|
||||
iou: number;
|
||||
overlapOverSmaller: number;
|
||||
classification: Exclude<
|
||||
L34EDiagnosticVerdict,
|
||||
"prediction_only" | "reference_only"
|
||||
>;
|
||||
}
|
||||
|
||||
export interface L34ECase {
|
||||
resultId: string;
|
||||
truthIslandSequence: number;
|
||||
imageId: number;
|
||||
frameIndex: number;
|
||||
groupId: string;
|
||||
sessionSeconds: number;
|
||||
sourceImageSha256: string;
|
||||
cameraWidth: number;
|
||||
cameraHeight: number;
|
||||
cameraUrl: string;
|
||||
predictions: readonly L34EPrediction[];
|
||||
references: readonly L34EReference[];
|
||||
associations: readonly L34EAssociation[];
|
||||
strictSummary: L34EStrictCaseSummary;
|
||||
diagnosticSummary: L34EDiagnosticSummary;
|
||||
groundTruth: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface L34EResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
status: "completed-self-review-diagnostic-not-truth";
|
||||
profile: {
|
||||
profileId: string;
|
||||
strictMatcher: string;
|
||||
diagnosticMatcher: string;
|
||||
classPolicy: string;
|
||||
referencePolicy: string;
|
||||
scope: string;
|
||||
};
|
||||
pipelineId: string;
|
||||
sourceSessionId: string;
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
metrics: {
|
||||
strictIou50: {
|
||||
frameCount: number;
|
||||
predictionCount: number;
|
||||
referenceCount: number;
|
||||
truePositive: number;
|
||||
falsePositive: number;
|
||||
falseNegative: number;
|
||||
classMismatch: number;
|
||||
duplicateFalsePositive: number;
|
||||
unmatchedFalsePositive: number;
|
||||
unmatchedFalseNegative: number;
|
||||
precisionIou50: number;
|
||||
recallIou50: number;
|
||||
f1Iou50: number;
|
||||
errorCaseCount: number;
|
||||
customReferenceCount: number;
|
||||
};
|
||||
diagnosticAssociation: Omit<L34EDiagnosticSummary, "severityScore"> & {
|
||||
errorCaseCount: number;
|
||||
};
|
||||
frameCountDisagreement: {
|
||||
candidateSurplusLowerBound: number;
|
||||
referenceSurplusLowerBound: number;
|
||||
equalCountFrameCount: number;
|
||||
};
|
||||
referenceQuality: "not-metric-grade-self-review";
|
||||
};
|
||||
cases: readonly L34ECaseSummary[];
|
||||
caseOrder: readonly number[];
|
||||
decision: {
|
||||
selfReviewComplete: boolean;
|
||||
diagnosticAlignmentAvailable: boolean;
|
||||
metricGradeReferenceAvailable: boolean;
|
||||
independentTruthAvailable: boolean;
|
||||
detectorRetuningAuthorized: boolean;
|
||||
candidateAccepted: boolean;
|
||||
l35BlindGateOpen: boolean;
|
||||
nextAction: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
groundTruth: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export class L34EContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "L34EContractError";
|
||||
}
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new L34EContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new L34EContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact<T>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) {
|
||||
throw new L34EContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function finite(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new L34EContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = finite(value, label);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new L34EContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function ratio(value: unknown, label: string): number {
|
||||
const parsed = finite(value, label);
|
||||
if (parsed < 0 || parsed > 1) {
|
||||
throw new L34EContractError(`${label}: нарушен диапазон.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function bool(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new L34EContractError(`${label}: ожидалось логическое значение.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function box(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): readonly [number, number, number, number] {
|
||||
if (!Array.isArray(value) || value.length !== 4) {
|
||||
throw new L34EContractError(`${label}: ожидалась рамка.`);
|
||||
}
|
||||
const parsed = value.map((entry) => finite(entry, label));
|
||||
if (!(0 <= parsed[0] && parsed[0] < parsed[2] && parsed[2] <= 800
|
||||
&& 0 <= parsed[1] && parsed[1] < parsed[3] && parsed[3] <= 600)) {
|
||||
throw new L34EContractError(`${label}: нарушена геометрия.`);
|
||||
}
|
||||
return parsed as [number, number, number, number];
|
||||
}
|
||||
|
||||
const DIAGNOSTIC_VERDICTS = new Set<L34EDiagnosticVerdict>([
|
||||
"strict_alignment",
|
||||
"strict_class_mismatch",
|
||||
"localization_disagreement",
|
||||
"class_and_localization_disagreement",
|
||||
"prediction_only",
|
||||
"reference_only",
|
||||
]);
|
||||
|
||||
function diagnosticVerdict(value: unknown, label: string): L34EDiagnosticVerdict {
|
||||
const parsed = text(value, label) as L34EDiagnosticVerdict;
|
||||
if (!DIAGNOSTIC_VERDICTS.has(parsed)) {
|
||||
throw new L34EContractError(`${label}: неизвестный verdict.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function nullableText(value: unknown, label: string): string | null {
|
||||
return value === null ? null : text(value, label);
|
||||
}
|
||||
|
||||
function nullableRatio(value: unknown, label: string): number | null {
|
||||
return value === null ? null : ratio(value, label);
|
||||
}
|
||||
|
||||
function strictSummary(value: unknown, label: string): L34EStrictCaseSummary {
|
||||
const item = object(value, label);
|
||||
return {
|
||||
predictionCount: integer(item.prediction_count, `${label}.prediction_count`),
|
||||
referenceCount: integer(item.reference_count, `${label}.reference_count`),
|
||||
truePositive: integer(item.true_positive, `${label}.true_positive`),
|
||||
falsePositive: integer(item.false_positive, `${label}.false_positive`),
|
||||
falseNegative: integer(item.false_negative, `${label}.false_negative`),
|
||||
classMismatch: integer(item.class_mismatch, `${label}.class_mismatch`),
|
||||
duplicateFalsePositive: integer(item.duplicate_false_positive, `${label}.duplicate_false_positive`),
|
||||
unmatchedFalsePositive: integer(item.unmatched_false_positive, `${label}.unmatched_false_positive`),
|
||||
unmatchedFalseNegative: integer(item.unmatched_false_negative, `${label}.unmatched_false_negative`),
|
||||
severityScore: integer(item.severity_score, `${label}.severity_score`),
|
||||
};
|
||||
}
|
||||
|
||||
function diagnosticSummary(value: unknown, label: string): L34EDiagnosticSummary {
|
||||
const item = object(value, label);
|
||||
return {
|
||||
predictionCount: integer(item.prediction_count, `${label}.prediction_count`),
|
||||
referenceCount: integer(item.reference_count, `${label}.reference_count`),
|
||||
associatedPairCount: integer(item.associated_pair_count, `${label}.pairs`),
|
||||
strictAlignment: integer(item.strict_alignment, `${label}.aligned`),
|
||||
strictClassMismatch: integer(item.strict_class_mismatch, `${label}.class`),
|
||||
localizationDisagreement: integer(item.localization_disagreement, `${label}.localization`),
|
||||
classAndLocalizationDisagreement: integer(item.class_and_localization_disagreement, `${label}.class_localization`),
|
||||
predictionOnly: integer(item.prediction_only, `${label}.prediction_only`),
|
||||
referenceOnly: integer(item.reference_only, `${label}.reference_only`),
|
||||
candidateAssociationCoverage: ratio(item.candidate_association_coverage, `${label}.candidate_coverage`),
|
||||
referenceAssociationCoverage: ratio(item.reference_association_coverage, `${label}.reference_coverage`),
|
||||
severityScore: integer(item.severity_score, `${label}.severity`),
|
||||
};
|
||||
}
|
||||
|
||||
function aggregateStrict(value: unknown, label: string) {
|
||||
const item = object(value, label);
|
||||
return {
|
||||
frameCount: integer(item.frame_count, `${label}.frames`),
|
||||
predictionCount: integer(item.prediction_count, `${label}.predictions`),
|
||||
referenceCount: integer(item.reference_count, `${label}.references`),
|
||||
truePositive: integer(item.true_positive, `${label}.tp`),
|
||||
falsePositive: integer(item.false_positive, `${label}.fp`),
|
||||
falseNegative: integer(item.false_negative, `${label}.fn`),
|
||||
classMismatch: integer(item.class_mismatch, `${label}.class`),
|
||||
duplicateFalsePositive: integer(item.duplicate_false_positive, `${label}.duplicate`),
|
||||
unmatchedFalsePositive: integer(item.unmatched_false_positive, `${label}.unmatched_fp`),
|
||||
unmatchedFalseNegative: integer(item.unmatched_false_negative, `${label}.unmatched_fn`),
|
||||
precisionIou50: ratio(item.precision_iou50, `${label}.precision`),
|
||||
recallIou50: ratio(item.recall_iou50, `${label}.recall`),
|
||||
f1Iou50: ratio(item.f1_iou50, `${label}.f1`),
|
||||
errorCaseCount: integer(item.error_case_count, `${label}.error_cases`),
|
||||
customReferenceCount: integer(item.custom_reference_count, `${label}.custom`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): L34EResult {
|
||||
const item = object(value, "L3.4E");
|
||||
const resultId = text(item.result_id, "L3.4E.result_id");
|
||||
if (!/^l34e-self-review-diagnostic-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new L34EContractError("L3.4E.result_id: нарушена идентичность.");
|
||||
}
|
||||
const profile = object(item.profile, "L3.4E.profile");
|
||||
const metrics = object(item.metrics, "L3.4E.metrics");
|
||||
const diagnostic = object(metrics.diagnostic_association, "L3.4E.diagnostic");
|
||||
const counts = object(metrics.frame_count_disagreement, "L3.4E.counts");
|
||||
const decision = object(item.decision, "L3.4E.decision");
|
||||
const cases = Array.isArray(item.cases)
|
||||
? item.cases.map((raw): L34ECaseSummary => {
|
||||
const current = object(raw, "L3.4E.case summary");
|
||||
return {
|
||||
truthIslandSequence: integer(current.truth_island_sequence, "L3.4E.case.sequence"),
|
||||
imageId: integer(current.image_id, "L3.4E.case.image"),
|
||||
frameIndex: integer(current.frame_index, "L3.4E.case.frame"),
|
||||
groupId: text(current.group_id, "L3.4E.case.group"),
|
||||
sessionSeconds: finite(current.session_seconds, "L3.4E.case.time"),
|
||||
sourceImageSha256: text(current.source_image_sha256, "L3.4E.case.source"),
|
||||
strictSummary: strictSummary(current.strict_summary, "L3.4E.case.strict"),
|
||||
diagnosticSummary: diagnosticSummary(current.diagnostic_summary, "L3.4E.case.diagnostic"),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
if (cases.length !== 32 || !Array.isArray(item.case_order)
|
||||
|| item.case_order.length !== 32) {
|
||||
throw new L34EContractError("L3.4E: требуется 32 кадра.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: text(item.created_at_utc, "L3.4E.created_at"),
|
||||
status: exact(item.status, "completed-self-review-diagnostic-not-truth", "L3.4E.status"),
|
||||
profile: {
|
||||
profileId: text(profile.profile_id, "L3.4E.profile.id"),
|
||||
strictMatcher: text(profile.strict_matcher, "L3.4E.profile.strict"),
|
||||
diagnosticMatcher: text(profile.diagnostic_matcher, "L3.4E.profile.diagnostic"),
|
||||
classPolicy: text(profile.class_policy, "L3.4E.profile.class"),
|
||||
referencePolicy: text(profile.reference_policy, "L3.4E.profile.reference"),
|
||||
scope: text(profile.scope, "L3.4E.profile.scope"),
|
||||
},
|
||||
pipelineId: text(item.pipeline_id, "L3.4E.pipeline"),
|
||||
sourceSessionId: text(item.source_session_id, "L3.4E.session"),
|
||||
cameraSourceId: exact(item.camera_source_id, "sensor.camera.right", "L3.4E.camera"),
|
||||
metrics: {
|
||||
strictIou50: aggregateStrict(metrics.strict_iou50, "L3.4E.strict"),
|
||||
diagnosticAssociation: {
|
||||
predictionCount: integer(diagnostic.prediction_count, "L3.4E.diagnostic.predictions"),
|
||||
referenceCount: integer(diagnostic.reference_count, "L3.4E.diagnostic.references"),
|
||||
associatedPairCount: integer(diagnostic.associated_pair_count, "L3.4E.diagnostic.pairs"),
|
||||
strictAlignment: integer(diagnostic.strict_alignment, "L3.4E.diagnostic.aligned"),
|
||||
strictClassMismatch: integer(diagnostic.strict_class_mismatch, "L3.4E.diagnostic.class"),
|
||||
localizationDisagreement: integer(diagnostic.localization_disagreement, "L3.4E.diagnostic.localization"),
|
||||
classAndLocalizationDisagreement: integer(diagnostic.class_and_localization_disagreement, "L3.4E.diagnostic.class_localization"),
|
||||
predictionOnly: integer(diagnostic.prediction_only, "L3.4E.diagnostic.prediction_only"),
|
||||
referenceOnly: integer(diagnostic.reference_only, "L3.4E.diagnostic.reference_only"),
|
||||
candidateAssociationCoverage: ratio(diagnostic.candidate_association_coverage, "L3.4E.diagnostic.candidate_coverage"),
|
||||
referenceAssociationCoverage: ratio(diagnostic.reference_association_coverage, "L3.4E.diagnostic.reference_coverage"),
|
||||
errorCaseCount: integer(diagnostic.error_case_count, "L3.4E.diagnostic.error_cases"),
|
||||
},
|
||||
frameCountDisagreement: {
|
||||
candidateSurplusLowerBound: integer(counts.candidate_surplus_lower_bound, "L3.4E.counts.candidate"),
|
||||
referenceSurplusLowerBound: integer(counts.reference_surplus_lower_bound, "L3.4E.counts.reference"),
|
||||
equalCountFrameCount: integer(counts.equal_count_frame_count, "L3.4E.counts.equal"),
|
||||
},
|
||||
referenceQuality: exact(metrics.reference_quality, "not-metric-grade-self-review", "L3.4E.reference quality"),
|
||||
},
|
||||
cases,
|
||||
caseOrder: item.case_order.map((entry) => integer(entry, "L3.4E.case_order")),
|
||||
decision: {
|
||||
selfReviewComplete: bool(decision.self_review_complete, "L3.4E.decision.review"),
|
||||
diagnosticAlignmentAvailable: bool(decision.diagnostic_alignment_available, "L3.4E.decision.alignment"),
|
||||
metricGradeReferenceAvailable: bool(decision.metric_grade_reference_available, "L3.4E.decision.metric"),
|
||||
independentTruthAvailable: bool(decision.independent_truth_available, "L3.4E.decision.truth"),
|
||||
detectorRetuningAuthorized: bool(decision.detector_retuning_authorized, "L3.4E.decision.retuning"),
|
||||
candidateAccepted: bool(decision.candidate_accepted, "L3.4E.decision.accepted"),
|
||||
l35BlindGateOpen: bool(decision.l35_blind_gate_open, "L3.4E.decision.gate"),
|
||||
nextAction: text(decision.next_action, "L3.4E.decision.next"),
|
||||
},
|
||||
limitations: Array.isArray(item.limitations)
|
||||
? item.limitations.map((entry) => text(entry, "L3.4E.limitation"))
|
||||
: [],
|
||||
groundTruth: exact(item.ground_truth, false, "L3.4E.truth"),
|
||||
access: exact(item.access, "read-only", "L3.4E.access"),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePrediction(raw: unknown, label: string): L34EPrediction {
|
||||
const item = object(raw, label);
|
||||
return {
|
||||
predictionIndex: integer(item.prediction_index, `${label}.index`),
|
||||
category: text(item.category, `${label}.category`),
|
||||
score: ratio(item.score, `${label}.score`),
|
||||
boxXyxy: box(item.box_xyxy, `${label}.box`),
|
||||
strictVerdict: text(item.verdict, `${label}.strict`),
|
||||
diagnosticVerdict: diagnosticVerdict(item.diagnostic_verdict, `${label}.diagnostic`),
|
||||
associatedObjectId: nullableText(item.associated_object_id, `${label}.object`),
|
||||
associationIou: nullableRatio(item.association_iou, `${label}.iou`),
|
||||
associationOverlapOverSmaller: nullableRatio(item.association_overlap_over_smaller, `${label}.overlap`),
|
||||
sourcePredictionIndices: Array.isArray(item.source_prediction_indices)
|
||||
? item.source_prediction_indices.map((entry) => integer(entry, `${label}.source`))
|
||||
: [],
|
||||
sourceRectificationTiles: Array.isArray(item.source_rectification_tiles)
|
||||
? item.source_rectification_tiles.map((entry) => text(entry, `${label}.tile`))
|
||||
: [],
|
||||
operationTypes: Array.isArray(item.operation_types)
|
||||
? item.operation_types.map((entry) => text(entry, `${label}.operation`))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function parseReference(raw: unknown, label: string): L34EReference {
|
||||
const item = object(raw, label);
|
||||
return {
|
||||
objectId: text(item.object_id, `${label}.id`),
|
||||
category: text(item.category, `${label}.category`),
|
||||
displayCategory: text(item.display_category, `${label}.display`),
|
||||
proposedLabel: nullableText(item.proposed_label, `${label}.proposed`),
|
||||
boxXyxy: box(item.box_xyxy, `${label}.box`),
|
||||
occluded: bool(item.occluded, `${label}.occluded`),
|
||||
truncated: bool(item.truncated, `${label}.truncated`),
|
||||
strictVerdict: text(item.verdict, `${label}.strict`),
|
||||
diagnosticVerdict: diagnosticVerdict(item.diagnostic_verdict, `${label}.diagnostic`),
|
||||
associatedPredictionIndex: item.associated_prediction_index === null
|
||||
? null
|
||||
: integer(item.associated_prediction_index, `${label}.prediction`),
|
||||
associationIou: nullableRatio(item.association_iou, `${label}.iou`),
|
||||
associationOverlapOverSmaller: nullableRatio(item.association_overlap_over_smaller, `${label}.overlap`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAssociation(raw: unknown, label: string): L34EAssociation {
|
||||
const item = object(raw, label);
|
||||
const classification = diagnosticVerdict(item.classification, `${label}.class`);
|
||||
if (classification === "prediction_only" || classification === "reference_only") {
|
||||
throw new L34EContractError(`${label}: ожидалась парная ассоциация.`);
|
||||
}
|
||||
return {
|
||||
predictionIndex: integer(item.prediction_index, `${label}.prediction`),
|
||||
objectId: text(item.object_id, `${label}.object`),
|
||||
predictionCategory: text(item.prediction_category, `${label}.prediction_category`),
|
||||
referenceCategory: text(item.reference_category, `${label}.reference_category`),
|
||||
iou: ratio(item.iou, `${label}.iou`),
|
||||
overlapOverSmaller: ratio(item.overlap_over_smaller, `${label}.overlap`),
|
||||
classification,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCase(value: unknown, resultId: string, sequence: number): L34ECase {
|
||||
const item = object(value, "L3.4E.case");
|
||||
exact(item.schema_version, "missioncore.l34e-self-review-diagnostic-case/v1", "L3.4E.case.schema");
|
||||
exact(item.result_id, resultId, "L3.4E.case.result");
|
||||
exact(item.truth_island_sequence, sequence, "L3.4E.case.sequence");
|
||||
const camera = object(item.camera, "L3.4E.case.camera");
|
||||
return {
|
||||
resultId,
|
||||
truthIslandSequence: sequence,
|
||||
imageId: integer(item.image_id, "L3.4E.case.image"),
|
||||
frameIndex: integer(item.frame_index, "L3.4E.case.frame"),
|
||||
groupId: text(item.group_id, "L3.4E.case.group"),
|
||||
sessionSeconds: finite(item.session_seconds, "L3.4E.case.time"),
|
||||
sourceImageSha256: text(item.source_image_sha256, "L3.4E.case.source"),
|
||||
cameraWidth: integer(camera.width, "L3.4E.case.width"),
|
||||
cameraHeight: integer(camera.height, "L3.4E.case.height"),
|
||||
cameraUrl: `/api/v1/laboratory/l34e/results/${resultId}/cases/${sequence}/camera`,
|
||||
predictions: Array.isArray(item.predictions)
|
||||
? item.predictions.map((raw, index) => parsePrediction(raw, `L3.4E.prediction.${index}`))
|
||||
: [],
|
||||
references: Array.isArray(item.references)
|
||||
? item.references.map((raw, index) => parseReference(raw, `L3.4E.reference.${index}`))
|
||||
: [],
|
||||
associations: Array.isArray(item.associations)
|
||||
? item.associations.map((raw, index) => parseAssociation(raw, `L3.4E.association.${index}`))
|
||||
: [],
|
||||
strictSummary: strictSummary(item.strict_summary, "L3.4E.case.strict"),
|
||||
diagnosticSummary: diagnosticSummary(item.diagnostic_summary, "L3.4E.case.diagnostic"),
|
||||
groundTruth: exact(item.ground_truth, false, "L3.4E.case.truth"),
|
||||
access: exact(item.access, "read-only", "L3.4E.case.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchL34ESelfReviewDiagnostic({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: L34ALaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<L34EResult> {
|
||||
const response = await fetcher("/api/v1/laboratory/l34e/results?limit=1", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new L34EContractError(`L3.4E недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
const catalog = object(await response.json(), "L3.4E.catalog");
|
||||
exact(catalog.schema_version, "missioncore.l34e-self-review-diagnostic-catalog/v1", "L3.4E.catalog.schema");
|
||||
if (!Array.isArray(catalog.items) || catalog.items.length !== 1) {
|
||||
throw new L34EContractError("L3.4E.catalog: ожидался один результат.");
|
||||
}
|
||||
return parseResult(catalog.items[0]);
|
||||
}
|
||||
|
||||
export async function fetchL34ECase(
|
||||
resultId: string,
|
||||
sequence: number,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: L34ALaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<L34ECase> {
|
||||
if (!/^l34e-self-review-diagnostic-[a-f0-9]{64}$/.test(resultId)
|
||||
|| !Number.isInteger(sequence) || sequence < 1 || sequence > 32) {
|
||||
throw new L34EContractError("L3.4E.case: неверная идентичность.");
|
||||
}
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/l34e/results/${resultId}/cases/${sequence}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new L34EContractError(`L3.4E.case недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
return parseCase(await response.json(), resultId, sequence);
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
import {
|
||||
annotationOperationKey,
|
||||
type L34AnnotationCategory,
|
||||
} from "./l34Annotation";
|
||||
import type { L34EDiagnosticVerdict } from "./l34eSelfReviewDiagnostic";
|
||||
|
||||
export interface L34FCandidateObject {
|
||||
candidateId: string;
|
||||
predictionIndex: number;
|
||||
category: string;
|
||||
boxXyxy: readonly [number, number, number, number];
|
||||
diagnosticVerdict: L34EDiagnosticVerdict;
|
||||
associatedObjectId: string | null;
|
||||
}
|
||||
|
||||
export interface L34FCase {
|
||||
diagnosticResultId: string;
|
||||
truthIslandSequence: number;
|
||||
imageId: number;
|
||||
frameIndex: number;
|
||||
groupId: string;
|
||||
sessionSeconds: number;
|
||||
sourceImageSha256: string;
|
||||
cameraWidth: number;
|
||||
cameraHeight: number;
|
||||
cameraUrl: string;
|
||||
candidateObjects: readonly L34FCandidateObject[];
|
||||
severityScore: number;
|
||||
modelScoresIncluded: false;
|
||||
groundTruth: false;
|
||||
}
|
||||
|
||||
export interface L34FObject {
|
||||
objectId: string;
|
||||
category: L34AnnotationCategory;
|
||||
proposedLabel: string | null;
|
||||
origin: "self_review_seed" | "adjudicated_manual";
|
||||
boxXyxy: readonly [number, number, number, number];
|
||||
occluded: boolean;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface L34FFrame {
|
||||
truthIslandSequence: number;
|
||||
imageId: number;
|
||||
frameIndex: number;
|
||||
sourceSha256: string;
|
||||
reviewed: boolean;
|
||||
objects: readonly L34FObject[];
|
||||
}
|
||||
|
||||
export interface L34FProgress {
|
||||
reviewedFrameCount: number;
|
||||
frameCount: 32;
|
||||
objectCount: number;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export interface L34FSessionSummary {
|
||||
sessionId: string;
|
||||
diagnosticResultId: string;
|
||||
title: string;
|
||||
reviewerSlot: number;
|
||||
revision: number;
|
||||
state: "draft" | "saved";
|
||||
createdAtUtc: string;
|
||||
updatedAtUtc: string;
|
||||
progress: L34FProgress;
|
||||
}
|
||||
|
||||
export interface L34FSession extends L34FSessionSummary {
|
||||
frames: readonly L34FFrame[];
|
||||
}
|
||||
|
||||
export interface L34FFrozenResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
sourceSessionId: "RAVNOVES00";
|
||||
diagnosticResultId: string;
|
||||
adjudicationSessionId: string;
|
||||
status: "completed-candidate-visible-adjudication-not-truth";
|
||||
metrics: {
|
||||
frameCount: number;
|
||||
sourceReferenceCount: number;
|
||||
adjudicatedReferenceCount: number;
|
||||
unchangedObjectCount: number;
|
||||
geometryChangedObjectCount: number;
|
||||
classChangedObjectCount: number;
|
||||
attributeChangedObjectCount: number;
|
||||
addedObjectCount: number;
|
||||
deletedObjectCount: number;
|
||||
changedFrameCount: number;
|
||||
};
|
||||
decision: {
|
||||
adjudicationComplete: boolean;
|
||||
engineeringReferenceAvailable: boolean;
|
||||
metricGradeReferenceAvailable: boolean;
|
||||
independentTruthAvailable: boolean;
|
||||
candidateAccepted: boolean;
|
||||
detectorRetuningAuthorized: boolean;
|
||||
l35BlindGateOpen: boolean;
|
||||
nextAction: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
groundTruth: false;
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export class L34FContractError extends Error {}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new L34FContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function list(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) throw new L34FContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new L34FContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
if (!Number.isInteger(value) || Number(value) < 0) {
|
||||
throw new L34FContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function finite(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new L34FContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") throw new L34FContractError(`${label}: ожидался boolean.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact<T>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) throw new L34FContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function box(value: unknown, label: string): readonly [number, number, number, number] {
|
||||
if (!Array.isArray(value) || value.length !== 4) {
|
||||
throw new L34FContractError(`${label}: ожидалась рамка.`);
|
||||
}
|
||||
const parsed = value.map((entry) => finite(entry, label));
|
||||
if (!(0 <= parsed[0] && parsed[0] < parsed[2] && parsed[2] <= 800
|
||||
&& 0 <= parsed[1] && parsed[1] < parsed[3] && parsed[3] <= 600)) {
|
||||
throw new L34FContractError(`${label}: нарушена геометрия.`);
|
||||
}
|
||||
return parsed as [number, number, number, number];
|
||||
}
|
||||
|
||||
function category(value: unknown, label: string): L34AnnotationCategory {
|
||||
if (![
|
||||
"person", "bicycle", "motorcycle", "car", "heavy_vehicle",
|
||||
"static_obstacle", "animal", "unmapped",
|
||||
].includes(String(value))) {
|
||||
throw new L34FContractError(`${label}: неизвестный класс.`);
|
||||
}
|
||||
return value as L34AnnotationCategory;
|
||||
}
|
||||
|
||||
function verdict(value: unknown, label: string): L34EDiagnosticVerdict {
|
||||
if (![
|
||||
"strict_alignment", "strict_class_mismatch", "localization_disagreement",
|
||||
"class_and_localization_disagreement", "prediction_only", "reference_only",
|
||||
].includes(String(value))) {
|
||||
throw new L34FContractError(`${label}: неизвестный verdict.`);
|
||||
}
|
||||
return value as L34EDiagnosticVerdict;
|
||||
}
|
||||
|
||||
function parseObject(value: unknown, label: string): L34FObject {
|
||||
const item = record(value, label);
|
||||
const parsedCategory = category(item.category, `${label}.category`);
|
||||
const proposedLabel = item.proposed_label;
|
||||
if (parsedCategory === "unmapped") text(proposedLabel, `${label}.proposed_label`);
|
||||
if (parsedCategory !== "unmapped" && proposedLabel !== null) {
|
||||
throw new L34FContractError(`${label}: unexpected proposed label.`);
|
||||
}
|
||||
const origin = item.origin;
|
||||
if (origin !== "self_review_seed" && origin !== "adjudicated_manual") {
|
||||
throw new L34FContractError(`${label}: неизвестное происхождение.`);
|
||||
}
|
||||
return {
|
||||
objectId: text(item.object_id, `${label}.id`),
|
||||
category: parsedCategory,
|
||||
proposedLabel: proposedLabel === null ? null : text(proposedLabel, `${label}.label`),
|
||||
origin,
|
||||
boxXyxy: box(item.box_xyxy, `${label}.box`),
|
||||
occluded: boolean(item.occluded, `${label}.occluded`),
|
||||
truncated: boolean(item.truncated, `${label}.truncated`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseProgress(value: unknown): L34FProgress {
|
||||
const item = record(value, "L3.4F.progress");
|
||||
const frameCount = integer(item.frame_count, "L3.4F.frame_count");
|
||||
if (frameCount !== 32) throw new L34FContractError("L3.4F: ожидались 32 кадра.");
|
||||
return {
|
||||
reviewedFrameCount: integer(item.reviewed_frame_count, "L3.4F.reviewed"),
|
||||
frameCount: 32,
|
||||
objectCount: integer(item.object_count, "L3.4F.objects"),
|
||||
complete: boolean(item.complete, "L3.4F.complete"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSummary(value: unknown): L34FSessionSummary {
|
||||
const item = record(value, "L3.4F.session");
|
||||
exact(item.schema_version, "missioncore.l34f-adjudication-session/v1", "L3.4F.schema");
|
||||
exact(item.contract_id, "l34e-candidate-visible-adjudication/v1", "L3.4F.contract");
|
||||
const authority = record(item.authority, "L3.4F.authority");
|
||||
exact(authority.ground_truth, false, "L3.4F.truth");
|
||||
exact(authority.independent_truth, false, "L3.4F.independent");
|
||||
exact(authority.metric_grade_reference, false, "L3.4F.metric_grade");
|
||||
exact(authority.candidate_accepted, false, "L3.4F.accepted");
|
||||
const assistance = record(item.assistance, "L3.4F.assistance");
|
||||
exact(assistance.mode, "candidate-visible-human-adjudication", "L3.4F.mode");
|
||||
exact(assistance.independent_truth_eligible, false, "L3.4F.eligible");
|
||||
const state = item.state;
|
||||
if (state !== "draft" && state !== "saved") {
|
||||
throw new L34FContractError("L3.4F.state: нарушен контракт.");
|
||||
}
|
||||
return {
|
||||
sessionId: text(item.session_id, "L3.4F.session_id"),
|
||||
diagnosticResultId: text(item.diagnostic_result_id, "L3.4F.result_id"),
|
||||
title: text(item.title, "L3.4F.title"),
|
||||
reviewerSlot: integer(item.reviewer_slot, "L3.4F.reviewer"),
|
||||
revision: integer(item.revision, "L3.4F.revision"),
|
||||
state,
|
||||
createdAtUtc: text(item.created_at_utc, "L3.4F.created"),
|
||||
updatedAtUtc: text(item.updated_at_utc, "L3.4F.updated"),
|
||||
progress: parseProgress(item.progress),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSession(value: unknown): L34FSession {
|
||||
const item = record(value, "L3.4F.session");
|
||||
const summary = parseSummary(item);
|
||||
const frames = list(item.frames, "L3.4F.frames").map((raw, index) => {
|
||||
const frame = record(raw, `L3.4F.frame.${index}`);
|
||||
return {
|
||||
truthIslandSequence: integer(frame.truth_island_sequence, `L3.4F.frame.${index}.sequence`),
|
||||
imageId: integer(frame.image_id, `L3.4F.frame.${index}.image`),
|
||||
frameIndex: integer(frame.frame_index, `L3.4F.frame.${index}.frame`),
|
||||
sourceSha256: text(frame.source_sha256, `L3.4F.frame.${index}.source`),
|
||||
reviewed: boolean(frame.reviewed, `L3.4F.frame.${index}.reviewed`),
|
||||
objects: list(frame.objects, `L3.4F.frame.${index}.objects`).map(
|
||||
(object, objectIndex) => parseObject(object, `L3.4F.object.${objectIndex}`),
|
||||
),
|
||||
};
|
||||
});
|
||||
if (frames.length !== 32) throw new L34FContractError("L3.4F: ожидались 32 кадра.");
|
||||
return { ...summary, frames };
|
||||
}
|
||||
|
||||
async function responseJson(response: Response, label: string): Promise<unknown> {
|
||||
if (response.ok) return response.json();
|
||||
let detail = "";
|
||||
try {
|
||||
const payload = record(await response.json(), label);
|
||||
detail = typeof payload.detail === "string" ? `: ${payload.detail}` : "";
|
||||
} catch {
|
||||
detail = "";
|
||||
}
|
||||
throw new L34FContractError(`${label} недоступен: HTTP ${response.status}${detail}.`);
|
||||
}
|
||||
|
||||
function assertDiagnosticId(resultId: string): void {
|
||||
if (!/^l34e-self-review-diagnostic-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new L34FContractError("L3.4F: неверный diagnostic result id.");
|
||||
}
|
||||
}
|
||||
|
||||
function base(resultId: string): string {
|
||||
assertDiagnosticId(resultId);
|
||||
return `/api/v1/laboratory/l34f/diagnostics/${resultId}`;
|
||||
}
|
||||
|
||||
export async function fetchL34FCase(
|
||||
resultId: string,
|
||||
sequence: number,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<L34FCase> {
|
||||
const response = await fetcher(`${base(resultId)}/cases/${sequence}`, {
|
||||
method: "GET", headers: { Accept: "application/json" }, signal,
|
||||
});
|
||||
const item = record(await responseJson(response, "Кадр L3.4F"), "L3.4F.case");
|
||||
exact(item.schema_version, "missioncore.l34f-adjudication-case/v1", "L3.4F.case.schema");
|
||||
exact(item.diagnostic_result_id, resultId, "L3.4F.case.result");
|
||||
exact(item.truth_island_sequence, sequence, "L3.4F.case.sequence");
|
||||
exact(item.model_scores_included, false, "L3.4F.case.scores");
|
||||
exact(item.ground_truth, false, "L3.4F.case.truth");
|
||||
const camera = record(item.camera, "L3.4F.camera");
|
||||
const summary = record(item.diagnostic_summary, "L3.4F.summary");
|
||||
return {
|
||||
diagnosticResultId: resultId,
|
||||
truthIslandSequence: sequence,
|
||||
imageId: integer(item.image_id, "L3.4F.case.image"),
|
||||
frameIndex: integer(item.frame_index, "L3.4F.case.frame"),
|
||||
groupId: text(item.group_id, "L3.4F.case.group"),
|
||||
sessionSeconds: finite(item.session_seconds, "L3.4F.case.time"),
|
||||
sourceImageSha256: text(item.source_image_sha256, "L3.4F.case.source"),
|
||||
cameraWidth: integer(camera.width, "L3.4F.case.width"),
|
||||
cameraHeight: integer(camera.height, "L3.4F.case.height"),
|
||||
cameraUrl: text(item.camera_url, "L3.4F.case.camera_url"),
|
||||
candidateObjects: list(item.candidate_objects, "L3.4F.candidates").map((raw, index) => {
|
||||
const object = record(raw, `L3.4F.candidate.${index}`);
|
||||
return {
|
||||
candidateId: text(object.candidate_id, `L3.4F.candidate.${index}.id`),
|
||||
predictionIndex: integer(object.prediction_index, `L3.4F.candidate.${index}.index`),
|
||||
category: text(object.category, `L3.4F.candidate.${index}.category`),
|
||||
boxXyxy: box(object.box_xyxy, `L3.4F.candidate.${index}.box`),
|
||||
diagnosticVerdict: verdict(object.diagnostic_verdict, `L3.4F.candidate.${index}.verdict`),
|
||||
associatedObjectId: object.associated_object_id === null
|
||||
? null
|
||||
: text(object.associated_object_id, `L3.4F.candidate.${index}.association`),
|
||||
};
|
||||
}),
|
||||
severityScore: integer(summary.severity_score, "L3.4F.case.severity"),
|
||||
modelScoresIncluded: false,
|
||||
groundTruth: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchL34FSessions(
|
||||
resultId: string,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<readonly L34FSessionSummary[]> {
|
||||
const response = await fetcher(`${base(resultId)}/sessions`, {
|
||||
method: "GET", headers: { Accept: "application/json" }, signal,
|
||||
});
|
||||
const catalog = record(await responseJson(response, "Сессии L3.4F"), "L3.4F.catalog");
|
||||
exact(catalog.schema_version, "missioncore.l34f-adjudication-session-catalog/v1", "L3.4F.catalog.schema");
|
||||
exact(catalog.diagnostic_result_id, resultId, "L3.4F.catalog.result");
|
||||
return list(catalog.items, "L3.4F.catalog.items").map(parseSummary);
|
||||
}
|
||||
|
||||
export async function fetchL34FSession(
|
||||
resultId: string,
|
||||
sessionId: string,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<L34FSession> {
|
||||
const response = await fetcher(`${base(resultId)}/sessions/${sessionId}`, {
|
||||
method: "GET", headers: { Accept: "application/json" }, signal,
|
||||
});
|
||||
return parseSession(await responseJson(response, "Сессия L3.4F"));
|
||||
}
|
||||
|
||||
export async function createL34FSession(
|
||||
resultId: string,
|
||||
{ fetcher = fetch }: { fetcher?: LaboratoryFetch } = {},
|
||||
): Promise<L34FSession> {
|
||||
const response = await fetcher(`${base(resultId)}/sessions`, {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ idempotency_key: annotationOperationKey("create-l34f") }),
|
||||
});
|
||||
return parseSession(await responseJson(response, "Создание сессии L3.4F"));
|
||||
}
|
||||
|
||||
export async function saveL34FSession(
|
||||
session: L34FSession,
|
||||
title: string,
|
||||
frames: readonly L34FFrame[],
|
||||
{ fetcher = fetch }: { fetcher?: LaboratoryFetch } = {},
|
||||
): Promise<L34FSession> {
|
||||
const response = await fetcher(
|
||||
`${base(session.diagnosticResultId)}/sessions/${session.sessionId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
expected_revision: session.revision,
|
||||
idempotency_key: annotationOperationKey("save-l34f"),
|
||||
title,
|
||||
frames: frames.map((frame) => ({
|
||||
truth_island_sequence: frame.truthIslandSequence,
|
||||
reviewed: frame.reviewed,
|
||||
objects: frame.objects.map((object) => ({
|
||||
object_id: object.objectId,
|
||||
category: object.category,
|
||||
proposed_label: object.proposedLabel,
|
||||
origin: object.origin,
|
||||
box_xyxy: [...object.boxXyxy],
|
||||
occluded: object.occluded,
|
||||
truncated: object.truncated,
|
||||
})),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
);
|
||||
return parseSession(await responseJson(response, "Сохранение сессии L3.4F"));
|
||||
}
|
||||
|
||||
export async function freezeL34FSession(
|
||||
session: L34FSession,
|
||||
{ fetcher = fetch }: { fetcher?: LaboratoryFetch } = {},
|
||||
): Promise<L34FFrozenResult> {
|
||||
const response = await fetcher(
|
||||
`${base(session.diagnosticResultId)}/sessions/${session.sessionId}/freeze`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ expected_revision: session.revision }),
|
||||
},
|
||||
);
|
||||
return parseFrozenResult(await responseJson(response, "Freeze L3.4F"));
|
||||
}
|
||||
|
||||
export async function fetchL34FFrozenResult({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<L34FFrozenResult> {
|
||||
const response = await fetcher("/api/v1/laboratory/l34f/results?limit=1", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
const catalog = record(await responseJson(response, "L3.4F"), "L3.4F.catalog");
|
||||
exact(
|
||||
catalog.schema_version,
|
||||
"missioncore.l34f-adjudicated-reference-catalog/v1",
|
||||
"L3.4F.catalog.schema",
|
||||
);
|
||||
const items = list(catalog.items, "L3.4F.catalog.items");
|
||||
if (items.length !== 1) {
|
||||
throw new L34FContractError("L3.4F.catalog: ожидался один результат.");
|
||||
}
|
||||
return parseFrozenResult(items[0]);
|
||||
}
|
||||
|
||||
export function parseFrozenResult(value: unknown): L34FFrozenResult {
|
||||
const item = record(value, "L3.4F.result");
|
||||
const metrics = record(item.metrics, "L3.4F.metrics");
|
||||
const decision = record(item.decision, "L3.4F.decision");
|
||||
const resultId = text(item.result_id, "L3.4F.result_id");
|
||||
if (!/^l34f-adjudicated-reference-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new L34FContractError("L3.4F.result_id: нарушена идентичность.");
|
||||
}
|
||||
const diagnosticResultId = text(item.diagnostic_result_id, "L3.4F.diagnostic_result_id");
|
||||
assertDiagnosticId(diagnosticResultId);
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: text(item.created_at_utc, "L3.4F.created_at"),
|
||||
sourceSessionId: exact(item.source_session_id, "RAVNOVES00", "L3.4F.source_session_id"),
|
||||
diagnosticResultId,
|
||||
adjudicationSessionId: text(item.adjudication_session_id, "L3.4F.adjudication_session_id"),
|
||||
status: exact(item.status, "completed-candidate-visible-adjudication-not-truth", "L3.4F.status"),
|
||||
metrics: {
|
||||
frameCount: integer(metrics.frame_count, "L3.4F.frames"),
|
||||
sourceReferenceCount: integer(metrics.source_reference_count, "L3.4F.source_references"),
|
||||
adjudicatedReferenceCount: integer(metrics.adjudicated_reference_count, "L3.4F.references"),
|
||||
unchangedObjectCount: integer(metrics.unchanged_object_count, "L3.4F.unchanged"),
|
||||
geometryChangedObjectCount: integer(metrics.geometry_changed_object_count, "L3.4F.geometry"),
|
||||
classChangedObjectCount: integer(metrics.class_changed_object_count, "L3.4F.class"),
|
||||
attributeChangedObjectCount: integer(metrics.attribute_changed_object_count, "L3.4F.attributes"),
|
||||
addedObjectCount: integer(metrics.added_object_count, "L3.4F.added"),
|
||||
deletedObjectCount: integer(metrics.deleted_object_count, "L3.4F.deleted"),
|
||||
changedFrameCount: integer(metrics.changed_frame_count, "L3.4F.changed_frames"),
|
||||
},
|
||||
decision: {
|
||||
adjudicationComplete: boolean(decision.adjudication_complete, "L3.4F.complete"),
|
||||
engineeringReferenceAvailable: boolean(decision.engineering_reference_available, "L3.4F.engineering"),
|
||||
metricGradeReferenceAvailable: boolean(decision.metric_grade_reference_available, "L3.4F.metric_grade"),
|
||||
independentTruthAvailable: boolean(decision.independent_truth_available, "L3.4F.independent"),
|
||||
candidateAccepted: boolean(decision.candidate_accepted, "L3.4F.accepted"),
|
||||
detectorRetuningAuthorized: boolean(decision.detector_retuning_authorized, "L3.4F.retuning"),
|
||||
l35BlindGateOpen: boolean(decision.l35_blind_gate_open, "L3.4F.l35"),
|
||||
nextAction: text(decision.next_action, "L3.4F.next"),
|
||||
},
|
||||
limitations: list(item.limitations, "L3.4F.limitations").map((raw, index) => text(raw, `L3.4F.limitation.${index}`)),
|
||||
groundTruth: exact(item.ground_truth, false, "L3.4F.truth"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import {
|
||||
AdvancedLaboratoryContractError,
|
||||
type LaboratoryFetch,
|
||||
} from "./advancedResults";
|
||||
|
||||
export interface L3VisualFrameSummary {
|
||||
frameId: string;
|
||||
inferenceMs: number;
|
||||
predictionCount: number;
|
||||
evaluatedPredictionCount: number;
|
||||
outsideSharedRangeCount: number;
|
||||
truthCount: number;
|
||||
truePositiveCount: number;
|
||||
falsePositiveCount: number;
|
||||
falseNegativeCount: number;
|
||||
truthClasses: readonly string[];
|
||||
}
|
||||
|
||||
export interface L3VisualBox {
|
||||
benchmarkClass: string;
|
||||
centerXyzM: readonly [number, number, number];
|
||||
sizeLwhM: readonly [number, number, number];
|
||||
yawRad: number;
|
||||
status:
|
||||
| "matched"
|
||||
| "false-negative"
|
||||
| "true-positive"
|
||||
| "false-positive"
|
||||
| "model-prediction";
|
||||
score: number | null;
|
||||
temporalStatus?: string;
|
||||
}
|
||||
|
||||
export interface L3VisualFrame {
|
||||
frameId: string;
|
||||
summary: L3VisualFrameSummary;
|
||||
sourcePointCount: number;
|
||||
sharedRangePointCount: number;
|
||||
sampledPointCount: number;
|
||||
pointsXyzi: readonly number[];
|
||||
truthBoxes: readonly L3VisualBox[];
|
||||
predictionBoxes: readonly L3VisualBox[];
|
||||
}
|
||||
|
||||
export interface L3PointPillarsVisualAuditResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
status: "operator-visual-review-required";
|
||||
sourceRunId: string;
|
||||
sourceFrameResultsIdentitySha256: string;
|
||||
datasetSourceId: string;
|
||||
datasetReleaseIdentitySha256: string;
|
||||
metrics: {
|
||||
frameCount: number;
|
||||
bevMap40: number;
|
||||
threeDMap40: number;
|
||||
falseOccupiedRate: number;
|
||||
inferenceP95Ms: number;
|
||||
modelOutputBoxCount: number;
|
||||
evaluatedBoxCount: number;
|
||||
outsideSharedRangeCount: number;
|
||||
};
|
||||
frames: readonly L3VisualFrameSummary[];
|
||||
}
|
||||
|
||||
const RESULT_ID = /^l3-pointpillars-visual-audit-[a-f0-9]{64}$/;
|
||||
const RUN_ID = /^l3-pointpillars-kitti-[a-f0-9]{64}$/;
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const FRAME_ID = /^[0-9]{6}$/;
|
||||
|
||||
function objectValue(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function arrayValue(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact(value: unknown, expected: string, label: string): string {
|
||||
if (value !== expected) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string, minimum = 0): number {
|
||||
if (
|
||||
typeof value !== "number"
|
||||
|| !Number.isFinite(value)
|
||||
|| value < minimum
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: неверное число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
const parsed = numberValue(value, label);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалось целое.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function tuple3(
|
||||
value: unknown,
|
||||
label: string,
|
||||
positive = false,
|
||||
): readonly [number, number, number] {
|
||||
const values = arrayValue(value, label);
|
||||
if (values.length !== 3) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалось 3 числа.`);
|
||||
}
|
||||
return [
|
||||
numberValue(values[0], `${label}[0]`, positive ? Number.MIN_VALUE : -Infinity),
|
||||
numberValue(values[1], `${label}[1]`, positive ? Number.MIN_VALUE : -Infinity),
|
||||
numberValue(values[2], `${label}[2]`, positive ? Number.MIN_VALUE : -Infinity),
|
||||
];
|
||||
}
|
||||
|
||||
function parseSummary(value: unknown): L3VisualFrameSummary {
|
||||
const item = objectValue(value, "L3 visual frame");
|
||||
const frameId = stringValue(item.frame_id, "L3 frame_id");
|
||||
if (!FRAME_ID.test(frameId)) {
|
||||
throw new AdvancedLaboratoryContractError("L3 frame_id: неверный формат.");
|
||||
}
|
||||
const truthClasses = arrayValue(item.truth_classes, "L3 truth_classes")
|
||||
.map((entry) => stringValue(entry, "L3 truth class"));
|
||||
if (
|
||||
truthClasses.some(
|
||||
(entry) => !["Car", "Pedestrian", "Cyclist"].includes(entry),
|
||||
)
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError("L3 truth class: неизвестен.");
|
||||
}
|
||||
return {
|
||||
frameId,
|
||||
inferenceMs: numberValue(item.inference_ms, "L3 inference_ms", Number.MIN_VALUE),
|
||||
predictionCount: integerValue(item.prediction_count, "L3 prediction_count"),
|
||||
evaluatedPredictionCount: integerValue(
|
||||
item.evaluated_prediction_count,
|
||||
"L3 evaluated_prediction_count",
|
||||
),
|
||||
outsideSharedRangeCount: integerValue(
|
||||
item.outside_shared_range_count,
|
||||
"L3 outside_shared_range_count",
|
||||
),
|
||||
truthCount: integerValue(item.truth_count, "L3 truth_count"),
|
||||
truePositiveCount: integerValue(
|
||||
item.true_positive_count,
|
||||
"L3 true_positive_count",
|
||||
),
|
||||
falsePositiveCount: integerValue(
|
||||
item.false_positive_count,
|
||||
"L3 false_positive_count",
|
||||
),
|
||||
falseNegativeCount: integerValue(
|
||||
item.false_negative_count,
|
||||
"L3 false_negative_count",
|
||||
),
|
||||
truthClasses,
|
||||
};
|
||||
}
|
||||
|
||||
function parseBox(value: unknown, truth: boolean): L3VisualBox {
|
||||
const box = objectValue(value, "L3 visual box");
|
||||
const benchmarkClass = stringValue(
|
||||
box.benchmark_class,
|
||||
"L3 benchmark_class",
|
||||
);
|
||||
if (!["Car", "Pedestrian", "Cyclist"].includes(benchmarkClass)) {
|
||||
throw new AdvancedLaboratoryContractError("L3 box class: неизвестен.");
|
||||
}
|
||||
const status = stringValue(box.status, "L3 box status");
|
||||
const allowed = truth
|
||||
? ["matched", "false-negative"]
|
||||
: ["true-positive", "false-positive"];
|
||||
if (!allowed.includes(status)) {
|
||||
throw new AdvancedLaboratoryContractError("L3 box status: неизвестен.");
|
||||
}
|
||||
return {
|
||||
benchmarkClass,
|
||||
centerXyzM: tuple3(box.center_xyz_m, "L3 center_xyz_m"),
|
||||
sizeLwhM: tuple3(box.size_lwh_m, "L3 size_lwh_m", true),
|
||||
yawRad: numberValue(box.yaw_rad, "L3 yaw_rad", -Infinity),
|
||||
status: status as L3VisualBox["status"],
|
||||
score: truth ? null : numberValue(box.score, "L3 score"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): L3PointPillarsVisualAuditResult {
|
||||
const result = objectValue(value, "L3 visual result");
|
||||
exact(
|
||||
result.schema_version,
|
||||
"missioncore.l3-pointpillars-visual-audit-result/v1",
|
||||
"L3 result.schema_version",
|
||||
);
|
||||
exact(result.access, "read-only", "L3 result.access");
|
||||
const resultId = stringValue(result.result_id, "L3 result_id");
|
||||
const sourceRunId = stringValue(result.source_run_id, "L3 source_run_id");
|
||||
const frameIdentity = stringValue(
|
||||
result.source_frame_results_identity_sha256,
|
||||
"L3 frame identity",
|
||||
);
|
||||
const datasetIdentity = stringValue(
|
||||
result.dataset_release_identity_sha256,
|
||||
"L3 dataset identity",
|
||||
);
|
||||
if (
|
||||
!RESULT_ID.test(resultId)
|
||||
|| !RUN_ID.test(sourceRunId)
|
||||
|| !SHA256.test(frameIdentity)
|
||||
|| !SHA256.test(datasetIdentity)
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"L3 result: нарушена идентичность.",
|
||||
);
|
||||
}
|
||||
const metrics = objectValue(result.metrics, "L3 metrics");
|
||||
const aggregates = objectValue(metrics.aggregates, "L3 aggregates");
|
||||
const volume = objectValue(
|
||||
aggregates.prediction_volume,
|
||||
"L3 prediction_volume",
|
||||
);
|
||||
const latency = objectValue(
|
||||
aggregates.inference_latency_ms,
|
||||
"L3 latency",
|
||||
);
|
||||
const frames = arrayValue(result.frames, "L3 frames").map(parseSummary);
|
||||
if (
|
||||
!frames.length
|
||||
|| frames.length > 24
|
||||
|| new Set(frames.map(({ frameId }) => frameId)).size !== frames.length
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"L3 frames: нарушен ограниченный каталог.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: stringValue(result.created_at_utc, "L3 created_at_utc"),
|
||||
status: exact(
|
||||
result.status,
|
||||
"operator-visual-review-required",
|
||||
"L3 status",
|
||||
) as "operator-visual-review-required",
|
||||
sourceRunId,
|
||||
sourceFrameResultsIdentitySha256: frameIdentity,
|
||||
datasetSourceId: stringValue(result.dataset_source_id, "L3 dataset_source_id"),
|
||||
datasetReleaseIdentitySha256: datasetIdentity,
|
||||
metrics: {
|
||||
frameCount: integerValue(metrics.frame_count, "L3 frame_count"),
|
||||
bevMap40: numberValue(aggregates.bev_map40, "L3 bev_map40"),
|
||||
threeDMap40: numberValue(aggregates["3d_map40"], "L3 3d_map40"),
|
||||
falseOccupiedRate: numberValue(
|
||||
aggregates.false_occupied_rate,
|
||||
"L3 false_occupied_rate",
|
||||
),
|
||||
inferenceP95Ms: numberValue(latency.p95, "L3 latency.p95"),
|
||||
modelOutputBoxCount: integerValue(
|
||||
volume.model_output_box_count,
|
||||
"L3 model_output_box_count",
|
||||
),
|
||||
evaluatedBoxCount: integerValue(
|
||||
volume.evaluated_box_count,
|
||||
"L3 evaluated_box_count",
|
||||
),
|
||||
outsideSharedRangeCount: integerValue(
|
||||
volume.outside_shared_range_count,
|
||||
"L3 outside_shared_range_count",
|
||||
),
|
||||
},
|
||||
frames,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchL3PointPillarsVisualAudit({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<L3PointPillarsVisualAuditResult | null> {
|
||||
const response = await fetcher(
|
||||
"/api/v1/laboratory/l3/pointpillars-visual-audits/results?limit=1",
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
`L3 visual audit недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const catalog = objectValue(await response.json(), "L3 result catalog");
|
||||
exact(
|
||||
catalog.schema_version,
|
||||
"missioncore.l3-pointpillars-visual-audit-catalog-results/v1",
|
||||
"L3 catalog.schema_version",
|
||||
);
|
||||
exact(catalog.access, "read-only", "L3 catalog.access");
|
||||
const items = arrayValue(catalog.items, "L3 catalog.items");
|
||||
if (items.length > 1) {
|
||||
throw new AdvancedLaboratoryContractError("L3 catalog: лишние результаты.");
|
||||
}
|
||||
return items.length ? parseResult(items[0]) : null;
|
||||
}
|
||||
|
||||
export async function fetchL3PointPillarsVisualFrame(
|
||||
resultId: string,
|
||||
frameId: string,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<L3VisualFrame> {
|
||||
if (!RESULT_ID.test(resultId) || !FRAME_ID.test(frameId)) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"L3 visual frame: неверная идентичность.",
|
||||
);
|
||||
}
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/l3/pointpillars-visual-audits/${resultId}/frames/${frameId}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
`L3 visual frame недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const payload = objectValue(await response.json(), "L3 visual frame");
|
||||
exact(
|
||||
payload.schema_version,
|
||||
"missioncore.l3-pointpillars-visual-frame/v1",
|
||||
"L3 frame.schema_version",
|
||||
);
|
||||
exact(payload.access, "read-only", "L3 frame.access");
|
||||
exact(payload.frame_id, frameId, "L3 frame.frame_id");
|
||||
const points = objectValue(payload.points, "L3 points");
|
||||
exact(points.layout, "flat-xyzi", "L3 points.layout");
|
||||
const pointValues = arrayValue(points.values, "L3 points.values").map(
|
||||
(value, index) => numberValue(value, `L3 points[${index}]`, -Infinity),
|
||||
);
|
||||
const sampledPointCount = integerValue(
|
||||
points.sampled_point_count,
|
||||
"L3 sampled_point_count",
|
||||
);
|
||||
if (sampledPointCount > 12_000 || pointValues.length !== sampledPointCount * 4) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"L3 points: нарушен ограниченный массив.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
frameId,
|
||||
summary: parseSummary(payload.summary),
|
||||
sourcePointCount: integerValue(
|
||||
points.source_point_count,
|
||||
"L3 source_point_count",
|
||||
),
|
||||
sharedRangePointCount: integerValue(
|
||||
points.shared_range_point_count,
|
||||
"L3 shared_range_point_count",
|
||||
),
|
||||
sampledPointCount,
|
||||
pointsXyzi: pointValues,
|
||||
truthBoxes: arrayValue(payload.truth_boxes, "L3 truth_boxes")
|
||||
.map((box) => parseBox(box, true)),
|
||||
predictionBoxes: arrayValue(
|
||||
payload.prediction_boxes,
|
||||
"L3 prediction_boxes",
|
||||
).map((box) => parseBox(box, false)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
fetchLaboratoryEvidenceReport,
|
||||
type JsonValue,
|
||||
} from "./evidenceReport";
|
||||
import {
|
||||
fetchM4ThreatReplayResult,
|
||||
type M4ThreatReplayResult,
|
||||
} from "./m4ReplayThreat";
|
||||
|
||||
export interface M47ReferenceGraphLabResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
graphResultId: string;
|
||||
linkedVisualResultId: string;
|
||||
graphId: "reference-perception-graph/v2";
|
||||
worker: {
|
||||
id: "worker-006";
|
||||
node: string;
|
||||
artifactSha256: string;
|
||||
codeRevision: string;
|
||||
elapsedSeconds: number;
|
||||
};
|
||||
frames: {
|
||||
expected: 4489;
|
||||
admitted: 4489;
|
||||
delivered: 4489;
|
||||
};
|
||||
parityMismatchCounts: Readonly<Record<
|
||||
| "source_binding"
|
||||
| "current"
|
||||
| "rolling_retained"
|
||||
| "held"
|
||||
| "expired"
|
||||
| "camera_uncertainty"
|
||||
| "threat_assessments",
|
||||
0
|
||||
>>;
|
||||
queueHighWatermarks: Readonly<Record<
|
||||
"detector" | "geometry" | "temporal" | "rolling" | "threat",
|
||||
number
|
||||
>>;
|
||||
limitations: readonly string[];
|
||||
visual: M4ThreatReplayResult;
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
class M47ReferenceGraphContractError extends Error {}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new M47ReferenceGraphContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new M47ReferenceGraphContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact<T extends string | number | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) {
|
||||
throw new M47ReferenceGraphContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function finite(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new M47ReferenceGraphContractError(`${label}: ожидалось неотрицательное число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string, length: 40 | 64): string {
|
||||
const parsed = text(value, label);
|
||||
if (!new RegExp(`^[a-f0-9]{${length}}$`).test(parsed)) {
|
||||
throw new M47ReferenceGraphContractError(`${label}: нарушена digest-идентичность.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function zeroParity(value: unknown): M47ReferenceGraphLabResult["parityMismatchCounts"] {
|
||||
const document = object(value, "M4.7 parity");
|
||||
const keys = [
|
||||
"source_binding",
|
||||
"current",
|
||||
"rolling_retained",
|
||||
"held",
|
||||
"expired",
|
||||
"camera_uncertainty",
|
||||
"threat_assessments",
|
||||
] as const;
|
||||
if (
|
||||
Object.keys(document).length !== keys.length
|
||||
|| keys.some((key) => document[key] !== 0)
|
||||
) {
|
||||
throw new M47ReferenceGraphContractError("M4.7 parity: обнаружено расхождение.");
|
||||
}
|
||||
return Object.fromEntries(keys.map((key) => [key, 0])) as unknown as (
|
||||
M47ReferenceGraphLabResult["parityMismatchCounts"]
|
||||
);
|
||||
}
|
||||
|
||||
function queueWatermarks(
|
||||
value: unknown,
|
||||
): M47ReferenceGraphLabResult["queueHighWatermarks"] {
|
||||
const document = object(value, "M4.7 queue watermarks");
|
||||
const keys = ["detector", "geometry", "temporal", "rolling", "threat"] as const;
|
||||
if (Object.keys(document).length !== keys.length) {
|
||||
throw new M47ReferenceGraphContractError("M4.7 queues: состав стадий изменился.");
|
||||
}
|
||||
return Object.fromEntries(keys.map((key) => {
|
||||
const watermark = finite(document[key], `M4.7 queue ${key}`);
|
||||
if (!Number.isInteger(watermark) || watermark > 2) {
|
||||
throw new M47ReferenceGraphContractError(`M4.7 queue ${key}: нарушена граница.`);
|
||||
}
|
||||
return [key, watermark];
|
||||
})) as unknown as M47ReferenceGraphLabResult["queueHighWatermarks"];
|
||||
}
|
||||
|
||||
function stringArray(value: JsonValue | undefined, label: string): readonly string[] {
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item.trim())) {
|
||||
throw new M47ReferenceGraphContractError(`${label}: ожидался список строк.`);
|
||||
}
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
export async function fetchM47ReferenceGraphLab({
|
||||
resultId,
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
resultId: string;
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<M47ReferenceGraphLabResult> {
|
||||
if (!/^m47-reference-graph-lab-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new M47ReferenceGraphContractError("M4.7 result id: нарушена идентичность.");
|
||||
}
|
||||
const evidence = await fetchLaboratoryEvidenceReport({
|
||||
workId: "m47-reference-graph-shadow",
|
||||
resultId,
|
||||
fetcher,
|
||||
signal,
|
||||
});
|
||||
const report = evidence.rawReport;
|
||||
exact(
|
||||
report.schema_version,
|
||||
"missioncore.reference-perception-graph-lab-report/v1",
|
||||
"M4.7 report schema",
|
||||
);
|
||||
exact(report.result_id, resultId, "M4.7 report result");
|
||||
const source = object(report.source, "M4.7 source");
|
||||
const method = object(report.method, "M4.7 method");
|
||||
const execution = object(report.execution, "M4.7 execution");
|
||||
const metrics = object(report.metrics, "M4.7 metrics");
|
||||
const frames = object(metrics.frames, "M4.7 frames");
|
||||
const acceptance = object(report.acceptance, "M4.7 acceptance");
|
||||
const authority = object(report.authority, "M4.7 authority");
|
||||
const visualEvidence = object(report.visual_evidence, "M4.7 visual evidence");
|
||||
exact(acceptance.accepted, true, "M4.7 acceptance");
|
||||
exact(authority.commands_enabled, false, "M4.7 commands");
|
||||
exact(authority.actuation_allowed, false, "M4.7 actuation");
|
||||
exact(authority.navigation_or_safety_accepted, false, "M4.7 safety authority");
|
||||
exact(visualEvidence.shared_recorded_clock, true, "M4.7 recorded clock");
|
||||
exact(
|
||||
visualEvidence.video_camera_3d_plan_available,
|
||||
true,
|
||||
"M4.7 visual evidence",
|
||||
);
|
||||
const linkedVisualResultId = text(
|
||||
visualEvidence.linked_result_id,
|
||||
"M4.7 linked visual result",
|
||||
);
|
||||
if (!/^m4-threat-replay-[a-f0-9]{64}$/.test(linkedVisualResultId)) {
|
||||
throw new M47ReferenceGraphContractError("M4.7 visual binding: нарушена идентичность.");
|
||||
}
|
||||
const visual = await fetchM4ThreatReplayResult({
|
||||
resultId: linkedVisualResultId,
|
||||
fetcher,
|
||||
signal,
|
||||
});
|
||||
if (!visual || visual.resultId !== linkedVisualResultId) {
|
||||
throw new M47ReferenceGraphContractError(
|
||||
"M4.7 visual binding: сервер вернул другой replay result.",
|
||||
);
|
||||
}
|
||||
const graphResultId = text(source.graph_result_id, "M4.7 graph result");
|
||||
if (!/^m47-reference-graph-[a-f0-9]{64}$/.test(graphResultId)) {
|
||||
throw new M47ReferenceGraphContractError("M4.7 graph result: нарушена идентичность.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: text(evidence.createdAtUtc, "M4.7 created at"),
|
||||
graphResultId,
|
||||
linkedVisualResultId,
|
||||
graphId: exact(method.graph_id, "reference-perception-graph/v2", "M4.7 graph id"),
|
||||
worker: {
|
||||
id: exact(execution.worker_id, "worker-006", "M4.7 worker"),
|
||||
node: text(execution.worker_node, "M4.7 worker node"),
|
||||
artifactSha256: digest(execution.artifact_sha256, "M4.7 artifact", 64),
|
||||
codeRevision: digest(execution.code_revision, "M4.7 revision", 40),
|
||||
elapsedSeconds: finite(execution.elapsed_seconds, "M4.7 elapsed"),
|
||||
},
|
||||
frames: {
|
||||
expected: exact(frames.expected, 4489, "M4.7 expected frames"),
|
||||
admitted: exact(frames.admitted, 4489, "M4.7 admitted frames"),
|
||||
delivered: exact(frames.delivered, 4489, "M4.7 delivered frames"),
|
||||
},
|
||||
parityMismatchCounts: zeroParity(metrics.parity_mismatch_counts),
|
||||
queueHighWatermarks: queueWatermarks(metrics.queue_high_watermarks),
|
||||
limitations: stringArray(report.limitations, "M4.7 limitations"),
|
||||
visual,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type {
|
||||
M4Matrix3,
|
||||
M4Point3,
|
||||
M4ThreatTimelineFrame,
|
||||
} from "./m4ReplayThreat";
|
||||
|
||||
export interface M4LocalSurfaceProfile {
|
||||
windowSeconds: number;
|
||||
voxelSizeM: number;
|
||||
radiusM: number;
|
||||
pointLimit: number;
|
||||
}
|
||||
|
||||
export interface M4LocalSurface {
|
||||
pointsBodyXyzM: readonly M4Point3[];
|
||||
sourceFrameCount: number;
|
||||
sourcePointCount: number;
|
||||
voxelCount: number;
|
||||
}
|
||||
|
||||
function bodyPointToMap(
|
||||
point: M4Point3,
|
||||
origin: M4Point3,
|
||||
basis: M4Matrix3,
|
||||
): M4Point3 {
|
||||
return [
|
||||
origin[0] + point[0] * basis[0][0] + point[1] * basis[0][1] + point[2] * basis[0][2],
|
||||
origin[1] + point[0] * basis[1][0] + point[1] * basis[1][1] + point[2] * basis[1][2],
|
||||
origin[2] + point[0] * basis[2][0] + point[1] * basis[2][1] + point[2] * basis[2][2],
|
||||
];
|
||||
}
|
||||
|
||||
function mapPointToBody(
|
||||
point: M4Point3,
|
||||
origin: M4Point3,
|
||||
basis: M4Matrix3,
|
||||
): M4Point3 {
|
||||
const delta: M4Point3 = [
|
||||
point[0] - origin[0],
|
||||
point[1] - origin[1],
|
||||
point[2] - origin[2],
|
||||
];
|
||||
return [
|
||||
delta[0] * basis[0][0] + delta[1] * basis[1][0] + delta[2] * basis[2][0],
|
||||
delta[0] * basis[0][1] + delta[1] * basis[1][1] + delta[2] * basis[2][1],
|
||||
delta[0] * basis[0][2] + delta[1] * basis[1][2] + delta[2] * basis[2][2],
|
||||
];
|
||||
}
|
||||
|
||||
function emptySurface(): M4LocalSurface {
|
||||
return {
|
||||
pointsBodyXyzM: [],
|
||||
sourceFrameCount: 0,
|
||||
sourcePointCount: 0,
|
||||
voxelCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildM4LocalSurface(
|
||||
availableFrames: readonly M4ThreatTimelineFrame[],
|
||||
activeFrame: M4ThreatTimelineFrame | null,
|
||||
profile: M4LocalSurfaceProfile,
|
||||
): M4LocalSurface {
|
||||
const activeBody = activeFrame?.bodyFrame;
|
||||
if (
|
||||
!activeFrame
|
||||
|| !activeBody
|
||||
|| profile.windowSeconds <= 0
|
||||
|| profile.voxelSizeM <= 0
|
||||
|| profile.radiusM <= 0
|
||||
|| profile.pointLimit < 1
|
||||
) {
|
||||
return emptySurface();
|
||||
}
|
||||
|
||||
const startTimeNs = activeFrame.sourceTimeNs - profile.windowSeconds * 1_000_000_000;
|
||||
const frames = availableFrames
|
||||
.filter((frame) => (
|
||||
frame.bodyFrame
|
||||
&& frame.sourceTimeNs >= startTimeNs
|
||||
&& frame.sourceTimeNs <= activeFrame.sourceTimeNs
|
||||
))
|
||||
.sort((left, right) => left.sequence - right.sequence);
|
||||
if (!frames.length) return emptySurface();
|
||||
|
||||
const radiusSquared = profile.radiusM * profile.radiusM;
|
||||
const voxels = new Map<string, M4Point3>();
|
||||
let sourcePointCount = 0;
|
||||
for (const frame of frames) {
|
||||
const sourceBody = frame.bodyFrame;
|
||||
if (!sourceBody) continue;
|
||||
sourcePointCount += frame.pointCloudBodyXyzM.length;
|
||||
for (const sourcePoint of frame.pointCloudBodyXyzM) {
|
||||
const mapPoint = bodyPointToMap(
|
||||
sourcePoint,
|
||||
sourceBody.originMapXyzM,
|
||||
sourceBody.basisMapFromBody,
|
||||
);
|
||||
const activePoint = mapPointToBody(
|
||||
mapPoint,
|
||||
activeBody.originMapXyzM,
|
||||
activeBody.basisMapFromBody,
|
||||
);
|
||||
if (
|
||||
activePoint[0] * activePoint[0]
|
||||
+ activePoint[1] * activePoint[1]
|
||||
+ activePoint[2] * activePoint[2]
|
||||
> radiusSquared
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const key = [
|
||||
Math.floor(mapPoint[0] / profile.voxelSizeM),
|
||||
Math.floor(mapPoint[1] / profile.voxelSizeM),
|
||||
Math.floor(mapPoint[2] / profile.voxelSizeM),
|
||||
].join(":");
|
||||
if (!voxels.has(key)) voxels.set(key, activePoint);
|
||||
}
|
||||
}
|
||||
|
||||
const retained = [...voxels.values()];
|
||||
const stride = Math.max(1, Math.ceil(retained.length / profile.pointLimit));
|
||||
return {
|
||||
pointsBodyXyzM: retained
|
||||
.filter((_, index) => index % stride === 0)
|
||||
.slice(0, profile.pointLimit),
|
||||
sourceFrameCount: frames.length,
|
||||
sourcePointCount,
|
||||
voxelCount: voxels.size,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,825 @@
|
||||
export type M4ThreatDecision = "threat" | "not-threat" | "unknown";
|
||||
export type M4ThreatMotion = "moving" | "stationary" | "unknown";
|
||||
export type M4Point3 = readonly [number, number, number];
|
||||
export type M4Matrix3 = readonly [M4Point3, M4Point3, M4Point3];
|
||||
|
||||
export interface M4ThreatReplayResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
profileId: string;
|
||||
rigProfileId: string;
|
||||
corridorProfileId: string;
|
||||
sourceResultIds: {
|
||||
detector: string;
|
||||
geometry: string;
|
||||
temporal: string;
|
||||
};
|
||||
metrics: {
|
||||
decisions: Record<M4ThreatDecision, number>;
|
||||
evidence: {
|
||||
cameraOnly: number;
|
||||
currentMetric: number;
|
||||
rollingMapRetained: number;
|
||||
staleOrHeld: number;
|
||||
};
|
||||
fixtures: {
|
||||
critical: number;
|
||||
criticalFalseNotThreat: number;
|
||||
passed: number;
|
||||
total: number;
|
||||
};
|
||||
runtime: {
|
||||
framesPerSecond: number;
|
||||
providerLatencyP50Ms: number;
|
||||
providerLatencyP95Ms: number;
|
||||
providerLatencyMaxMs: number;
|
||||
};
|
||||
bodyFrame: {
|
||||
available: number;
|
||||
qualified: number;
|
||||
rejected: number;
|
||||
cameraForwardAlignmentDeg: {
|
||||
p95: number;
|
||||
maximum: number;
|
||||
};
|
||||
};
|
||||
reasonCounts: Readonly<Record<string, number>>;
|
||||
};
|
||||
configuration: {
|
||||
virtualBodyM: readonly [number, number];
|
||||
nominalSensorHeightM: number;
|
||||
forwardCorridorM: number;
|
||||
predictionHorizonSeconds: number;
|
||||
bodyFrame: {
|
||||
origin: "local-surface-vertical-projection";
|
||||
up: "vendor-slam-map-gravity-axis";
|
||||
forward: "smoothed-slam-trajectory-validated-by-camera-axis";
|
||||
};
|
||||
};
|
||||
limitations: readonly string[];
|
||||
}
|
||||
|
||||
export interface M4ThreatAssessment {
|
||||
componentId: string;
|
||||
decision: M4ThreatDecision;
|
||||
corridorIntersection: "intersects" | "clear" | "unknown";
|
||||
relativeSpeedMps: number | null;
|
||||
closestApproachM: number | null;
|
||||
ttcSeconds: number | null;
|
||||
reasonCodes: readonly string[];
|
||||
}
|
||||
|
||||
export interface M4ThreatMetricVisual {
|
||||
componentId: string;
|
||||
state: "current" | "retained" | "held" | "expired";
|
||||
motion: M4ThreatMotion;
|
||||
centroidBodyXyzM: M4Point3;
|
||||
cellCentersBodyXyzM: readonly M4Point3[];
|
||||
assessment: M4ThreatAssessment;
|
||||
}
|
||||
|
||||
export interface M4ThreatCameraProposal {
|
||||
proposalId: string;
|
||||
bboxXyxy: readonly [number, number, number, number];
|
||||
objectness: number;
|
||||
semanticHint: string | null;
|
||||
occupiedSupport: boolean;
|
||||
rangeM: number | null;
|
||||
threatDecision: M4ThreatDecision | null;
|
||||
threatReasonCodes: readonly string[];
|
||||
}
|
||||
|
||||
export interface M4ThreatVisualFrame {
|
||||
resultId: string;
|
||||
cameraUrl: string;
|
||||
ordinal: number;
|
||||
sequence: number;
|
||||
frameId: string;
|
||||
sourceTimeNs: number;
|
||||
pointCloudBodyXyzM: readonly M4Point3[];
|
||||
pointCloudSourceCount: number;
|
||||
pointCloudSampleCount: number;
|
||||
pointCloudLayer: "current-increment";
|
||||
rollingMapComponentCount: number;
|
||||
metricObstacles: readonly M4ThreatMetricVisual[];
|
||||
cameraProposals: readonly M4ThreatCameraProposal[];
|
||||
rig: {
|
||||
lengthM: number;
|
||||
widthM: number;
|
||||
nominalSensorHeightM: number;
|
||||
};
|
||||
corridor: {
|
||||
forwardLengthM: number;
|
||||
rearMarginM: number;
|
||||
halfWidthM: number;
|
||||
predictionHorizonSeconds: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface M4ThreatVisualIndexItem {
|
||||
ordinal: number;
|
||||
sequence: number;
|
||||
frameId: string;
|
||||
sourceTimeNs: number;
|
||||
metricObstacleCount: number;
|
||||
cameraProposalCount: number;
|
||||
pointCloudSampleCount: number;
|
||||
}
|
||||
|
||||
export interface M4ThreatTimelineFrame {
|
||||
sequence: number;
|
||||
frameId: string;
|
||||
sourceTimeNs: number;
|
||||
sessionSeconds: number;
|
||||
sourceAvailable: boolean;
|
||||
spatialAvailable: boolean;
|
||||
bodyFrame: {
|
||||
originMapXyzM: M4Point3;
|
||||
basisMapFromBody: M4Matrix3;
|
||||
} | null;
|
||||
pointCloudBodyXyzM: readonly M4Point3[];
|
||||
pointCloudSourceCount: number;
|
||||
pointCloudSampleCount: number;
|
||||
pointCloudLayer: "current-increment";
|
||||
rollingMapComponentCount: number;
|
||||
metricObstacles: readonly M4ThreatMetricVisual[];
|
||||
cameraProposals: readonly M4ThreatCameraProposal[];
|
||||
decisionCounts: Record<M4ThreatDecision, number>;
|
||||
cameraUrl: string;
|
||||
}
|
||||
|
||||
export interface M4ThreatTimeline {
|
||||
resultId: string;
|
||||
recordedSourceSessionId: "20260720T065719Z_viewer_live";
|
||||
imageWidth: 800;
|
||||
imageHeight: 600;
|
||||
frameCount: 4489;
|
||||
frameTimesNs: readonly number[];
|
||||
timelineStartSeconds: number;
|
||||
timelineEndSeconds: number;
|
||||
nominalFrameIntervalSeconds: number;
|
||||
nominalRateHz: number;
|
||||
maxChunkFrames: number;
|
||||
pointSampleLimit: number;
|
||||
maximumSourcePointsPerFrame: number;
|
||||
pointDelivery: "exact-current-increment";
|
||||
sourceRepresentationId: "registered-map-increment-v1";
|
||||
localSurfaceVisualization: {
|
||||
derivation: "bounded-registered-increment-accumulation";
|
||||
windowSeconds: number;
|
||||
voxelSizeM: number;
|
||||
radiusM: number;
|
||||
pointLimit: number;
|
||||
authority: "visual-derived";
|
||||
};
|
||||
occupiedVoxelSizeM: number;
|
||||
rig: M4ThreatVisualFrame["rig"];
|
||||
corridor: M4ThreatVisualFrame["corridor"];
|
||||
}
|
||||
|
||||
export interface M4ThreatTimelineChunk {
|
||||
resultId: string;
|
||||
startSequence: number;
|
||||
frameCount: number;
|
||||
nextSequence: number | null;
|
||||
frames: readonly M4ThreatTimelineFrame[];
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
class M4ThreatContractError extends Error {}
|
||||
const object = (value: unknown, label: string): Record<string, unknown> => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new M4ThreatContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
const array = (value: unknown, label: string): readonly unknown[] => {
|
||||
if (!Array.isArray(value)) throw new M4ThreatContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
};
|
||||
const text = (value: unknown, label: string): string => {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new M4ThreatContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const number = (value: unknown, label: string): number => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new M4ThreatContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const integer = (value: unknown, label: string): number => {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new M4ThreatContractError(`${label}: ожидалось целое.`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
const exact = <T extends string | number | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T => {
|
||||
if (value !== expected) throw new M4ThreatContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
};
|
||||
const optionalNumber = (value: unknown, label: string): number | null => (
|
||||
value === null ? null : number(value, label)
|
||||
);
|
||||
const vector = (value: unknown, size: number, label: string): number[] => {
|
||||
const parsed = array(value, label).map((item) => number(item, label));
|
||||
if (parsed.length !== size) throw new M4ThreatContractError(`${label}: неверная размерность.`);
|
||||
return parsed;
|
||||
};
|
||||
const point3 = (value: unknown, label: string): M4Point3 => {
|
||||
const parsed = vector(value, 3, label);
|
||||
return [parsed[0]!, parsed[1]!, parsed[2]!];
|
||||
};
|
||||
const decision = (value: unknown, label: string): M4ThreatDecision => {
|
||||
if (value !== "threat" && value !== "not-threat" && value !== "unknown") {
|
||||
throw new M4ThreatContractError(`${label}: неизвестное решение.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const motion = (value: unknown): M4ThreatMotion => {
|
||||
if (value !== "moving" && value !== "stationary" && value !== "unknown") {
|
||||
throw new M4ThreatContractError("M4.6 motion: неизвестное состояние.");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const resultId = (value: unknown): string => {
|
||||
const parsed = text(value, "M4.6 result id");
|
||||
if (!/^m4-threat-replay-[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new M4ThreatContractError("M4.6 result id: нарушена идентичность.");
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
function parseAssessment(value: unknown): M4ThreatAssessment {
|
||||
const item = object(value, "M4.6 assessment");
|
||||
const intersection = text(item.corridor_intersection, "M4.6 intersection");
|
||||
if (intersection !== "intersects" && intersection !== "clear" && intersection !== "unknown") {
|
||||
throw new M4ThreatContractError("M4.6 intersection: неизвестное состояние.");
|
||||
}
|
||||
return {
|
||||
componentId: text(item.component_id, "M4.6 component"),
|
||||
decision: decision(item.decision, "M4.6 decision"),
|
||||
corridorIntersection: intersection,
|
||||
relativeSpeedMps: optionalNumber(item.relative_speed_mps, "M4.6 relative speed"),
|
||||
closestApproachM: optionalNumber(item.closest_approach_m, "M4.6 closest approach"),
|
||||
ttcSeconds: optionalNumber(item.ttc_seconds, "M4.6 TTC"),
|
||||
reasonCodes: array(item.reason_codes, "M4.6 reasons").map((reason) => text(reason, "M4.6 reason")),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCameraProposal(value: unknown): M4ThreatCameraProposal {
|
||||
const item = object(value, "M4.6 camera proposal");
|
||||
return {
|
||||
proposalId: text(item.proposal_id, "M4.6 proposal id"),
|
||||
bboxXyxy: vector(item.bbox_xyxy, 4, "M4.6 bbox") as [number, number, number, number],
|
||||
objectness: number(item.objectness, "M4.6 objectness"),
|
||||
semanticHint: item.semantic_hint === null ? null : text(item.semantic_hint, "M4.6 hint"),
|
||||
occupiedSupport: typeof item.occupied_support === "boolean" ? item.occupied_support : false,
|
||||
rangeM: optionalNumber(item.range_m, "M4.6 range"),
|
||||
threatDecision: item.threat_decision === null
|
||||
? null
|
||||
: decision(item.threat_decision, "M4.6 camera threat"),
|
||||
threatReasonCodes: array(item.threat_reason_codes, "M4.6 threat reasons").map(
|
||||
(reason) => text(reason, "M4.6 threat reason"),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseMetricVisual(value: unknown): M4ThreatMetricVisual {
|
||||
const item = object(value, "M4.6 metric visual");
|
||||
const state = text(item.state, "M4.6 temporal state");
|
||||
if (
|
||||
state !== "current"
|
||||
&& state !== "retained"
|
||||
&& state !== "held"
|
||||
&& state !== "expired"
|
||||
) {
|
||||
throw new M4ThreatContractError("M4.6 temporal state: неизвестное состояние.");
|
||||
}
|
||||
return {
|
||||
componentId: text(item.component_id, "M4.6 visual component"),
|
||||
state,
|
||||
motion: motion(item.motion),
|
||||
centroidBodyXyzM: vector(item.centroid_body_xyz_m, 3, "M4.6 centroid") as [number, number, number],
|
||||
cellCentersBodyXyzM: array(item.cell_centers_body_xyz_m, "M4.6 cells").map(
|
||||
(point) => vector(point, 3, "M4.6 cell") as [number, number, number],
|
||||
),
|
||||
assessment: parseAssessment(item.assessment),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchM4ThreatReplayResult({
|
||||
resultId: requestedResultId,
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
resultId?: string;
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<M4ThreatReplayResult | null> {
|
||||
if (
|
||||
requestedResultId !== undefined
|
||||
&& !/^m4-threat-replay-[a-f0-9]{64}$/.test(requestedResultId)
|
||||
) {
|
||||
throw new M4ThreatContractError("M4.6 requested result id: нарушена идентичность.");
|
||||
}
|
||||
let response = await fetcher(
|
||||
requestedResultId === undefined
|
||||
? "/api/v1/laboratory/m4-threat/results?limit=1"
|
||||
: `/api/v1/laboratory/m4-threat/results/${requestedResultId}`,
|
||||
{ headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
// Rolling deployments may briefly pair the new UI with the previous read-only
|
||||
// API. Preserve exact-result binding by selecting the requested result from
|
||||
// the catalog instead of silently falling back to whatever happens to be latest.
|
||||
let catalogFallback = false;
|
||||
if (requestedResultId !== undefined && response.status === 404) {
|
||||
response = await fetcher("/api/v1/laboratory/m4-threat/results?limit=10", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
catalogFallback = true;
|
||||
}
|
||||
if (!response.ok) throw new M4ThreatContractError(`M4.6 LAB недоступен: HTTP ${response.status}.`);
|
||||
const payload = await response.json();
|
||||
let item: Record<string, unknown>;
|
||||
if (requestedResultId === undefined || catalogFallback) {
|
||||
const catalog = object(payload, "M4.6 catalog");
|
||||
exact(catalog.schema_version, "missioncore.m4-threat-replay-catalog/v1", "M4.6 catalog schema");
|
||||
const items = array(catalog.items, "M4.6 results");
|
||||
if (!items.length) return null;
|
||||
const selected = requestedResultId === undefined
|
||||
? items[0]
|
||||
: items.find((candidate) => (
|
||||
typeof candidate === "object"
|
||||
&& candidate !== null
|
||||
&& (candidate as Record<string, unknown>).result_id === requestedResultId
|
||||
));
|
||||
if (selected === undefined) {
|
||||
throw new M4ThreatContractError("M4.6 requested result: точный результат отсутствует в каталоге.");
|
||||
}
|
||||
item = object(selected, "M4.6 result");
|
||||
} else {
|
||||
item = object(payload, "M4.6 result");
|
||||
}
|
||||
exact(item.schema_version, "missioncore.m4-threat-replay-view/v1", "M4.6 view schema");
|
||||
exact(item.accepted, true, "M4.6 acceptance");
|
||||
exact(item.authority, "replay-simulated", "M4.6 authority");
|
||||
exact(item.physical_collision_accepted, false, "M4.6 physical authority");
|
||||
exact(item.actuation_allowed, false, "M4.6 actuation");
|
||||
const metrics = object(item.metrics, "M4.6 metrics");
|
||||
const decisions = object(metrics.decisions, "M4.6 decisions");
|
||||
const evidence = object(metrics.evidence, "M4.6 evidence");
|
||||
const fixtures = object(metrics.fixtures, "M4.6 fixtures");
|
||||
const runtime = object(metrics.runtime, "M4.6 runtime");
|
||||
const bodyFrame = object(metrics.body_frame, "M4.6 body frame");
|
||||
const cameraAlignment = object(
|
||||
bodyFrame.camera_forward_alignment_deg,
|
||||
"M4.6 camera alignment",
|
||||
);
|
||||
const configuration = object(item.configuration, "M4.6 configuration");
|
||||
const configuredBodyFrame = object(configuration.body_frame, "M4.6 configured body frame");
|
||||
const sourceResultIds = object(item.source_result_ids, "M4.6 sources");
|
||||
const parsedResultId = resultId(item.result_id);
|
||||
if (requestedResultId !== undefined) {
|
||||
exact(parsedResultId, requestedResultId, "M4.6 requested result");
|
||||
}
|
||||
return {
|
||||
resultId: parsedResultId,
|
||||
createdAtUtc: text(item.created_at_utc, "M4.6 created"),
|
||||
profileId: text(item.profile_id, "M4.6 profile"),
|
||||
rigProfileId: text(item.rig_profile_id, "M4.6 rig"),
|
||||
corridorProfileId: text(item.corridor_profile_id, "M4.6 corridor"),
|
||||
sourceResultIds: {
|
||||
detector: text(sourceResultIds.detector, "M4.6 detector"),
|
||||
geometry: text(sourceResultIds.geometry, "M4.6 geometry"),
|
||||
temporal: text(sourceResultIds.temporal, "M4.6 temporal"),
|
||||
},
|
||||
metrics: {
|
||||
decisions: {
|
||||
threat: integer(decisions.threat, "M4.6 threat count"),
|
||||
"not-threat": integer(decisions["not-threat"], "M4.6 clear count"),
|
||||
unknown: integer(decisions.unknown, "M4.6 unknown count"),
|
||||
},
|
||||
evidence: {
|
||||
cameraOnly: integer(evidence["camera-only"], "M4.6 camera-only"),
|
||||
currentMetric: integer(evidence["current-metric"], "M4.6 metric"),
|
||||
rollingMapRetained: evidence["rolling-map-retained"] === undefined
|
||||
? 0
|
||||
: integer(evidence["rolling-map-retained"], "M4.6 rolling map"),
|
||||
staleOrHeld: integer(evidence["stale-or-held"], "M4.6 stale"),
|
||||
},
|
||||
fixtures: {
|
||||
critical: integer(fixtures.critical, "M4.6 critical fixtures"),
|
||||
criticalFalseNotThreat: integer(fixtures.critical_false_not_threat, "M4.6 false-safe"),
|
||||
passed: integer(fixtures.passed, "M4.6 fixtures passed"),
|
||||
total: integer(fixtures.total, "M4.6 fixtures total"),
|
||||
},
|
||||
runtime: {
|
||||
framesPerSecond: number(runtime.frames_per_second, "M4.6 FPS"),
|
||||
providerLatencyP50Ms: number(runtime.provider_latency_p50_ms, "M4.6 p50"),
|
||||
providerLatencyP95Ms: number(runtime.provider_latency_p95_ms, "M4.6 p95"),
|
||||
providerLatencyMaxMs: number(runtime.provider_latency_max_ms, "M4.6 max"),
|
||||
},
|
||||
bodyFrame: {
|
||||
available: integer(bodyFrame.available, "M4.6 available body frames"),
|
||||
qualified: integer(bodyFrame.qualified, "M4.6 qualified body frames"),
|
||||
rejected: integer(bodyFrame.rejected, "M4.6 rejected body frames"),
|
||||
cameraForwardAlignmentDeg: {
|
||||
p95: number(cameraAlignment.p95, "M4.6 body frame camera alignment p95"),
|
||||
maximum: number(cameraAlignment.maximum, "M4.6 body frame camera alignment maximum"),
|
||||
},
|
||||
},
|
||||
reasonCounts: Object.fromEntries(
|
||||
Object.entries(object(metrics.reason_counts, "M4.6 reasons")).map(
|
||||
([key, value]) => [key, integer(value, `M4.6 ${key}`)],
|
||||
),
|
||||
),
|
||||
},
|
||||
configuration: {
|
||||
virtualBodyM: vector(configuration.virtual_body_m, 2, "M4.6 body") as [number, number],
|
||||
nominalSensorHeightM: number(configuration.nominal_sensor_height_m, "M4.6 height"),
|
||||
forwardCorridorM: number(configuration.forward_corridor_m, "M4.6 corridor"),
|
||||
predictionHorizonSeconds: number(configuration.prediction_horizon_seconds, "M4.6 horizon"),
|
||||
bodyFrame: {
|
||||
origin: exact(
|
||||
configuredBodyFrame.origin,
|
||||
"local-surface-vertical-projection",
|
||||
"M4.6 body frame origin",
|
||||
),
|
||||
up: exact(
|
||||
configuredBodyFrame.up,
|
||||
"vendor-slam-map-gravity-axis",
|
||||
"M4.6 body frame up",
|
||||
),
|
||||
forward: exact(
|
||||
configuredBodyFrame.forward,
|
||||
"smoothed-slam-trajectory-validated-by-camera-axis",
|
||||
"M4.6 body frame forward",
|
||||
),
|
||||
},
|
||||
},
|
||||
limitations: array(item.limitations, "M4.6 limitations").map((value) => text(value, "M4.6 limitation")),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchM4ThreatVisualIndex(
|
||||
result: string,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<readonly M4ThreatVisualIndexItem[]> {
|
||||
const response = await fetcher(`/api/v1/laboratory/m4-threat/results/${result}/visuals`, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new M4ThreatContractError(`M4.6 visual index: HTTP ${response.status}.`);
|
||||
const payload = object(await response.json(), "M4.6 visual index");
|
||||
exact(payload.schema_version, "missioncore.m4-threat-visual-catalog/v1", "M4.6 visual schema");
|
||||
exact(payload.result_id, result, "M4.6 visual result");
|
||||
return array(payload.items, "M4.6 visual items").map((raw) => {
|
||||
const item = object(raw, "M4.6 visual item");
|
||||
return {
|
||||
ordinal: integer(item.ordinal, "M4.6 visual ordinal"),
|
||||
sequence: integer(item.sequence, "M4.6 visual sequence"),
|
||||
frameId: text(item.frame_id, "M4.6 visual frame"),
|
||||
sourceTimeNs: integer(item.source_time_ns, "M4.6 visual time"),
|
||||
metricObstacleCount: integer(item.metric_obstacle_count, "M4.6 visual metric"),
|
||||
cameraProposalCount: integer(item.camera_proposal_count, "M4.6 visual camera"),
|
||||
pointCloudSampleCount: integer(item.point_cloud_sample_count, "M4.6 visual points"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchM4ThreatVisual(
|
||||
result: string,
|
||||
ordinal: number,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<M4ThreatVisualFrame> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/m4-threat/results/${result}/visuals/${ordinal}`,
|
||||
{ headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) throw new M4ThreatContractError(`M4.6 visual frame: HTTP ${response.status}.`);
|
||||
const item = object(await response.json(), "M4.6 visual frame");
|
||||
const frameSchema = text(item.schema_version, "M4.6 frame schema");
|
||||
if (
|
||||
frameSchema !== "missioncore.perception-threat-visual-frame/v1"
|
||||
&& frameSchema !== "missioncore.perception-threat-visual-frame/v2"
|
||||
) {
|
||||
throw new M4ThreatContractError("M4.6 frame schema: нарушен контракт.");
|
||||
}
|
||||
const rollingMapV2 = frameSchema === "missioncore.perception-threat-visual-frame/v2";
|
||||
exact(item.result_id, result, "M4.6 frame result");
|
||||
const rig = object(item.rig, "M4.6 visual rig");
|
||||
const corridor = object(item.corridor, "M4.6 visual corridor");
|
||||
return {
|
||||
resultId: result,
|
||||
cameraUrl: text(item.camera_url, "M4.6 camera URL"),
|
||||
ordinal: integer(item.ordinal, "M4.6 ordinal"),
|
||||
sequence: integer(item.sequence, "M4.6 sequence"),
|
||||
frameId: text(item.frame_id, "M4.6 frame id"),
|
||||
sourceTimeNs: integer(item.source_time_ns, "M4.6 frame time"),
|
||||
pointCloudBodyXyzM: array(item.point_cloud_body_xyz_m, "M4.6 points").map(
|
||||
(point) => vector(point, 3, "M4.6 point") as [number, number, number],
|
||||
),
|
||||
pointCloudSourceCount: integer(item.point_cloud_source_count, "M4.6 source points"),
|
||||
pointCloudSampleCount: integer(item.point_cloud_sample_count, "M4.6 sample points"),
|
||||
pointCloudLayer: rollingMapV2
|
||||
? exact(item.point_cloud_layer, "current-increment", "M4.6 point layer")
|
||||
: "current-increment",
|
||||
rollingMapComponentCount: rollingMapV2
|
||||
? integer(item.rolling_map_component_count, "M4.6 rolling components")
|
||||
: 0,
|
||||
metricObstacles: array(item.metric_obstacles, "M4.6 metric visuals").map(parseMetricVisual),
|
||||
cameraProposals: array(item.camera_proposals, "M4.6 camera proposals").map(parseCameraProposal),
|
||||
rig: {
|
||||
lengthM: number(rig.length_m, "M4.6 rig length"),
|
||||
widthM: number(rig.width_m, "M4.6 rig width"),
|
||||
nominalSensorHeightM: number(rig.nominal_sensor_height_m, "M4.6 sensor height"),
|
||||
},
|
||||
corridor: {
|
||||
forwardLengthM: number(corridor.forward_length_m, "M4.6 forward corridor"),
|
||||
rearMarginM: number(corridor.rear_margin_m, "M4.6 rear corridor"),
|
||||
halfWidthM: number(corridor.half_width_m, "M4.6 half width"),
|
||||
predictionHorizonSeconds: number(corridor.prediction_horizon_seconds, "M4.6 visual horizon"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchM4ThreatTimeline(
|
||||
result: string,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<M4ThreatTimeline> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/m4-threat/results/${result}/timeline`,
|
||||
{ headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) throw new M4ThreatContractError(`M4.6 timeline: HTTP ${response.status}.`);
|
||||
const payload = object(await response.json(), "M4.6 timeline");
|
||||
exact(
|
||||
payload.schema_version,
|
||||
"missioncore.recorded-spatial-evidence-timeline/v1",
|
||||
"M4.6 timeline schema",
|
||||
);
|
||||
exact(payload.result_id, result, "M4.6 timeline result");
|
||||
exact(payload.authority, "replay-simulated", "M4.6 timeline authority");
|
||||
const recorded = object(payload.recorded_source, "M4.6 recorded source");
|
||||
exact(
|
||||
recorded.session_id,
|
||||
"20260720T065719Z_viewer_live",
|
||||
"M4.6 recorded session",
|
||||
);
|
||||
exact(recorded.source_id, "RAVNOVES00", "M4.6 recorded source id");
|
||||
exact(
|
||||
recorded.representation_id,
|
||||
"registered-map-increment-v1",
|
||||
"M4.6 recorded representation",
|
||||
);
|
||||
exact(
|
||||
recorded.synchronization,
|
||||
"host-arrival-best-effort",
|
||||
"M4.6 recorded synchronization",
|
||||
);
|
||||
const frameCount = exact(payload.frame_count, 4489, "M4.6 timeline frame count");
|
||||
const frameTimesNs = array(payload.frame_times_ns, "M4.6 timeline index").map(
|
||||
(value) => integer(value, "M4.6 timeline time"),
|
||||
);
|
||||
if (
|
||||
frameTimesNs.length !== frameCount
|
||||
|| frameTimesNs.some((value, index) => index > 0 && value <= (frameTimesNs[index - 1] ?? value))
|
||||
) {
|
||||
throw new M4ThreatContractError("M4.6 timeline index: нарушен порядок.");
|
||||
}
|
||||
const rig = object(payload.rig, "M4.6 timeline rig");
|
||||
const corridor = object(payload.corridor, "M4.6 timeline corridor");
|
||||
const localSurface = object(
|
||||
payload.local_surface_visualization,
|
||||
"M4.6 local surface profile",
|
||||
);
|
||||
return {
|
||||
resultId: result,
|
||||
recordedSourceSessionId: "20260720T065719Z_viewer_live",
|
||||
imageWidth: exact(payload.image_width, 800, "M4.6 image width"),
|
||||
imageHeight: exact(payload.image_height, 600, "M4.6 image height"),
|
||||
frameCount,
|
||||
frameTimesNs,
|
||||
timelineStartSeconds: number(payload.timeline_start_seconds, "M4.6 timeline start"),
|
||||
timelineEndSeconds: number(payload.timeline_end_seconds, "M4.6 timeline end"),
|
||||
nominalFrameIntervalSeconds: number(
|
||||
payload.nominal_frame_interval_seconds,
|
||||
"M4.6 timeline interval",
|
||||
),
|
||||
nominalRateHz: number(payload.nominal_rate_hz, "M4.6 timeline rate"),
|
||||
maxChunkFrames: integer(payload.max_chunk_frames, "M4.6 max chunk"),
|
||||
pointSampleLimit: integer(payload.point_sample_limit, "M4.6 point limit"),
|
||||
maximumSourcePointsPerFrame: integer(
|
||||
payload.maximum_source_points_per_frame,
|
||||
"M4.6 maximum source points",
|
||||
),
|
||||
pointDelivery: exact(
|
||||
payload.point_delivery,
|
||||
"exact-current-increment",
|
||||
"M4.6 point delivery",
|
||||
),
|
||||
sourceRepresentationId: "registered-map-increment-v1",
|
||||
localSurfaceVisualization: {
|
||||
derivation: exact(
|
||||
localSurface.derivation,
|
||||
"bounded-registered-increment-accumulation",
|
||||
"M4.6 local surface derivation",
|
||||
),
|
||||
windowSeconds: number(localSurface.window_seconds, "M4.6 local surface window"),
|
||||
voxelSizeM: number(localSurface.voxel_size_m, "M4.6 local surface voxel"),
|
||||
radiusM: number(localSurface.radius_m, "M4.6 local surface radius"),
|
||||
pointLimit: integer(localSurface.point_limit, "M4.6 local surface point limit"),
|
||||
authority: exact(
|
||||
localSurface.authority,
|
||||
"visual-derived",
|
||||
"M4.6 local surface authority",
|
||||
),
|
||||
},
|
||||
occupiedVoxelSizeM: number(
|
||||
corridor.occupied_voxel_size_m,
|
||||
"M4.6 occupied voxel size",
|
||||
),
|
||||
rig: {
|
||||
lengthM: number(rig.length_m, "M4.6 rig length"),
|
||||
widthM: number(rig.width_m, "M4.6 rig width"),
|
||||
nominalSensorHeightM: number(rig.nominal_sensor_height_m, "M4.6 sensor height"),
|
||||
},
|
||||
corridor: {
|
||||
forwardLengthM: number(corridor.forward_length_m, "M4.6 forward corridor"),
|
||||
rearMarginM: number(corridor.rear_margin_m, "M4.6 rear corridor"),
|
||||
halfWidthM: number(corridor.half_width_m, "M4.6 half width"),
|
||||
predictionHorizonSeconds: number(
|
||||
corridor.prediction_horizon_seconds,
|
||||
"M4.6 prediction horizon",
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchM4ThreatTimelineChunk(
|
||||
result: string,
|
||||
startSequence: number,
|
||||
frameCount: number,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<M4ThreatTimelineChunk> {
|
||||
const params = new URLSearchParams({
|
||||
start: String(startSequence),
|
||||
count: String(frameCount),
|
||||
});
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/m4-threat/results/${result}/timeline/chunk?${params}`,
|
||||
{ headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) throw new M4ThreatContractError(`M4.6 timeline chunk: HTTP ${response.status}.`);
|
||||
const payload = object(await response.json(), "M4.6 timeline chunk");
|
||||
exact(
|
||||
payload.schema_version,
|
||||
"missioncore.recorded-spatial-evidence-chunk/v1",
|
||||
"M4.6 timeline chunk schema",
|
||||
);
|
||||
exact(payload.result_id, result, "M4.6 timeline chunk result");
|
||||
exact(payload.authority, "replay-simulated", "M4.6 timeline chunk authority");
|
||||
const parsedStart = integer(payload.start_sequence, "M4.6 timeline chunk start");
|
||||
if (parsedStart !== startSequence) {
|
||||
throw new M4ThreatContractError("M4.6 timeline chunk start: нарушен контракт.");
|
||||
}
|
||||
const frames = array(payload.frames, "M4.6 timeline frames").map((raw, offset) =>
|
||||
parseTimelineFrame(raw, result, parsedStart + offset));
|
||||
const parsedCount = integer(payload.frame_count, "M4.6 timeline chunk count");
|
||||
if (parsedCount !== frames.length || parsedCount > frameCount) {
|
||||
throw new M4ThreatContractError("M4.6 timeline chunk count: нарушен контракт.");
|
||||
}
|
||||
return {
|
||||
resultId: result,
|
||||
startSequence: parsedStart,
|
||||
frameCount: parsedCount,
|
||||
nextSequence: payload.next_sequence === null
|
||||
? null
|
||||
: integer(payload.next_sequence, "M4.6 timeline next sequence"),
|
||||
frames,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTimelineFrame(
|
||||
value: unknown,
|
||||
result: string,
|
||||
expectedSequence: number,
|
||||
): M4ThreatTimelineFrame {
|
||||
const item = object(value, "M4.6 timeline frame");
|
||||
exact(
|
||||
item.schema_version,
|
||||
"missioncore.recorded-spatial-evidence-frame/v1",
|
||||
"M4.6 timeline frame schema",
|
||||
);
|
||||
exact(item.authority, "replay-simulated", "M4.6 timeline frame authority");
|
||||
const sequence = integer(item.sequence, "M4.6 timeline sequence");
|
||||
if (sequence !== expectedSequence) {
|
||||
throw new M4ThreatContractError("M4.6 timeline frame order: нарушен контракт.");
|
||||
}
|
||||
const counts = object(item.decision_counts, "M4.6 timeline decisions");
|
||||
const spatialAvailable = typeof item.spatial_available === "boolean"
|
||||
&& item.spatial_available;
|
||||
const bodyFrame = item.body_frame === null
|
||||
? null
|
||||
: object(item.body_frame, "M4.6 timeline body frame");
|
||||
if (spatialAvailable !== (bodyFrame !== null)) {
|
||||
throw new M4ThreatContractError("M4.6 timeline body frame: нарушена доступность.");
|
||||
}
|
||||
const basis = bodyFrame === null
|
||||
? null
|
||||
: array(bodyFrame.basis_map_from_body, "M4.6 timeline body basis").map(
|
||||
(row) => point3(row, "M4.6 timeline body basis row"),
|
||||
);
|
||||
if (basis !== null && basis.length !== 3) {
|
||||
throw new M4ThreatContractError("M4.6 timeline body basis: нарушен размер.");
|
||||
}
|
||||
const cameraUrl = text(item.camera_url, "M4.6 timeline camera URL");
|
||||
if (!cameraUrl.includes(`/results/${result}/timeline/frames/${sequence}/camera`)) {
|
||||
throw new M4ThreatContractError("M4.6 timeline camera URL: нарушена идентичность.");
|
||||
}
|
||||
return {
|
||||
sequence,
|
||||
frameId: text(item.frame_id, "M4.6 timeline frame id"),
|
||||
sourceTimeNs: integer(item.source_time_ns, "M4.6 timeline source time"),
|
||||
sessionSeconds: number(item.session_seconds, "M4.6 timeline time"),
|
||||
sourceAvailable: typeof item.source_available === "boolean" && item.source_available,
|
||||
spatialAvailable,
|
||||
bodyFrame: bodyFrame === null || basis === null
|
||||
? null
|
||||
: {
|
||||
originMapXyzM: point3(
|
||||
bodyFrame.origin_map_xyz_m,
|
||||
"M4.6 timeline body origin",
|
||||
),
|
||||
basisMapFromBody: [basis[0]!, basis[1]!, basis[2]!],
|
||||
},
|
||||
pointCloudBodyXyzM: array(item.point_cloud_body_xyz_m, "M4.6 timeline points").map(
|
||||
(point) => vector(point, 3, "M4.6 timeline point") as [number, number, number],
|
||||
),
|
||||
pointCloudSourceCount: integer(item.point_cloud_source_count, "M4.6 source points"),
|
||||
pointCloudSampleCount: integer(item.point_cloud_sample_count, "M4.6 sampled points"),
|
||||
pointCloudLayer: exact(
|
||||
item.point_cloud_layer,
|
||||
"current-increment",
|
||||
"M4.6 timeline point layer",
|
||||
),
|
||||
rollingMapComponentCount: integer(
|
||||
item.rolling_map_component_count,
|
||||
"M4.6 rolling components",
|
||||
),
|
||||
metricObstacles: array(item.metric_obstacles, "M4.6 timeline obstacles").map(
|
||||
parseMetricVisual,
|
||||
),
|
||||
cameraProposals: array(item.camera_proposals, "M4.6 timeline proposals").map(
|
||||
parseCameraProposal,
|
||||
),
|
||||
decisionCounts: {
|
||||
threat: integer(counts.threat, "M4.6 timeline threat"),
|
||||
"not-threat": integer(counts["not-threat"], "M4.6 timeline clear"),
|
||||
unknown: integer(counts.unknown, "M4.6 timeline unknown"),
|
||||
},
|
||||
cameraUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectM4ThreatTimelineSequence(
|
||||
frameTimesNs: readonly number[],
|
||||
seconds: number,
|
||||
): number | null {
|
||||
if (!frameTimesNs.length || !Number.isFinite(seconds)) return null;
|
||||
const targetNs = seconds * 1_000_000_000;
|
||||
let low = 0;
|
||||
let high = frameTimesNs.length - 1;
|
||||
while (low < high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
const current = frameTimesNs[middle];
|
||||
if (current === undefined || current < targetNs) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
const current = frameTimesNs[low];
|
||||
const previousIndex = Math.max(0, low - 1);
|
||||
const previous = frameTimesNs[previousIndex];
|
||||
if (current === undefined) return frameTimesNs.length - 1;
|
||||
if (previous === undefined) return low;
|
||||
return Math.abs(previous - targetNs) <= Math.abs(current - targetNs) ? previousIndex : low;
|
||||
}
|
||||
|
||||
export function selectM4ThreatTimelineFrame(
|
||||
frames: readonly M4ThreatTimelineFrame[],
|
||||
seconds: number,
|
||||
): M4ThreatTimelineFrame | null {
|
||||
if (!frames.length) return null;
|
||||
const local = selectM4ThreatTimelineSequence(
|
||||
frames.map((frame) => frame.sourceTimeNs),
|
||||
seconds,
|
||||
);
|
||||
return local === null ? null : frames[local] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
export type LaboratoryValueSignal = "progress" | "retained" | "failed";
|
||||
export type LaboratoryValueLifecycle = "current" | "legacy";
|
||||
export type LaboratoryVisualEvidence = "available" | "partial" | "missing";
|
||||
|
||||
export interface LaboratoryValueReviewEntry {
|
||||
catalogId: string;
|
||||
evidenceId: string;
|
||||
signal: LaboratoryValueSignal;
|
||||
lifecycle: LaboratoryValueLifecycle;
|
||||
visualEvidence: LaboratoryVisualEvidence;
|
||||
}
|
||||
|
||||
export interface LaboratoryValueReviewIndex {
|
||||
reviewedAtUtc: string;
|
||||
items: readonly LaboratoryValueReviewEntry[];
|
||||
}
|
||||
|
||||
export class LaboratoryValueReviewContractError extends Error {}
|
||||
|
||||
const ENTRY_KEYS = [
|
||||
"access",
|
||||
"catalog_id",
|
||||
"evidence_id",
|
||||
"lifecycle",
|
||||
"signal",
|
||||
"visual_evidence",
|
||||
] as const;
|
||||
|
||||
function objectValue(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new LaboratoryValueReviewContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
if (actual.join("\0") !== [...expected].sort().join("\0")) {
|
||||
throw new LaboratoryValueReviewContractError(`${label}: нарушен состав полей.`);
|
||||
}
|
||||
}
|
||||
|
||||
function textValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim() || value !== value.trim()) {
|
||||
throw new LaboratoryValueReviewContractError(`${label}: ожидалась непустая строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseEntry(value: unknown): LaboratoryValueReviewEntry {
|
||||
const item = objectValue(value, "LAB value-review item");
|
||||
exactKeys(item, ENTRY_KEYS, "LAB value-review item");
|
||||
if (item.access !== "read-only") {
|
||||
throw new LaboratoryValueReviewContractError("LAB value-review item: доступ не read-only.");
|
||||
}
|
||||
if (!(["progress", "retained", "failed"] as const).includes(
|
||||
item.signal as LaboratoryValueSignal,
|
||||
)) {
|
||||
throw new LaboratoryValueReviewContractError("LAB value-review item: неизвестный signal.");
|
||||
}
|
||||
if (!(["current", "legacy"] as const).includes(
|
||||
item.lifecycle as LaboratoryValueLifecycle,
|
||||
)) {
|
||||
throw new LaboratoryValueReviewContractError("LAB value-review item: неизвестный lifecycle.");
|
||||
}
|
||||
if (!(["available", "partial", "missing"] as const).includes(
|
||||
item.visual_evidence as LaboratoryVisualEvidence,
|
||||
)) {
|
||||
throw new LaboratoryValueReviewContractError(
|
||||
"LAB value-review item: неизвестный visual_evidence.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
catalogId: textValue(item.catalog_id, "LAB value-review catalog_id"),
|
||||
evidenceId: textValue(item.evidence_id, "LAB value-review evidence_id"),
|
||||
signal: item.signal as LaboratoryValueSignal,
|
||||
lifecycle: item.lifecycle as LaboratoryValueLifecycle,
|
||||
visualEvidence: item.visual_evidence as LaboratoryVisualEvidence,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLaboratoryValueReviewIndex({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: typeof fetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<LaboratoryValueReviewIndex> {
|
||||
const response = await fetcher("/api/v1/laboratory/value-review-index", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new LaboratoryValueReviewContractError(
|
||||
`Value-review индекс LAB недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const payload = objectValue(await response.json(), "LAB value-review index");
|
||||
exactKeys(
|
||||
payload,
|
||||
["access", "items", "reviewed_at_utc", "schema_version"],
|
||||
"LAB value-review index",
|
||||
);
|
||||
if (
|
||||
payload.schema_version !== "missioncore.laboratory-value-review-index/v1"
|
||||
|| payload.access !== "read-only"
|
||||
|| !Array.isArray(payload.items)
|
||||
|| payload.items.length > 128
|
||||
) {
|
||||
throw new LaboratoryValueReviewContractError(
|
||||
"LAB value-review index: нарушен контракт.",
|
||||
);
|
||||
}
|
||||
const items = payload.items.map(parseEntry);
|
||||
if (new Set(items.map((item) => item.catalogId)).size !== items.length) {
|
||||
throw new LaboratoryValueReviewContractError(
|
||||
"LAB value-review index: catalog_id продублирован.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
reviewedAtUtc: textValue(payload.reviewed_at_utc, "LAB value-review reviewed_at_utc"),
|
||||
items,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,878 @@
|
||||
export type DatasetRepresentationId =
|
||||
| "native-scan"
|
||||
| "normalized-scan"
|
||||
| "rolling-local-map";
|
||||
|
||||
export type DatasetSourceAdmissionStatus =
|
||||
| "blocked-storage-policy"
|
||||
| "ready-for-download"
|
||||
| "ready-for-smoke"
|
||||
| "downloading"
|
||||
| "downloaded"
|
||||
| "verifying"
|
||||
| "verified"
|
||||
| "frame-ready"
|
||||
| "smoke-ready"
|
||||
| "dataset-ready";
|
||||
|
||||
export interface DatasetSource {
|
||||
sourceId: string;
|
||||
sourceKind: "goose" | "rellis";
|
||||
displayName: string;
|
||||
role: string;
|
||||
license: string;
|
||||
commercialUse:
|
||||
| "allowed-with-share-alike"
|
||||
| "research-only-license-review-required";
|
||||
format: string;
|
||||
frameSemantics: "one-lidar-revolution";
|
||||
sensor: string;
|
||||
platforms: string[];
|
||||
superclasses: string[];
|
||||
smokeExampleMb: number | null;
|
||||
primaryScanArchiveGb: number;
|
||||
labelArchiveGb: number | null;
|
||||
posesArchiveGb: number | null;
|
||||
fragmentCount: number;
|
||||
annotatedScanCount: number;
|
||||
admissionStatus: DatasetSourceAdmissionStatus;
|
||||
archive: {
|
||||
bytesTransferred: number;
|
||||
totalBytes: number;
|
||||
sizeBytes: number | null;
|
||||
sha256: string | null;
|
||||
integrity: string;
|
||||
vendorChecksumAvailable: false;
|
||||
} | null;
|
||||
frame: {
|
||||
frameId: string;
|
||||
pointCount: number;
|
||||
semanticClassCount: number;
|
||||
groundTruthGroundFraction: number;
|
||||
previewPointCount: number;
|
||||
previewSha256: string | null;
|
||||
previewAvailable: true;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface DatasetGatewayCatalog {
|
||||
storage: {
|
||||
configured: boolean;
|
||||
admitted: boolean;
|
||||
status: "ready" | "blocked-storage-policy";
|
||||
attestation: "worker-manifest" | "local-worker-path" | "none";
|
||||
manifestValid: boolean;
|
||||
requiredWindowsRoot: string;
|
||||
requiredWslRoot: string;
|
||||
};
|
||||
sources: DatasetSource[];
|
||||
representations: Array<{
|
||||
id: DatasetRepresentationId;
|
||||
title: string;
|
||||
purpose: string;
|
||||
accumulation: boolean;
|
||||
}>;
|
||||
pipeline: Array<{
|
||||
stage: string;
|
||||
requires: string[];
|
||||
produces: string;
|
||||
}>;
|
||||
currentInput: {
|
||||
representation: "vendor-mapped-increment";
|
||||
nativeScan: false;
|
||||
perPointTime: false;
|
||||
ringOrLine: false;
|
||||
admittedForPatchworkpp: false;
|
||||
reason: string;
|
||||
};
|
||||
nextAction: string;
|
||||
}
|
||||
|
||||
export interface DatasetNativeScanPreview {
|
||||
sourceId: "goose-3d/v2025-08-22" | "rellis-3d/v1.1";
|
||||
frameId: string;
|
||||
sourcePointCount: number;
|
||||
pointCount: number;
|
||||
pointsXyzM: Array<[number, number, number]>;
|
||||
remission0To255: number[];
|
||||
semanticLabelIds: number[];
|
||||
semanticRgb0To255: number[];
|
||||
groundTruthGround: number[];
|
||||
evaluationMask: number[];
|
||||
classes: Array<{
|
||||
labelId: number;
|
||||
className: string;
|
||||
hex: string;
|
||||
challengeCategoryId: number | null;
|
||||
challengeCategoryName: string | null;
|
||||
groundTarget: "ground" | "non-ground" | "ignore" | null;
|
||||
sourcePointCount: number | null;
|
||||
}>;
|
||||
coordinateFrame: {
|
||||
frameId: string;
|
||||
x: "forward";
|
||||
y: "left";
|
||||
z: "up";
|
||||
} | null;
|
||||
compatibilitySmokeOnly: boolean;
|
||||
}
|
||||
|
||||
export interface DatasetGroundMetrics {
|
||||
precision: number;
|
||||
recall: number;
|
||||
f1: number;
|
||||
groundIou: number;
|
||||
accuracy: number;
|
||||
artificialGroundRecall: number;
|
||||
naturalGroundRecall: number;
|
||||
obstacleNonGroundRecall: number;
|
||||
}
|
||||
|
||||
export interface DatasetGroundComparison {
|
||||
sourceId: "goose-3d/v2025-08-22";
|
||||
frameId: string;
|
||||
pointCount: number;
|
||||
currentGround: number[];
|
||||
patchworkGround: number[];
|
||||
patchworkAssigned: number[];
|
||||
groundTruthGround: number[];
|
||||
evaluated: number[];
|
||||
currentDisagreement: number[];
|
||||
patchworkDisagreement: number[];
|
||||
metrics: {
|
||||
current: DatasetGroundMetrics;
|
||||
patchworkpp: DatasetGroundMetrics;
|
||||
};
|
||||
latencyMs: {
|
||||
current: number;
|
||||
patchworkpp: number;
|
||||
};
|
||||
patchworkAssignedFraction: number;
|
||||
inputProfile: {
|
||||
frameId: "sensor/lidar/vls128_roof";
|
||||
sensorHeightM: number;
|
||||
heightEvidence: "published-dimensioned-schematic";
|
||||
normalizedScanProduced: false;
|
||||
};
|
||||
}
|
||||
|
||||
export class DatasetGatewayContractError extends Error {}
|
||||
|
||||
type DatasetFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
const SAFE_ID = /^[a-z0-9][a-z0-9._:/-]{0,159}$/;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new DatasetGatewayContractError(`${label}: ожидался объект`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new DatasetGatewayContractError(`${label}: ожидался массив`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string, safe = false): string {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| !value
|
||||
|| (safe && !SAFE_ID.test(value))
|
||||
) {
|
||||
throw new DatasetGatewayContractError(`${label}: некорректная строка`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function strings(value: unknown, label: string): string[] {
|
||||
return array(value, label).map((item, index) =>
|
||||
string(item, `${label}[${index}]`, true)
|
||||
);
|
||||
}
|
||||
|
||||
function displayStrings(value: unknown, label: string): string[] {
|
||||
return array(value, label).map((item, index) =>
|
||||
string(item, `${label}[${index}]`)
|
||||
);
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new DatasetGatewayContractError(`${label}: ожидался boolean`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new DatasetGatewayContractError(`${label}: некорректное число`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableNumber(value: unknown, label: string): number | null {
|
||||
return value === null ? null : number(value, label);
|
||||
}
|
||||
|
||||
function nullableDigest(value: unknown, label: string): string | null {
|
||||
if (value === null) return null;
|
||||
const digest = string(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(digest)) {
|
||||
throw new DatasetGatewayContractError(`${label}: некорректный SHA-256`);
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
|
||||
function integers(
|
||||
value: unknown,
|
||||
label: string,
|
||||
maximum: number,
|
||||
): number[] {
|
||||
return array(value, label).map((item, index) => {
|
||||
if (
|
||||
typeof item !== "number"
|
||||
|| !Number.isInteger(item)
|
||||
|| item < 0
|
||||
|| item > maximum
|
||||
) {
|
||||
throw new DatasetGatewayContractError(`${label}[${index}]: некорректное число`);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
export function parseDatasetGatewayCatalog(
|
||||
value: unknown,
|
||||
): DatasetGatewayCatalog {
|
||||
const source = record(value, "Dataset Gateway");
|
||||
if (
|
||||
source.schema_version !== "missioncore.dataset-gateway-catalog/v3"
|
||||
|| source.access !== "read-only"
|
||||
) {
|
||||
throw new DatasetGatewayContractError("Dataset Gateway contract несовместим");
|
||||
}
|
||||
const storage = record(source.storage, "storage");
|
||||
const storageStatus = storage.status;
|
||||
if (storageStatus !== "ready" && storageStatus !== "blocked-storage-policy") {
|
||||
throw new DatasetGatewayContractError("storage.status: неизвестное значение");
|
||||
}
|
||||
if (storage.path_exposed !== false) {
|
||||
throw new DatasetGatewayContractError("Dataset Gateway раскрыл локальный путь");
|
||||
}
|
||||
const storageAttestation = storage.attestation;
|
||||
if (
|
||||
storageAttestation !== "worker-manifest"
|
||||
&& storageAttestation !== "local-worker-path"
|
||||
&& storageAttestation !== "none"
|
||||
) {
|
||||
throw new DatasetGatewayContractError("storage.attestation: неизвестное значение");
|
||||
}
|
||||
const sources = array(source.sources, "sources");
|
||||
if (sources.length !== 2) {
|
||||
throw new DatasetGatewayContractError("Ожидались два закреплённых dataset source");
|
||||
}
|
||||
const parsedSources = sources.map((value, index): DatasetSource => {
|
||||
const dataset = record(value, `sources[${index}]`);
|
||||
const sourceKind = string(dataset.source_kind, "source_kind", true);
|
||||
const sourceId = string(dataset.source_id, "source_id", true);
|
||||
if (
|
||||
(sourceKind !== "goose" || sourceId !== "goose-3d/v2025-08-22")
|
||||
&& (sourceKind !== "rellis" || sourceId !== "rellis-3d/v1.1")
|
||||
) {
|
||||
throw new DatasetGatewayContractError("Dataset source identity неизвестна");
|
||||
}
|
||||
if (dataset.frame_semantics !== "one-lidar-revolution") {
|
||||
throw new DatasetGatewayContractError("Dataset frame semantics несовместима");
|
||||
}
|
||||
const download = record(dataset.download, `sources[${index}].download`);
|
||||
const statistics = record(dataset.statistics, `sources[${index}].statistics`);
|
||||
const admission = record(dataset.admission, `sources[${index}].admission`);
|
||||
if (download.automatic !== false) {
|
||||
throw new DatasetGatewayContractError(
|
||||
"Большой dataset нельзя загружать автоматически",
|
||||
);
|
||||
}
|
||||
const admissionStatus = admission.status;
|
||||
if (
|
||||
admissionStatus !== "ready-for-download"
|
||||
&& admissionStatus !== "ready-for-smoke"
|
||||
&& admissionStatus !== "blocked-storage-policy"
|
||||
&& admissionStatus !== "downloading"
|
||||
&& admissionStatus !== "downloaded"
|
||||
&& admissionStatus !== "verifying"
|
||||
&& admissionStatus !== "verified"
|
||||
&& admissionStatus !== "frame-ready"
|
||||
&& admissionStatus !== "smoke-ready"
|
||||
&& admissionStatus !== "dataset-ready"
|
||||
) {
|
||||
throw new DatasetGatewayContractError("source admission status неизвестен");
|
||||
}
|
||||
const archive = admission.archive === null
|
||||
? null
|
||||
: record(admission.archive, `sources[${index}].admission.archive`);
|
||||
const archiveValue = archive
|
||||
? {
|
||||
bytesTransferred: number(
|
||||
archive.bytes_transferred,
|
||||
"archive.bytes_transferred",
|
||||
),
|
||||
totalBytes: number(archive.total_bytes, "archive.total_bytes"),
|
||||
sizeBytes: nullableNumber(archive.size_bytes, "archive.size_bytes"),
|
||||
sha256: nullableDigest(archive.sha256, "archive.sha256"),
|
||||
integrity: string(archive.integrity, "archive.integrity", true),
|
||||
vendorChecksumAvailable: (() => {
|
||||
if (archive.vendor_checksum_available !== false) {
|
||||
throw new DatasetGatewayContractError(
|
||||
"archive.vendor_checksum_available: ожидался false",
|
||||
);
|
||||
}
|
||||
return false as const;
|
||||
})(),
|
||||
}
|
||||
: null;
|
||||
const admittedFrame = admission.frame === null
|
||||
? null
|
||||
: record(admission.frame, `sources[${index}].admission.frame`);
|
||||
const frameValue = admittedFrame
|
||||
? {
|
||||
frameId: string(admittedFrame.frame_id, "frame.frame_id", true),
|
||||
pointCount: number(admittedFrame.point_count, "frame.point_count"),
|
||||
semanticClassCount: number(
|
||||
admittedFrame.semantic_class_count,
|
||||
"frame.semantic_class_count",
|
||||
),
|
||||
groundTruthGroundFraction: number(
|
||||
admittedFrame.ground_truth_ground_fraction,
|
||||
"frame.ground_truth_ground_fraction",
|
||||
),
|
||||
previewPointCount: number(
|
||||
admittedFrame.preview_point_count,
|
||||
"frame.preview_point_count",
|
||||
),
|
||||
previewSha256: nullableDigest(
|
||||
admittedFrame.preview_sha256,
|
||||
"frame.preview_sha256",
|
||||
),
|
||||
previewAvailable: (() => {
|
||||
if (admittedFrame.preview_available !== true) {
|
||||
throw new DatasetGatewayContractError(
|
||||
"frame.preview_available: ожидался true",
|
||||
);
|
||||
}
|
||||
return true as const;
|
||||
})(),
|
||||
}
|
||||
: null;
|
||||
if (
|
||||
sourceKind === "goose"
|
||||
&& frameValue
|
||||
&& frameValue.previewSha256 === null
|
||||
) {
|
||||
throw new DatasetGatewayContractError("GOOSE frame.preview_sha256 отсутствует");
|
||||
}
|
||||
const commercialUse = dataset.commercial_use;
|
||||
if (
|
||||
commercialUse !== "allowed-with-share-alike"
|
||||
&& commercialUse !== "research-only-license-review-required"
|
||||
) {
|
||||
throw new DatasetGatewayContractError("Dataset license scope неизвестен");
|
||||
}
|
||||
return {
|
||||
sourceId,
|
||||
sourceKind,
|
||||
displayName: string(dataset.display_name, "display_name"),
|
||||
role: string(dataset.role, "role", true),
|
||||
license: string(dataset.license, "license"),
|
||||
commercialUse,
|
||||
format: string(dataset.format, "format", true),
|
||||
frameSemantics: "one-lidar-revolution",
|
||||
sensor: string(dataset.sensor, "sensor"),
|
||||
platforms: displayStrings(dataset.platforms, "platforms"),
|
||||
superclasses: strings(dataset.superclasses, "superclasses"),
|
||||
smokeExampleMb: nullableNumber(
|
||||
download.smoke_example_mb,
|
||||
"smoke_example_mb",
|
||||
),
|
||||
primaryScanArchiveGb: number(
|
||||
download.primary_scan_archive_gb,
|
||||
"primary_scan_archive_gb",
|
||||
),
|
||||
labelArchiveGb: nullableNumber(
|
||||
download.label_archive_gb,
|
||||
"label_archive_gb",
|
||||
),
|
||||
posesArchiveGb: nullableNumber(
|
||||
download.poses_archive_gb,
|
||||
"poses_archive_gb",
|
||||
),
|
||||
fragmentCount: number(statistics.fragment_count, "fragment_count"),
|
||||
annotatedScanCount: number(
|
||||
statistics.annotated_scan_count,
|
||||
"annotated_scan_count",
|
||||
),
|
||||
admissionStatus,
|
||||
archive: archiveValue,
|
||||
frame: frameValue,
|
||||
};
|
||||
});
|
||||
if (
|
||||
parsedSources[0]?.sourceKind !== "goose"
|
||||
|| parsedSources[1]?.sourceKind !== "rellis"
|
||||
) {
|
||||
throw new DatasetGatewayContractError("Dataset source order несовместим");
|
||||
}
|
||||
const representations = array(
|
||||
source.representations,
|
||||
"representations",
|
||||
).map((value, index) => {
|
||||
const item = record(value, `representations[${index}]`);
|
||||
const id = item.id;
|
||||
if (
|
||||
id !== "native-scan"
|
||||
&& id !== "normalized-scan"
|
||||
&& id !== "rolling-local-map"
|
||||
) {
|
||||
throw new DatasetGatewayContractError("Неизвестная LiDAR representation");
|
||||
}
|
||||
const normalizedId: DatasetRepresentationId = id;
|
||||
return {
|
||||
id: normalizedId,
|
||||
title: string(item.title, "representation.title"),
|
||||
purpose: string(item.purpose, "representation.purpose", true),
|
||||
accumulation: boolean(item.accumulation, "representation.accumulation"),
|
||||
};
|
||||
});
|
||||
const pipeline = array(source.pipeline, "pipeline").map((value, index) => {
|
||||
const item = record(value, `pipeline[${index}]`);
|
||||
return {
|
||||
stage: string(item.stage, "pipeline.stage", true),
|
||||
requires: strings(item.requires, "pipeline.requires"),
|
||||
produces: string(item.produces, "pipeline.produces", true),
|
||||
};
|
||||
});
|
||||
const inputs = array(source.known_inputs, "known_inputs");
|
||||
const currentInput = record(inputs[0], "known_inputs[0]");
|
||||
if (
|
||||
currentInput.representation !== "vendor-mapped-increment"
|
||||
|| currentInput.native_scan !== false
|
||||
|| currentInput.per_point_time !== false
|
||||
|| currentInput.ring_or_line !== false
|
||||
|| currentInput.admitted_for_patchworkpp !== false
|
||||
) {
|
||||
throw new DatasetGatewayContractError("Vendor-map boundary завышен");
|
||||
}
|
||||
return {
|
||||
storage: {
|
||||
configured: boolean(storage.configured, "storage.configured"),
|
||||
admitted: boolean(storage.admitted, "storage.admitted"),
|
||||
status: storageStatus,
|
||||
attestation: storageAttestation,
|
||||
manifestValid: boolean(storage.manifest_valid, "storage.manifest_valid"),
|
||||
requiredWindowsRoot: string(
|
||||
storage.required_windows_root,
|
||||
"storage.required_windows_root",
|
||||
),
|
||||
requiredWslRoot: string(storage.required_wsl_root, "storage.required_wsl_root"),
|
||||
},
|
||||
sources: parsedSources,
|
||||
representations,
|
||||
pipeline,
|
||||
currentInput: {
|
||||
representation: "vendor-mapped-increment",
|
||||
nativeScan: false,
|
||||
perPointTime: false,
|
||||
ringOrLine: false,
|
||||
admittedForPatchworkpp: false,
|
||||
reason: string(currentInput.reason, "known_inputs.reason", true),
|
||||
},
|
||||
nextAction: string(source.next_action, "next_action", true),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDatasetNativeScanPreview(
|
||||
value: unknown,
|
||||
): DatasetNativeScanPreview {
|
||||
const source = record(value, "Dataset preview");
|
||||
const isGoose = source.schema_version === "missioncore.dataset-native-scan-preview/v1"
|
||||
&& source.source_id === "goose-3d/v2025-08-22";
|
||||
const isRellis = source.schema_version === "missioncore.dataset-native-scan-preview/v2"
|
||||
&& source.source_id === "rellis-3d/v1.1";
|
||||
if (
|
||||
(!isGoose && !isRellis)
|
||||
|| source.representation !== "native-scan"
|
||||
|| source.sampling !== "deterministic-even-index"
|
||||
) {
|
||||
throw new DatasetGatewayContractError("Dataset preview contract несовместим");
|
||||
}
|
||||
const sourcePointCount = number(source.source_point_count, "source_point_count");
|
||||
const pointCount = number(source.point_count, "point_count");
|
||||
const pointsXyzM = array(source.points_xyz_m, "points_xyz_m").map(
|
||||
(value, index): [number, number, number] => {
|
||||
const point = array(value, `points_xyz_m[${index}]`);
|
||||
if (
|
||||
point.length !== 3
|
||||
|| point.some((coordinate) =>
|
||||
typeof coordinate !== "number" || !Number.isFinite(coordinate)
|
||||
)
|
||||
) {
|
||||
throw new DatasetGatewayContractError(
|
||||
`points_xyz_m[${index}]: некорректная точка`,
|
||||
);
|
||||
}
|
||||
return [point[0] as number, point[1] as number, point[2] as number];
|
||||
},
|
||||
);
|
||||
const remission0To255 = integers(
|
||||
source.remission_0_to_255,
|
||||
"remission_0_to_255",
|
||||
255,
|
||||
);
|
||||
const semanticLabelIds = integers(
|
||||
source.semantic_label_ids,
|
||||
"semantic_label_ids",
|
||||
65_535,
|
||||
);
|
||||
const semanticRgb0To255 = integers(
|
||||
source.semantic_rgb_0_to_255,
|
||||
"semantic_rgb_0_to_255",
|
||||
255,
|
||||
);
|
||||
const groundTruthGround = integers(
|
||||
source.ground_truth_ground,
|
||||
"ground_truth_ground",
|
||||
1,
|
||||
);
|
||||
const evaluationMask = isRellis
|
||||
? integers(source.evaluation_mask, "evaluation_mask", 1)
|
||||
: Array.from({ length: pointCount }, () => 1);
|
||||
if (
|
||||
pointCount < 1
|
||||
|| pointCount > 50_000
|
||||
|| pointCount > sourcePointCount
|
||||
|| pointsXyzM.length !== pointCount
|
||||
|| remission0To255.length !== pointCount
|
||||
|| semanticLabelIds.length !== pointCount
|
||||
|| semanticRgb0To255.length !== pointCount * 3
|
||||
|| groundTruthGround.length !== pointCount
|
||||
|| evaluationMask.length !== pointCount
|
||||
) {
|
||||
throw new DatasetGatewayContractError("Dataset preview arrays не выровнены");
|
||||
}
|
||||
const classes = array(source.classes, "classes").map((value, index) => {
|
||||
const item = record(value, `classes[${index}]`);
|
||||
const groundTarget = item.ground_target;
|
||||
if (
|
||||
groundTarget !== undefined
|
||||
&& groundTarget !== "ground"
|
||||
&& groundTarget !== "non-ground"
|
||||
&& groundTarget !== "ignore"
|
||||
) {
|
||||
throw new DatasetGatewayContractError("class.ground_target неизвестен");
|
||||
}
|
||||
const normalizedGroundTarget: "ground" | "non-ground" | "ignore" | null =
|
||||
groundTarget === undefined ? null : groundTarget;
|
||||
return {
|
||||
labelId: number(item.label_id, "class.label_id"),
|
||||
className: string(item.class_name, "class.class_name", true),
|
||||
hex: string(item.hex, "class.hex"),
|
||||
challengeCategoryId: item.challenge_category_id === undefined
|
||||
? null
|
||||
: number(item.challenge_category_id, "class.challenge_category_id"),
|
||||
challengeCategoryName: item.challenge_category_name === undefined
|
||||
? null
|
||||
: string(
|
||||
item.challenge_category_name,
|
||||
"class.challenge_category_name",
|
||||
true,
|
||||
),
|
||||
groundTarget: normalizedGroundTarget,
|
||||
sourcePointCount: item.source_point_count === undefined
|
||||
? null
|
||||
: number(item.source_point_count, "class.source_point_count"),
|
||||
};
|
||||
});
|
||||
const safety = record(source.safety, "safety");
|
||||
if (
|
||||
safety.visualization_only !== true
|
||||
|| safety.navigation_or_safety_accepted !== false
|
||||
|| (isRellis && typeof safety.compatibility_smoke_only !== "boolean")
|
||||
) {
|
||||
throw new DatasetGatewayContractError("Dataset preview safety boundary нарушен");
|
||||
}
|
||||
const coordinateFrame = isRellis
|
||||
? (() => {
|
||||
const frame = record(source.coordinate_frame, "coordinate_frame");
|
||||
if (
|
||||
frame.frame_id !== "sensor/lidar/os1"
|
||||
|| frame.x !== "forward"
|
||||
|| frame.y !== "left"
|
||||
|| frame.z !== "up"
|
||||
|| frame.handedness !== "right"
|
||||
|| frame.transform_applied !== false
|
||||
) {
|
||||
throw new DatasetGatewayContractError("RELLIS coordinate frame несовместим");
|
||||
}
|
||||
return {
|
||||
frameId: "sensor/lidar/os1",
|
||||
x: "forward" as const,
|
||||
y: "left" as const,
|
||||
z: "up" as const,
|
||||
};
|
||||
})()
|
||||
: null;
|
||||
return {
|
||||
sourceId: isRellis ? "rellis-3d/v1.1" : "goose-3d/v2025-08-22",
|
||||
frameId: string(source.frame_id, "frame_id", true),
|
||||
sourcePointCount,
|
||||
pointCount,
|
||||
pointsXyzM,
|
||||
remission0To255,
|
||||
semanticLabelIds,
|
||||
semanticRgb0To255,
|
||||
groundTruthGround,
|
||||
evaluationMask,
|
||||
classes,
|
||||
coordinateFrame,
|
||||
compatibilitySmokeOnly: isRellis
|
||||
? safety.compatibility_smoke_only as boolean
|
||||
: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDatasetGroundComparison(
|
||||
value: unknown,
|
||||
): DatasetGroundComparison {
|
||||
const source = record(value, "Ground comparison");
|
||||
if (
|
||||
source.schema_version !== "missioncore.dataset-ground-comparison-preview/v2"
|
||||
|| source.source_id !== "goose-3d/v2025-08-22"
|
||||
|| source.sampling !== "deterministic-even-index"
|
||||
) {
|
||||
throw new DatasetGatewayContractError("Ground comparison contract несовместим");
|
||||
}
|
||||
const pointCount = number(source.point_count, "point_count");
|
||||
const currentGround = integers(source.current_ground, "current_ground", 1);
|
||||
const patchworkGround = integers(source.patchwork_ground, "patchwork_ground", 1);
|
||||
const patchworkAssigned = integers(
|
||||
source.patchwork_assigned,
|
||||
"patchwork_assigned",
|
||||
1,
|
||||
);
|
||||
const groundTruthGround = integers(
|
||||
source.ground_truth_ground,
|
||||
"ground_truth_ground",
|
||||
1,
|
||||
);
|
||||
const evaluated = integers(source.evaluated, "evaluated", 1);
|
||||
const currentDisagreement = integers(
|
||||
source.current_disagreement,
|
||||
"current_disagreement",
|
||||
1,
|
||||
);
|
||||
const patchworkDisagreement = integers(
|
||||
source.patchwork_disagreement,
|
||||
"patchwork_disagreement",
|
||||
1,
|
||||
);
|
||||
if (
|
||||
pointCount < 1
|
||||
|| pointCount > 50_000
|
||||
|| currentGround.length !== pointCount
|
||||
|| patchworkGround.length !== pointCount
|
||||
|| patchworkAssigned.length !== pointCount
|
||||
|| groundTruthGround.length !== pointCount
|
||||
|| evaluated.length !== pointCount
|
||||
|| currentDisagreement.length !== pointCount
|
||||
|| patchworkDisagreement.length !== pointCount
|
||||
) {
|
||||
throw new DatasetGatewayContractError("Ground comparison arrays не выровнены");
|
||||
}
|
||||
const metrics = record(source.metrics, "metrics");
|
||||
const parseMetrics = (
|
||||
value: unknown,
|
||||
label: string,
|
||||
): DatasetGroundMetrics => {
|
||||
const providerMetrics = record(value, label);
|
||||
const fraction = (key: string): number => {
|
||||
const result = number(providerMetrics[key], `${label}.${key}`);
|
||||
if (result > 1) {
|
||||
throw new DatasetGatewayContractError(`${label}.${key}: ожидалась доля`);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
return {
|
||||
precision: fraction("precision"),
|
||||
recall: fraction("recall"),
|
||||
f1: fraction("f1"),
|
||||
groundIou: fraction("ground_iou"),
|
||||
accuracy: fraction("accuracy"),
|
||||
artificialGroundRecall: fraction("artificial_ground_recall"),
|
||||
naturalGroundRecall: fraction("natural_ground_recall"),
|
||||
obstacleNonGroundRecall: fraction("obstacle_non_ground_recall"),
|
||||
};
|
||||
};
|
||||
const currentMetrics = parseMetrics(metrics.current, "metrics.current");
|
||||
const patchworkMetrics = parseMetrics(
|
||||
metrics.patchworkpp,
|
||||
"metrics.patchworkpp",
|
||||
);
|
||||
const providers = record(source.providers, "providers");
|
||||
const currentProvider = record(providers.current, "providers.current");
|
||||
const patchworkProvider = record(providers.patchworkpp, "providers.patchworkpp");
|
||||
if (
|
||||
currentProvider.provider_id !== "missioncore-local-percentile-ground/v1"
|
||||
|| currentProvider.ground_truth !== false
|
||||
|| !/^[a-f0-9]{64}$/.test(
|
||||
string(
|
||||
currentProvider.implementation_sha256,
|
||||
"providers.current.implementation_sha256",
|
||||
),
|
||||
)
|
||||
|| patchworkProvider.provider_id !== "patchworkpp/v1.4.1"
|
||||
|| patchworkProvider.source_tag !== "v1.4.1"
|
||||
|| patchworkProvider.source_commit
|
||||
!== "3e6903a1d5537a4cc2ace897b0bbb98a92d6014c"
|
||||
|| patchworkProvider.ground_truth !== false
|
||||
|| !/^[a-f0-9]{64}$/.test(
|
||||
string(
|
||||
patchworkProvider.binary_sha256,
|
||||
"providers.patchworkpp.binary_sha256",
|
||||
),
|
||||
)
|
||||
) {
|
||||
throw new DatasetGatewayContractError("Ground comparison provider несовместим");
|
||||
}
|
||||
const latency = record(source.latency_ms, "latency_ms");
|
||||
const inputProfile = record(source.input_profile, "input_profile");
|
||||
const sensorFrame = record(inputProfile.sensor_frame, "input_profile.sensor_frame");
|
||||
const height = record(inputProfile.height, "input_profile.height");
|
||||
const scope = record(inputProfile.scope, "input_profile.scope");
|
||||
const sensorHeightM = number(
|
||||
height.sensor_above_ground_m,
|
||||
"height.sensor_above_ground_m",
|
||||
);
|
||||
if (
|
||||
inputProfile.schema_version !== "missioncore.goose-patchwork-profile/v1"
|
||||
|| inputProfile.source_id !== "goose-3d/v2025-08-22"
|
||||
|| inputProfile.representation !== "native-scan"
|
||||
|| sensorFrame.frame_id !== "sensor/lidar/vls128_roof"
|
||||
|| sensorFrame.handedness !== "right"
|
||||
|| sensorFrame.x !== "forward"
|
||||
|| sensorFrame.y !== "left"
|
||||
|| sensorFrame.z !== "up"
|
||||
|| sensorFrame.one_revolution !== true
|
||||
|| height.base_link_above_ground_m !== 0.64
|
||||
|| height.lidar_above_base_link_m !== 1.6
|
||||
|| sensorHeightM !== 2.24
|
||||
|| height.evidence !== "published-dimensioned-schematic"
|
||||
|| scope.patchworkpp_eligible !== true
|
||||
|| scope.normalized_scan_produced !== false
|
||||
|| scope.complete_vehicle_transform_known !== false
|
||||
|| scope.deskew_claimed !== false
|
||||
) {
|
||||
throw new DatasetGatewayContractError("GOOSE Patchwork++ profile несовместим");
|
||||
}
|
||||
const patchworkAssignedFraction = number(
|
||||
source.patchwork_assigned_fraction,
|
||||
"patchwork_assigned_fraction",
|
||||
);
|
||||
if (patchworkAssignedFraction > 1) {
|
||||
throw new DatasetGatewayContractError(
|
||||
"patchwork_assigned_fraction: ожидалась доля",
|
||||
);
|
||||
}
|
||||
const safety = record(source.safety, "safety");
|
||||
if (
|
||||
safety.qualification_only !== true
|
||||
|| safety.navigation_or_safety_accepted !== false
|
||||
) {
|
||||
throw new DatasetGatewayContractError("Ground comparison safety boundary нарушен");
|
||||
}
|
||||
return {
|
||||
sourceId: "goose-3d/v2025-08-22",
|
||||
frameId: string(source.frame_id, "frame_id", true),
|
||||
pointCount,
|
||||
currentGround,
|
||||
patchworkGround,
|
||||
patchworkAssigned,
|
||||
groundTruthGround,
|
||||
evaluated,
|
||||
currentDisagreement,
|
||||
patchworkDisagreement,
|
||||
metrics: {
|
||||
current: currentMetrics,
|
||||
patchworkpp: patchworkMetrics,
|
||||
},
|
||||
latencyMs: {
|
||||
current: number(latency.current, "latency_ms.current"),
|
||||
patchworkpp: number(latency.patchworkpp, "latency_ms.patchworkpp"),
|
||||
},
|
||||
patchworkAssignedFraction,
|
||||
inputProfile: {
|
||||
frameId: "sensor/lidar/vls128_roof",
|
||||
sensorHeightM,
|
||||
heightEvidence: "published-dimensioned-schematic",
|
||||
normalizedScanProduced: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function responseJson(response: Response): Promise<unknown> {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Dataset Gateway HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchDatasetGatewayCatalog(
|
||||
options: { signal?: AbortSignal; fetcher?: DatasetFetch } = {},
|
||||
): Promise<DatasetGatewayCatalog> {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher("/api/v1/lidar/dataset-gateway", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
});
|
||||
return parseDatasetGatewayCatalog(await responseJson(response));
|
||||
}
|
||||
|
||||
export async function fetchDatasetNativeScanPreview(
|
||||
options: {
|
||||
sourceId?: DatasetNativeScanPreview["sourceId"];
|
||||
signal?: AbortSignal;
|
||||
fetcher?: DatasetFetch;
|
||||
} = {},
|
||||
): Promise<DatasetNativeScanPreview> {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const sourceId = options.sourceId ?? "goose-3d/v2025-08-22";
|
||||
const response = await fetcher(
|
||||
`/api/v1/lidar/dataset-gateway/preview?source_id=${encodeURIComponent(sourceId)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseDatasetNativeScanPreview(await responseJson(response));
|
||||
}
|
||||
|
||||
export async function fetchDatasetGroundComparison(
|
||||
options: { signal?: AbortSignal; fetcher?: DatasetFetch } = {},
|
||||
): Promise<DatasetGroundComparison> {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
"/api/v1/lidar/dataset-gateway/ground-comparison",
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseDatasetGroundComparison(await responseJson(response));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
||||
import type {
|
||||
MapGeoPoint,
|
||||
MapGhostPin,
|
||||
} from "./mapView";
|
||||
|
||||
const EARTH_RADIUS_METERS = 6_371_008.8;
|
||||
|
||||
export function generateGhostPins({
|
||||
anchor,
|
||||
count,
|
||||
radiusMeters,
|
||||
maximumSpeedMetersPerSecond,
|
||||
random = Math.random,
|
||||
timestamp = Date.now(),
|
||||
}: {
|
||||
anchor: MapGeoPoint;
|
||||
count: number;
|
||||
radiusMeters: number;
|
||||
maximumSpeedMetersPerSecond: number;
|
||||
random?: () => number;
|
||||
timestamp?: number;
|
||||
}): MapGhostPin[] {
|
||||
const safeCount = Math.min(100, Math.max(1, Math.round(count)));
|
||||
const safeRadius = Math.min(100_000, Math.max(100, radiusMeters));
|
||||
const safeMaximumSpeed = Math.min(
|
||||
100,
|
||||
Math.max(0.1, maximumSpeedMetersPerSecond),
|
||||
);
|
||||
const labels = new Set<string>();
|
||||
return Array.from({ length: safeCount }, (_, index) => {
|
||||
const distance = safeRadius * Math.sqrt(clampUnit(random()));
|
||||
const bearing = clampUnit(random()) * 360;
|
||||
const position = destinationPoint(anchor, bearing, distance);
|
||||
const label = nextNumericLabel(labels, random);
|
||||
return {
|
||||
id: [
|
||||
"ghost",
|
||||
Math.max(0, Math.round(timestamp)).toString(36),
|
||||
index.toString(36),
|
||||
Math.floor(clampUnit(random()) * 0xff_ffff).toString(36),
|
||||
].join("-"),
|
||||
label,
|
||||
...position,
|
||||
headingDegrees: clampUnit(random()) * 360,
|
||||
speedMetersPerSecond:
|
||||
safeMaximumSpeed * (0.2 + clampUnit(random()) * 0.8),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function nextNumericLabel(
|
||||
labels: Set<string>,
|
||||
random: () => number,
|
||||
): string {
|
||||
let label = String(100_000 + Math.floor(clampUnit(random()) * 900_000));
|
||||
for (let attempt = 0; labels.has(label) && attempt < 16; attempt += 1) {
|
||||
label = String(100_000 + Math.floor(clampUnit(random()) * 900_000));
|
||||
}
|
||||
if (labels.has(label)) {
|
||||
label = String(100_000 + (labels.size % 900_000));
|
||||
}
|
||||
labels.add(label);
|
||||
return label;
|
||||
}
|
||||
|
||||
function destinationPoint(
|
||||
origin: MapGeoPoint,
|
||||
headingDegrees: number,
|
||||
distanceMeters: number,
|
||||
): MapGeoPoint {
|
||||
const angularDistance = distanceMeters / EARTH_RADIUS_METERS;
|
||||
const bearing = toRadians(headingDegrees);
|
||||
const latitude = toRadians(origin.latitude);
|
||||
const longitude = toRadians(origin.longitude);
|
||||
const nextLatitude = Math.asin(
|
||||
Math.sin(latitude) * Math.cos(angularDistance)
|
||||
+ Math.cos(latitude) * Math.sin(angularDistance) * Math.cos(bearing),
|
||||
);
|
||||
const nextLongitude = longitude + Math.atan2(
|
||||
Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(latitude),
|
||||
Math.cos(angularDistance) - Math.sin(latitude) * Math.sin(nextLatitude),
|
||||
);
|
||||
return {
|
||||
longitude: ((toDegrees(nextLongitude) + 540) % 360) - 180,
|
||||
latitude: Math.min(90, Math.max(-90, toDegrees(nextLatitude))),
|
||||
};
|
||||
}
|
||||
|
||||
function clampUnit(value: number): number {
|
||||
return Math.min(0.999_999_999, Math.max(0, Number.isFinite(value) ? value : 0));
|
||||
}
|
||||
|
||||
function toRadians(value: number): number {
|
||||
return value * Math.PI / 180;
|
||||
}
|
||||
|
||||
function toDegrees(value: number): number {
|
||||
return value * 180 / Math.PI;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface MapGatewayHealthSnapshot {
|
||||
ionConfigured: boolean;
|
||||
assetAllowlist: number[];
|
||||
cache: {
|
||||
mode: string;
|
||||
writePolicy: string;
|
||||
entries: number;
|
||||
bytes: number;
|
||||
maxBytes: number | null;
|
||||
atCapacity: boolean;
|
||||
persistent: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MapGatewayHealth {
|
||||
state: "idle" | "loading" | "ready" | "stale" | "error";
|
||||
snapshot: MapGatewayHealthSnapshot | null;
|
||||
code: string | null;
|
||||
}
|
||||
|
||||
export function useMapGatewayHealth(active: boolean): MapGatewayHealth {
|
||||
const [health, setHealth] = useState<MapGatewayHealth>({
|
||||
state: "idle",
|
||||
snapshot: null,
|
||||
code: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
let disposed = false;
|
||||
let running = false;
|
||||
let lastKnownGood: MapGatewayHealthSnapshot | null = null;
|
||||
let activeController: AbortController | null = null;
|
||||
|
||||
const poll = async () => {
|
||||
if (running) return;
|
||||
running = true;
|
||||
const controller = new AbortController();
|
||||
activeController = controller;
|
||||
const timeout = window.setTimeout(() => controller.abort(), 10_000);
|
||||
if (!lastKnownGood && !disposed) {
|
||||
setHealth({ state: "loading", snapshot: null, code: null });
|
||||
}
|
||||
try {
|
||||
const response = await fetch("/api/v1/map/gateway/health", {
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(safeCode(payload));
|
||||
}
|
||||
const snapshot = decodeHealth(payload);
|
||||
lastKnownGood = snapshot;
|
||||
if (!disposed) {
|
||||
setHealth({ state: "ready", snapshot, code: null });
|
||||
}
|
||||
} catch (reason) {
|
||||
if (!disposed) {
|
||||
setHealth({
|
||||
state: lastKnownGood ? "stale" : "error",
|
||||
snapshot: lastKnownGood,
|
||||
code: reason instanceof Error ? reason.message : "map_gateway_unavailable",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
if (activeController === controller) activeController = null;
|
||||
running = false;
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
const interval = window.setInterval(() => void poll(), 15_000);
|
||||
return () => {
|
||||
disposed = true;
|
||||
activeController?.abort();
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
function decodeHealth(value: unknown): MapGatewayHealthSnapshot {
|
||||
const document = record(value, "gateway health");
|
||||
if (document.ok !== true || document.service !== "nodedc-map-gateway") {
|
||||
throw new Error("map_gateway_invalid_response");
|
||||
}
|
||||
const cache = record(document.cache, "gateway health.cache");
|
||||
const assetAllowlist = array(document.assetAllowlist, "gateway health.assetAllowlist");
|
||||
if (
|
||||
typeof document.ionConfigured !== "boolean" ||
|
||||
!assetAllowlist.every((asset) => typeof asset === "number" && Number.isSafeInteger(asset))
|
||||
) {
|
||||
throw new Error("map_gateway_invalid_response");
|
||||
}
|
||||
return {
|
||||
ionConfigured: document.ionConfigured,
|
||||
assetAllowlist: assetAllowlist as number[],
|
||||
cache: {
|
||||
mode: string(cache.mode),
|
||||
writePolicy: string(cache.writePolicy),
|
||||
entries: integer(cache.entries),
|
||||
bytes: integer(cache.bytes),
|
||||
maxBytes: cache.maxBytes === null ? null : integer(cache.maxBytes),
|
||||
atCapacity: boolean(cache.atCapacity),
|
||||
persistent: boolean(cache.persistent),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function safeCode(value: unknown): string {
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
const document = value as Record<string, unknown>;
|
||||
if (typeof document.code === "string" && /^[a-z][a-z0-9_]{0,95}$/.test(document.code)) {
|
||||
return document.code;
|
||||
}
|
||||
}
|
||||
return "map_gateway_unavailable";
|
||||
}
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${path} is invalid`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, path: string): unknown[] {
|
||||
if (!Array.isArray(value)) throw new Error(`${path} is invalid`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown): string {
|
||||
if (typeof value !== "string") throw new Error("map_gateway_invalid_response");
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error("map_gateway_invalid_response");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boolean(value: unknown): boolean {
|
||||
if (typeof value !== "boolean") throw new Error("map_gateway_invalid_response");
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
decodeMapViewDocument,
|
||||
defaultMapViewDocument,
|
||||
encodeMapViewPut,
|
||||
type MapViewDocument,
|
||||
} from "./mapView";
|
||||
|
||||
export interface MapViewController {
|
||||
document: MapViewDocument;
|
||||
state: "loading" | "ready" | "saving" | "error";
|
||||
error: string | null;
|
||||
save: (draft: MapViewDocument) => Promise<MapViewDocument>;
|
||||
reload: () => Promise<MapViewDocument>;
|
||||
}
|
||||
|
||||
async function responseError(response: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const body = await response.json() as { detail?: unknown; code?: unknown };
|
||||
if (typeof body.detail === "string" && body.detail.trim()) return body.detail;
|
||||
if (typeof body.code === "string" && body.code.trim()) return body.code;
|
||||
} catch {
|
||||
// Reverse-proxy HTML or an interrupted response receives stable product copy.
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function loadMapView(signal?: AbortSignal): Promise<MapViewDocument> {
|
||||
const response = await fetch("/api/v1/map/view", {
|
||||
signal,
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseError(
|
||||
response,
|
||||
"Не удалось загрузить состояние карты.",
|
||||
));
|
||||
}
|
||||
return decodeMapViewDocument(await response.json());
|
||||
}
|
||||
|
||||
export function useMapView(): MapViewController {
|
||||
const [document, setDocument] = useState<MapViewDocument>(defaultMapViewDocument);
|
||||
const [state, setState] = useState<MapViewController["state"]>("loading");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setState("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const next = await loadMapView();
|
||||
setDocument(next);
|
||||
setState("ready");
|
||||
return next;
|
||||
} catch (reason) {
|
||||
const message = reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось загрузить состояние карты.";
|
||||
setState("error");
|
||||
setError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void loadMapView(controller.signal).then((next) => {
|
||||
setDocument(next);
|
||||
setState("ready");
|
||||
setError(null);
|
||||
}).catch((reason: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setState("error");
|
||||
setError(reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось загрузить состояние карты.");
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async (draft: MapViewDocument) => {
|
||||
setState("saving");
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/v1/map/view", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(encodeMapViewPut(draft)),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseError(
|
||||
response,
|
||||
response.status === 412
|
||||
? "Состояние карты изменилось в другом окне. Обновите его перед сохранением."
|
||||
: "Не удалось сохранить состояние карты.",
|
||||
));
|
||||
}
|
||||
const accepted = decodeMapViewDocument(await response.json());
|
||||
setDocument(accepted);
|
||||
setState("ready");
|
||||
return accepted;
|
||||
} catch (reason) {
|
||||
const message = reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось сохранить состояние карты.";
|
||||
setState("error");
|
||||
setError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { document, state, error, save, reload };
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import type {
|
||||
ObservationSourceDelivery,
|
||||
ObservationSourceDescriptor,
|
||||
} from "../runtime/contracts";
|
||||
|
||||
export const CAMERA_WAKE_GAP_MS = 5_000;
|
||||
export const CAMERA_HIDDEN_REOPEN_MS = 1_000;
|
||||
export const CAMERA_REOPEN_COOLDOWN_MS = 1_000;
|
||||
|
||||
export type CameraPlaybackRecoveryEvent =
|
||||
| { type: "document-hidden" }
|
||||
| { type: "document-visible" }
|
||||
| { type: "network-online" }
|
||||
| { type: "page-restore"; persisted: boolean }
|
||||
| { type: "heartbeat" };
|
||||
|
||||
export interface CameraPlaybackRecoveryState {
|
||||
authorityIdentity: string;
|
||||
hiddenAt: number | null;
|
||||
lastObservedAt: number;
|
||||
lastReopenAt: number | null;
|
||||
}
|
||||
|
||||
export interface CameraPlaybackRecoveryContext {
|
||||
activeAuthorityIdentity: string | null;
|
||||
now: number;
|
||||
documentVisible: boolean;
|
||||
networkOnline: boolean;
|
||||
transportHealthy?: boolean;
|
||||
}
|
||||
|
||||
export interface CameraPlaybackRecoveryDecision {
|
||||
state: CameraPlaybackRecoveryState;
|
||||
reopen: boolean;
|
||||
}
|
||||
|
||||
export function cameraTransportCallbackIsCurrent(
|
||||
activeEpoch: number,
|
||||
callbackEpoch: number,
|
||||
disposed: boolean,
|
||||
): boolean {
|
||||
return !disposed && activeEpoch === callbackEpoch;
|
||||
}
|
||||
|
||||
export function cameraBrowserTransportIdentity(
|
||||
delivery: ObservationSourceDelivery & { kind: "mse-fmp4-websocket" },
|
||||
authorityIdentity: string | null,
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
delivery.id,
|
||||
delivery.url,
|
||||
delivery.mediaType,
|
||||
authorityIdentity,
|
||||
]);
|
||||
}
|
||||
|
||||
function trimmedString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value) && (value as number) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind browser-only recovery to one exact, server-authoritative live camera.
|
||||
* A point-cloud recovery, retained delivery or selected camera without the
|
||||
* active acquisition/session tuple must not reopen a decoder.
|
||||
*/
|
||||
export function liveCameraPlaybackAuthorityIdentity(
|
||||
source: ObservationSourceDescriptor,
|
||||
): string | null {
|
||||
const delivery = source.delivery;
|
||||
const activation = source.activation;
|
||||
const deviceId = trimmedString(source.binding?.deviceId);
|
||||
const deviceSessionId = trimmedString(source.binding?.deviceSessionId);
|
||||
const acquisitionId = trimmedString(source.binding?.acquisitionId);
|
||||
const sourceId = trimmedString(source.sourceId);
|
||||
const descriptorId = trimmedString(source.id);
|
||||
const activationGroupId = trimmedString(activation?.groupId);
|
||||
const deliveryId = trimmedString(delivery?.id);
|
||||
const deliveryUrl = trimmedString(delivery?.url);
|
||||
const presentationLease = source.presentationLease;
|
||||
const recoveryPresentation = presentationLease?.kind === "active-stream-recovery";
|
||||
const recoveryLeaseValid = Boolean(
|
||||
recoveryPresentation
|
||||
&& trimmedString(presentationLease.runtimeId)
|
||||
&& trimmedString(presentationLease.acquisitionId) === acquisitionId
|
||||
&& positiveInteger(presentationLease.acquisitionStateRevision)
|
||||
&& positiveInteger(presentationLease.producerGeneration)
|
||||
&& positiveInteger(presentationLease.recoveryGeneration),
|
||||
);
|
||||
const availabilityAuthoritative = source.availability === "streaming"
|
||||
|| (
|
||||
recoveryLeaseValid
|
||||
&& (source.availability === "connecting" || source.availability === "degraded")
|
||||
);
|
||||
const mediaType = delivery?.kind === "mse-fmp4-websocket"
|
||||
? trimmedString(delivery.mediaType)
|
||||
: "";
|
||||
if (
|
||||
source.modality !== "video"
|
||||
|| !availabilityAuthoritative
|
||||
|| (presentationLease != null && !recoveryLeaseValid)
|
||||
|| delivery?.kind !== "mse-fmp4-websocket"
|
||||
|| activation?.selected !== true
|
||||
|| activation.maxActive !== 1
|
||||
|| !activationGroupId
|
||||
|| !descriptorId
|
||||
|| !sourceId
|
||||
|| !deliveryId
|
||||
|| !deliveryUrl
|
||||
|| !/^video\/mp4(?:\s*;|$)/i.test(mediaType)
|
||||
|| !deviceId
|
||||
|| !deviceSessionId
|
||||
|| !acquisitionId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify([
|
||||
descriptorId,
|
||||
sourceId,
|
||||
deviceId,
|
||||
deviceSessionId,
|
||||
acquisitionId,
|
||||
activationGroupId,
|
||||
activation.maxActive,
|
||||
deliveryId,
|
||||
deliveryUrl,
|
||||
mediaType,
|
||||
recoveryLeaseValid ? [
|
||||
presentationLease?.runtimeId,
|
||||
presentationLease?.acquisitionStateRevision,
|
||||
presentationLease?.producerGeneration,
|
||||
presentationLease?.recoveryGeneration,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
export function initialCameraPlaybackRecoveryState(
|
||||
authorityIdentity: string,
|
||||
now: number,
|
||||
): CameraPlaybackRecoveryState {
|
||||
return {
|
||||
authorityIdentity,
|
||||
hiddenAt: null,
|
||||
lastObservedAt: now,
|
||||
lastReopenAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce browser lifecycle signals without performing I/O. `reopen=true`
|
||||
* means replace the current browser WebSocket + MSE pair; it never means a
|
||||
* device START/STOP, camera selection or network mutation.
|
||||
*/
|
||||
export function reduceCameraPlaybackRecovery(
|
||||
current: CameraPlaybackRecoveryState,
|
||||
event: CameraPlaybackRecoveryEvent,
|
||||
context: CameraPlaybackRecoveryContext,
|
||||
): CameraPlaybackRecoveryDecision {
|
||||
const { now } = context;
|
||||
if (event.type === "document-hidden") {
|
||||
return {
|
||||
state: {
|
||||
...current,
|
||||
hiddenAt: now,
|
||||
lastObservedAt: now,
|
||||
},
|
||||
reopen: false,
|
||||
};
|
||||
}
|
||||
|
||||
const authorityCurrent = Boolean(
|
||||
context.activeAuthorityIdentity
|
||||
&& context.activeAuthorityIdentity === current.authorityIdentity,
|
||||
);
|
||||
const documentReady = context.documentVisible && context.networkOnline;
|
||||
let candidate = false;
|
||||
let hiddenAt = current.hiddenAt;
|
||||
|
||||
if (event.type === "document-visible") {
|
||||
candidate = hiddenAt !== null && now - hiddenAt >= CAMERA_HIDDEN_REOPEN_MS;
|
||||
hiddenAt = null;
|
||||
} else if (event.type === "network-online") {
|
||||
candidate = true;
|
||||
} else if (event.type === "page-restore") {
|
||||
candidate = event.persisted;
|
||||
} else if (event.type === "heartbeat") {
|
||||
candidate = context.transportHealthy !== true
|
||||
&& now - current.lastObservedAt >= CAMERA_WAKE_GAP_MS;
|
||||
}
|
||||
|
||||
const outsideCooldown = current.lastReopenAt === null
|
||||
|| now - current.lastReopenAt >= CAMERA_REOPEN_COOLDOWN_MS;
|
||||
const reopen = candidate && authorityCurrent && documentReady && outsideCooldown;
|
||||
return {
|
||||
state: {
|
||||
...current,
|
||||
hiddenAt,
|
||||
lastObservedAt: now,
|
||||
lastReopenAt: reopen ? now : current.lastReopenAt,
|
||||
},
|
||||
reopen,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import type {
|
||||
ObservationSourceDescriptor,
|
||||
SpatialSourceDescriptor,
|
||||
} from "../runtime/contracts";
|
||||
|
||||
export interface LiveReceiverWatchdogState {
|
||||
lastBackendActivitySequence: number | null;
|
||||
lastViewerRangeMaxNs: number | null;
|
||||
staticRangeBackendAdvances: number;
|
||||
stalledSinceMs: number | null;
|
||||
stallReported: boolean;
|
||||
}
|
||||
|
||||
export type LiveReceiverWatchdogSignal = "none" | "receiver-advanced" | "stalled";
|
||||
|
||||
export interface LiveReceiverWatchdogResult {
|
||||
state: LiveReceiverWatchdogState;
|
||||
signal: LiveReceiverWatchdogSignal;
|
||||
stalledForMs: number;
|
||||
}
|
||||
|
||||
export interface LiveReceiverRecoveryState {
|
||||
attempts: number;
|
||||
awaitingRecovery: boolean;
|
||||
}
|
||||
|
||||
export interface LiveReceiverOpenWatchdogState {
|
||||
lastBackendActivitySequence: number | null;
|
||||
openedAtMs: number;
|
||||
}
|
||||
|
||||
export type LiveReceiverOpenWatchdogSignal =
|
||||
| "wait-for-store"
|
||||
| "restart-receiver";
|
||||
|
||||
export interface LiveReceiverOpenWatchdogResult {
|
||||
state: LiveReceiverOpenWatchdogState;
|
||||
recoveryState: LiveReceiverRecoveryState;
|
||||
signal: LiveReceiverOpenWatchdogSignal;
|
||||
openForMs: number;
|
||||
}
|
||||
|
||||
export type LiveReceiverRecoverySignal = "retry" | "exhausted" | "stale";
|
||||
|
||||
export interface LiveReceiverRecoveryResult {
|
||||
state: LiveReceiverRecoveryState;
|
||||
signal: LiveReceiverRecoverySignal;
|
||||
attempt: number;
|
||||
delayMs: number | null;
|
||||
}
|
||||
|
||||
export interface LiveReceiverRecoveryRequest {
|
||||
activeAuthorityIdentity: string | null;
|
||||
expectedAuthorityIdentity: string | null;
|
||||
disposed?: boolean;
|
||||
maxAttempts?: number;
|
||||
}
|
||||
|
||||
export const LIVE_RECEIVER_STALL_THRESHOLD_MS = 5_000;
|
||||
export const LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS = 3;
|
||||
// The bridge publishes its URL only after StoreInfo, blueprint and static
|
||||
// scene data have been flushed. Poll at the original four-second boundary,
|
||||
// but never discard a receiver while this exact acquisition is still proving
|
||||
// fresh publication progress: doing so throws away its partial store replay and
|
||||
// can keep a healthy long-running stream blank indefinitely.
|
||||
export const LIVE_RECEIVER_OPEN_CHECK_INTERVAL_MS = 4_000;
|
||||
const LIVE_RECEIVER_RECOVERY_DELAYS_MS = [400, 1_000, 2_000, 5_000] as const;
|
||||
|
||||
export function liveReceiverRecoveryRetryDelay(attempt: number): number {
|
||||
const normalizedAttempt = Number.isSafeInteger(attempt) && attempt > 0 ? attempt : 1;
|
||||
return LIVE_RECEIVER_RECOVERY_DELAYS_MS[
|
||||
Math.min(normalizedAttempt - 1, LIVE_RECEIVER_RECOVERY_DELAYS_MS.length - 1)
|
||||
];
|
||||
}
|
||||
|
||||
function trimmedString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value) && (value as number) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind durable browser-only Rerun recovery to one exact authoritative spatial
|
||||
* presentation. A healthy live descriptor is fenced by its acquisition and
|
||||
* session tuple; a reconnecting/degraded descriptor additionally requires the
|
||||
* server-issued active-stream-recovery generation lease.
|
||||
*/
|
||||
export function liveRerunRecoveryAuthorityIdentity(
|
||||
source: ObservationSourceDescriptor | null | undefined,
|
||||
spatialSource: SpatialSourceDescriptor | null | undefined,
|
||||
): string | null {
|
||||
if (!source || !spatialSource) return null;
|
||||
const descriptorId = trimmedString(source.id);
|
||||
const sourceId = trimmedString(source.sourceId);
|
||||
const semanticChannelId = trimmedString(source.semanticChannelId);
|
||||
const previewUrl = trimmedString(source.previewUrl);
|
||||
const spatialId = trimmedString(spatialSource.id);
|
||||
const spatialUrl = trimmedString(spatialSource.url);
|
||||
const deviceId = trimmedString(source.binding?.deviceId);
|
||||
const deviceSessionId = trimmedString(source.binding?.deviceSessionId);
|
||||
const acquisitionId = trimmedString(source.binding?.acquisitionId);
|
||||
const pluginId = trimmedString(source.provider?.pluginId);
|
||||
const pluginVersion = trimmedString(source.provider?.pluginVersion);
|
||||
const modelId = trimmedString(source.provider?.modelId);
|
||||
const compatibilityProfileId = trimmedString(source.provider?.compatibilityProfileId);
|
||||
const clockId = trimmedString(source.capabilities?.clockId);
|
||||
const presentationLease = source.presentationLease;
|
||||
const recoveryPresentation = presentationLease?.kind === "active-stream-recovery";
|
||||
const recoveryLeaseValid = Boolean(
|
||||
recoveryPresentation
|
||||
&& trimmedString(presentationLease.runtimeId)
|
||||
&& trimmedString(presentationLease.acquisitionId) === acquisitionId
|
||||
&& positiveInteger(presentationLease.acquisitionStateRevision)
|
||||
&& positiveInteger(presentationLease.producerGeneration)
|
||||
&& positiveInteger(presentationLease.recoveryGeneration),
|
||||
);
|
||||
const availabilityAuthoritative = source.availability === "streaming"
|
||||
|| (
|
||||
recoveryLeaseValid
|
||||
&& (source.availability === "connecting" || source.availability === "degraded")
|
||||
);
|
||||
if (
|
||||
source.modality !== "point-cloud"
|
||||
|| source.transport !== "rerun-grpc"
|
||||
|| spatialSource.kind !== "rerun-grpc"
|
||||
|| source.capabilities.timelineMode !== "live-only"
|
||||
|| source.capabilities.spatialRegistration !== "native"
|
||||
|| source.delivery != null
|
||||
|| !availabilityAuthoritative
|
||||
|| (presentationLease != null && !recoveryLeaseValid)
|
||||
|| !descriptorId
|
||||
|| !sourceId
|
||||
|| !semanticChannelId
|
||||
|| !previewUrl
|
||||
|| previewUrl !== spatialUrl
|
||||
|| !spatialId
|
||||
|| spatialId !== acquisitionId
|
||||
|| !deviceId
|
||||
|| !deviceSessionId
|
||||
|| !acquisitionId
|
||||
|| clockId !== acquisitionId
|
||||
|| !pluginId
|
||||
|| !pluginVersion
|
||||
|| !modelId
|
||||
|| !compatibilityProfileId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify([
|
||||
descriptorId,
|
||||
sourceId,
|
||||
semanticChannelId,
|
||||
deviceId,
|
||||
deviceSessionId,
|
||||
acquisitionId,
|
||||
spatialId,
|
||||
spatialUrl,
|
||||
pluginId,
|
||||
pluginVersion,
|
||||
modelId,
|
||||
compatibilityProfileId,
|
||||
recoveryLeaseValid ? [
|
||||
presentationLease?.runtimeId,
|
||||
presentationLease?.acquisitionStateRevision,
|
||||
presentationLease?.producerGeneration,
|
||||
presentationLease?.recoveryGeneration,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
export function liveReceiverRecoveryAuthorityIsCurrent(
|
||||
activeAuthorityIdentity: string | null,
|
||||
expectedAuthorityIdentity: string | null,
|
||||
disposed = false,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
!disposed
|
||||
&& expectedAuthorityIdentity
|
||||
&& activeAuthorityIdentity === expectedAuthorityIdentity,
|
||||
);
|
||||
}
|
||||
|
||||
export function initialLiveReceiverWatchdogState(): LiveReceiverWatchdogState {
|
||||
return {
|
||||
lastBackendActivitySequence: null,
|
||||
lastViewerRangeMaxNs: null,
|
||||
staticRangeBackendAdvances: 0,
|
||||
stalledSinceMs: null,
|
||||
stallReported: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function initialLiveReceiverRecoveryState(): LiveReceiverRecoveryState {
|
||||
return {
|
||||
attempts: 0,
|
||||
awaitingRecovery: false,
|
||||
};
|
||||
}
|
||||
|
||||
function validBackendActivitySequence(value: number | null | undefined): number | null {
|
||||
return Number.isSafeInteger(value) && (value ?? -1) >= 0 ? (value as number) : null;
|
||||
}
|
||||
|
||||
export function initialLiveReceiverOpenWatchdogState(
|
||||
backendActivitySequence: number | null = null,
|
||||
openedAtMs = Date.now(),
|
||||
): LiveReceiverOpenWatchdogState {
|
||||
return {
|
||||
lastBackendActivitySequence: validBackendActivitySequence(backendActivitySequence),
|
||||
openedAtMs: Number.isFinite(openedAtMs) ? openedAtMs : Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep one still-opening Rerun receiver alive while the backend is proving
|
||||
* fresh publication progress. Recreating the WASM receiver on a fixed timer
|
||||
* can repeatedly discard an otherwise healthy late StoreInfo replay. Preserve
|
||||
* that receiver for as long as the backend proves fresh publication. A true
|
||||
* lack of backend progress delegates to the bounded restart policy on the next
|
||||
* check. Recovery debt is cleared only after viewer admission, never merely
|
||||
* because the backend counter advanced.
|
||||
*/
|
||||
export function advanceLiveReceiverOpenWatchdog(
|
||||
current: LiveReceiverOpenWatchdogState,
|
||||
recoveryState: LiveReceiverRecoveryState,
|
||||
backendActivitySequence: number | null,
|
||||
nowMs = Date.now(),
|
||||
): LiveReceiverOpenWatchdogResult {
|
||||
const sequence = validBackendActivitySequence(backendActivitySequence);
|
||||
const previous = current.lastBackendActivitySequence;
|
||||
const backendAdvanced = sequence !== null && (
|
||||
(previous === null && sequence > 0) ||
|
||||
(previous !== null && sequence > previous)
|
||||
);
|
||||
const state = {
|
||||
lastBackendActivitySequence: sequence === null
|
||||
? previous
|
||||
: Math.max(sequence, previous ?? 0),
|
||||
openedAtMs: current.openedAtMs,
|
||||
};
|
||||
const openForMs = Math.max(0, nowMs - current.openedAtMs);
|
||||
if (backendAdvanced) {
|
||||
return {
|
||||
state,
|
||||
recoveryState,
|
||||
signal: "wait-for-store",
|
||||
openForMs,
|
||||
};
|
||||
}
|
||||
return {
|
||||
state,
|
||||
recoveryState,
|
||||
signal: "restart-receiver",
|
||||
openForMs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound viewer-only restarts independently from scanner and acquisition
|
||||
* lifecycle unless the caller proves that one exact live/recovery authority
|
||||
* is still current. Under that fence retries remain durable and use a capped
|
||||
* delay while the attempt counter stays truthful. The caller may dispose and
|
||||
* recreate only the browser receiver; it must never issue START/STOP or
|
||||
* reconnect the physical device.
|
||||
*/
|
||||
export function requestLiveReceiverRecovery(
|
||||
current: LiveReceiverRecoveryState,
|
||||
request?: LiveReceiverRecoveryRequest,
|
||||
): LiveReceiverRecoveryResult {
|
||||
const maxAttempts = positiveInteger(request?.maxAttempts)
|
||||
? request.maxAttempts
|
||||
: LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS;
|
||||
const expectedAuthorityIdentity = request?.expectedAuthorityIdentity ?? null;
|
||||
const exactAuthorityCurrent = liveReceiverRecoveryAuthorityIsCurrent(
|
||||
request?.activeAuthorityIdentity ?? null,
|
||||
expectedAuthorityIdentity,
|
||||
request?.disposed === true,
|
||||
);
|
||||
if (
|
||||
request
|
||||
&& (
|
||||
request.disposed === true
|
||||
|| request.activeAuthorityIdentity !== expectedAuthorityIdentity
|
||||
)
|
||||
) {
|
||||
return {
|
||||
state: current,
|
||||
signal: "stale",
|
||||
attempt: current.attempts,
|
||||
delayMs: null,
|
||||
};
|
||||
}
|
||||
if (current.attempts >= maxAttempts && !exactAuthorityCurrent) {
|
||||
return {
|
||||
state: {
|
||||
attempts: current.attempts,
|
||||
awaitingRecovery: false,
|
||||
},
|
||||
signal: "exhausted",
|
||||
attempt: current.attempts,
|
||||
delayMs: null,
|
||||
};
|
||||
}
|
||||
const attempt = current.attempts + 1;
|
||||
return {
|
||||
state: {
|
||||
attempts: attempt,
|
||||
awaitingRecovery: true,
|
||||
},
|
||||
signal: "retry",
|
||||
attempt,
|
||||
delayMs: exactAuthorityCurrent ? liveReceiverRecoveryRetryDelay(attempt) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a disposable receiver stall only when the backend frame counter keeps
|
||||
* advancing while Rerun's visible time range does not. Scanner commands are
|
||||
* deliberately absent from this state machine.
|
||||
*/
|
||||
export function advanceLiveReceiverWatchdog(
|
||||
current: LiveReceiverWatchdogState,
|
||||
sample: {
|
||||
nowMs: number;
|
||||
backendActivitySequence: number | null;
|
||||
viewerRangeMaxNs: number | null;
|
||||
},
|
||||
thresholdMs = LIVE_RECEIVER_STALL_THRESHOLD_MS,
|
||||
): LiveReceiverWatchdogResult {
|
||||
const backendSequence = validBackendActivitySequence(sample.backendActivitySequence);
|
||||
const viewerRange = Number.isFinite(sample.viewerRangeMaxNs) &&
|
||||
(sample.viewerRangeMaxNs ?? -1) >= 0
|
||||
? sample.viewerRangeMaxNs
|
||||
: null;
|
||||
const state = { ...current };
|
||||
|
||||
if (state.lastBackendActivitySequence === null && state.lastViewerRangeMaxNs === null) {
|
||||
state.lastBackendActivitySequence = backendSequence;
|
||||
state.lastViewerRangeMaxNs = viewerRange;
|
||||
return { state, signal: "none", stalledForMs: 0 };
|
||||
}
|
||||
|
||||
const backendAdvanced = backendSequence !== null &&
|
||||
(state.lastBackendActivitySequence === null ||
|
||||
backendSequence > state.lastBackendActivitySequence);
|
||||
const receiverAdvanced = viewerRange !== null &&
|
||||
(state.lastViewerRangeMaxNs === null || viewerRange > state.lastViewerRangeMaxNs);
|
||||
|
||||
if (backendSequence !== null) {
|
||||
state.lastBackendActivitySequence = Math.max(
|
||||
backendSequence,
|
||||
state.lastBackendActivitySequence ?? 0,
|
||||
);
|
||||
}
|
||||
if (viewerRange !== null) {
|
||||
state.lastViewerRangeMaxNs = Math.max(viewerRange, state.lastViewerRangeMaxNs ?? 0);
|
||||
}
|
||||
|
||||
if (receiverAdvanced) {
|
||||
const hadStaticBackendProgress = state.stalledSinceMs !== null;
|
||||
state.staticRangeBackendAdvances = 0;
|
||||
state.stalledSinceMs = null;
|
||||
state.stallReported = false;
|
||||
return {
|
||||
state,
|
||||
signal: hadStaticBackendProgress ? "receiver-advanced" : "none",
|
||||
stalledForMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (backendAdvanced) {
|
||||
state.staticRangeBackendAdvances += 1;
|
||||
if (state.staticRangeBackendAdvances >= 2 && state.stalledSinceMs === null) {
|
||||
state.stalledSinceMs = sample.nowMs;
|
||||
}
|
||||
}
|
||||
const stalledForMs = state.stalledSinceMs === null
|
||||
? 0
|
||||
: Math.max(0, sample.nowMs - state.stalledSinceMs);
|
||||
if (
|
||||
!state.stallReported &&
|
||||
state.staticRangeBackendAdvances >= 2 &&
|
||||
stalledForMs >= thresholdMs
|
||||
) {
|
||||
state.stallReported = true;
|
||||
return { state, signal: "stalled", stalledForMs };
|
||||
}
|
||||
return { state, signal: "none", stalledForMs };
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
export type LiveViewerDiagnosticEventCode =
|
||||
| "live_receiver_stalled"
|
||||
| "live_receiver_restart_requested"
|
||||
| "live_receiver_recovered"
|
||||
| "live_receiver_recovery_exhausted"
|
||||
| "live_receiver_active_store_admitted"
|
||||
| "live_receiver_error"
|
||||
| "live_camera_source_projected"
|
||||
| "live_camera_window_admitted"
|
||||
| "live_camera_player_effect_started"
|
||||
| "live_camera_playback_authority_missing"
|
||||
| "live_camera_media_source_open"
|
||||
| "live_camera_websocket_open"
|
||||
| "live_camera_transport_restart_requested"
|
||||
| "live_camera_transport_playing";
|
||||
|
||||
export type LiveViewerFailureStage =
|
||||
| "recording-open-timeout"
|
||||
| "viewer-start"
|
||||
| "module-load"
|
||||
| "receiver-stalled"
|
||||
| "camera-first-media-timeout"
|
||||
| "camera-first-playable-timeout"
|
||||
| "camera-queue-capacity"
|
||||
| "camera-append-quota"
|
||||
| "camera-append-error"
|
||||
| "camera-source-buffer-error"
|
||||
| "camera-fragment-decode"
|
||||
| "camera-websocket-error"
|
||||
| "camera-websocket-close"
|
||||
| "camera-heartbeat-reopen"
|
||||
| "camera-visibility-reopen"
|
||||
| "camera-network-online-reopen"
|
||||
| "camera-page-restore-reopen";
|
||||
|
||||
export interface LiveViewerDiagnostic {
|
||||
eventCode: LiveViewerDiagnosticEventCode;
|
||||
failureStage?: LiveViewerFailureStage | null;
|
||||
streamId?: string | null;
|
||||
backendActivitySequence?: number | null;
|
||||
viewerRangeMaxNs?: number | null;
|
||||
stalledForMs?: number | null;
|
||||
recoveryAttempt?: number | null;
|
||||
cameraQueueBytes?: number | null;
|
||||
cameraQueueSegments?: number | null;
|
||||
cameraRetryCount?: number | null;
|
||||
websocketCloseCode?: number | null;
|
||||
transportEpoch?: number | null;
|
||||
cameraAppendErrorName?: string | null;
|
||||
cameraMediaSourceState?: "closed" | "open" | "ended" | null;
|
||||
cameraVideoErrorCode?: number | null;
|
||||
}
|
||||
|
||||
export interface LiveViewerLineage {
|
||||
uiBuildId: string;
|
||||
documentInstanceId: string;
|
||||
viewerInstanceId: string;
|
||||
lifecycleGeneration: number;
|
||||
}
|
||||
|
||||
export interface LiveViewerDiagnosticScheduler {
|
||||
setTimeout(callback: () => void, delayMilliseconds: number): number;
|
||||
clearTimeout(handle: number): void;
|
||||
setInterval(callback: () => void, delayMilliseconds: number): number;
|
||||
clearInterval(handle: number): void;
|
||||
}
|
||||
|
||||
export interface LiveViewerDiagnosticLifecycle {
|
||||
readonly lineage: LiveViewerLineage;
|
||||
readonly signal: AbortSignal;
|
||||
active(): boolean;
|
||||
admitted(): boolean;
|
||||
post(event: LiveViewerDiagnostic): void;
|
||||
verifyBuild(): void;
|
||||
armAdmissionTimeout(callback: () => void, delayMilliseconds: number): void;
|
||||
armAdmissionInterval(callback: () => void, delayMilliseconds: number): void;
|
||||
clearAdmissionTimeout(): void;
|
||||
clearAdmissionInterval(): void;
|
||||
markAdmitted(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface StaleUiBuild {
|
||||
loadedUiBuildId: string;
|
||||
expectedUiBuildId: string;
|
||||
}
|
||||
|
||||
export interface UiBuildStaleCoordinator {
|
||||
subscribe(listener: (event: StaleUiBuild) => void): () => void;
|
||||
report(event: StaleUiBuild): void;
|
||||
stale(): boolean;
|
||||
}
|
||||
|
||||
const LIVE_VIEWER_DIAGNOSTIC_SCHEMA = "missioncore.live-viewer-diagnostic/v2";
|
||||
const LIVE_VIEWER_CLIENT_CONTRACT_SCHEMA = "missioncore.live-viewer-client-contract/v1";
|
||||
const UI_BUILD_HEADER = "x-missioncore-ui-build";
|
||||
const DEVELOPMENT_UI_BUILD_ID = "development";
|
||||
const SAFE_STREAM_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const HASHED_UI_BUILD_ID = /^\/assets\/[A-Za-z0-9._/-]+-[A-Za-z0-9_-]{8,}\.js$/;
|
||||
const UI_BUILD_CHECK_INTERVAL_MILLISECONDS = 15_000;
|
||||
|
||||
let documentInstanceId: string | null = null;
|
||||
let sharedUiBuildCoordinator: UiBuildStaleCoordinator | null = null;
|
||||
let buildMonitorSubscribers = 0;
|
||||
let buildMonitorInterval: number | null = null;
|
||||
let buildMonitorAbort: AbortController | null = null;
|
||||
let buildMonitorOnlineListener: (() => void) | null = null;
|
||||
let buildMonitorVisibilityListener: (() => void) | null = null;
|
||||
|
||||
function randomInstanceId(): string {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
|
||||
function safeInteger(value: number | null | undefined): number | undefined {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function browserScheduler(): LiveViewerDiagnosticScheduler {
|
||||
return {
|
||||
setTimeout: (callback, delayMilliseconds) => window.setTimeout(callback, delayMilliseconds),
|
||||
clearTimeout: (handle) => window.clearTimeout(handle),
|
||||
setInterval: (callback, delayMilliseconds) => window.setInterval(callback, delayMilliseconds),
|
||||
clearInterval: (handle) => window.clearInterval(handle),
|
||||
};
|
||||
}
|
||||
|
||||
export function uiBuildIdFromModuleScripts(
|
||||
scriptSources: readonly string[],
|
||||
baseUrl: string,
|
||||
): string {
|
||||
for (const source of scriptSources) {
|
||||
try {
|
||||
const pathname = new URL(source, baseUrl).pathname;
|
||||
if (HASHED_UI_BUILD_ID.test(pathname)) return pathname;
|
||||
} catch {
|
||||
// A malformed non-entry script is not the running application build.
|
||||
}
|
||||
}
|
||||
return DEVELOPMENT_UI_BUILD_ID;
|
||||
}
|
||||
|
||||
export function currentUiBuildId(): string {
|
||||
const scripts = Array.from(
|
||||
document.querySelectorAll<HTMLScriptElement>('script[type="module"][src]'),
|
||||
(script) => script.src,
|
||||
);
|
||||
return uiBuildIdFromModuleScripts(scripts, window.location.href);
|
||||
}
|
||||
|
||||
export function liveViewerDocumentInstanceId(): string {
|
||||
documentInstanceId ??= randomInstanceId();
|
||||
return documentInstanceId;
|
||||
}
|
||||
|
||||
export function createLiveViewerInstanceId(): string {
|
||||
return randomInstanceId();
|
||||
}
|
||||
|
||||
export function createLiveViewerLineage(
|
||||
viewerInstanceId: string,
|
||||
lifecycleGeneration: number,
|
||||
overrides: Partial<Pick<LiveViewerLineage, "uiBuildId" | "documentInstanceId">> = {},
|
||||
): LiveViewerLineage {
|
||||
if (!Number.isSafeInteger(lifecycleGeneration) || lifecycleGeneration < 1) {
|
||||
throw new Error("Live viewer lifecycle generation must be a positive safe integer");
|
||||
}
|
||||
return {
|
||||
uiBuildId: overrides.uiBuildId ?? currentUiBuildId(),
|
||||
documentInstanceId: overrides.documentInstanceId ?? liveViewerDocumentInstanceId(),
|
||||
viewerInstanceId,
|
||||
lifecycleGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
export function liveViewerDiagnosticBody(
|
||||
event: LiveViewerDiagnostic,
|
||||
lineage: LiveViewerLineage,
|
||||
): Record<string, string | number> {
|
||||
const streamId = event.streamId?.trim();
|
||||
return {
|
||||
schema_version: LIVE_VIEWER_DIAGNOSTIC_SCHEMA,
|
||||
event_code: event.eventCode,
|
||||
ui_build_id: lineage.uiBuildId,
|
||||
document_instance_id: lineage.documentInstanceId,
|
||||
viewer_instance_id: lineage.viewerInstanceId,
|
||||
lifecycle_generation: lineage.lifecycleGeneration,
|
||||
...(event.failureStage ? { failure_stage: event.failureStage } : {}),
|
||||
...(streamId && SAFE_STREAM_ID.test(streamId) ? { stream_id: streamId } : {}),
|
||||
...(safeInteger(event.backendActivitySequence) === undefined
|
||||
? {}
|
||||
: { backend_activity_sequence: safeInteger(event.backendActivitySequence) }),
|
||||
...(safeInteger(event.viewerRangeMaxNs) === undefined
|
||||
? {}
|
||||
: { viewer_range_max_ns: safeInteger(event.viewerRangeMaxNs) }),
|
||||
...(safeInteger(event.stalledForMs) === undefined
|
||||
? {}
|
||||
: { stalled_for_ms: safeInteger(event.stalledForMs) }),
|
||||
...(safeInteger(event.recoveryAttempt) === undefined
|
||||
? {}
|
||||
: { recovery_attempt: safeInteger(event.recoveryAttempt) }),
|
||||
...(safeInteger(event.cameraQueueBytes) === undefined
|
||||
? {}
|
||||
: { camera_queue_bytes: safeInteger(event.cameraQueueBytes) }),
|
||||
...(safeInteger(event.cameraQueueSegments) === undefined
|
||||
? {}
|
||||
: { camera_queue_segments: safeInteger(event.cameraQueueSegments) }),
|
||||
...(safeInteger(event.cameraRetryCount) === undefined
|
||||
? {}
|
||||
: { camera_retry_count: safeInteger(event.cameraRetryCount) }),
|
||||
...(safeInteger(event.websocketCloseCode) === undefined
|
||||
? {}
|
||||
: { websocket_close_code: safeInteger(event.websocketCloseCode) }),
|
||||
...(safeInteger(event.transportEpoch) === undefined
|
||||
? {}
|
||||
: { transport_epoch: safeInteger(event.transportEpoch) }),
|
||||
...(event.cameraAppendErrorName
|
||||
? { camera_append_error_name: event.cameraAppendErrorName }
|
||||
: {}),
|
||||
...(event.cameraMediaSourceState
|
||||
? { camera_media_source_state: event.cameraMediaSourceState }
|
||||
: {}),
|
||||
...(safeInteger(event.cameraVideoErrorCode) === undefined
|
||||
? {}
|
||||
: { camera_video_error_code: safeInteger(event.cameraVideoErrorCode) }),
|
||||
};
|
||||
}
|
||||
|
||||
export function createUiBuildStaleCoordinator(): UiBuildStaleCoordinator {
|
||||
let staleEvent: StaleUiBuild | null = null;
|
||||
return {
|
||||
subscribe(_listener) {
|
||||
// Build drift is diagnostic-only. The loaded document and its local
|
||||
// Rerun/camera transports remain authoritative until normal unmount or
|
||||
// their existing bounded recovery replaces them.
|
||||
return () => undefined;
|
||||
},
|
||||
report(event) {
|
||||
if (event.loadedUiBuildId === event.expectedUiBuildId || staleEvent) return;
|
||||
staleEvent = event;
|
||||
},
|
||||
stale() {
|
||||
return staleEvent !== null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function browserUiBuildCoordinator(): UiBuildStaleCoordinator {
|
||||
sharedUiBuildCoordinator ??= createUiBuildStaleCoordinator();
|
||||
return sharedUiBuildCoordinator;
|
||||
}
|
||||
|
||||
function inspectUiBuildResponse(
|
||||
response: Response,
|
||||
loadedUiBuildId: string,
|
||||
signal?: AbortSignal,
|
||||
): void {
|
||||
if (signal?.aborted || loadedUiBuildId === DEVELOPMENT_UI_BUILD_ID) return;
|
||||
const expectedUiBuildId = response.headers.get(UI_BUILD_HEADER);
|
||||
if (
|
||||
expectedUiBuildId &&
|
||||
expectedUiBuildId !== loadedUiBuildId &&
|
||||
(response.status === 409 || response.ok)
|
||||
) {
|
||||
browserUiBuildCoordinator().report({ loadedUiBuildId, expectedUiBuildId });
|
||||
}
|
||||
}
|
||||
|
||||
export function postLiveViewerDiagnostic(
|
||||
event: LiveViewerDiagnostic,
|
||||
lineage: LiveViewerLineage,
|
||||
signal?: AbortSignal,
|
||||
): void {
|
||||
if (signal?.aborted) return;
|
||||
void fetch("/api/v1/viewer/live-diagnostics", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(liveViewerDiagnosticBody(event, lineage)),
|
||||
keepalive: true,
|
||||
signal,
|
||||
}).then((response) => {
|
||||
inspectUiBuildResponse(response, lineage.uiBuildId, signal);
|
||||
}).catch(() => {
|
||||
// Diagnostics and build fencing must never interfere with receiver recovery.
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyLiveViewerClientBuild(
|
||||
lineage: Pick<LiveViewerLineage, "uiBuildId">,
|
||||
signal?: AbortSignal,
|
||||
): void {
|
||||
if (
|
||||
signal?.aborted ||
|
||||
lineage.uiBuildId === DEVELOPMENT_UI_BUILD_ID ||
|
||||
browserUiBuildCoordinator().stale()
|
||||
) return;
|
||||
void fetch("/api/v1/viewer/client-contract", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"X-MissionCore-UI-Build": lineage.uiBuildId,
|
||||
},
|
||||
cache: "no-store",
|
||||
signal,
|
||||
}).then(async (response) => {
|
||||
if (signal?.aborted) return;
|
||||
inspectUiBuildResponse(response, lineage.uiBuildId, signal);
|
||||
if (!response.ok) return;
|
||||
const contract = await response.json() as unknown;
|
||||
if (
|
||||
signal?.aborted ||
|
||||
typeof contract !== "object" ||
|
||||
contract === null ||
|
||||
!("schema_version" in contract) ||
|
||||
contract.schema_version !== LIVE_VIEWER_CLIENT_CONTRACT_SCHEMA ||
|
||||
!("ui_build_id" in contract) ||
|
||||
typeof contract.ui_build_id !== "string" ||
|
||||
contract.ui_build_id === lineage.uiBuildId
|
||||
) return;
|
||||
browserUiBuildCoordinator().report({
|
||||
loadedUiBuildId: lineage.uiBuildId,
|
||||
expectedUiBuildId: contract.ui_build_id,
|
||||
});
|
||||
}).catch(() => {
|
||||
// Offline periods are handled by the existing live-stream recovery path.
|
||||
});
|
||||
}
|
||||
|
||||
function startBuildMonitor(): void {
|
||||
if (buildMonitorAbort || typeof window === "undefined") return;
|
||||
const lineage = createLiveViewerLineage(createLiveViewerInstanceId(), 1);
|
||||
const controller = new AbortController();
|
||||
buildMonitorAbort = controller;
|
||||
const verify = createAbortFencedBuildVerifier(
|
||||
controller.signal,
|
||||
(signal) => verifyLiveViewerClientBuild(lineage, signal),
|
||||
);
|
||||
buildMonitorInterval = window.setInterval(
|
||||
verify,
|
||||
UI_BUILD_CHECK_INTERVAL_MILLISECONDS,
|
||||
);
|
||||
buildMonitorOnlineListener = verify;
|
||||
buildMonitorVisibilityListener = () => {
|
||||
if (document.visibilityState === "visible") verify();
|
||||
};
|
||||
window.addEventListener("online", buildMonitorOnlineListener);
|
||||
document.addEventListener("visibilitychange", buildMonitorVisibilityListener);
|
||||
verify();
|
||||
}
|
||||
|
||||
export function createAbortFencedBuildVerifier(
|
||||
signal: AbortSignal,
|
||||
verifier: (signal: AbortSignal) => void,
|
||||
): () => void {
|
||||
return () => {
|
||||
if (signal.aborted) return;
|
||||
verifier(signal);
|
||||
};
|
||||
}
|
||||
|
||||
function stopBuildMonitor(): void {
|
||||
buildMonitorAbort?.abort();
|
||||
buildMonitorAbort = null;
|
||||
if (buildMonitorInterval !== null) window.clearInterval(buildMonitorInterval);
|
||||
buildMonitorInterval = null;
|
||||
if (buildMonitorOnlineListener) {
|
||||
window.removeEventListener("online", buildMonitorOnlineListener);
|
||||
}
|
||||
if (buildMonitorVisibilityListener) {
|
||||
document.removeEventListener("visibilitychange", buildMonitorVisibilityListener);
|
||||
}
|
||||
buildMonitorOnlineListener = null;
|
||||
buildMonitorVisibilityListener = null;
|
||||
}
|
||||
|
||||
export function subscribeToLiveViewerBuildFence(
|
||||
listener: (event: StaleUiBuild) => void,
|
||||
): () => void {
|
||||
const unsubscribe = browserUiBuildCoordinator().subscribe(listener);
|
||||
buildMonitorSubscribers += 1;
|
||||
if (buildMonitorSubscribers === 1) startBuildMonitor();
|
||||
return () => {
|
||||
unsubscribe();
|
||||
buildMonitorSubscribers = Math.max(0, buildMonitorSubscribers - 1);
|
||||
if (buildMonitorSubscribers === 0) stopBuildMonitor();
|
||||
};
|
||||
}
|
||||
|
||||
export function createLiveViewerDiagnosticLifecycle({
|
||||
lineage,
|
||||
scheduler = browserScheduler(),
|
||||
diagnosticPoster = postLiveViewerDiagnostic,
|
||||
buildVerifier = verifyLiveViewerClientBuild,
|
||||
}: {
|
||||
lineage: LiveViewerLineage;
|
||||
scheduler?: LiveViewerDiagnosticScheduler;
|
||||
diagnosticPoster?: (
|
||||
event: LiveViewerDiagnostic,
|
||||
lineage: LiveViewerLineage,
|
||||
signal?: AbortSignal,
|
||||
) => void;
|
||||
buildVerifier?: (
|
||||
lineage: Pick<LiveViewerLineage, "uiBuildId">,
|
||||
signal?: AbortSignal,
|
||||
) => void;
|
||||
}): LiveViewerDiagnosticLifecycle {
|
||||
const abort = new AbortController();
|
||||
let isActive = true;
|
||||
let isAdmitted = false;
|
||||
let admissionTimeout: number | null = null;
|
||||
let admissionInterval: number | null = null;
|
||||
const clearAdmissionTimeout = () => {
|
||||
if (admissionTimeout === null) return;
|
||||
scheduler.clearTimeout(admissionTimeout);
|
||||
admissionTimeout = null;
|
||||
};
|
||||
const clearAdmissionInterval = () => {
|
||||
if (admissionInterval === null) return;
|
||||
scheduler.clearInterval(admissionInterval);
|
||||
admissionInterval = null;
|
||||
};
|
||||
return {
|
||||
lineage,
|
||||
signal: abort.signal,
|
||||
active: () => isActive,
|
||||
admitted: () => isAdmitted,
|
||||
post(event) {
|
||||
if (!isActive) return;
|
||||
diagnosticPoster(event, lineage, abort.signal);
|
||||
},
|
||||
verifyBuild() {
|
||||
if (!isActive) return;
|
||||
buildVerifier(lineage, abort.signal);
|
||||
},
|
||||
armAdmissionTimeout(callback, delayMilliseconds) {
|
||||
clearAdmissionTimeout();
|
||||
if (!isActive || isAdmitted) return;
|
||||
admissionTimeout = scheduler.setTimeout(() => {
|
||||
admissionTimeout = null;
|
||||
if (isActive && !isAdmitted) callback();
|
||||
}, delayMilliseconds);
|
||||
},
|
||||
armAdmissionInterval(callback, delayMilliseconds) {
|
||||
clearAdmissionInterval();
|
||||
if (!isActive || isAdmitted) return;
|
||||
admissionInterval = scheduler.setInterval(() => {
|
||||
if (isActive && !isAdmitted) callback();
|
||||
}, delayMilliseconds);
|
||||
},
|
||||
clearAdmissionTimeout,
|
||||
clearAdmissionInterval,
|
||||
markAdmitted() {
|
||||
if (!isActive) return;
|
||||
isAdmitted = true;
|
||||
clearAdmissionTimeout();
|
||||
clearAdmissionInterval();
|
||||
},
|
||||
dispose() {
|
||||
if (!isActive) return;
|
||||
isActive = false;
|
||||
abort.abort();
|
||||
clearAdmissionTimeout();
|
||||
clearAdmissionInterval();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
export type PerceptionPreparationPhase =
|
||||
| "idle"
|
||||
| "cache-lookup"
|
||||
| "queued"
|
||||
| "artifact-validation"
|
||||
| "rendering"
|
||||
| "cache-write"
|
||||
| "ready"
|
||||
| "unavailable"
|
||||
| "error";
|
||||
|
||||
export interface PerceptionPreparationStatus {
|
||||
state: "idle" | "preparing" | "ready" | "unavailable" | "error";
|
||||
phase: PerceptionPreparationPhase;
|
||||
elapsedSeconds: number;
|
||||
byteLength: number | null;
|
||||
}
|
||||
|
||||
const RECORDED_PERCEPTION_PATH =
|
||||
/^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/perception\.rrd$/;
|
||||
const SAFE_RECORDING_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
export async function fetchPerceptionPreparationStatus(
|
||||
endpointUrl: string,
|
||||
recordingId: string,
|
||||
{
|
||||
origin,
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
origin: string;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
},
|
||||
): Promise<PerceptionPreparationStatus> {
|
||||
const base = new URL(origin);
|
||||
const endpoint = new URL(endpointUrl, base.origin);
|
||||
if (
|
||||
endpoint.origin !== base.origin ||
|
||||
endpoint.search ||
|
||||
endpoint.hash ||
|
||||
!RECORDED_PERCEPTION_PATH.test(endpoint.pathname) ||
|
||||
!SAFE_RECORDING_ID.test(recordingId)
|
||||
) {
|
||||
throw new Error("Unsafe perception preparation status request");
|
||||
}
|
||||
endpoint.pathname = endpoint.pathname.replace(/\/perception\.rrd$/, "/perception/status");
|
||||
endpoint.searchParams.set("recording_id", recordingId);
|
||||
const response = await fetcher(endpoint.href, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Perception preparation status is unavailable");
|
||||
}
|
||||
const value: unknown = await response.json();
|
||||
if (!isPerceptionPreparationStatus(value)) {
|
||||
throw new Error("Invalid perception preparation status");
|
||||
}
|
||||
return {
|
||||
state: value.state,
|
||||
phase: value.phase,
|
||||
elapsedSeconds: value.elapsed_seconds,
|
||||
byteLength: value.byte_length,
|
||||
};
|
||||
}
|
||||
|
||||
export function perceptionPreparationMessage(
|
||||
phase: PerceptionPreparationPhase,
|
||||
): string | null {
|
||||
switch (phase) {
|
||||
case "cache-lookup":
|
||||
return "Проверяем готовый AI-слой.";
|
||||
case "queued":
|
||||
return "AI-слой ожидает последовательной подготовки.";
|
||||
case "artifact-validation":
|
||||
return "Проверяем опубликованный AI-результат.";
|
||||
case "rendering":
|
||||
return "Формируем AI-слой.";
|
||||
case "cache-write":
|
||||
return "Сохраняем AI-слой.";
|
||||
case "ready":
|
||||
return "AI-слой готов. Загружаем.";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isPerceptionPreparationStatus(
|
||||
value: unknown,
|
||||
): value is {
|
||||
state: PerceptionPreparationStatus["state"];
|
||||
phase: PerceptionPreparationPhase;
|
||||
elapsed_seconds: number;
|
||||
byte_length: number | null;
|
||||
} {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
return (
|
||||
["idle", "preparing", "ready", "unavailable", "error"].includes(
|
||||
String(candidate.state),
|
||||
) &&
|
||||
[
|
||||
"idle",
|
||||
"cache-lookup",
|
||||
"queued",
|
||||
"artifact-validation",
|
||||
"rendering",
|
||||
"cache-write",
|
||||
"ready",
|
||||
"unavailable",
|
||||
"error",
|
||||
].includes(String(candidate.phase)) &&
|
||||
typeof candidate.elapsed_seconds === "number" &&
|
||||
Number.isFinite(candidate.elapsed_seconds) &&
|
||||
candidate.elapsed_seconds >= 0 &&
|
||||
(candidate.byte_length === null ||
|
||||
(typeof candidate.byte_length === "number" &&
|
||||
Number.isSafeInteger(candidate.byte_length) &&
|
||||
candidate.byte_length >= 0))
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export type ObservationSessionStatus =
|
||||
| "interrupted"
|
||||
| "failed";
|
||||
|
||||
export type ObservationSessionScope = "all" | "source" | "laboratory";
|
||||
|
||||
export interface ObservationLabInstance {
|
||||
labId: string;
|
||||
sourceSessionId: string;
|
||||
@@ -1077,15 +1079,21 @@ function requirePreparationEtag(
|
||||
export async function fetchObservationSessionCatalog({
|
||||
signal,
|
||||
limit,
|
||||
scope = "all",
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
limit?: number;
|
||||
scope?: ObservationSessionScope;
|
||||
fetcher?: ObservationSessionFetch;
|
||||
} = {}): Promise<ObservationSessionCatalog> {
|
||||
const query = Number.isInteger(limit) && Number(limit) >= 1 && Number(limit) <= 100
|
||||
? `?limit=${Number(limit)}`
|
||||
: "";
|
||||
const queryParameters = new URLSearchParams();
|
||||
if (Number.isInteger(limit) && Number(limit) >= 1 && Number(limit) <= 100) {
|
||||
queryParameters.set("limit", String(Number(limit)));
|
||||
}
|
||||
if (scope !== "all") queryParameters.set("scope", scope);
|
||||
const serializedQuery = queryParameters.toString();
|
||||
const query = serializedQuery ? `?${serializedQuery}` : "";
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(`/api/v1/observation-sessions${query}`, {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { ObservationSourceDescriptor } from "../runtime/contracts";
|
||||
import {
|
||||
createLiveViewerInstanceId,
|
||||
createLiveViewerLineage,
|
||||
postLiveViewerDiagnostic,
|
||||
type LiveViewerDiagnostic,
|
||||
type LiveViewerLineage,
|
||||
} from "./liveViewerDiagnostics";
|
||||
import {
|
||||
closeObservationSource,
|
||||
openObservationSource,
|
||||
@@ -54,6 +61,7 @@ export interface ObservationLayoutController {
|
||||
setFloatingMaximized: (sourceId: string, maximized: boolean) => void;
|
||||
setWindowRect: (sourceId: string, rect: ObservationWindowRect) => void;
|
||||
setViewportSize: (size: ObservationViewportSize) => void;
|
||||
activateAutomaticDefaults: () => void;
|
||||
snapshot: () => ObservationLayoutSnapshot | null;
|
||||
restore: (snapshot: ObservationLayoutSnapshot) => void;
|
||||
}
|
||||
@@ -68,6 +76,156 @@ function canOpenByDefault(source: ObservationSourceDescriptor): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One exact live presentation lease. The stable source id is deliberately not
|
||||
* sufficient: the same live camera and even the same browser delivery generation
|
||||
* can be reused by a later acquisition.
|
||||
*/
|
||||
export function automaticLivePresentationIdentity(
|
||||
source: ObservationSourceDescriptor,
|
||||
): string | null {
|
||||
const acquisitionId = source.binding.acquisitionId?.trim();
|
||||
const deliveryId = source.delivery?.id?.trim();
|
||||
if (
|
||||
!acquisitionId
|
||||
|| !deliveryId
|
||||
|| !canOpenByDefault(source)
|
||||
|| source.activation?.selected !== true
|
||||
) return null;
|
||||
return JSON.stringify([source.id, acquisitionId, deliveryId]);
|
||||
}
|
||||
|
||||
export interface LiveCameraPresentationDiagnostic {
|
||||
key: string;
|
||||
event: LiveViewerDiagnostic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only boundary markers for the automatic camera path. They distinguish
|
||||
* a catalog/delivery failure from layout admission and player mounting without
|
||||
* changing presentation state or contacting the device.
|
||||
*/
|
||||
export function pendingLiveCameraPresentationDiagnostics(
|
||||
sources: readonly ObservationSourceDescriptor[],
|
||||
visibleSourceIds: ReadonlySet<string>,
|
||||
emittedKeys: ReadonlySet<string>,
|
||||
): LiveCameraPresentationDiagnostic[] {
|
||||
const pending: LiveCameraPresentationDiagnostic[] = [];
|
||||
for (const source of sources) {
|
||||
if (source.modality !== "video") continue;
|
||||
const presentationIdentity = automaticLivePresentationIdentity(source);
|
||||
const deliveryId = source.delivery?.id?.trim();
|
||||
if (!presentationIdentity || !deliveryId) continue;
|
||||
const projectedKey = `projected:${presentationIdentity}`;
|
||||
if (!emittedKeys.has(projectedKey)) {
|
||||
pending.push({
|
||||
key: projectedKey,
|
||||
event: {
|
||||
eventCode: "live_camera_source_projected",
|
||||
streamId: deliveryId,
|
||||
},
|
||||
});
|
||||
}
|
||||
const admittedKey = `admitted:${presentationIdentity}`;
|
||||
if (visibleSourceIds.has(source.id) && !emittedKeys.has(admittedKey)) {
|
||||
pending.push({
|
||||
key: admittedKey,
|
||||
event: {
|
||||
eventCode: "live_camera_window_admitted",
|
||||
streamId: deliveryId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** A deliberate close fences every delivery generation in that acquisition. */
|
||||
export function livePresentationCloseFence(
|
||||
source: ObservationSourceDescriptor,
|
||||
): string | null {
|
||||
const acquisitionId = source.binding.acquisitionId?.trim();
|
||||
return acquisitionId ? JSON.stringify([source.id, acquisitionId]) : null;
|
||||
}
|
||||
|
||||
function acquisitionIdFromLivePresentationCloseFence(fence: string): string | null {
|
||||
try {
|
||||
const decoded: unknown = JSON.parse(fence);
|
||||
if (
|
||||
!Array.isArray(decoded)
|
||||
|| decoded.length !== 2
|
||||
|| typeof decoded[0] !== "string"
|
||||
|| typeof decoded[1] !== "string"
|
||||
|| !decoded[0].trim()
|
||||
|| !decoded[1].trim()
|
||||
) return null;
|
||||
return decoded[1].trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LiveDefaultPresentationAdmission {
|
||||
visibleIds: string[];
|
||||
removedIds: string[];
|
||||
admittedIdentities: string[];
|
||||
}
|
||||
|
||||
export function admitLiveDefaultPresentations(
|
||||
currentIds: readonly string[],
|
||||
sources: readonly ObservationSourceDescriptor[],
|
||||
admittedIdentities: ReadonlySet<string>,
|
||||
closedAcquisitionSources: ReadonlySet<string>,
|
||||
): LiveDefaultPresentationAdmission {
|
||||
let change = { visibleIds: [...currentIds], removedIds: [] as string[] };
|
||||
const removed = new Set<string>();
|
||||
const admitted: string[] = [];
|
||||
for (const source of sources) {
|
||||
const presentationIdentity = automaticLivePresentationIdentity(source);
|
||||
if (
|
||||
!presentationIdentity
|
||||
|| admittedIdentities.has(presentationIdentity)
|
||||
|| closedAcquisitionSources.has(livePresentationCloseFence(source) ?? "")
|
||||
) continue;
|
||||
change = openObservationSource(change.visibleIds, source.id, sources);
|
||||
change.removedIds.forEach((sourceId) => removed.add(sourceId));
|
||||
admitted.push(presentationIdentity);
|
||||
}
|
||||
return {
|
||||
visibleIds: change.visibleIds,
|
||||
removedIds: [...removed],
|
||||
admittedIdentities: admitted,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A restored workspace may hide cameras that the device plugin has not selected.
|
||||
* It must still admit the exact selected delivery in a fresh browser document,
|
||||
* a replacement delivery for a camera already presented in this acquisition,
|
||||
* or the first delivery of a later acquisition in the same browser document.
|
||||
* An explicit close remains a separate acquisition-scoped fence in
|
||||
* `admitLiveDefaultPresentations`; whether it may de-select the source remains
|
||||
* the plugin's current activation-authority decision.
|
||||
*/
|
||||
export function restoredLayoutMayAdmitLiveDefault(
|
||||
sources: readonly ObservationSourceDescriptor[],
|
||||
presentedAcquisitionSources: ReadonlySet<string>,
|
||||
): boolean {
|
||||
const selectedSources = sources.filter(automaticLivePresentationIdentity);
|
||||
if (selectedSources.length === 0) return false;
|
||||
if (presentedAcquisitionSources.size === 0) return true;
|
||||
return selectedSources.some((source) => {
|
||||
const lineage = livePresentationCloseFence(source);
|
||||
if (!lineage) return false;
|
||||
if (presentedAcquisitionSources.has(lineage)) return true;
|
||||
const acquisitionId = source.binding.acquisitionId?.trim();
|
||||
if (!acquisitionId) return false;
|
||||
return ![...presentedAcquisitionSources].some(
|
||||
(presented) => acquisitionIdFromLivePresentationCloseFence(presented) === acquisitionId,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): string {
|
||||
return sources
|
||||
.map((source) => [
|
||||
@@ -107,6 +265,11 @@ export function useObservationLayout(
|
||||
const viewportSizeRef = useRef<ObservationViewportSize | null>(null);
|
||||
const desiredSnapshotRef = useRef<ObservationLayoutSnapshot | null>(null);
|
||||
const restoredLayoutAuthorityRef = useRef(false);
|
||||
const admittedLivePresentationIdentitiesRef = useRef(new Set<string>());
|
||||
const closedLiveAcquisitionSourcesRef = useRef(new Set<string>());
|
||||
const presentedLiveAcquisitionSourcesRef = useRef(new Set<string>());
|
||||
const emittedLiveCameraDiagnosticKeysRef = useRef(new Set<string>());
|
||||
const liveCameraDiagnosticLineageRef = useRef<LiveViewerLineage | null>(null);
|
||||
const initializedCatalog = useRef<string | null>(null);
|
||||
const sourceIdList = sources.map((source) => source.id).sort();
|
||||
const sourceIdsIdentity = sourceIdList.join("\u0000");
|
||||
@@ -119,6 +282,12 @@ export function useObservationLayout(
|
||||
|
||||
const commitVisibleIds = useCallback((next: readonly string[]) => {
|
||||
const unique = [...new Set(next)];
|
||||
const visible = new Set(unique);
|
||||
for (const source of sourcesRef.current) {
|
||||
if (!visible.has(source.id) || !automaticLivePresentationIdentity(source)) continue;
|
||||
const lineage = livePresentationCloseFence(source);
|
||||
if (lineage) presentedLiveAcquisitionSourcesRef.current.add(lineage);
|
||||
}
|
||||
visibleIdsRef.current = unique;
|
||||
setVisibleIds(unique);
|
||||
}, []);
|
||||
@@ -284,33 +453,55 @@ export function useObservationLayout(
|
||||
sources,
|
||||
]);
|
||||
|
||||
const selectedDeliveryIdentity = sources
|
||||
.filter((source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery)
|
||||
.map((source) => [
|
||||
source.id,
|
||||
source.delivery?.id,
|
||||
source.activation?.groupId,
|
||||
source.activation?.maxActive,
|
||||
].join(":"))
|
||||
.sort()
|
||||
.join("|");
|
||||
const selectedDeliveryIdentity = JSON.stringify(sources
|
||||
.map(automaticLivePresentationIdentity)
|
||||
.filter((candidate): candidate is string => candidate !== null)
|
||||
.sort());
|
||||
|
||||
const admitAutomaticLivePresentations = useCallback(() => {
|
||||
const currentSources = sourcesRef.current;
|
||||
if (
|
||||
restoredLayoutAuthorityRef.current
|
||||
&& !restoredLayoutMayAdmitLiveDefault(
|
||||
currentSources,
|
||||
presentedLiveAcquisitionSourcesRef.current,
|
||||
)
|
||||
) return;
|
||||
const admission = admitLiveDefaultPresentations(
|
||||
visibleIdsRef.current,
|
||||
currentSources,
|
||||
admittedLivePresentationIdentitiesRef.current,
|
||||
closedLiveAcquisitionSourcesRef.current,
|
||||
);
|
||||
if (!admission.admittedIdentities.length) return;
|
||||
admission.admittedIdentities.forEach((presentationIdentity) => {
|
||||
admittedLivePresentationIdentitiesRef.current.add(presentationIdentity);
|
||||
});
|
||||
commitVisibleIds(admission.visibleIds);
|
||||
clearPresentation(admission.removedIds, false);
|
||||
persistLiveLayout();
|
||||
}, [clearPresentation, commitVisibleIds, persistLiveLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (restoredLayoutAuthorityRef.current) return;
|
||||
const selected = sources.filter(
|
||||
(source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery,
|
||||
admitAutomaticLivePresentations();
|
||||
}, [admitAutomaticLivePresentations, selectedDeliveryIdentity]);
|
||||
|
||||
useEffect(() => {
|
||||
const diagnostics = pendingLiveCameraPresentationDiagnostics(
|
||||
sources,
|
||||
new Set(visibleIds),
|
||||
emittedLiveCameraDiagnosticKeysRef.current,
|
||||
);
|
||||
if (!selected.length) return;
|
||||
let change = { visibleIds: visibleIdsRef.current, removedIds: [] as string[] };
|
||||
const removed = new Set<string>();
|
||||
for (const source of selected) {
|
||||
change = openObservationSource(change.visibleIds, source.id, sources);
|
||||
change.removedIds.forEach((sourceId) => removed.add(sourceId));
|
||||
if (!diagnostics.length) return;
|
||||
liveCameraDiagnosticLineageRef.current ??= createLiveViewerLineage(
|
||||
createLiveViewerInstanceId(),
|
||||
1,
|
||||
);
|
||||
for (const diagnostic of diagnostics) {
|
||||
emittedLiveCameraDiagnosticKeysRef.current.add(diagnostic.key);
|
||||
postLiveViewerDiagnostic(diagnostic.event, liveCameraDiagnosticLineageRef.current);
|
||||
}
|
||||
commitVisibleIds(change.visibleIds);
|
||||
clearPresentation([...removed], false);
|
||||
persistLiveLayout();
|
||||
}, [clearPresentation, commitVisibleIds, persistLiveLayout, selectedDeliveryIdentity]);
|
||||
}, [selectedDeliveryIdentity, sources, visibleIds]);
|
||||
|
||||
const markPending = useCallback((source: ObservationSourceDescriptor, pending: boolean) => {
|
||||
const groupId = source.activation?.groupId;
|
||||
@@ -334,6 +525,8 @@ export function useObservationLayout(
|
||||
markPending(source, false);
|
||||
}
|
||||
}
|
||||
const closeFence = livePresentationCloseFence(source);
|
||||
if (closeFence) closedLiveAcquisitionSourcesRef.current.add(closeFence);
|
||||
restoredLayoutAuthorityRef.current = false;
|
||||
const change = closeObservationSource(visibleIdsRef.current, sourceId);
|
||||
commitVisibleIds(change.visibleIds);
|
||||
@@ -356,6 +549,8 @@ export function useObservationLayout(
|
||||
markPending(source, false);
|
||||
}
|
||||
}
|
||||
const closeFence = livePresentationCloseFence(source);
|
||||
if (closeFence) closedLiveAcquisitionSourcesRef.current.delete(closeFence);
|
||||
restoredLayoutAuthorityRef.current = false;
|
||||
const change = openObservationSource(visibleIdsRef.current, sourceId, sources);
|
||||
commitVisibleIds(change.visibleIds);
|
||||
@@ -427,6 +622,46 @@ export function useObservationLayout(
|
||||
}
|
||||
}, [applyDesiredSnapshot, persistLiveLayout]);
|
||||
|
||||
const activateAutomaticDefaults = useCallback(() => {
|
||||
// A new operator-started live acquisition owns its initial presentation.
|
||||
// Keep saved geometry, but do not let an older layout suppress a camera
|
||||
// that the device plugin has just selected and delivered automatically.
|
||||
restoredLayoutAuthorityRef.current = false;
|
||||
const currentSources = sourcesRef.current;
|
||||
let nextVisibleIds = visibleIdsRef.current;
|
||||
for (const source of currentSources.filter(canOpenByDefault)) {
|
||||
if (automaticLivePresentationIdentity(source)) continue;
|
||||
nextVisibleIds = openObservationSource(
|
||||
nextVisibleIds,
|
||||
source.id,
|
||||
currentSources,
|
||||
).visibleIds;
|
||||
}
|
||||
const admission = admitLiveDefaultPresentations(
|
||||
nextVisibleIds,
|
||||
currentSources,
|
||||
admittedLivePresentationIdentitiesRef.current,
|
||||
closedLiveAcquisitionSourcesRef.current,
|
||||
);
|
||||
admission.admittedIdentities.forEach((presentationIdentity) => {
|
||||
admittedLivePresentationIdentitiesRef.current.add(presentationIdentity);
|
||||
});
|
||||
nextVisibleIds = admission.visibleIds;
|
||||
commitVisibleIds(nextVisibleIds);
|
||||
clearPresentation(admission.removedIds, false);
|
||||
const firstFloatingDefault = currentSources.find(
|
||||
(source) => (
|
||||
canOpenByDefault(source)
|
||||
&& source.capabilities.overlay
|
||||
&& nextVisibleIds.includes(source.id)
|
||||
),
|
||||
);
|
||||
if (firstFloatingDefault) {
|
||||
commitActiveFloatingSourceId(firstFloatingDefault.id);
|
||||
}
|
||||
persistLiveLayout();
|
||||
}, [clearPresentation, commitActiveFloatingSourceId, commitVisibleIds, persistLiveLayout]);
|
||||
|
||||
const snapshot = useCallback((): ObservationLayoutSnapshot | null => {
|
||||
if (!viewportSizeRef.current) return null;
|
||||
persistLiveLayout();
|
||||
@@ -444,7 +679,10 @@ export function useObservationLayout(
|
||||
});
|
||||
restoredLayoutAuthorityRef.current = true;
|
||||
applyDesiredSnapshot(desiredSnapshotRef.current, "reset");
|
||||
}, [applyDesiredSnapshot]);
|
||||
// The selected delivery may already have arrived before the saved layout.
|
||||
// Re-run admission here so restore ordering cannot strand its camera window.
|
||||
admitAutomaticLivePresentations();
|
||||
}, [admitAutomaticLivePresentations, applyDesiredSnapshot]);
|
||||
|
||||
const visibleSourceIds = useMemo(() => new Set(visibleIds), [visibleIds]);
|
||||
const pendingSourceIds = useMemo(() => new Set(pendingIds), [pendingIds]);
|
||||
@@ -464,6 +702,7 @@ export function useObservationLayout(
|
||||
setFloatingMaximized,
|
||||
setWindowRect,
|
||||
setViewportSize,
|
||||
activateAutomaticDefaults,
|
||||
snapshot,
|
||||
restore,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ObservationSessionFetch,
|
||||
type ObservationSessionPreparation,
|
||||
type ObservationSessionReplayLaunch,
|
||||
type ObservationSessionScope,
|
||||
type ObservationSessionSummary,
|
||||
} from "./sessionArchive";
|
||||
|
||||
@@ -51,6 +52,20 @@ export interface ObservationReplayCoordinator {
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export async function deleteObservationSessionAfterTeardown(
|
||||
sessionId: string,
|
||||
{
|
||||
onDeleteBegin,
|
||||
deleteSession = deleteObservationSession,
|
||||
}: {
|
||||
onDeleteBegin?: (sessionId: string) => void | Promise<void>;
|
||||
deleteSession?: (sessionId: string) => Promise<void>;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
await onDeleteBegin?.(sessionId);
|
||||
await deleteSession(sessionId);
|
||||
}
|
||||
|
||||
export interface ObservationPreparationPollingOptions {
|
||||
signal: AbortSignal;
|
||||
fetcher?: ObservationSessionFetch;
|
||||
@@ -369,12 +384,17 @@ export function clearObservationReplayPreparation(
|
||||
|
||||
export function useObservationSessions({
|
||||
limit = 100,
|
||||
scope = "all",
|
||||
pollingEnabled = true,
|
||||
replayEnabled = true,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
onDeleteBegin,
|
||||
}: {
|
||||
limit?: number;
|
||||
scope?: ObservationSessionScope;
|
||||
pollingEnabled?: boolean;
|
||||
replayEnabled?: boolean;
|
||||
/** Called only after the archive is ready, immediately before replacing the old viewer. */
|
||||
onReplayBegin?: (
|
||||
@@ -389,6 +409,8 @@ export function useObservationSessions({
|
||||
session: ObservationSessionSummary,
|
||||
outcome: ObservationReplayOutcome,
|
||||
) => void | Promise<void>;
|
||||
/** Releases an active viewer before its evidence and derived cache are deleted. */
|
||||
onDeleteBegin?: (sessionId: string) => void | Promise<void>;
|
||||
} = {}): ObservationSessionsController {
|
||||
const [items, setItems] = useState<ObservationSessionSummary[]>([]);
|
||||
const [state, setState] = useState<ObservationSessionsLoadState>("idle");
|
||||
@@ -425,7 +447,10 @@ export function useObservationSessions({
|
||||
const sequence = ++catalogSequence.current;
|
||||
if (foreground) setState("loading");
|
||||
try {
|
||||
const catalog = await fetchObservationSessionCatalog({ limit: safeLimit });
|
||||
const catalog = await fetchObservationSessionCatalog({
|
||||
limit: safeLimit,
|
||||
scope,
|
||||
});
|
||||
if (!mounted.current || sequence !== catalogSequence.current) return false;
|
||||
setItems(catalog.items.slice(0, safeLimit));
|
||||
setState("ready");
|
||||
@@ -437,7 +462,7 @@ export function useObservationSessions({
|
||||
setError(errorMessage(loadError));
|
||||
return false;
|
||||
}
|
||||
}, [safeLimit]);
|
||||
}, [safeLimit, scope]);
|
||||
|
||||
const refresh = useCallback(() => loadCatalog(true), [loadCatalog]);
|
||||
|
||||
@@ -452,7 +477,7 @@ export function useObservationSessions({
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "ready") return;
|
||||
if (!pollingEnabled || state !== "ready") return;
|
||||
let disposed = false;
|
||||
let timer = 0;
|
||||
const poll = async () => {
|
||||
@@ -465,7 +490,7 @@ export function useObservationSessions({
|
||||
disposed = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [loadCatalog, state]);
|
||||
}, [loadCatalog, pollingEnabled, state]);
|
||||
|
||||
const executeReplay = useCallback(async (
|
||||
session: ObservationSessionSummary,
|
||||
@@ -600,7 +625,7 @@ export function useObservationSessions({
|
||||
setDeletingSessionId(sessionId);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteObservationSession(sessionId);
|
||||
await deleteObservationSessionAfterTeardown(sessionId, { onDeleteBegin });
|
||||
if (!mounted.current) return false;
|
||||
setItems((current) => current.filter((item) => item.id !== sessionId));
|
||||
setFailedSessionId((current) => current === sessionId ? null : current);
|
||||
@@ -611,7 +636,7 @@ export function useObservationSessions({
|
||||
} finally {
|
||||
if (mounted.current) setDeletingSessionId(null);
|
||||
}
|
||||
}, [deletingSessionId, replayingSessionId]);
|
||||
}, [deletingSessionId, onDeleteBegin, replayingSessionId]);
|
||||
|
||||
return {
|
||||
items,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user