mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 10:42:45 -05:00
slop
This commit is contained in:
@@ -2,18 +2,27 @@
|
||||
|
||||
## Resume here
|
||||
|
||||
Latest change: one ongoing learner, gated Latest/Previous checkpoints, and
|
||||
admin-created permanent snapshots replace automatic permanent publications.
|
||||
Latest change: format 3 command-event policy replaces format 2 continuous wheel
|
||||
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.
|
||||
Copyable diagnostics and main-card wheel/action displays with a session threshold
|
||||
slider are implemented. Older model files remain stored but cannot run in this format.
|
||||
Recording, bounded dataset/training, permanent named models, live inference,
|
||||
checked autonomous command submission, and the verified VIP card are coded.
|
||||
Human commands do not stop or pause autonomy (explicit latest user requirement).
|
||||
Focused local checks passed; actual recording/training, live autonomous behavior,
|
||||
concurrent server performance, and browser appearance remain unverified.
|
||||
The standalone CPU benchmark was run successfully on the actual server.
|
||||
Implemented: remove wheel-speed telemetry and previous drive-command history
|
||||
from inputs (raw recordings unchanged); preserve exact numeric zeros; reuse
|
||||
recordings with a fresh resume-v4.pt / schedule-v4.json lineage. Existing format 2
|
||||
snapshots remain stored and are visibly incompatible. One ongoing learner, gated
|
||||
Latest/Previous, and admin-created permanent snapshots remain.
|
||||
|
||||
Main card shows decoded command parameters, score, applied threshold marker, and
|
||||
submission results, including below-threshold proposals. Diagnostics reportVersion
|
||||
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
|
||||
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
|
||||
status and have the reader reject unusable recordings automatically.
|
||||
|
||||
Latest increment: internal card gating, dock lead-in/tail capture, live prediction
|
||||
diagnostics, ongoing retained-data sampling, and gated model publication are coded.
|
||||
See the final handoff section for settings and the new training lineage.
|
||||
Latest increment: see the command-event policy handoff at the end. Earlier
|
||||
format 2 wheel-specific design notes below are historical and superseded.
|
||||
|
||||
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
|
||||
@@ -835,3 +843,74 @@ scheduled. Selection guarantees a held-out recording, not usable evaluation
|
||||
windows: decoding/vocabulary/sample checks still apply in the worker.
|
||||
Node syntax checks and diff whitespace checks passed; actual job selection on
|
||||
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.
|
||||
const { isDeepStrictEqual } = require('node:util');
|
||||
const POLICY_VERSION = 3;
|
||||
|
||||
function controlProfile(meta = {}) {
|
||||
return Object.fromEntries(['cameraServo', 'peripherals', 'headlight', 'laser', 'roomba', 'platform']
|
||||
@@ -7,9 +8,9 @@ function controlProfile(meta = {}) {
|
||||
}
|
||||
|
||||
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);
|
||||
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.' }),
|
||||
driving: strictObject({
|
||||
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.' }),
|
||||
}, { description: 'Live model execution. Manual inputs do not stop or pause the model.', required: ['threads', 'actionThreshold', 'staleMs'] }),
|
||||
recording: strictObject({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Export an explicit allowlist, never the raw socket/session/configuration object.
|
||||
const { redact } = require('./errors');
|
||||
const { POLICY_VERSION } = require('./capabilities');
|
||||
const os = require('node:os');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
@@ -7,7 +8,7 @@ const { createHash } = require('node:crypto');
|
||||
const applicationVersion = require('../../../package.json').version;
|
||||
const hash = createHash('sha256');
|
||||
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)));
|
||||
}
|
||||
const implementationSha256 = hash.digest('hex');
|
||||
@@ -29,8 +30,8 @@ function report(state, controller) {
|
||||
const metrics = training.diagnostics || {};
|
||||
const session = state.session;
|
||||
return {
|
||||
reportVersion: 2, generatedAtUnixMs: state.updatedAt,
|
||||
software: { applicationVersion, implementationSha256, policyVersion: 2, node: process.version,
|
||||
reportVersion: 3, generatedAtUnixMs: state.updatedAt,
|
||||
software: { applicationVersion, implementationSha256, policyVersion: POLICY_VERSION, node: process.version,
|
||||
dependencies: metrics.dependencies || null },
|
||||
host: { logicalCpus: os.cpus().length, totalMemoryBytes: os.totalmem(), freeMemoryBytes: os.freemem(), loadAverage: os.loadavg() },
|
||||
settings: state.limits,
|
||||
@@ -52,7 +53,7 @@ function report(state, controller) {
|
||||
controller: controller ? { modelId: controller.modelId, sensorPresent: controller.sensorPresent || null,
|
||||
stopReason: redact(controller.stopReason), stopCategory: errorCategory(controller.stopReason), history: (controller.history || []).map((item) => ({
|
||||
...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,
|
||||
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) })),
|
||||
|
||||
@@ -127,21 +127,19 @@ function createDriving({ root, config, onChange, activity }) {
|
||||
session.latencyMs = message.latencyMs;
|
||||
session.proposals = Array.isArray(message.proposals) ? message.proposals.slice(0, 64) : [];
|
||||
session.appliedThreshold = message.threshold;
|
||||
session.wheelSpeeds = message.wheelSpeeds;
|
||||
session.sensorPresent = message.sensorPresent;
|
||||
session.submissions = [];
|
||||
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.
|
||||
for (const command of message.commands) transmit(session, command);
|
||||
// Bound report history independently of camera and UI frame rates.
|
||||
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,
|
||||
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 })),
|
||||
submissions: session.submissions.slice(0, 16).map(({ command, result }) => ({ type: command.type, result,
|
||||
wheels: command.type === 'drive' ? command.driveDirect : undefined })) });
|
||||
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, command })) });
|
||||
if (session.history.length > 60) session.history.shift();
|
||||
}
|
||||
} 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
|
||||
// a queue so a model cannot disappear between resolving its alias and loading it.
|
||||
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');
|
||||
const source = path.join(checkpointRoot(root), state.candidate);
|
||||
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 words = [['amber', 'silver', 'quiet', 'curious'], ['otter', 'finch', 'fox', 'wren']];
|
||||
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 {
|
||||
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;
|
||||
state = { ...saved.progress, status: 'waiting' };
|
||||
if (!ledger || typeof ledger !== 'object') throw new Error('Invalid training schedule');
|
||||
@@ -46,7 +46,7 @@ function createTrainer({ root, config, actionThreshold, logger }) {
|
||||
}
|
||||
|
||||
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.rename(`${filename}.tmp`, filename);
|
||||
}
|
||||
@@ -83,7 +83,7 @@ function createTrainer({ root, config, actionThreshold, logger }) {
|
||||
partitions[heldOut ? 'heldOut' : 'training'].push(id);
|
||||
}
|
||||
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 selection = {
|
||||
atUnixMs: Date.now(),
|
||||
|
||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
import av
|
||||
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):
|
||||
@@ -72,27 +72,13 @@ def prepare(directory, schema, stats):
|
||||
if not sensors:
|
||||
raise ValueError('No sensor observations')
|
||||
candidate = copy.deepcopy(schema)
|
||||
history, targets, wheels = [], [], [(stamp, None) for stamp in boundaries]
|
||||
history, targets = [], []
|
||||
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:
|
||||
command = data.get('command') or {}
|
||||
request = requests.get(data.get('requestId'))
|
||||
human = (data.get('source') == 'client' and request and data.get('commandId') in accepted
|
||||
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)
|
||||
if command.get('type') in ('reset', 'reboot', 'update', 'sensorStream'):
|
||||
boundaries.append(data['ts'])
|
||||
encoded = encode_command(command, candidate, extend=True)
|
||||
if encoded is None:
|
||||
continue
|
||||
@@ -112,22 +98,21 @@ def prepare(directory, schema, stats):
|
||||
if not targets:
|
||||
raise ValueError('No accepted client operating commands')
|
||||
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):
|
||||
directory = Path(directory)
|
||||
stats = stats if stats is not None else {}
|
||||
metadata, sensors, wheels, history, targets, boundaries = prepare(directory, schema, stats)
|
||||
wheel_times = [row[0] for row in wheels]
|
||||
stats.update({'usableWindows': 0, 'movingWindows': 0, 'stoppedWindows': 0,
|
||||
'unknownWheelWindows': 0, 'staleSensorWindows': 0, 'boundaryWindows': 0,
|
||||
metadata, sensors, history, targets, boundaries = prepare(directory, schema, stats)
|
||||
stats.update({'usableWindows': 0, 'commandWindows': 0, 'stopWindows': 0, 'noCommandWindows': 0,
|
||||
'staleSensorWindows': 0, 'boundaryWindows': 0,
|
||||
'ambiguousWindows': 0, 'videoFrames': 0, 'maxVideoGapMs': 0})
|
||||
sensor_times = [row[0] for row in sensors]
|
||||
target_times = [row[0] for row in targets]
|
||||
available = np.zeros(SLOTS, dtype=np.float32)
|
||||
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_times = np.full(SLOTS, -np.inf)
|
||||
cursor = 0
|
||||
@@ -164,8 +149,10 @@ def windows(directory, schema, cancelled=lambda: False, stats=None):
|
||||
last_sample = stamp
|
||||
while cursor < len(history) and history[cursor][0] < stamp:
|
||||
when, slot, values = history[cursor]
|
||||
command_values[slot] = values
|
||||
command_times[slot] = when
|
||||
# Do not let drive predictions simply copy earlier human/model drive commands.
|
||||
if schema[slot]['template']['type'] != 'drive':
|
||||
command_values[slot] = values
|
||||
command_times[slot] = when
|
||||
cursor += 1
|
||||
sensor_index = bisect.bisect_right(sensor_times, stamp) - 1
|
||||
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]):
|
||||
stats['boundaryWindows'] += 1
|
||||
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)
|
||||
end = bisect.bisect_left(target_times, stamp + 100)
|
||||
actions = np.zeros(SLOTS, dtype=np.float32)
|
||||
@@ -202,13 +183,8 @@ def windows(directory, schema, cancelled=lambda: False, stats=None):
|
||||
families = set()
|
||||
for _, slot, vector in targets[start:end]:
|
||||
template = schema[slot]['template']
|
||||
family = template['type']
|
||||
if family == 'drive':
|
||||
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)))
|
||||
family = command_family(template)
|
||||
if ((actions[slot] and (template['type'] in ('headlight', 'laser', 'horn', 'song', 'raw') or not np.array_equal(values[slot], vector)))
|
||||
or (not actions[slot] and family in families)):
|
||||
ambiguous = True
|
||||
break
|
||||
@@ -218,5 +194,6 @@ def windows(directory, schema, cancelled=lambda: False, stats=None):
|
||||
stats['ambiguousWindows'] += 1
|
||||
continue
|
||||
stats['usableWindows'] += 1
|
||||
stats['movingWindows' if np.max(np.abs(wheel_target)) > .02 else 'stoppedWindows'] += 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['commandWindows' if actions.any() else 'noCommandWindows'] += 1
|
||||
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 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():
|
||||
@@ -78,7 +78,7 @@ def main():
|
||||
args.threshold = max(.05, min(.99, float(event['threshold'])))
|
||||
elif event['kind'] == 'sensor':
|
||||
sensors, sensor_time = event['data'], event['ts']
|
||||
elif event['kind'] == 'command':
|
||||
elif event['kind'] == 'command' and event['data'].get('type') != 'drive':
|
||||
try:
|
||||
encoded = encode_command(event['data'], schema)
|
||||
if encoded:
|
||||
@@ -104,58 +104,30 @@ def main():
|
||||
continue
|
||||
before = time.perf_counter()
|
||||
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))
|
||||
probabilities = logits[0].sigmoid().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):
|
||||
raise ValueError('Nonfinite model output')
|
||||
families = {}
|
||||
active = set()
|
||||
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)
|
||||
winners = select_slots(probabilities, schema, args.threshold)
|
||||
active = {slot for slot in range(len(schema)) if probabilities[slot] >= args.threshold}
|
||||
latched.intersection_update(active)
|
||||
# Choose the winning action before latching, so a held toggle cannot
|
||||
# expose a competing lower-confidence toggle on the following tick.
|
||||
commands = [command for _, command, slot, discrete in families.values()
|
||||
if not discrete or slot not in latched]
|
||||
winners = {row[2]: row for row in families.values()}
|
||||
for proposal in proposals:
|
||||
slot = proposal['slot']
|
||||
if slot in active:
|
||||
proposal['reason'] = ('Competing action' if slot not in winners else
|
||||
'Held discrete action' if winners[slot][3] and slot in latched else 'Proposed')
|
||||
for _, _, slot, discrete in families.values():
|
||||
if discrete:
|
||||
proposals, commands = [], []
|
||||
for slot, descriptor in enumerate(schema):
|
||||
command = decode_command(descriptor, predictions[slot])
|
||||
discrete = command['type'] in ('headlight', 'laser', 'horn', 'song', 'raw')
|
||||
reason = ('Below threshold' if slot not in active else
|
||||
'Competing action' if slot not in winners else
|
||||
'Held discrete action' if discrete and slot in latched else 'Proposed')
|
||||
proposals.append({'slot': slot, 'score': probabilities[slot], 'command': command, 'reason': reason})
|
||||
if reason == 'Proposed':
|
||||
commands.append(command)
|
||||
if slot in winners and discrete:
|
||||
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,
|
||||
'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)
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import numpy as np
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
VERSION = 2
|
||||
VERSION = 3
|
||||
SLOTS = 64
|
||||
VALUES = 8
|
||||
FRAMES = 4
|
||||
@@ -26,7 +26,6 @@ SENSORS = [
|
||||
("voltageMv", 16000), ("currentMa", 3000), ("batteryChargeMah", 4000),
|
||||
("batteryCapacityMah", 4000), ("batteryTemperatureC", 50),
|
||||
("chargingState.code", 5), ("chargingSources.homeBase", 1), ("oiMode.code", 4),
|
||||
("wheelSpeedsMmPerSecond.left", 500), ("wheelSpeedsMmPerSecond.right", 500),
|
||||
("wheelLeftCurrentMa", 2000), ("wheelRightCurrentMa", 2000),
|
||||
("mainBrushCurrentMa", 2000), ("sideBrushCurrentMa", 2000),
|
||||
("lightBumpLeftSignal", 4096), ("lightBumpRightSignal", 4096),
|
||||
@@ -62,6 +61,10 @@ def describe_command(command):
|
||||
def visit(value, path=()):
|
||||
if isinstance(value, dict):
|
||||
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 not math.isfinite(value) or len(values) >= VALUES:
|
||||
raise ValueError('Nonfinite command or too many numeric control fields')
|
||||
@@ -97,6 +100,44 @@ def encode_command(command, schema, extend=False):
|
||||
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):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -109,7 +150,6 @@ class Policy(nn.Module):
|
||||
nn.Linear(128 * 3 * 4, 128), nn.ReLU(),
|
||||
)
|
||||
self.history = nn.GRU(128 + STATE_SIZE, 128, batch_first=True)
|
||||
self.wheels = nn.Linear(128, 2)
|
||||
self.actions = nn.Linear(128, SLOTS)
|
||||
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)
|
||||
history, _ = self.history(torch.cat((encoded, state), dim=-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):
|
||||
return {'version': VERSION, 'architecture': 'spatial-cnn-gru-wheels-v2',
|
||||
return {'version': VERSION, 'architecture': 'spatial-cnn-gru-events-v3',
|
||||
'frames': FRAMES, 'height': HEIGHT, 'width': WIDTH,
|
||||
'historyStrideMs': 300, 'decisionIntervalMs': 100,
|
||||
'stateSize': STATE_SIZE, 'sensorFields': SENSORS,
|
||||
'normalization': '2/pi * atan(value/scale); missing sensors have mask=0',
|
||||
'maxCommandShapes': SLOTS, 'numericFieldsPerShape': VALUES,
|
||||
'commands': schema, 'actionSemantics': 'Continuous wheels at +100ms; accessories are events in the next 100ms',
|
||||
'wheelScaleMmPerSecond': 500, 'wheelOrder': ['left', 'right'],
|
||||
'wheelCommandExpiryMs': None, 'unknownWheelIntent': 'Excluded until next accepted human drive',
|
||||
'commands': schema, 'actionSemantics': 'All controls are command events in the next 100ms; no event preserves existing rover state',
|
||||
'driveCommandHistoryInput': False, 'zeroNumericValues': 'Exact template constants',
|
||||
'timing': 'Server-receipt alignment; browser display latency unmeasured'}
|
||||
|
||||
@@ -41,7 +41,7 @@ def main():
|
||||
import torch
|
||||
from torch.nn import functional as functional
|
||||
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:
|
||||
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']
|
||||
model = Policy().cpu()
|
||||
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0001)
|
||||
# A fresh training lineage prevents previously trained footage leaking into validation.
|
||||
resume_path = root / 'training' / 'resume-v3.pt'
|
||||
# The command-event policy has different inputs, targets and weights. Keep its
|
||||
# optimizer lineage separate; reuse recordings with their unchanged partition.
|
||||
resume_path = root / 'training' / 'resume-v4.pt'
|
||||
schema, steps, examples, last_published = [], 0, 0, 0
|
||||
trained_rovers = set()
|
||||
control_profiles = []
|
||||
@@ -107,7 +108,7 @@ def main():
|
||||
candidate_schema = copy.deepcopy(schema)
|
||||
samples, seen = [], 0
|
||||
stats = {}
|
||||
buckets = {key: [] for key in ('stopped', 'forward', 'reverse', 'turn')}
|
||||
buckets = {key: [] for key in ('stop', 'command', 'none')}
|
||||
counts = dict.fromkeys(buckets, 0)
|
||||
held_out = is_held_out(session_id)
|
||||
try:
|
||||
@@ -115,17 +116,16 @@ def main():
|
||||
for sample in windows(directory, candidate_schema, cancelled, stats):
|
||||
seen += 1
|
||||
# Keep evaluation naturally distributed. Training reserves space
|
||||
# for ordinary turns/reversing so idle or straight motion cannot
|
||||
# consume the entire sample budget. No scripted driving required.
|
||||
# for recorded commands and releases so periods without commands
|
||||
# cannot consume the entire sample budget. No scripted driving required.
|
||||
if held_out:
|
||||
reservoir, count, capacity = samples, seen, per_session
|
||||
else:
|
||||
left, right = sample[5]
|
||||
category = ('stopped' if max(abs(left), abs(right)) <= .02 else
|
||||
'turn' if abs(left - right) > .1 else
|
||||
'reverse' if left + right < 0 else 'forward')
|
||||
category = ('stop' if any(sample[2][slot] and is_stop(descriptor)
|
||||
for slot, descriptor in enumerate(candidate_schema)) else
|
||||
'command' if sample[2].any() else 'none')
|
||||
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:
|
||||
reservoir.append(sample)
|
||||
else:
|
||||
@@ -164,7 +164,7 @@ def main():
|
||||
except (ValueError, KeyError, TypeError, OSError, EOFError, IndexError, OverflowError, av.error.FFmpegError) as error:
|
||||
rejected[session_id] = str(error)[:500]
|
||||
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),
|
||||
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')
|
||||
@@ -188,17 +188,16 @@ def main():
|
||||
if cancelled() or steps - initial_steps >= config['maxStepsPerJob']:
|
||||
break
|
||||
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)
|
||||
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,
|
||||
pos_weight=positive_weight, reduction='none')
|
||||
event_loss = (event_loss * available).sum() / available.sum().clamp_min(1)
|
||||
mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1)
|
||||
value_loss = ((predictions - values).square() * mask).sum() / mask.sum().clamp_min(1)
|
||||
wheel_loss = functional.smooth_l1_loss(wheel_predictions, wheel_targets)
|
||||
loss = wheel_loss + event_loss + value_loss
|
||||
loss = event_loss + value_loss
|
||||
if not torch.isfinite(loss):
|
||||
raise ValueError('Nonfinite training loss; checkpoint not saved')
|
||||
loss.backward()
|
||||
@@ -209,7 +208,7 @@ def main():
|
||||
losses.append(loss.item())
|
||||
if steps % 10 == 0:
|
||||
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']:
|
||||
break
|
||||
if steps == initial_steps:
|
||||
@@ -224,60 +223,84 @@ def main():
|
||||
if validation and not cancelled():
|
||||
model.eval()
|
||||
true_positive = predicted_positive = actual_positive = 0
|
||||
value_error = value_count = 0
|
||||
evaluated = 0
|
||||
wheel_error = stopped_error = persistence_error = 0
|
||||
changed_error = changed_persistence = changed_count = moving_count = 0
|
||||
value_error = value_count = evaluated = 0
|
||||
stop_actual = stop_detected = false_stops = 0
|
||||
drive_actual = drive_detected = 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():
|
||||
for offset in range(0, len(validation), config['batchSize']):
|
||||
if cancelled():
|
||||
break
|
||||
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)]
|
||||
logits, predictions, wheel_predictions = model(images.float() / 255, states)
|
||||
predicted = (logits.sigmoid() >= job['actionThreshold']) & available.bool()
|
||||
actual = targets.bool() & available.bool()
|
||||
images, states, targets, values, available = [torch.from_numpy(np.stack(items)) for items in zip(*batch)]
|
||||
logits, predictions = model(images.float() / 255, states)
|
||||
predicted = torch.zeros_like(targets, dtype=torch.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()
|
||||
predicted_positive += predicted.sum().item()
|
||||
actual_positive += actual.sum().item()
|
||||
mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1)
|
||||
value_error += ((predictions - values).square() * mask).sum().item()
|
||||
value_count += mask.sum().item()
|
||||
errors = (wheel_predictions - wheel_targets).abs().mean(dim=1) * 500
|
||||
persistence = (previous_wheels - wheel_targets).abs().mean(dim=1) * 500
|
||||
changed = (previous_wheels - wheel_targets).abs().amax(dim=1) > .02
|
||||
changed_error += errors[changed].sum().item()
|
||||
changed_persistence += persistence[changed].sum().item()
|
||||
changed_count += changed.sum().item()
|
||||
moving_count += (wheel_targets.abs().amax(dim=1) > .02).sum().item()
|
||||
wheel_error += errors.sum().item()
|
||||
stopped_error += wheel_targets.abs().mean(dim=1).sum().item() * 500
|
||||
persistence_error += persistence.sum().item()
|
||||
empty = ~actual.any(dim=1)
|
||||
empty_windows += empty.sum().item()
|
||||
unwanted_windows += (empty & predicted.any(dim=1)).sum().item()
|
||||
if stop_slots:
|
||||
stop_actual += actual[:, stop_slots].sum().item()
|
||||
stop_detected += (actual[:, stop_slots] & predicted[:, stop_slots]).sum().item()
|
||||
false_stops += (predicted[:, stop_slots] & ~actual[:, stop_slots]).sum().item()
|
||||
if drive_slots:
|
||||
drive_actual += actual[:, drive_slots].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)
|
||||
precision = true_positive / max(1, predicted_positive)
|
||||
recall = true_positive / max(1, actual_positive)
|
||||
mse = value_error / max(1, value_count)
|
||||
wheel_mae = wheel_error / max(1, evaluated)
|
||||
stopped_mae = stopped_error / max(1, evaluated)
|
||||
persistence_mae = persistence_error / max(1, evaluated)
|
||||
changed_mae = changed_error / max(1, changed_count)
|
||||
changed_baseline = changed_persistence / max(1, changed_count)
|
||||
# Check control changes separately: copying the previous speed can look
|
||||
# excellent on long straight runs without learning to steer or stop.
|
||||
wheels_pass = (evaluated == len(validation) and evaluated >= 128 and moving_count >= 32
|
||||
and changed_count >= 32 and wheel_mae < stopped_mae
|
||||
and wheel_mae <= persistence_mae and changed_mae < changed_baseline)
|
||||
accessories_pass = (predicted_positive == 0 if actual_positive == 0 else
|
||||
actual_positive >= 32 and precision >= .5 and recall >= .5)
|
||||
passed = wheels_pass and accessories_pass and (value_count == 0 or mse <= .1)
|
||||
evaluation = {'status': 'passed imitation gate' if passed else 'imitation gate not met',
|
||||
stop_recall = stop_detected / max(1, stop_actual)
|
||||
held_out_sessions = sum(row.get('partition') == 'heldOut' and not row.get('skipReason') for row in dataset_stats)
|
||||
gate_reasons = []
|
||||
for condition, reason in [
|
||||
(evaluated == len(validation), 'Evaluation interrupted'),
|
||||
(held_out_sessions >= 2, 'Need two usable held-out recordings'),
|
||||
(evaluated >= 128, 'Need 128 held-out windows'),
|
||||
(actual_positive >= 32, 'Need 32 held-out command events'),
|
||||
(stop_actual >= 8, 'Need eight held-out stop events'),
|
||||
(drive_actual >= 16, 'Need 16 held-out movement commands'),
|
||||
(drive_detected / max(1, drive_actual) >= .5, 'Movement command recall below 0.5'),
|
||||
(precision >= .5, 'Command precision below 0.5'),
|
||||
(recall >= .5, 'Command recall below 0.5'),
|
||||
(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,
|
||||
'numericMse': mse, 'positiveActions': actual_positive,
|
||||
'wheelMaeMmPerSecond': wheel_mae, 'stoppedBaselineMaeMmPerSecond': stopped_mae,
|
||||
'previousSpeedBaselineMaeMmPerSecond': persistence_mae,
|
||||
'changedWheelMaeMmPerSecond': changed_mae, 'changedBaselineMaeMmPerSecond': changed_baseline,
|
||||
'changedWindows': changed_count, 'movingWindows': moving_count,
|
||||
'wheelsPassed': wheels_pass, 'accessoriesPassed': accessories_pass}
|
||||
'predictedActions': predicted_positive, 'falsePositiveActions': predicted_positive - true_positive,
|
||||
'stopEvents': stop_actual, 'missedStops': stop_actual - stop_detected,
|
||||
'stopRecall': stop_recall, 'falseStops': false_stops,
|
||||
'movementEvents': drive_actual, 'movementRecall': drive_detected / max(1, drive_actual),
|
||||
'noCommandWindows': empty_windows, 'unwantedCommandWindows': unwanted_windows,
|
||||
'commands': per_command, 'gateReasons': gate_reasons}
|
||||
evaluation_seconds = time.monotonic() - evaluation_started
|
||||
publication = {'distinctMinutes': unique_count / 600,
|
||||
'newMinutes': (unique_count - published_windows) / 600,
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "VITE_ROVERD_URL=http://192.168.0.86:8080 vite",
|
||||
"dev": "VITE_ROVERD_URL=https://rover.otter.land vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js",
|
||||
|
||||
@@ -25,22 +25,20 @@ function Group({ title, children }) {
|
||||
</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' };
|
||||
|
||||
function WheelMeter({ label, speed }) {
|
||||
const known = Number.isFinite(speed);
|
||||
const value = known ? Math.max(-500, Math.min(500, speed)) : 0;
|
||||
return <div className="surface-muted min-w-0 space-y-1">
|
||||
<div className="flex justify-between gap-2 text-xs"><span>{label}</span><span>{known ? `${value} mm/s` : 'Waiting'}</span></div>
|
||||
<div role="meter" aria-label={label} aria-valuemin={-500} aria-valuemax={500} aria-valuenow={value}
|
||||
aria-valuetext={known ? `${value} millimeters per second` : 'Unavailable'} className="relative h-3 overflow-hidden rounded bg-slate-800">
|
||||
<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" />
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-500"><span>Reverse</span><span>Forward</span></div>
|
||||
</div>;
|
||||
function commandParameters(command) {
|
||||
const fields = [];
|
||||
const visit = (value, path) => {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
for (const [key, item] of Object.entries(value)) visit(item, path ? `${path}.${key}` : key);
|
||||
} else {
|
||||
fields.push(`${path}: ${typeof value === 'number' ? Number(value.toFixed(2)) : JSON.stringify(value)}`);
|
||||
}
|
||||
};
|
||||
for (const [key, value] of Object.entries(command || {})) if (key !== 'type') visit(value, key);
|
||||
return fields.join(' · ');
|
||||
}
|
||||
|
||||
export default function VipRoverLearningCard() {
|
||||
@@ -135,7 +133,7 @@ function LearningCard({ roverId }) {
|
||||
<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}>
|
||||
{!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>
|
||||
<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'}
|
||||
@@ -150,12 +148,8 @@ function LearningCard({ roverId }) {
|
||||
</div>}
|
||||
{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>
|
||||
<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">
|
||||
<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"
|
||||
value={thresholdDraft ?? session.threshold ?? .7} disabled={working || !state?.control?.canAdjust}
|
||||
onChange={(event) => setThresholdDraft(Number(event.target.value))}
|
||||
@@ -163,14 +157,20 @@ function LearningCard({ roverId }) {
|
||||
</label>
|
||||
<div className="grid max-h-52 grid-cols-1 gap-1 overflow-y-auto @[28rem]:grid-cols-2">
|
||||
{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
|
||||
&& item.command.peripheral?.control === proposal.command.peripheral?.control)));
|
||||
const status = proposal.reason === 'Proposed' && submission ? submission.result : proposal.reason;
|
||||
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="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>
|
||||
{submission && <p className="break-words text-slate-400">Sent: {commandParameters(submission.command)}</p>}
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
@@ -202,8 +202,11 @@ function LearningCard({ roverId }) {
|
||||
<Row label="Status">{training?.status || 'Unavailable'}</Row>
|
||||
<Row label="Steps">{display(training?.steps)}</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="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="Event / parameter losses">{training?.losses ? `${training.losses.events?.toFixed(4) ?? 'Unavailable'} / ${training.losses.commandValues?.toFixed(4) ?? 'Unavailable'}` : 'Unavailable'}</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="Current job age">{training?.status === 'training' ? age(now, training.startedAt) : 'Not running'}</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>
|
||||
</dl>{training?.error && <p className="break-words text-amber-300">{training.error}</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>)}
|
||||
</Group>
|
||||
<Group title="Selected model"><dl>
|
||||
|
||||
Reference in New Issue
Block a user