fix: align discharge sorting and camera classification

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Даня Архипов
2026-08-02 07:56:35 +00:00
parent 30a9f7efb3
commit 6e3b6ae3f0
19 changed files with 881 additions and 142 deletions

View File

@@ -1,6 +1,7 @@
/**
* Continuous Playback Engine — manages auto-demo on main page.
* Classification is always driven by classifyItem (live rule engine).
* Playlist selects SKU only. Sorter route / category is assigned by the
* physical camera classification pipeline (see cameraClassification.ts).
* Supports speed control, case seek, seeded variability, and event journal.
*/
@@ -9,6 +10,7 @@ import { DEMO_PLAYLIST, type PlaylistCase, PLAYLIST_LENGTH } from './demoPlaylis
import { classifyItem } from './classifier';
import { resolveItem } from '../data/resolveItem';
import { createSeededRng, DEFAULT_DEMO_SEED, seededOffset } from './seededRng';
import { resetCameraClassifications } from './cameraClassification';
/** Playback status for the continuous demo. */
export type PlaybackStatus = 'idle' | 'running' | 'paused' | 'finished';
@@ -41,9 +43,9 @@ export interface PhaseConfig {
*/
export const CASE_PHASES: PhaseConfig[] = [
{ phase: 'spawn', durationMs: 300, label: 'Spawn at A' },
// Stage 2B §10: 1900 ms so that feed(300)+1900+600+1000+1000+1000 = 5500 ms
// == A->GATE distance (5.5 m) at 1.0 m/s — continuous motion, no dwell.
{ phase: 'move_to_detection', durationMs: 1900, label: 'Moving to camera' },
// A→GATE = 5.95 m at 1.0 m/s. feed(300)+2350+600+1000+1000+1000 = 6250 ms
// → travel from feed start = 5950 ms = GATE.x − A.x.
{ phase: 'move_to_detection', durationMs: 2350, label: 'Moving to camera' },
{ phase: 'detection', durationMs: 600, label: 'CV Detection' },
{ phase: 'measurement', durationMs: 1000, label: 'Laser measurement' },
{ phase: 'classification', durationMs: 1000, label: 'Classification' },
@@ -127,11 +129,6 @@ function caseDurationMs(playlistCase: PlaylistCase): number {
return getPlaylistCaseDurationMs(playlistCase);
}
function classifyCase(playlistCase: PlaylistCase): ClassificationResult {
const item = resolveItem(playlistCase.itemId);
return classifyItem(item);
}
function buildJitter(seed: number, caseIndex: number): { x: number; z: number; yaw: number } {
const rng = createSeededRng(seed + caseIndex * 9973);
return {
@@ -158,11 +155,9 @@ function pushEvent(
}
function initCaseFields(playlistCase: PlaylistCase, seed: number, caseIndex: number, events: EventLogEntry[], simTime: number) {
const classification = classifyCase(playlistCase);
const warnings = [
...(playlistCase.warning ? [playlistCase.warning] : []),
...classification.warnings,
];
// SKU only — no preassigned category / route for motion.
void resolveItem(playlistCase.itemId);
const warnings = playlistCase.warning ? [playlistCase.warning] : [];
return {
currentCase: playlistCase,
currentCaseIndex: caseIndex,
@@ -170,8 +165,8 @@ function initCaseFields(playlistCase: PlaylistCase, seed: number, caseIndex: num
currentPhase: 'spawn' as CasePhase,
phaseElapsedMs: 0,
caseElapsedMs: 0,
targetCategory: classification.category,
classification,
targetCategory: null as Category | null,
classification: null as ClassificationResult | null,
command: 'IDLE',
warning: warnings[0] ?? null,
positionJitter: buildJitter(seed, caseIndex),
@@ -179,16 +174,29 @@ function initCaseFields(playlistCase: PlaylistCase, seed: number, caseIndex: num
itemId: playlistCase.itemId,
type: 'system',
message: `Case start: ${playlistCase.title}`,
category: classification.category,
category: undefined,
status: 'info',
}),
};
}
/** Bind camera classification result into playback UI state (never at spawn). */
export function applyCameraClassificationToPlayback(
state: ContinuousPlaybackState,
classification: ClassificationResult,
): ContinuousPlaybackState {
return {
...state,
targetCategory: classification.category,
classification,
warning: classification.warnings[0] ?? state.warning,
};
}
/** Create initial playback state. */
export function createPlaybackState(seed: number = DEFAULT_DEMO_SEED): ContinuousPlaybackState {
const firstCase = DEMO_PLAYLIST[0];
const classification = classifyCase(firstCase);
resetCameraClassifications();
return {
status: 'idle',
currentCaseIndex: 0,
@@ -213,6 +221,7 @@ export function createPlaybackState(seed: number = DEFAULT_DEMO_SEED): Continuou
/** Start playback from the beginning. */
export function startPlayback(state: ContinuousPlaybackState): ContinuousPlaybackState {
const firstCase = DEMO_PLAYLIST[0];
resetCameraClassifications();
const fields = initCaseFields(firstCase, state.seed, 0, [], 0);
return {
...state,
@@ -294,7 +303,7 @@ function advanceToNextCase(state: ContinuousPlaybackState): ContinuousPlaybackSt
return { ...state, ...fields };
}
function getCommandForPhase(phase: CasePhase, category: Category): string {
function getCommandForPhase(phase: CasePhase, category: Category | null): string {
switch (phase) {
case 'spawn':
case 'move_to_detection':
@@ -308,7 +317,7 @@ function getCommandForPhase(phase: CasePhase, category: Category): string {
case 'command_sent':
case 'routing':
case 'exit':
return `ROUTE_TO_${category}`;
return category ? `ROUTE_TO_${category}` : 'AWAITING_CLASSIFICATION';
case 'fault_hold':
return 'FAULT';
case 'emergency_hold':
@@ -410,7 +419,9 @@ export function updatePlayback(
}
}
if (newState.status === 'running' && newState.targetCategory) {
if (newState.status === 'running') {
// Phase commands (incl. FAULT) update without a preassigned route;
// ROUTE_TO_* only appears after camera classification binds category.
newState.command = getCommandForPhase(newState.currentPhase, newState.targetCategory);
}
@@ -457,12 +468,12 @@ export function getTotalProgress(state: ContinuousPlaybackState): number {
return (completedCases + currentCaseProgress) / PLAYLIST_LENGTH;
}
/** Assert playlist expectedCategory matches live classifier (for tests). */
/** Assert playlist expectedCategory matches live classifier (test metadata only). */
export function assertPlaylistClassifierConsistency(): Array<{ id: string; expected: Category; actual: Category }> {
const mismatches: Array<{ id: string; expected: Category; actual: Category }> = [];
for (const c of DEMO_PLAYLIST) {
if (c.faultType) continue;
const result = classifyCase(c);
const result = classifyItem(resolveItem(c.itemId));
if (result.category !== c.expectedCategory) {
mismatches.push({ id: c.id, expected: c.expectedCategory, actual: result.category });
}