mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-15 03:02:17 -05:00
slop
This commit is contained in:
@@ -2,18 +2,27 @@
|
|||||||
|
|
||||||
## Resume here
|
## Resume here
|
||||||
|
|
||||||
Latest change: one ongoing learner, gated Latest/Previous checkpoints, and
|
Latest change: format 3 command-event policy replaces format 2 continuous wheel
|
||||||
admin-created permanent snapshots replace automatic permanent publications.
|
speeds. All supported controls, including drive and recorded zero-speed stops,
|
||||||
|
use scored command templates and the same threshold/checked command pipe. The
|
||||||
|
network produces scores and numeric values; it never generates JSON text.
|
||||||
|
|
||||||
Status: policy format 2 now predicts continuous wheel speeds plus accessory events.
|
Implemented: remove wheel-speed telemetry and previous drive-command history
|
||||||
Copyable diagnostics and main-card wheel/action displays with a session threshold
|
from inputs (raw recordings unchanged); preserve exact numeric zeros; reuse
|
||||||
slider are implemented. Older model files remain stored but cannot run in this format.
|
recordings with a fresh resume-v4.pt / schedule-v4.json lineage. Existing format 2
|
||||||
Recording, bounded dataset/training, permanent named models, live inference,
|
snapshots remain stored and are visibly incompatible. One ongoing learner, gated
|
||||||
checked autonomous command submission, and the verified VIP card are coded.
|
Latest/Previous, and admin-created permanent snapshots remain.
|
||||||
Human commands do not stop or pause autonomy (explicit latest user requirement).
|
|
||||||
Focused local checks passed; actual recording/training, live autonomous behavior,
|
Main card shows decoded command parameters, score, applied threshold marker, and
|
||||||
concurrent server performance, and browser appearance remain unverified.
|
submission results, including below-threshold proposals. Diagnostics reportVersion
|
||||||
The standalone CPU benchmark was run successfully on the actual server.
|
3 includes command predictions/submissions and event evaluation. Manual inputs
|
||||||
|
still never stop or pause autonomy. Disabled remains dormant/off by default.
|
||||||
|
|
||||||
|
Focused Node syntax, Python production worker entry points, and card ESLint
|
||||||
|
passed. New policy: 803,440 parameters, 30 sensor fields, stateSize 700. No live
|
||||||
|
recordings/camera/rover pipeline or authenticated browser were available locally.
|
||||||
|
Actual training, stop behavior, concurrent performance and visual appearance
|
||||||
|
remain unverified. No tests were added and no processes were left running.
|
||||||
|
|
||||||
This document preserves the conversation's decisions across compactions and
|
This document preserves the conversation's decisions across compactions and
|
||||||
usage cutoffs. Keep the checklist and handoff section current during authorized
|
usage cutoffs. Keep the checklist and handoff section current during authorized
|
||||||
@@ -30,9 +39,8 @@ The user declined the manual server checklist and explicitly asked to continue.
|
|||||||
Do not block further work or repeatedly ask them to do it. Preserve the unverified
|
Do not block further work or repeatedly ask them to do it. Preserve the unverified
|
||||||
status and have the reader reject unusable recordings automatically.
|
status and have the reader reject unusable recordings automatically.
|
||||||
|
|
||||||
Latest increment: internal card gating, dock lead-in/tail capture, live prediction
|
Latest increment: see the command-event policy handoff at the end. Earlier
|
||||||
diagnostics, ongoing retained-data sampling, and gated model publication are coded.
|
format 2 wheel-specific design notes below are historical and superseded.
|
||||||
See the final handoff section for settings and the new training lineage.
|
|
||||||
|
|
||||||
Next work: verify the integrated feature against actual recordings and the real
|
Next work: verify the integrated feature against actual recordings and the real
|
||||||
camera/rover pipeline when that environment is available; inspect the VIP card in
|
camera/rover pipeline when that environment is available; inspect the VIP card in
|
||||||
@@ -835,3 +843,74 @@ scheduled. Selection guarantees a held-out recording, not usable evaluation
|
|||||||
windows: decoding/vocabulary/sample checks still apply in the worker.
|
windows: decoding/vocabulary/sample checks still apply in the worker.
|
||||||
Node syntax checks and diff whitespace checks passed; actual job selection on
|
Node syntax checks and diff whitespace checks passed; actual job selection on
|
||||||
production recordings remains unverified. No new tests or background processes.
|
production recordings remains unverified. No new tests or background processes.
|
||||||
|
|
||||||
|
### Active implementation: command-event policy (authorized IPI)
|
||||||
|
|
||||||
|
Supersedes format 2 continuous wheel output. The model predicts numeric command
|
||||||
|
scores and payload values; ordinary code constructs commands for the existing
|
||||||
|
shared command pipe. It does not generate JSON text. Drive, recorded key-release
|
||||||
|
stops, and accessories all use the threshold. Remove wheel-speed sensors and
|
||||||
|
previous drive-command inputs, but preserve raw recordings. Exact zero values
|
||||||
|
remain template constants so a demonstrated stop cannot regress into creeping.
|
||||||
|
|
||||||
|
Keep one continuing learner, Latest/Previous, admin snapshots, recording lifecycle,
|
||||||
|
permissions, and manual inputs that never cancel autonomy. No prescribed human
|
||||||
|
driving. Start a new format 3 lineage, reuse recordings, show older snapshots as
|
||||||
|
incompatible. No model-size increase or recording redesign in this change.
|
||||||
|
|
||||||
|
Update the upper card to show every command proposal with decoded parameters,
|
||||||
|
score, threshold marker and dispatch result, including below-threshold proposals.
|
||||||
|
Update copied diagnostics and evaluation for command events, parameter error,
|
||||||
|
missed stops and unwanted commands. Evaluate more than one held-out recording;
|
||||||
|
event validation is not proof of autonomous driving or physical rollout quality.
|
||||||
|
|
||||||
|
Progress: implemented; focused checks passed. User's unrelated webui/package.json
|
||||||
|
edit was preserved. No changes to runtime recording, disabled startup, control
|
||||||
|
permissions or manual-input override semantics.
|
||||||
|
|
||||||
|
Implementation details for resume:
|
||||||
|
- workers/policy.py VERSION=3, CNN/GRU unchanged in size; two heads for scores and
|
||||||
|
command parameters. Shared select_slots chooses one winner per command family
|
||||||
|
(peripheral id/control are separate families). No unconditional drive head.
|
||||||
|
- Numeric zeros and address fields stay exact template constants. Stop is the
|
||||||
|
normal recorded driveDirect {left:0,right:0} command. Unknown templates still
|
||||||
|
reject explicitly at the 64-shape limit. Arrays remain exact demonstrations.
|
||||||
|
- workers/dataset.py yields five arrays: images, state, event labels, parameters,
|
||||||
|
available shapes. It includes accepted human drive events, excludes ambiguous
|
||||||
|
conflicting events in the same 100ms window, and no longer requires an inferred
|
||||||
|
held wheel-speed target. Raw recorded sensors/commands are unchanged.
|
||||||
|
- Training reserves separate bounded samples for stop events, other commands and
|
||||||
|
no-command windows. Evaluation remains uniformly sampled. Scheduler selects
|
||||||
|
up to three training and two held-out recordings under the unchanged SHA split.
|
||||||
|
- Gate: complete evaluation, >=2 usable held-out sessions, >=128 windows, >=32
|
||||||
|
command events, >=8 stops, >=16 movement events; command precision/recall, stop
|
||||||
|
recall and movement recall each >=0.5; normalized numeric MSE <=0.1 where
|
||||||
|
applicable. Diagnostics expose each failing reason and per-command counts.
|
||||||
|
These are experimental screening thresholds, not a guarantee of useful driving.
|
||||||
|
- New optimizer/scheduler filenames prevent silently reusing format 2 weights or
|
||||||
|
old rejected-session bookkeeping. No config values need changing. Existing
|
||||||
|
permanent format 2 snapshots are preserved, but cannot start; attempting to
|
||||||
|
snapshot an old candidate reports that a new command-policy checkpoint is needed.
|
||||||
|
- Live inference keeps existing discrete toggle latching and common command limits.
|
||||||
|
The threshold applies to drive and stop just like the other command predictions.
|
||||||
|
No-event means send nothing: it DOES NOT implicitly stop an already moving rover.
|
||||||
|
It must learn explicit stop events; removing the copy shortcut is not proof that
|
||||||
|
learning will succeed. Other motion-related sensors still remain as observations.
|
||||||
|
- Upper card replaces separate wheel meters with generic scored proposals and
|
||||||
|
decoded parameters; shows applied threshold and post-clamp submitted parameters.
|
||||||
|
Copied diagnostics retain top eight proposals and up to 16 submissions per
|
||||||
|
sampled controller-history entry, including their command objects.
|
||||||
|
|
||||||
|
Verification: actual train.py --check-dependencies loaded Torch/NumPy/PyAV and
|
||||||
|
constructed format 3 (803,440 parameters); infer.py --help loaded successfully;
|
||||||
|
Node --check passed for six changed service modules; targeted card ESLint passed.
|
||||||
|
No new tests or throwaway programs. Live end-to-end training/inference and browser
|
||||||
|
visual verification require the actual environment and have not been performed.
|
||||||
|
|
||||||
|
Deferred explicitly: chronological commanded-motion replay/closed-loop evaluation,
|
||||||
|
prepared frame caching and larger architectures. Current evaluation uses held-out
|
||||||
|
100ms event windows with the live threshold/family selection, but cannot assess
|
||||||
|
temporal latching or accumulated motion from random reservoir samples. It does
|
||||||
|
not claim to verify actual physical command execution. After normal deployment,
|
||||||
|
let the new learner process retained recordings; copied reportVersion 3 diagnostics
|
||||||
|
provide the next evidence. Do not demand prescribed driving or a manual checklist.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Match actuator configuration, not a rover's display name or changing telemetry.
|
// Match actuator configuration, not a rover's display name or changing telemetry.
|
||||||
const { isDeepStrictEqual } = require('node:util');
|
const { isDeepStrictEqual } = require('node:util');
|
||||||
|
const POLICY_VERSION = 3;
|
||||||
|
|
||||||
function controlProfile(meta = {}) {
|
function controlProfile(meta = {}) {
|
||||||
return Object.fromEntries(['cameraServo', 'peripherals', 'headlight', 'laser', 'roomba', 'platform']
|
return Object.fromEntries(['cameraServo', 'peripherals', 'headlight', 'laser', 'roomba', 'platform']
|
||||||
@@ -7,9 +8,9 @@ function controlProfile(meta = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function compatible(model, record) {
|
function compatible(model, record) {
|
||||||
if (model?.specification?.version !== 2 || !record) return false;
|
if (model?.specification?.version !== POLICY_VERSION || !record) return false;
|
||||||
const profile = controlProfile(record.meta);
|
const profile = controlProfile(record.meta);
|
||||||
return Array.isArray(model.controlProfiles) && model.controlProfiles.some((saved) => isDeepStrictEqual(saved, profile));
|
return Array.isArray(model.controlProfiles) && model.controlProfiles.some((saved) => isDeepStrictEqual(saved, profile));
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { controlProfile, compatible };
|
module.exports = { POLICY_VERSION, controlProfile, compatible };
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ module.exports = {
|
|||||||
enabled: boolean({ description: 'Enable human-session recording, background learning, and verified-user model control. Disabled starts no workers or storage.' }),
|
enabled: boolean({ description: 'Enable human-session recording, background learning, and verified-user model control. Disabled starts no workers or storage.' }),
|
||||||
driving: strictObject({
|
driving: strictObject({
|
||||||
threads: integer({ minimum: 1, maximum: 16, description: 'CPU threads per active model controller.' }),
|
threads: integer({ minimum: 1, maximum: 16, description: 'CPU threads per active model controller.' }),
|
||||||
actionThreshold: number({ minimum: 0.5, maximum: 0.99, description: 'Minimum predicted action probability for issuing a new control event.' }),
|
actionThreshold: number({ minimum: 0.5, maximum: 0.99, description: 'Minimum model score for issuing a command, including drive and stop commands.' }),
|
||||||
staleMs: integer({ minimum: 1000, maximum: 5000, description: 'Stop when video, sensors, or model predictions are older than this many milliseconds.' }),
|
staleMs: integer({ minimum: 1000, maximum: 5000, description: 'Stop when video, sensors, or model predictions are older than this many milliseconds.' }),
|
||||||
}, { description: 'Live model execution. Manual inputs do not stop or pause the model.', required: ['threads', 'actionThreshold', 'staleMs'] }),
|
}, { description: 'Live model execution. Manual inputs do not stop or pause the model.', required: ['threads', 'actionThreshold', 'staleMs'] }),
|
||||||
recording: strictObject({
|
recording: strictObject({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Export an explicit allowlist, never the raw socket/session/configuration object.
|
// Export an explicit allowlist, never the raw socket/session/configuration object.
|
||||||
const { redact } = require('./errors');
|
const { redact } = require('./errors');
|
||||||
|
const { POLICY_VERSION } = require('./capabilities');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
@@ -7,7 +8,7 @@ const { createHash } = require('node:crypto');
|
|||||||
const applicationVersion = require('../../../package.json').version;
|
const applicationVersion = require('../../../package.json').version;
|
||||||
const hash = createHash('sha256');
|
const hash = createHash('sha256');
|
||||||
for (const file of ['workers/policy.py', 'workers/dataset.py', 'workers/train.py', 'workers/infer.py',
|
for (const file of ['workers/policy.py', 'workers/dataset.py', 'workers/train.py', 'workers/infer.py',
|
||||||
'errors.js', 'storage.js', 'runtime.js', 'models.js', 'driving.js', 'recording.js', 'training.js', 'socketGateway.js', 'diagnostics.js']) {
|
'capabilities.js', 'errors.js', 'storage.js', 'runtime.js', 'models.js', 'driving.js', 'recording.js', 'training.js', 'socketGateway.js', 'diagnostics.js']) {
|
||||||
hash.update(fs.readFileSync(path.join(__dirname, file)));
|
hash.update(fs.readFileSync(path.join(__dirname, file)));
|
||||||
}
|
}
|
||||||
const implementationSha256 = hash.digest('hex');
|
const implementationSha256 = hash.digest('hex');
|
||||||
@@ -29,8 +30,8 @@ function report(state, controller) {
|
|||||||
const metrics = training.diagnostics || {};
|
const metrics = training.diagnostics || {};
|
||||||
const session = state.session;
|
const session = state.session;
|
||||||
return {
|
return {
|
||||||
reportVersion: 2, generatedAtUnixMs: state.updatedAt,
|
reportVersion: 3, generatedAtUnixMs: state.updatedAt,
|
||||||
software: { applicationVersion, implementationSha256, policyVersion: 2, node: process.version,
|
software: { applicationVersion, implementationSha256, policyVersion: POLICY_VERSION, node: process.version,
|
||||||
dependencies: metrics.dependencies || null },
|
dependencies: metrics.dependencies || null },
|
||||||
host: { logicalCpus: os.cpus().length, totalMemoryBytes: os.totalmem(), freeMemoryBytes: os.freemem(), loadAverage: os.loadavg() },
|
host: { logicalCpus: os.cpus().length, totalMemoryBytes: os.totalmem(), freeMemoryBytes: os.freemem(), loadAverage: os.loadavg() },
|
||||||
settings: state.limits,
|
settings: state.limits,
|
||||||
@@ -52,7 +53,7 @@ function report(state, controller) {
|
|||||||
controller: controller ? { modelId: controller.modelId, sensorPresent: controller.sensorPresent || null,
|
controller: controller ? { modelId: controller.modelId, sensorPresent: controller.sensorPresent || null,
|
||||||
stopReason: redact(controller.stopReason), stopCategory: errorCategory(controller.stopReason), history: (controller.history || []).map((item) => ({
|
stopReason: redact(controller.stopReason), stopCategory: errorCategory(controller.stopReason), history: (controller.history || []).map((item) => ({
|
||||||
...item, submissions: item.submissions.map((submission) => ({ type: submission.type,
|
...item, submissions: item.submissions.map((submission) => ({ type: submission.type,
|
||||||
wheels: submission.wheels, result: redact(submission.result) })) })) } : null,
|
command: submission.command, result: redact(submission.result) })) })) } : null,
|
||||||
sensorFields: metrics.policy?.sensorFields || null,
|
sensorFields: metrics.policy?.sensorFields || null,
|
||||||
timing: { alignment: 'server_receipt', browserVideoLatencyMs: null, wheelCommandExpiryMs: null },
|
timing: { alignment: 'server_receipt', browserVideoLatencyMs: null, wheelCommandExpiryMs: null },
|
||||||
events: state.activity.map(({ at, message, detail, error, stderr }) => ({ atUnixMs: at, message: redact(message), detail: redact(detail), error: error || null, stderr: redact(stderr) })),
|
events: state.activity.map(({ at, message, detail, error, stderr }) => ({ atUnixMs: at, message: redact(message), detail: redact(detail), error: error || null, stderr: redact(stderr) })),
|
||||||
|
|||||||
@@ -127,21 +127,19 @@ function createDriving({ root, config, onChange, activity }) {
|
|||||||
session.latencyMs = message.latencyMs;
|
session.latencyMs = message.latencyMs;
|
||||||
session.proposals = Array.isArray(message.proposals) ? message.proposals.slice(0, 64) : [];
|
session.proposals = Array.isArray(message.proposals) ? message.proposals.slice(0, 64) : [];
|
||||||
session.appliedThreshold = message.threshold;
|
session.appliedThreshold = message.threshold;
|
||||||
session.wheelSpeeds = message.wheelSpeeds;
|
|
||||||
session.sensorPresent = message.sensorPresent;
|
session.sensorPresent = message.sensorPresent;
|
||||||
session.submissions = [];
|
session.submissions = [];
|
||||||
if (!Array.isArray(message.commands) || message.commands.length > 65) throw new Error('Invalid action batch');
|
if (!Array.isArray(message.commands) || message.commands.length > 65) throw new Error('Invalid action batch');
|
||||||
session.predictionStatus = message.wheelSpeeds?.some((value) => value !== 0) ? 'Sending wheel controls' : 'Predicting stopped wheels';
|
session.predictionStatus = message.commands.length ? `${message.commands.length} command(s) proposed` : 'No command proposed';
|
||||||
// Manual commands intentionally do not stop or pause this controller.
|
// Manual commands intentionally do not stop or pause this controller.
|
||||||
for (const command of message.commands) transmit(session, command);
|
for (const command of message.commands) transmit(session, command);
|
||||||
// Bound report history independently of camera and UI frame rates.
|
// Bound report history independently of camera and UI frame rates.
|
||||||
if (!session.history.length || Date.now() - session.history.at(-1).at >= 500) {
|
if (!session.history.length || Date.now() - session.history.at(-1).at >= 500) {
|
||||||
session.history.push({ at: Date.now(), wheelSpeeds: session.wheelSpeeds,
|
session.history.push({ at: Date.now(),
|
||||||
latencyMs: session.latencyMs, frameAgeMs: Date.now() - session.frameAt,
|
latencyMs: session.latencyMs, frameAgeMs: Date.now() - session.frameAt,
|
||||||
sensorAgeMs: Date.now() - session.sensorAt, threshold: session.appliedThreshold,
|
sensorAgeMs: Date.now() - session.sensorAt, threshold: session.appliedThreshold,
|
||||||
proposals: [...session.proposals].sort((a, b) => b.score - a.score).slice(0, 8).map(({ slot, score, reason }) => ({ slot, score, reason })),
|
proposals: [...session.proposals].sort((a, b) => b.score - a.score).slice(0, 8).map(({ slot, score, reason, command }) => ({ slot, score, reason, command })),
|
||||||
submissions: session.submissions.slice(0, 16).map(({ command, result }) => ({ type: command.type, result,
|
submissions: session.submissions.slice(0, 16).map(({ command, result }) => ({ type: command.type, result, command })) });
|
||||||
wheels: command.type === 'drive' ? command.driveDirect : undefined })) });
|
|
||||||
if (session.history.length > 60) session.history.shift();
|
if (session.history.length > 60) session.history.shift();
|
||||||
}
|
}
|
||||||
} catch (error) { stop(roverId, error.message); break; }
|
} catch (error) { stop(roverId, error.message); break; }
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const { POLICY_VERSION } = require('./capabilities');
|
||||||
// Mutable aliases reference immutable checkpoints. All readers and retention share
|
// Mutable aliases reference immutable checkpoints. All readers and retention share
|
||||||
// a queue so a model cannot disappear between resolving its alias and loading it.
|
// a queue so a model cannot disappear between resolving its alias and loading it.
|
||||||
const fs = require('node:fs/promises');
|
const fs = require('node:fs/promises');
|
||||||
@@ -82,6 +83,7 @@ function saveSnapshot(root) {
|
|||||||
if (!UUID.test(state.candidate || '')) throw new Error('No completed learner checkpoint yet');
|
if (!UUID.test(state.candidate || '')) throw new Error('No completed learner checkpoint yet');
|
||||||
const source = path.join(checkpointRoot(root), state.candidate);
|
const source = path.join(checkpointRoot(root), state.candidate);
|
||||||
const original = await metadata(source);
|
const original = await metadata(source);
|
||||||
|
if (original.specification?.version !== POLICY_VERSION) throw new Error('Waiting for a completed command-policy checkpoint');
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const words = [['amber', 'silver', 'quiet', 'curious'], ['otter', 'finch', 'fox', 'wren']];
|
const words = [['amber', 'silver', 'quiet', 'curious'], ['otter', 'finch', 'fox', 'wren']];
|
||||||
const name = `${words[0][randomInt(4)]}-${words[1][randomInt(4)]}-${id.slice(0, 8)}`;
|
const name = `${words[0][randomInt(4)]}-${words[1][randomInt(4)]}-${id.slice(0, 8)}`;
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function createTrainer({ root, config, actionThreshold, logger }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(await fs.readFile(path.join(trainingRoot, 'schedule-v3.json'), 'utf8'));
|
const saved = JSON.parse(await fs.readFile(path.join(trainingRoot, 'schedule-v4.json'), 'utf8'));
|
||||||
ledger = saved.sessions;
|
ledger = saved.sessions;
|
||||||
state = { ...saved.progress, status: 'waiting' };
|
state = { ...saved.progress, status: 'waiting' };
|
||||||
if (!ledger || typeof ledger !== 'object') throw new Error('Invalid training schedule');
|
if (!ledger || typeof ledger !== 'object') throw new Error('Invalid training schedule');
|
||||||
@@ -46,7 +46,7 @@ function createTrainer({ root, config, actionThreshold, logger }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveLedger() {
|
async function saveLedger() {
|
||||||
const filename = path.join(trainingRoot, 'schedule-v3.json');
|
const filename = path.join(trainingRoot, 'schedule-v4.json');
|
||||||
await fs.writeFile(`${filename}.tmp`, JSON.stringify({ sessions: ledger, progress: state }));
|
await fs.writeFile(`${filename}.tmp`, JSON.stringify({ sessions: ledger, progress: state }));
|
||||||
await fs.rename(`${filename}.tmp`, filename);
|
await fs.rename(`${filename}.tmp`, filename);
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ function createTrainer({ root, config, actionThreshold, logger }) {
|
|||||||
partitions[heldOut ? 'heldOut' : 'training'].push(id);
|
partitions[heldOut ? 'heldOut' : 'training'].push(id);
|
||||||
}
|
}
|
||||||
const trainingSessions = partitions.training.slice(0, partitions.heldOut.length ? 3 : 4);
|
const trainingSessions = partitions.training.slice(0, partitions.heldOut.length ? 3 : 4);
|
||||||
const heldOutSessions = trainingSessions.length ? partitions.heldOut.slice(0, 1) : [];
|
const heldOutSessions = trainingSessions.length ? partitions.heldOut.slice(0, 2) : [];
|
||||||
const selected = [...trainingSessions, ...heldOutSessions];
|
const selected = [...trainingSessions, ...heldOutSessions];
|
||||||
const selection = {
|
const selection = {
|
||||||
atUnixMs: Date.now(),
|
atUnixMs: Date.now(),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
|||||||
import av
|
import av
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from policy import SLOTS, VALUES, HEIGHT, WIDTH, encode_command, sensor_vector
|
from policy import SLOTS, VALUES, HEIGHT, WIDTH, SENSORS, command_family, is_stop, encode_command, sensor_vector
|
||||||
|
|
||||||
|
|
||||||
def load_events(directory):
|
def load_events(directory):
|
||||||
@@ -72,27 +72,13 @@ def prepare(directory, schema, stats):
|
|||||||
if not sensors:
|
if not sensors:
|
||||||
raise ValueError('No sensor observations')
|
raise ValueError('No sensor observations')
|
||||||
candidate = copy.deepcopy(schema)
|
candidate = copy.deepcopy(schema)
|
||||||
history, targets, wheels = [], [], [(stamp, None) for stamp in boundaries]
|
history, targets = [], []
|
||||||
stats.update({'sensorFrames': len(sensors), 'dispatches': len(dispatches),
|
stats.update({'sensorFrames': len(sensors), 'dispatches': len(dispatches),
|
||||||
'failedCommands': len(failed), 'sensorPresentFraction': np.mean([row[1][32:] for row in sensors], axis=0).tolist()})
|
'failedCommands': len(failed), 'sensorPresentFraction': np.mean([row[1][len(SENSORS):] for row in sensors], axis=0).tolist()})
|
||||||
for data in dispatches:
|
for data in dispatches:
|
||||||
command = data.get('command') or {}
|
command = data.get('command') or {}
|
||||||
request = requests.get(data.get('requestId'))
|
if command.get('type') in ('reset', 'reboot', 'update', 'sensorStream'):
|
||||||
human = (data.get('source') == 'client' and request and data.get('commandId') in accepted
|
boundaries.append(data['ts'])
|
||||||
and data.get('commandId') not in failed)
|
|
||||||
stamp = data['ts']
|
|
||||||
if command.get('type') == 'drive':
|
|
||||||
direct = command.get('driveDirect') or {}
|
|
||||||
pair = [direct.get('left'), direct.get('right')]
|
|
||||||
if human and all(isinstance(v, (int, float)) and math.isfinite(v) for v in pair):
|
|
||||||
wheels.append((stamp, np.clip(np.array(pair, dtype=np.float32), -metadata.get('wheelSpeedLimit', 500), metadata.get('wheelSpeedLimit', 500)) / 500))
|
|
||||||
else:
|
|
||||||
wheels.append((stamp, None))
|
|
||||||
elif command.get('type') in ('raw', 'reset', 'reboot', 'update', 'sensorStream'):
|
|
||||||
# Raw OI can change mode or motion; do not assume the last wheel
|
|
||||||
# command survives it. A later explicit human drive resolves intent.
|
|
||||||
wheels.append((stamp, None))
|
|
||||||
boundaries.append(stamp)
|
|
||||||
encoded = encode_command(command, candidate, extend=True)
|
encoded = encode_command(command, candidate, extend=True)
|
||||||
if encoded is None:
|
if encoded is None:
|
||||||
continue
|
continue
|
||||||
@@ -112,22 +98,21 @@ def prepare(directory, schema, stats):
|
|||||||
if not targets:
|
if not targets:
|
||||||
raise ValueError('No accepted client operating commands')
|
raise ValueError('No accepted client operating commands')
|
||||||
schema[:] = candidate
|
schema[:] = candidate
|
||||||
return metadata, sensors, sorted(wheels, key=lambda x: x[0]), sorted(history, key=lambda x: x[0]), sorted(targets, key=lambda x: x[0]), sorted(boundaries)
|
return metadata, sensors, sorted(history, key=lambda x: x[0]), sorted(targets, key=lambda x: x[0]), sorted(boundaries)
|
||||||
|
|
||||||
|
|
||||||
def windows(directory, schema, cancelled=lambda: False, stats=None):
|
def windows(directory, schema, cancelled=lambda: False, stats=None):
|
||||||
directory = Path(directory)
|
directory = Path(directory)
|
||||||
stats = stats if stats is not None else {}
|
stats = stats if stats is not None else {}
|
||||||
metadata, sensors, wheels, history, targets, boundaries = prepare(directory, schema, stats)
|
metadata, sensors, history, targets, boundaries = prepare(directory, schema, stats)
|
||||||
wheel_times = [row[0] for row in wheels]
|
stats.update({'usableWindows': 0, 'commandWindows': 0, 'stopWindows': 0, 'noCommandWindows': 0,
|
||||||
stats.update({'usableWindows': 0, 'movingWindows': 0, 'stoppedWindows': 0,
|
'staleSensorWindows': 0, 'boundaryWindows': 0,
|
||||||
'unknownWheelWindows': 0, 'staleSensorWindows': 0, 'boundaryWindows': 0,
|
|
||||||
'ambiguousWindows': 0, 'videoFrames': 0, 'maxVideoGapMs': 0})
|
'ambiguousWindows': 0, 'videoFrames': 0, 'maxVideoGapMs': 0})
|
||||||
sensor_times = [row[0] for row in sensors]
|
sensor_times = [row[0] for row in sensors]
|
||||||
target_times = [row[0] for row in targets]
|
target_times = [row[0] for row in targets]
|
||||||
available = np.zeros(SLOTS, dtype=np.float32)
|
available = np.zeros(SLOTS, dtype=np.float32)
|
||||||
for _, slot, _ in history:
|
for _, slot, _ in history:
|
||||||
available[slot] = float(schema[slot]['template']['type'] != 'drive')
|
available[slot] = 1
|
||||||
command_values = np.zeros((SLOTS, VALUES), dtype=np.float32)
|
command_values = np.zeros((SLOTS, VALUES), dtype=np.float32)
|
||||||
command_times = np.full(SLOTS, -np.inf)
|
command_times = np.full(SLOTS, -np.inf)
|
||||||
cursor = 0
|
cursor = 0
|
||||||
@@ -164,8 +149,10 @@ def windows(directory, schema, cancelled=lambda: False, stats=None):
|
|||||||
last_sample = stamp
|
last_sample = stamp
|
||||||
while cursor < len(history) and history[cursor][0] < stamp:
|
while cursor < len(history) and history[cursor][0] < stamp:
|
||||||
when, slot, values = history[cursor]
|
when, slot, values = history[cursor]
|
||||||
command_values[slot] = values
|
# Do not let drive predictions simply copy earlier human/model drive commands.
|
||||||
command_times[slot] = when
|
if schema[slot]['template']['type'] != 'drive':
|
||||||
|
command_values[slot] = values
|
||||||
|
command_times[slot] = when
|
||||||
cursor += 1
|
cursor += 1
|
||||||
sensor_index = bisect.bisect_right(sensor_times, stamp) - 1
|
sensor_index = bisect.bisect_right(sensor_times, stamp) - 1
|
||||||
if sensor_index < 0 or stamp - sensor_times[sensor_index] > 1000:
|
if sensor_index < 0 or stamp - sensor_times[sensor_index] > 1000:
|
||||||
@@ -188,12 +175,6 @@ def windows(directory, schema, cancelled=lambda: False, stats=None):
|
|||||||
if bisect.bisect_right(boundaries, stamp + 100) > bisect.bisect_left(boundaries, chosen[0][0]):
|
if bisect.bisect_right(boundaries, stamp + 100) > bisect.bisect_left(boundaries, chosen[0][0]):
|
||||||
stats['boundaryWindows'] += 1
|
stats['boundaryWindows'] += 1
|
||||||
continue
|
continue
|
||||||
before = bisect.bisect_left(wheel_times, stamp) - 1
|
|
||||||
after = bisect.bisect_right(wheel_times, stamp + 100) - 1
|
|
||||||
if before < 0 or after < 0 or wheels[before][1] is None or wheels[after][1] is None:
|
|
||||||
stats['unknownWheelWindows'] += 1
|
|
||||||
continue
|
|
||||||
wheel_target, previous_wheels = wheels[after][1], wheels[before][1]
|
|
||||||
start = bisect.bisect_left(target_times, stamp)
|
start = bisect.bisect_left(target_times, stamp)
|
||||||
end = bisect.bisect_left(target_times, stamp + 100)
|
end = bisect.bisect_left(target_times, stamp + 100)
|
||||||
actions = np.zeros(SLOTS, dtype=np.float32)
|
actions = np.zeros(SLOTS, dtype=np.float32)
|
||||||
@@ -202,13 +183,8 @@ def windows(directory, schema, cancelled=lambda: False, stats=None):
|
|||||||
families = set()
|
families = set()
|
||||||
for _, slot, vector in targets[start:end]:
|
for _, slot, vector in targets[start:end]:
|
||||||
template = schema[slot]['template']
|
template = schema[slot]['template']
|
||||||
family = template['type']
|
family = command_family(template)
|
||||||
if family == 'drive':
|
if ((actions[slot] and (template['type'] in ('headlight', 'laser', 'horn', 'song', 'raw') or not np.array_equal(values[slot], vector)))
|
||||||
continue
|
|
||||||
if family == 'peripheral':
|
|
||||||
peripheral = template.get('peripheral') or {}
|
|
||||||
family += ':' + str(peripheral.get('id')) + ':' + str(peripheral.get('control'))
|
|
||||||
if ((actions[slot] and (not schema[slot]['fields'] or not np.array_equal(values[slot], vector)))
|
|
||||||
or (not actions[slot] and family in families)):
|
or (not actions[slot] and family in families)):
|
||||||
ambiguous = True
|
ambiguous = True
|
||||||
break
|
break
|
||||||
@@ -218,5 +194,6 @@ def windows(directory, schema, cancelled=lambda: False, stats=None):
|
|||||||
stats['ambiguousWindows'] += 1
|
stats['ambiguousWindows'] += 1
|
||||||
continue
|
continue
|
||||||
stats['usableWindows'] += 1
|
stats['usableWindows'] += 1
|
||||||
stats['movingWindows' if np.max(np.abs(wheel_target)) > .02 else 'stoppedWindows'] += 1
|
stats['commandWindows' if actions.any() else 'noCommandWindows'] += 1
|
||||||
yield np.stack([row[1] for row in chosen]), np.stack([row[2] for row in chosen]), actions, values, available, wheel_target, previous_wheels
|
stats['stopWindows'] += int(any(actions[slot] and is_stop(descriptor) for slot, descriptor in enumerate(schema)))
|
||||||
|
yield np.stack([row[1] for row in chosen]), np.stack([row[2] for row in chosen]), actions, values, available
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import av
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import torch
|
import torch
|
||||||
|
|
||||||
from policy import Policy, VERSION, SLOTS, VALUES, HEIGHT, WIDTH, encode_command, sensor_vector
|
from policy import Policy, VERSION, SLOTS, VALUES, HEIGHT, WIDTH, SENSORS, select_slots, decode_command, encode_command, sensor_vector
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -78,7 +78,7 @@ def main():
|
|||||||
args.threshold = max(.05, min(.99, float(event['threshold'])))
|
args.threshold = max(.05, min(.99, float(event['threshold'])))
|
||||||
elif event['kind'] == 'sensor':
|
elif event['kind'] == 'sensor':
|
||||||
sensors, sensor_time = event['data'], event['ts']
|
sensors, sensor_time = event['data'], event['ts']
|
||||||
elif event['kind'] == 'command':
|
elif event['kind'] == 'command' and event['data'].get('type') != 'drive':
|
||||||
try:
|
try:
|
||||||
encoded = encode_command(event['data'], schema)
|
encoded = encode_command(event['data'], schema)
|
||||||
if encoded:
|
if encoded:
|
||||||
@@ -104,58 +104,30 @@ def main():
|
|||||||
continue
|
continue
|
||||||
before = time.perf_counter()
|
before = time.perf_counter()
|
||||||
with torch.inference_mode():
|
with torch.inference_mode():
|
||||||
logits, numeric, wheel_output = model(torch.from_numpy(np.stack([row[1] for row in selected])).unsqueeze(0).float() / 255,
|
logits, numeric = model(torch.from_numpy(np.stack([row[1] for row in selected])).unsqueeze(0).float() / 255,
|
||||||
torch.from_numpy(np.stack([row[2] for row in selected])).unsqueeze(0))
|
torch.from_numpy(np.stack([row[2] for row in selected])).unsqueeze(0))
|
||||||
probabilities = logits[0].sigmoid().tolist()
|
probabilities = logits[0].sigmoid().tolist()
|
||||||
predictions = numeric[0].tolist()
|
predictions = numeric[0].tolist()
|
||||||
wheel_speeds = [round(value * 500) for value in wheel_output[0].tolist()]
|
|
||||||
if not all(math.isfinite(value) for value in probabilities):
|
if not all(math.isfinite(value) for value in probabilities):
|
||||||
raise ValueError('Nonfinite model output')
|
raise ValueError('Nonfinite model output')
|
||||||
families = {}
|
winners = select_slots(probabilities, schema, args.threshold)
|
||||||
active = set()
|
active = {slot for slot in range(len(schema)) if probabilities[slot] >= args.threshold}
|
||||||
proposals = []
|
|
||||||
for slot, descriptor in enumerate(schema):
|
|
||||||
if descriptor['template']['type'] == 'drive':
|
|
||||||
continue
|
|
||||||
command = json.loads(json.dumps(descriptor['template']))
|
|
||||||
family = command['type']
|
|
||||||
if family == 'peripheral':
|
|
||||||
family += ':' + str(command['peripheral']['id']) + ':' + str(command['peripheral']['control'])
|
|
||||||
for index, field in enumerate(descriptor['fields']):
|
|
||||||
number = predictions[slot][index]
|
|
||||||
if not math.isfinite(number):
|
|
||||||
raise ValueError('Nonfinite control value')
|
|
||||||
number = math.tan(max(-.98, min(.98, number)) * math.pi / 2) * field['scale']
|
|
||||||
target = command
|
|
||||||
for key in field['path'][:-1]:
|
|
||||||
target = target[key]
|
|
||||||
target[field['path'][-1]] = round(number) if field['integer'] else number
|
|
||||||
proposals.append({'slot': slot, 'score': probabilities[slot], 'command': command,
|
|
||||||
'reason': 'Below threshold'})
|
|
||||||
if probabilities[slot] < args.threshold:
|
|
||||||
continue
|
|
||||||
active.add(slot)
|
|
||||||
discrete = command['type'] in ('headlight', 'laser', 'horn', 'song', 'raw')
|
|
||||||
if family not in families or probabilities[slot] > families[family][0]:
|
|
||||||
families[family] = (probabilities[slot], command, slot, discrete)
|
|
||||||
latched.intersection_update(active)
|
latched.intersection_update(active)
|
||||||
# Choose the winning action before latching, so a held toggle cannot
|
proposals, commands = [], []
|
||||||
# expose a competing lower-confidence toggle on the following tick.
|
for slot, descriptor in enumerate(schema):
|
||||||
commands = [command for _, command, slot, discrete in families.values()
|
command = decode_command(descriptor, predictions[slot])
|
||||||
if not discrete or slot not in latched]
|
discrete = command['type'] in ('headlight', 'laser', 'horn', 'song', 'raw')
|
||||||
winners = {row[2]: row for row in families.values()}
|
reason = ('Below threshold' if slot not in active else
|
||||||
for proposal in proposals:
|
'Competing action' if slot not in winners else
|
||||||
slot = proposal['slot']
|
'Held discrete action' if discrete and slot in latched else 'Proposed')
|
||||||
if slot in active:
|
proposals.append({'slot': slot, 'score': probabilities[slot], 'command': command, 'reason': reason})
|
||||||
proposal['reason'] = ('Competing action' if slot not in winners else
|
if reason == 'Proposed':
|
||||||
'Held discrete action' if winners[slot][3] and slot in latched else 'Proposed')
|
commands.append(command)
|
||||||
for _, _, slot, discrete in families.values():
|
if slot in winners and discrete:
|
||||||
if discrete:
|
|
||||||
latched.add(slot)
|
latched.add(slot)
|
||||||
commands.insert(0, {'type': 'drive', 'driveDirect': dict(zip(('left', 'right'), wheel_speeds))})
|
|
||||||
print(json.dumps({'kind': 'prediction', 'frameAt': stamp, 'sensorAt': sensor_time,
|
print(json.dumps({'kind': 'prediction', 'frameAt': stamp, 'sensorAt': sensor_time,
|
||||||
'latencyMs': (time.perf_counter() - before) * 1000,
|
'latencyMs': (time.perf_counter() - before) * 1000,
|
||||||
'wheelSpeeds': wheel_speeds, 'sensorPresent': sensor_vector(sensors)[32:].tolist(),
|
'sensorPresent': sensor_vector(sensors)[len(SENSORS):].tolist(),
|
||||||
'commands': commands, 'proposals': proposals, 'threshold': args.threshold}, allow_nan=False), flush=True)
|
'commands': commands, 'proposals': proposals, 'threshold': args.threshold}, allow_nan=False), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import numpy as np
|
|||||||
import torch
|
import torch
|
||||||
from torch import nn
|
from torch import nn
|
||||||
|
|
||||||
VERSION = 2
|
VERSION = 3
|
||||||
SLOTS = 64
|
SLOTS = 64
|
||||||
VALUES = 8
|
VALUES = 8
|
||||||
FRAMES = 4
|
FRAMES = 4
|
||||||
@@ -26,7 +26,6 @@ SENSORS = [
|
|||||||
("voltageMv", 16000), ("currentMa", 3000), ("batteryChargeMah", 4000),
|
("voltageMv", 16000), ("currentMa", 3000), ("batteryChargeMah", 4000),
|
||||||
("batteryCapacityMah", 4000), ("batteryTemperatureC", 50),
|
("batteryCapacityMah", 4000), ("batteryTemperatureC", 50),
|
||||||
("chargingState.code", 5), ("chargingSources.homeBase", 1), ("oiMode.code", 4),
|
("chargingState.code", 5), ("chargingSources.homeBase", 1), ("oiMode.code", 4),
|
||||||
("wheelSpeedsMmPerSecond.left", 500), ("wheelSpeedsMmPerSecond.right", 500),
|
|
||||||
("wheelLeftCurrentMa", 2000), ("wheelRightCurrentMa", 2000),
|
("wheelLeftCurrentMa", 2000), ("wheelRightCurrentMa", 2000),
|
||||||
("mainBrushCurrentMa", 2000), ("sideBrushCurrentMa", 2000),
|
("mainBrushCurrentMa", 2000), ("sideBrushCurrentMa", 2000),
|
||||||
("lightBumpLeftSignal", 4096), ("lightBumpRightSignal", 4096),
|
("lightBumpLeftSignal", 4096), ("lightBumpRightSignal", 4096),
|
||||||
@@ -62,6 +61,10 @@ def describe_command(command):
|
|||||||
def visit(value, path=()):
|
def visit(value, path=()):
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return {key: visit(item, (*path, key)) for key, item in sorted(value.items())}
|
return {key: visit(item, (*path, key)) for key, item in sorted(value.items())}
|
||||||
|
# Exact zeros preserve demonstrated releases/stops. Identity fields must
|
||||||
|
# also stay exact: regression must never invent a peripheral address.
|
||||||
|
if value == 0 or (path and path[-1] in ('id', 'control')):
|
||||||
|
return copy.deepcopy(value)
|
||||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||||
if not math.isfinite(value) or len(values) >= VALUES:
|
if not math.isfinite(value) or len(values) >= VALUES:
|
||||||
raise ValueError('Nonfinite command or too many numeric control fields')
|
raise ValueError('Nonfinite command or too many numeric control fields')
|
||||||
@@ -97,6 +100,44 @@ def encode_command(command, schema, extend=False):
|
|||||||
return slot, vector
|
return slot, vector
|
||||||
|
|
||||||
|
|
||||||
|
def command_family(command):
|
||||||
|
if command['type'] == 'peripheral':
|
||||||
|
peripheral = command.get('peripheral') or {}
|
||||||
|
return ('peripheral', str(peripheral.get('id')), str(peripheral.get('control')))
|
||||||
|
return (command['type'],)
|
||||||
|
|
||||||
|
|
||||||
|
def select_slots(scores, schema, threshold):
|
||||||
|
winners = {}
|
||||||
|
for slot, descriptor in enumerate(schema):
|
||||||
|
if scores[slot] < threshold:
|
||||||
|
continue
|
||||||
|
family = command_family(descriptor['template'])
|
||||||
|
if family not in winners or scores[slot] > scores[winners[family]]:
|
||||||
|
winners[family] = slot
|
||||||
|
return set(winners.values())
|
||||||
|
|
||||||
|
|
||||||
|
def decode_command(descriptor, values):
|
||||||
|
command = copy.deepcopy(descriptor['template'])
|
||||||
|
for index, field in enumerate(descriptor['fields']):
|
||||||
|
number = float(values[index])
|
||||||
|
if not math.isfinite(number):
|
||||||
|
raise ValueError('Nonfinite control value')
|
||||||
|
number = math.tan(max(-.98, min(.98, number)) * math.pi / 2) * field['scale']
|
||||||
|
target = command
|
||||||
|
for key in field['path'][:-1]:
|
||||||
|
target = target[key]
|
||||||
|
target[field['path'][-1]] = round(number) if field['integer'] else number
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def is_stop(descriptor):
|
||||||
|
command = descriptor['template']
|
||||||
|
direct = command.get('driveDirect') or {}
|
||||||
|
return command['type'] == 'drive' and direct.get('left') == 0 and direct.get('right') == 0
|
||||||
|
|
||||||
|
|
||||||
class Policy(nn.Module):
|
class Policy(nn.Module):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -109,7 +150,6 @@ class Policy(nn.Module):
|
|||||||
nn.Linear(128 * 3 * 4, 128), nn.ReLU(),
|
nn.Linear(128 * 3 * 4, 128), nn.ReLU(),
|
||||||
)
|
)
|
||||||
self.history = nn.GRU(128 + STATE_SIZE, 128, batch_first=True)
|
self.history = nn.GRU(128 + STATE_SIZE, 128, batch_first=True)
|
||||||
self.wheels = nn.Linear(128, 2)
|
|
||||||
self.actions = nn.Linear(128, SLOTS)
|
self.actions = nn.Linear(128, SLOTS)
|
||||||
self.values = nn.Linear(128, SLOTS * VALUES)
|
self.values = nn.Linear(128, SLOTS * VALUES)
|
||||||
|
|
||||||
@@ -118,17 +158,16 @@ class Policy(nn.Module):
|
|||||||
encoded = self.vision(images.reshape(-1, 3, HEIGHT, WIDTH)).reshape(batch, frames, -1)
|
encoded = self.vision(images.reshape(-1, 3, HEIGHT, WIDTH)).reshape(batch, frames, -1)
|
||||||
history, _ = self.history(torch.cat((encoded, state), dim=-1))
|
history, _ = self.history(torch.cat((encoded, state), dim=-1))
|
||||||
last = history[:, -1]
|
last = history[:, -1]
|
||||||
return self.actions(last), self.values(last).reshape(batch, SLOTS, VALUES).tanh(), self.wheels(last).tanh()
|
return self.actions(last), self.values(last).reshape(batch, SLOTS, VALUES).tanh()
|
||||||
|
|
||||||
|
|
||||||
def specification(schema):
|
def specification(schema):
|
||||||
return {'version': VERSION, 'architecture': 'spatial-cnn-gru-wheels-v2',
|
return {'version': VERSION, 'architecture': 'spatial-cnn-gru-events-v3',
|
||||||
'frames': FRAMES, 'height': HEIGHT, 'width': WIDTH,
|
'frames': FRAMES, 'height': HEIGHT, 'width': WIDTH,
|
||||||
'historyStrideMs': 300, 'decisionIntervalMs': 100,
|
'historyStrideMs': 300, 'decisionIntervalMs': 100,
|
||||||
'stateSize': STATE_SIZE, 'sensorFields': SENSORS,
|
'stateSize': STATE_SIZE, 'sensorFields': SENSORS,
|
||||||
'normalization': '2/pi * atan(value/scale); missing sensors have mask=0',
|
'normalization': '2/pi * atan(value/scale); missing sensors have mask=0',
|
||||||
'maxCommandShapes': SLOTS, 'numericFieldsPerShape': VALUES,
|
'maxCommandShapes': SLOTS, 'numericFieldsPerShape': VALUES,
|
||||||
'commands': schema, 'actionSemantics': 'Continuous wheels at +100ms; accessories are events in the next 100ms',
|
'commands': schema, 'actionSemantics': 'All controls are command events in the next 100ms; no event preserves existing rover state',
|
||||||
'wheelScaleMmPerSecond': 500, 'wheelOrder': ['left', 'right'],
|
'driveCommandHistoryInput': False, 'zeroNumericValues': 'Exact template constants',
|
||||||
'wheelCommandExpiryMs': None, 'unknownWheelIntent': 'Excluded until next accepted human drive',
|
|
||||||
'timing': 'Server-receipt alignment; browser display latency unmeasured'}
|
'timing': 'Server-receipt alignment; browser display latency unmeasured'}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def main():
|
|||||||
import torch
|
import torch
|
||||||
from torch.nn import functional as functional
|
from torch.nn import functional as functional
|
||||||
from dataset import windows
|
from dataset import windows
|
||||||
from policy import Policy, VERSION, SLOTS, VALUES, specification
|
from policy import Policy, VERSION, SLOTS, VALUES, specification, is_stop, select_slots
|
||||||
|
|
||||||
if args.check_dependencies:
|
if args.check_dependencies:
|
||||||
emit('dependencies', torch=str(torch.__version__), numpy=str(np.__version__), av=str(av.__version__),
|
emit('dependencies', torch=str(torch.__version__), numpy=str(np.__version__), av=str(av.__version__),
|
||||||
@@ -70,8 +70,9 @@ def main():
|
|||||||
cancelled = lambda: stopped or time.monotonic() - started >= config['maxJobSeconds']
|
cancelled = lambda: stopped or time.monotonic() - started >= config['maxJobSeconds']
|
||||||
model = Policy().cpu()
|
model = Policy().cpu()
|
||||||
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0001)
|
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0001)
|
||||||
# A fresh training lineage prevents previously trained footage leaking into validation.
|
# The command-event policy has different inputs, targets and weights. Keep its
|
||||||
resume_path = root / 'training' / 'resume-v3.pt'
|
# optimizer lineage separate; reuse recordings with their unchanged partition.
|
||||||
|
resume_path = root / 'training' / 'resume-v4.pt'
|
||||||
schema, steps, examples, last_published = [], 0, 0, 0
|
schema, steps, examples, last_published = [], 0, 0, 0
|
||||||
trained_rovers = set()
|
trained_rovers = set()
|
||||||
control_profiles = []
|
control_profiles = []
|
||||||
@@ -107,7 +108,7 @@ def main():
|
|||||||
candidate_schema = copy.deepcopy(schema)
|
candidate_schema = copy.deepcopy(schema)
|
||||||
samples, seen = [], 0
|
samples, seen = [], 0
|
||||||
stats = {}
|
stats = {}
|
||||||
buckets = {key: [] for key in ('stopped', 'forward', 'reverse', 'turn')}
|
buckets = {key: [] for key in ('stop', 'command', 'none')}
|
||||||
counts = dict.fromkeys(buckets, 0)
|
counts = dict.fromkeys(buckets, 0)
|
||||||
held_out = is_held_out(session_id)
|
held_out = is_held_out(session_id)
|
||||||
try:
|
try:
|
||||||
@@ -115,17 +116,16 @@ def main():
|
|||||||
for sample in windows(directory, candidate_schema, cancelled, stats):
|
for sample in windows(directory, candidate_schema, cancelled, stats):
|
||||||
seen += 1
|
seen += 1
|
||||||
# Keep evaluation naturally distributed. Training reserves space
|
# Keep evaluation naturally distributed. Training reserves space
|
||||||
# for ordinary turns/reversing so idle or straight motion cannot
|
# for recorded commands and releases so periods without commands
|
||||||
# consume the entire sample budget. No scripted driving required.
|
# cannot consume the entire sample budget. No scripted driving required.
|
||||||
if held_out:
|
if held_out:
|
||||||
reservoir, count, capacity = samples, seen, per_session
|
reservoir, count, capacity = samples, seen, per_session
|
||||||
else:
|
else:
|
||||||
left, right = sample[5]
|
category = ('stop' if any(sample[2][slot] and is_stop(descriptor)
|
||||||
category = ('stopped' if max(abs(left), abs(right)) <= .02 else
|
for slot, descriptor in enumerate(candidate_schema)) else
|
||||||
'turn' if abs(left - right) > .1 else
|
'command' if sample[2].any() else 'none')
|
||||||
'reverse' if left + right < 0 else 'forward')
|
|
||||||
counts[category] += 1
|
counts[category] += 1
|
||||||
reservoir, count, capacity = buckets[category], counts[category], max(1, per_session // 4)
|
reservoir, count, capacity = buckets[category], counts[category], max(1, per_session // 3)
|
||||||
if len(reservoir) < capacity:
|
if len(reservoir) < capacity:
|
||||||
reservoir.append(sample)
|
reservoir.append(sample)
|
||||||
else:
|
else:
|
||||||
@@ -164,7 +164,7 @@ def main():
|
|||||||
except (ValueError, KeyError, TypeError, OSError, EOFError, IndexError, OverflowError, av.error.FFmpegError) as error:
|
except (ValueError, KeyError, TypeError, OSError, EOFError, IndexError, OverflowError, av.error.FFmpegError) as error:
|
||||||
rejected[session_id] = str(error)[:500]
|
rejected[session_id] = str(error)[:500]
|
||||||
emit('skipped', sessionId=session_id, reason=rejected[session_id])
|
emit('skipped', sessionId=session_id, reason=rejected[session_id])
|
||||||
if len(pool) < config['minimumSamples'] or not any(np.max(np.abs(sample[5])) > .02 for sample in pool) or cancelled():
|
if len(pool) < config['minimumSamples'] or not any(sample[2].any() for sample in pool) or cancelled():
|
||||||
emit('complete', trained=False, accepted=[], rejected=rejected, samples=len(pool),
|
emit('complete', trained=False, accepted=[], rejected=rejected, samples=len(pool),
|
||||||
diagnostics={'dataset': dataset_stats, 'decodeSeconds': time.monotonic() - decode_started},
|
diagnostics={'dataset': dataset_stats, 'decodeSeconds': time.monotonic() - decode_started},
|
||||||
reason='Stopped/time limit' if cancelled() else 'Waiting for enough training-partition samples with human actions')
|
reason='Stopped/time limit' if cancelled() else 'Waiting for enough training-partition samples with human actions')
|
||||||
@@ -188,17 +188,16 @@ def main():
|
|||||||
if cancelled() or steps - initial_steps >= config['maxStepsPerJob']:
|
if cancelled() or steps - initial_steps >= config['maxStepsPerJob']:
|
||||||
break
|
break
|
||||||
batch = pool[offset:offset + config['batchSize']]
|
batch = pool[offset:offset + config['batchSize']]
|
||||||
images, states, targets, values, available, wheel_targets, previous_wheels = [torch.from_numpy(np.stack(items)) for items in zip(*batch)]
|
images, states, targets, values, available = [torch.from_numpy(np.stack(items)) for items in zip(*batch)]
|
||||||
images = images.float().div_(255)
|
images = images.float().div_(255)
|
||||||
optimizer.zero_grad(set_to_none=True)
|
optimizer.zero_grad(set_to_none=True)
|
||||||
action_logits, predictions, wheel_predictions = model(images, states)
|
action_logits, predictions = model(images, states)
|
||||||
event_loss = functional.binary_cross_entropy_with_logits(action_logits, targets,
|
event_loss = functional.binary_cross_entropy_with_logits(action_logits, targets,
|
||||||
pos_weight=positive_weight, reduction='none')
|
pos_weight=positive_weight, reduction='none')
|
||||||
event_loss = (event_loss * available).sum() / available.sum().clamp_min(1)
|
event_loss = (event_loss * available).sum() / available.sum().clamp_min(1)
|
||||||
mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1)
|
mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1)
|
||||||
value_loss = ((predictions - values).square() * mask).sum() / mask.sum().clamp_min(1)
|
value_loss = ((predictions - values).square() * mask).sum() / mask.sum().clamp_min(1)
|
||||||
wheel_loss = functional.smooth_l1_loss(wheel_predictions, wheel_targets)
|
loss = event_loss + value_loss
|
||||||
loss = wheel_loss + event_loss + value_loss
|
|
||||||
if not torch.isfinite(loss):
|
if not torch.isfinite(loss):
|
||||||
raise ValueError('Nonfinite training loss; checkpoint not saved')
|
raise ValueError('Nonfinite training loss; checkpoint not saved')
|
||||||
loss.backward()
|
loss.backward()
|
||||||
@@ -209,7 +208,7 @@ def main():
|
|||||||
losses.append(loss.item())
|
losses.append(loss.item())
|
||||||
if steps % 10 == 0:
|
if steps % 10 == 0:
|
||||||
emit('progress', steps=steps, examples=examples, loss=loss.item(),
|
emit('progress', steps=steps, examples=examples, loss=loss.item(),
|
||||||
losses={'wheels': wheel_loss.item(), 'events': event_loss.item(), 'accessoryValues': value_loss.item()})
|
losses={'events': event_loss.item(), 'commandValues': value_loss.item()})
|
||||||
if cancelled() or steps - initial_steps >= config['maxStepsPerJob']:
|
if cancelled() or steps - initial_steps >= config['maxStepsPerJob']:
|
||||||
break
|
break
|
||||||
if steps == initial_steps:
|
if steps == initial_steps:
|
||||||
@@ -224,60 +223,84 @@ def main():
|
|||||||
if validation and not cancelled():
|
if validation and not cancelled():
|
||||||
model.eval()
|
model.eval()
|
||||||
true_positive = predicted_positive = actual_positive = 0
|
true_positive = predicted_positive = actual_positive = 0
|
||||||
value_error = value_count = 0
|
value_error = value_count = evaluated = 0
|
||||||
evaluated = 0
|
stop_actual = stop_detected = false_stops = 0
|
||||||
wheel_error = stopped_error = persistence_error = 0
|
drive_actual = drive_detected = 0
|
||||||
changed_error = changed_persistence = changed_count = moving_count = 0
|
empty_windows = unwanted_windows = 0
|
||||||
|
stop_slots = [slot for slot, descriptor in enumerate(schema) if is_stop(descriptor)]
|
||||||
|
drive_slots = [slot for slot, descriptor in enumerate(schema)
|
||||||
|
if descriptor['template']['type'] == 'drive' and not is_stop(descriptor)]
|
||||||
|
per_command = [{'slot': slot, 'command': descriptor['template'], 'actual': 0,
|
||||||
|
'predicted': 0, 'matched': 0} for slot, descriptor in enumerate(schema)]
|
||||||
with torch.inference_mode():
|
with torch.inference_mode():
|
||||||
for offset in range(0, len(validation), config['batchSize']):
|
for offset in range(0, len(validation), config['batchSize']):
|
||||||
if cancelled():
|
if cancelled():
|
||||||
break
|
break
|
||||||
batch = validation[offset:offset + config['batchSize']]
|
batch = validation[offset:offset + config['batchSize']]
|
||||||
images, states, targets, values, available, wheel_targets, previous_wheels = [torch.from_numpy(np.stack(items)) for items in zip(*batch)]
|
images, states, targets, values, available = [torch.from_numpy(np.stack(items)) for items in zip(*batch)]
|
||||||
logits, predictions, wheel_predictions = model(images.float() / 255, states)
|
logits, predictions = model(images.float() / 255, states)
|
||||||
predicted = (logits.sigmoid() >= job['actionThreshold']) & available.bool()
|
predicted = torch.zeros_like(targets, dtype=torch.bool)
|
||||||
actual = targets.bool() & available.bool()
|
# Use the same threshold and competing-command selection as live
|
||||||
|
# inference. Reservoir samples cannot assess temporal latching or
|
||||||
|
# closed-loop motion; report this explicitly rather than implying it.
|
||||||
|
for row, scores in enumerate(logits.sigmoid().tolist()):
|
||||||
|
for slot in select_slots(scores, schema, job['actionThreshold']):
|
||||||
|
predicted[row, slot] = True
|
||||||
|
actual = targets.bool()
|
||||||
true_positive += (predicted & actual).sum().item()
|
true_positive += (predicted & actual).sum().item()
|
||||||
predicted_positive += predicted.sum().item()
|
predicted_positive += predicted.sum().item()
|
||||||
actual_positive += actual.sum().item()
|
actual_positive += actual.sum().item()
|
||||||
mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1)
|
mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1)
|
||||||
value_error += ((predictions - values).square() * mask).sum().item()
|
value_error += ((predictions - values).square() * mask).sum().item()
|
||||||
value_count += mask.sum().item()
|
value_count += mask.sum().item()
|
||||||
errors = (wheel_predictions - wheel_targets).abs().mean(dim=1) * 500
|
empty = ~actual.any(dim=1)
|
||||||
persistence = (previous_wheels - wheel_targets).abs().mean(dim=1) * 500
|
empty_windows += empty.sum().item()
|
||||||
changed = (previous_wheels - wheel_targets).abs().amax(dim=1) > .02
|
unwanted_windows += (empty & predicted.any(dim=1)).sum().item()
|
||||||
changed_error += errors[changed].sum().item()
|
if stop_slots:
|
||||||
changed_persistence += persistence[changed].sum().item()
|
stop_actual += actual[:, stop_slots].sum().item()
|
||||||
changed_count += changed.sum().item()
|
stop_detected += (actual[:, stop_slots] & predicted[:, stop_slots]).sum().item()
|
||||||
moving_count += (wheel_targets.abs().amax(dim=1) > .02).sum().item()
|
false_stops += (predicted[:, stop_slots] & ~actual[:, stop_slots]).sum().item()
|
||||||
wheel_error += errors.sum().item()
|
if drive_slots:
|
||||||
stopped_error += wheel_targets.abs().mean(dim=1).sum().item() * 500
|
drive_actual += actual[:, drive_slots].sum().item()
|
||||||
persistence_error += persistence.sum().item()
|
drive_detected += (actual[:, drive_slots] & predicted[:, drive_slots]).sum().item()
|
||||||
|
for item in per_command:
|
||||||
|
slot = item['slot']
|
||||||
|
item['actual'] += actual[:, slot].sum().item()
|
||||||
|
item['predicted'] += predicted[:, slot].sum().item()
|
||||||
|
item['matched'] += (actual[:, slot] & predicted[:, slot]).sum().item()
|
||||||
evaluated += len(batch)
|
evaluated += len(batch)
|
||||||
precision = true_positive / max(1, predicted_positive)
|
precision = true_positive / max(1, predicted_positive)
|
||||||
recall = true_positive / max(1, actual_positive)
|
recall = true_positive / max(1, actual_positive)
|
||||||
mse = value_error / max(1, value_count)
|
mse = value_error / max(1, value_count)
|
||||||
wheel_mae = wheel_error / max(1, evaluated)
|
stop_recall = stop_detected / max(1, stop_actual)
|
||||||
stopped_mae = stopped_error / max(1, evaluated)
|
held_out_sessions = sum(row.get('partition') == 'heldOut' and not row.get('skipReason') for row in dataset_stats)
|
||||||
persistence_mae = persistence_error / max(1, evaluated)
|
gate_reasons = []
|
||||||
changed_mae = changed_error / max(1, changed_count)
|
for condition, reason in [
|
||||||
changed_baseline = changed_persistence / max(1, changed_count)
|
(evaluated == len(validation), 'Evaluation interrupted'),
|
||||||
# Check control changes separately: copying the previous speed can look
|
(held_out_sessions >= 2, 'Need two usable held-out recordings'),
|
||||||
# excellent on long straight runs without learning to steer or stop.
|
(evaluated >= 128, 'Need 128 held-out windows'),
|
||||||
wheels_pass = (evaluated == len(validation) and evaluated >= 128 and moving_count >= 32
|
(actual_positive >= 32, 'Need 32 held-out command events'),
|
||||||
and changed_count >= 32 and wheel_mae < stopped_mae
|
(stop_actual >= 8, 'Need eight held-out stop events'),
|
||||||
and wheel_mae <= persistence_mae and changed_mae < changed_baseline)
|
(drive_actual >= 16, 'Need 16 held-out movement commands'),
|
||||||
accessories_pass = (predicted_positive == 0 if actual_positive == 0 else
|
(drive_detected / max(1, drive_actual) >= .5, 'Movement command recall below 0.5'),
|
||||||
actual_positive >= 32 and precision >= .5 and recall >= .5)
|
(precision >= .5, 'Command precision below 0.5'),
|
||||||
passed = wheels_pass and accessories_pass and (value_count == 0 or mse <= .1)
|
(recall >= .5, 'Command recall below 0.5'),
|
||||||
evaluation = {'status': 'passed imitation gate' if passed else 'imitation gate not met',
|
(stop_recall >= .5, 'Stop recall below 0.5'),
|
||||||
|
(value_count == 0 or mse <= .1, 'Command parameter error above 0.1'),
|
||||||
|
]:
|
||||||
|
if not condition:
|
||||||
|
gate_reasons.append(reason)
|
||||||
|
evaluation = {'status': 'imitation gate not met' if gate_reasons else 'passed imitation gate',
|
||||||
|
'method': 'Held-out 100ms command events; not a closed-loop rollout',
|
||||||
|
'threshold': job['actionThreshold'], 'heldOutSessions': held_out_sessions,
|
||||||
'windows': evaluated, 'precision': precision, 'recall': recall,
|
'windows': evaluated, 'precision': precision, 'recall': recall,
|
||||||
'numericMse': mse, 'positiveActions': actual_positive,
|
'numericMse': mse, 'positiveActions': actual_positive,
|
||||||
'wheelMaeMmPerSecond': wheel_mae, 'stoppedBaselineMaeMmPerSecond': stopped_mae,
|
'predictedActions': predicted_positive, 'falsePositiveActions': predicted_positive - true_positive,
|
||||||
'previousSpeedBaselineMaeMmPerSecond': persistence_mae,
|
'stopEvents': stop_actual, 'missedStops': stop_actual - stop_detected,
|
||||||
'changedWheelMaeMmPerSecond': changed_mae, 'changedBaselineMaeMmPerSecond': changed_baseline,
|
'stopRecall': stop_recall, 'falseStops': false_stops,
|
||||||
'changedWindows': changed_count, 'movingWindows': moving_count,
|
'movementEvents': drive_actual, 'movementRecall': drive_detected / max(1, drive_actual),
|
||||||
'wheelsPassed': wheels_pass, 'accessoriesPassed': accessories_pass}
|
'noCommandWindows': empty_windows, 'unwantedCommandWindows': unwanted_windows,
|
||||||
|
'commands': per_command, 'gateReasons': gate_reasons}
|
||||||
evaluation_seconds = time.monotonic() - evaluation_started
|
evaluation_seconds = time.monotonic() - evaluation_started
|
||||||
publication = {'distinctMinutes': unique_count / 600,
|
publication = {'distinctMinutes': unique_count / 600,
|
||||||
'newMinutes': (unique_count - published_windows) / 600,
|
'newMinutes': (unique_count - published_windows) / 600,
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "VITE_ROVERD_URL=http://192.168.0.86:8080 vite",
|
"dev": "VITE_ROVERD_URL=https://rover.otter.land vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js",
|
"test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js",
|
||||||
|
|||||||
@@ -25,22 +25,20 @@ function Group({ title, children }) {
|
|||||||
</section>;
|
</section>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionNames = { motors: 'Brushes / vacuum', servo: 'Camera tilt', headlight: 'Headlight',
|
const actionNames = { drive: 'Drive', motors: 'Brushes / vacuum', servo: 'Camera tilt', headlight: 'Headlight',
|
||||||
laser: 'Laser', horn: 'Horn', peripheral: 'Accessory', song: 'Beeper', raw: 'Recorded control' };
|
laser: 'Laser', horn: 'Horn', peripheral: 'Accessory', song: 'Beeper', raw: 'Recorded control' };
|
||||||
|
|
||||||
function WheelMeter({ label, speed }) {
|
function commandParameters(command) {
|
||||||
const known = Number.isFinite(speed);
|
const fields = [];
|
||||||
const value = known ? Math.max(-500, Math.min(500, speed)) : 0;
|
const visit = (value, path) => {
|
||||||
return <div className="surface-muted min-w-0 space-y-1">
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
<div className="flex justify-between gap-2 text-xs"><span>{label}</span><span>{known ? `${value} mm/s` : 'Waiting'}</span></div>
|
for (const [key, item] of Object.entries(value)) visit(item, path ? `${path}.${key}` : key);
|
||||||
<div role="meter" aria-label={label} aria-valuemin={-500} aria-valuemax={500} aria-valuenow={value}
|
} else {
|
||||||
aria-valuetext={known ? `${value} millimeters per second` : 'Unavailable'} className="relative h-3 overflow-hidden rounded bg-slate-800">
|
fields.push(`${path}: ${typeof value === 'number' ? Number(value.toFixed(2)) : JSON.stringify(value)}`);
|
||||||
<div className={`absolute inset-y-0 rounded ${value < 0 ? 'bg-amber-400' : 'bg-sky-400'}`}
|
}
|
||||||
style={{ left: `${value < 0 ? 50 + value / 10 : 50}%`, width: `${Math.abs(value) / 10}%` }} />
|
};
|
||||||
<div className="absolute inset-y-0 left-1/2 w-px bg-slate-400" />
|
for (const [key, value] of Object.entries(command || {})) if (key !== 'type') visit(value, key);
|
||||||
</div>
|
return fields.join(' · ');
|
||||||
<div className="flex justify-between text-xs text-slate-500"><span>Reverse</span><span>Forward</span></div>
|
|
||||||
</div>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VipRoverLearningCard() {
|
export default function VipRoverLearningCard() {
|
||||||
@@ -135,7 +133,7 @@ function LearningCard({ roverId }) {
|
|||||||
<div className="flex flex-wrap items-center justify-center gap-1">
|
<div className="flex flex-wrap items-center justify-center gap-1">
|
||||||
<select aria-label="Rover model" className={fieldClass} value={chosen?.id || ''} onChange={(event) => setSelectedId(event.target.value)} disabled={working || !models.length}>
|
<select aria-label="Rover model" className={fieldClass} value={chosen?.id || ''} onChange={(event) => setSelectedId(event.target.value)} disabled={working || !models.length}>
|
||||||
{!models.length && <option value="">No published models yet</option>}
|
{!models.length && <option value="">No published models yet</option>}
|
||||||
{models.map((model) => <option key={model.id} value={model.id}>{favorites.includes(model.id) ? '★ ' : ''}{model.name}{model.experimental ? ' (experimental)' : ''}{model.compatible ? '' : model.formatVersion !== 2 ? ' (older model format)' : ' (incompatible)'}</option>)}
|
{models.map((model) => <option key={model.id} value={model.id}>{favorites.includes(model.id) ? '★ ' : ''}{model.name}{model.experimental ? ' (experimental)' : ''}{model.compatible ? '' : model.formatVersion !== 3 ? ' (older model format)' : ' (incompatible)'}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<button type="button" className="button-dark text-sm disabled:opacity-50" aria-pressed={Boolean(chosen && favorites.includes(chosen.id))} disabled={!chosen} onClick={favorite}>
|
<button type="button" className="button-dark text-sm disabled:opacity-50" aria-pressed={Boolean(chosen && favorites.includes(chosen.id))} disabled={!chosen} onClick={favorite}>
|
||||||
{chosen && favorites.includes(chosen.id) ? '★ Favorite' : '☆ Favorite'}
|
{chosen && favorites.includes(chosen.id) ? '★ Favorite' : '☆ Favorite'}
|
||||||
@@ -150,12 +148,8 @@ function LearningCard({ roverId }) {
|
|||||||
</div>}
|
</div>}
|
||||||
{session && <section className="space-y-2" aria-label="Live model actions">
|
{session && <section className="space-y-2" aria-label="Live model actions">
|
||||||
<p className="text-center text-xs text-sky-300">{session.predictionStatus || 'Waiting for fresh video and sensors'}</p>
|
<p className="text-center text-xs text-sky-300">{session.predictionStatus || 'Waiting for fresh video and sensors'}</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<WheelMeter label="Predicted left wheel" speed={session.wheelSpeeds?.[0]} />
|
|
||||||
<WheelMeter label="Predicted right wheel" speed={session.wheelSpeeds?.[1]} />
|
|
||||||
</div>
|
|
||||||
<label className="surface-muted block space-y-1 text-xs">
|
<label className="surface-muted block space-y-1 text-xs">
|
||||||
<span className="flex justify-between gap-2"><span>Accessory action threshold</span><span>{(thresholdDraft ?? session.threshold ?? .7).toFixed(2)}</span></span>
|
<span className="flex justify-between gap-2"><span>Command threshold</span><span>{(thresholdDraft ?? session.threshold ?? .7).toFixed(2)}</span></span>
|
||||||
<input type="range" min="0.05" max="0.99" step="0.01" className="w-full accent-sky-400"
|
<input type="range" min="0.05" max="0.99" step="0.01" className="w-full accent-sky-400"
|
||||||
value={thresholdDraft ?? session.threshold ?? .7} disabled={working || !state?.control?.canAdjust}
|
value={thresholdDraft ?? session.threshold ?? .7} disabled={working || !state?.control?.canAdjust}
|
||||||
onChange={(event) => setThresholdDraft(Number(event.target.value))}
|
onChange={(event) => setThresholdDraft(Number(event.target.value))}
|
||||||
@@ -163,14 +157,20 @@ function LearningCard({ roverId }) {
|
|||||||
</label>
|
</label>
|
||||||
<div className="grid max-h-52 grid-cols-1 gap-1 overflow-y-auto @[28rem]:grid-cols-2">
|
<div className="grid max-h-52 grid-cols-1 gap-1 overflow-y-auto @[28rem]:grid-cols-2">
|
||||||
{session.proposals?.map((proposal) => {
|
{session.proposals?.map((proposal) => {
|
||||||
const submission = session.submissions?.find((item) => item.command.type === proposal.command.type
|
const submission = proposal.reason === 'Proposed' && session.submissions?.find((item) => item.command.type === proposal.command.type
|
||||||
&& (proposal.command.type !== 'peripheral' || (item.command.peripheral?.id === proposal.command.peripheral?.id
|
&& (proposal.command.type !== 'peripheral' || (item.command.peripheral?.id === proposal.command.peripheral?.id
|
||||||
&& item.command.peripheral?.control === proposal.command.peripheral?.control)));
|
&& item.command.peripheral?.control === proposal.command.peripheral?.control)));
|
||||||
const status = proposal.reason === 'Proposed' && submission ? submission.result : proposal.reason;
|
const status = proposal.reason === 'Proposed' && submission ? submission.result : proposal.reason;
|
||||||
return <div key={proposal.slot} className="surface-muted space-y-1 text-xs">
|
return <div key={proposal.slot} className="surface-muted space-y-1 text-xs">
|
||||||
<div className="flex justify-between gap-2"><span>{actionNames[proposal.command.type] || proposal.command.type}{proposal.command.type === 'peripheral' ? ` · ${proposal.command.peripheral?.id} / ${proposal.command.peripheral?.control}` : ''}</span><span>{proposal.score.toFixed(2)}</span></div>
|
<div className="flex justify-between gap-2"><span>{actionNames[proposal.command.type] || proposal.command.type}{proposal.command.type === 'peripheral' ? ` · ${proposal.command.peripheral?.id} / ${proposal.command.peripheral?.control}` : ''}</span><span>{proposal.score.toFixed(2)}</span></div>
|
||||||
<div className="h-1.5 overflow-hidden rounded bg-slate-800"><div className={status === 'issued' ? 'h-full bg-emerald-400' : 'h-full bg-slate-500'} style={{ width: `${proposal.score * 100}%` }} /></div>
|
<p className="break-words text-slate-300">{commandParameters(proposal.command)}</p>
|
||||||
|
<div role="meter" aria-label={`${actionNames[proposal.command.type] || proposal.command.type} score`}
|
||||||
|
aria-valuemin={0} aria-valuemax={1} aria-valuenow={proposal.score} className="relative h-2 overflow-hidden rounded bg-slate-800">
|
||||||
|
<div className={status === 'issued' ? 'h-full bg-emerald-400' : 'h-full bg-sky-500'} style={{ width: `${proposal.score * 100}%` }} />
|
||||||
|
<div className="absolute inset-y-0 w-px bg-white" style={{ left: `${(session.appliedThreshold ?? session.threshold ?? .7) * 100}%` }} />
|
||||||
|
</div>
|
||||||
<p className={status === 'issued' ? 'text-emerald-300' : 'text-slate-400'}>{status === 'issued' ? 'Submitted' : status}</p>
|
<p className={status === 'issued' ? 'text-emerald-300' : 'text-slate-400'}>{status === 'issued' ? 'Submitted' : status}</p>
|
||||||
|
{submission && <p className="break-words text-slate-400">Sent: {commandParameters(submission.command)}</p>}
|
||||||
</div>;
|
</div>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -202,8 +202,11 @@ function LearningCard({ roverId }) {
|
|||||||
<Row label="Status">{training?.status || 'Unavailable'}</Row>
|
<Row label="Status">{training?.status || 'Unavailable'}</Row>
|
||||||
<Row label="Steps">{display(training?.steps)}</Row>
|
<Row label="Steps">{display(training?.steps)}</Row>
|
||||||
<Row label="Examples processed">{display(training?.examples)}</Row>
|
<Row label="Examples processed">{display(training?.examples)}</Row>
|
||||||
<Row label="Wheel / event / accessory losses">{training?.losses ? `${training.losses.wheels.toFixed(4)} / ${training.losses.events.toFixed(4)} / ${training.losses.accessoryValues.toFixed(4)}` : 'Unavailable'}</Row>
|
<Row label="Event / parameter losses">{training?.losses ? `${training.losses.events?.toFixed(4) ?? 'Unavailable'} / ${training.losses.commandValues?.toFixed(4) ?? 'Unavailable'}` : 'Unavailable'}</Row>
|
||||||
<Row label="Wheel error / stopped baseline / previous speed baseline">{training?.evaluation?.wheelMaeMmPerSecond == null ? 'Unavailable' : `${training.evaluation.wheelMaeMmPerSecond.toFixed(1)} / ${training.evaluation.stoppedBaselineMaeMmPerSecond.toFixed(1)} / ${training.evaluation.previousSpeedBaselineMaeMmPerSecond.toFixed(1)} mm/s`}</Row>
|
<Row label="Parameter error">{training?.evaluation?.numericMse?.toFixed(4) ?? 'Unavailable'}</Row>
|
||||||
|
<Row label="Missed / total stop events">{training?.evaluation?.stopEvents == null ? 'Unavailable' : `${training.evaluation.missedStops} / ${training.evaluation.stopEvents}`}</Row>
|
||||||
|
<Row label="Unwanted / no-command windows">{training?.evaluation?.noCommandWindows == null ? 'Unavailable' : `${training.evaluation.unwantedCommandWindows} / ${training.evaluation.noCommandWindows}`}</Row>
|
||||||
|
<Row label="Usable held-out recordings">{display(training?.evaluation?.heldOutSessions)}</Row>
|
||||||
<Row label="Training loss">{training?.loss == null ? 'Unavailable' : training.loss.toFixed(4)}</Row>
|
<Row label="Training loss">{training?.loss == null ? 'Unavailable' : training.loss.toFixed(4)}</Row>
|
||||||
<Row label="Current job age">{training?.status === 'training' ? age(now, training.startedAt) : 'Not running'}</Row>
|
<Row label="Current job age">{training?.status === 'training' ? age(now, training.startedAt) : 'Not running'}</Row>
|
||||||
<Row label="Next attempt">{time(training?.nextAttemptAt)}</Row>
|
<Row label="Next attempt">{time(training?.nextAttemptAt)}</Row>
|
||||||
@@ -220,6 +223,7 @@ function LearningCard({ roverId }) {
|
|||||||
<Row label="Steps between Latest updates">{display(limits?.training.checkpointEverySteps)}</Row>
|
<Row label="Steps between Latest updates">{display(limits?.training.checkpointEverySteps)}</Row>
|
||||||
</dl>{training?.error && <p className="break-words text-amber-300">{training.error}</p>}
|
</dl>{training?.error && <p className="break-words text-amber-300">{training.error}</p>}
|
||||||
{training?.reason && <p>{training.reason}</p>}
|
{training?.reason && <p>{training.reason}</p>}
|
||||||
|
{training?.evaluation?.gateReasons?.map((reason) => <p key={reason}>{reason}</p>)}
|
||||||
{training?.rejectionReasons?.map((reason) => <p className="break-words text-amber-300" key={reason}>{reason}</p>)}
|
{training?.rejectionReasons?.map((reason) => <p className="break-words text-amber-300" key={reason}>{reason}</p>)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group title="Selected model"><dl>
|
<Group title="Selected model"><dl>
|
||||||
|
|||||||
Reference in New Issue
Block a user