mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-09 22:13:43 +08:00
Give Auto Learn a clear Galaxy tuner and saved-state status
This commit is contained in:
@@ -5,13 +5,51 @@ from openpilot.starpilot.flm.training_report import read_report, report_path
|
||||
|
||||
|
||||
class FlmTrainingResults:
|
||||
def __init__(self, path=None, *, clock=time.monotonic_ns):
|
||||
def __init__(self, path=None, *, clock=time.monotonic_ns, params=None):
|
||||
self.clock = clock
|
||||
self.params = params
|
||||
try:
|
||||
self.path = report_path() if path is None else path
|
||||
except (OSError, ValueError):
|
||||
self.path = None
|
||||
|
||||
def snapshot(self):
|
||||
return read_report(self.path, now_ns=self.clock()) if self.path is not None else {
|
||||
result = read_report(self.path, now_ns=self.clock()) if self.path is not None else {
|
||||
"version": 1, "current": None, "history": []}
|
||||
if self.params is not None:
|
||||
result['preference'] = self.preference()
|
||||
return result
|
||||
|
||||
def preference(self):
|
||||
from capnp import KjException
|
||||
from opendbc.car.structs import car
|
||||
from openpilot.starpilot.lateral.learning_preferences import KEY, MAX_BYTES, LEGACY_KEY, read_preference
|
||||
from openpilot.starpilot.saved_source import read_saved
|
||||
from openpilot.starpilot.schema_cache import inspect_cache
|
||||
|
||||
unknown = {'version': 1, 'valid': False, 'enabled': None, 'parked': None, 'vehicle': None}
|
||||
try:
|
||||
offroad = read_saved(self.params, 'IsOffroad', 1)
|
||||
if not offroad[1] or offroad[0] not in (b'0', b'1'):
|
||||
return unknown
|
||||
parked = offroad[0] == b'1'
|
||||
key = 'CarParamsPersistent' if parked else 'CarParams'
|
||||
snapshots = {name: read_saved(self.params, name, limit) for name, limit in (
|
||||
(key, 1024 * 1024), (KEY, MAX_BYTES), (LEGACY_KEY, 1))}
|
||||
raw, readable = snapshots[key]
|
||||
if raw is None or not readable:
|
||||
return {**unknown, 'parked': parked}
|
||||
payload = inspect_cache(key, raw).payload if parked else raw
|
||||
if payload is None:
|
||||
return {**unknown, 'parked': parked}
|
||||
with car.CarParams.from_bytes(payload, traversal_limit_in_words=1024 * 1024 // 8) as cp:
|
||||
choice = read_preference(self.params, cp, preference_snapshot=snapshots[KEY], legacy_snapshot=snapshots[LEGACY_KEY])
|
||||
vehicle, not_car = str(cp.carFingerprint), bool(cp.notCar)
|
||||
# A mode, vehicle or preference change invalidates this read as a whole.
|
||||
if (not_car or not choice.valid or offroad != read_saved(self.params, 'IsOffroad', 1) or
|
||||
any(snapshot != read_saved(self.params, name, 1024 * 1024 if name == key else MAX_BYTES if name == KEY else 1)
|
||||
for name, snapshot in snapshots.items())):
|
||||
return {**unknown, 'parked': parked}
|
||||
return {'version': 1, 'valid': True, 'enabled': choice.enabled, 'parked': parked, 'vehicle': vehicle}
|
||||
except (OSError, RuntimeError, AttributeError, TypeError, ValueError, OverflowError, KjException):
|
||||
return unknown
|
||||
|
||||
@@ -102,7 +102,10 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
|
||||
from openpilot.starpilot.galaxy.companion import CompanionRegistry, APK_PATH
|
||||
from openpilot.starpilot.gps.phone import PhoneGpsPublisher
|
||||
companion_source = companion if companion is not None else CompanionRegistry()
|
||||
training_results_source = training_results if training_results is not None else FlmTrainingResults()
|
||||
if training_results is None:
|
||||
from openpilot.common.params import Params
|
||||
training_results = FlmTrainingResults(params=Params())
|
||||
training_results_source = training_results
|
||||
phone_location_source = phone_location if phone_location is not None else PhoneGpsPublisher()
|
||||
cloud = cloud_provider
|
||||
hotspot_source = hotspot
|
||||
|
||||
@@ -125,3 +125,83 @@ def test_unknown_fit_window_counts_are_not_reported_as_zero(tmp_path):
|
||||
def test_invalid_namespace_only_disables_report(monkeypatch):
|
||||
monkeypatch.setenv('OPENPILOT_PREFIX', '../not-a-report-namespace')
|
||||
assert FlmTrainingResults(clock=lambda: NOW).snapshot() == {'version': 1, 'current': None, 'history': []}
|
||||
|
||||
|
||||
class ChoiceParams:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
|
||||
def get_param_path(self, key):
|
||||
return str(self.root / key)
|
||||
|
||||
def put(self, key, value, **_kwargs):
|
||||
(self.root / key).write_bytes(value)
|
||||
|
||||
|
||||
def choice_fixture(tmp_path, *, parked=True, enabled=True):
|
||||
from opendbc.car.structs import car
|
||||
from openpilot.starpilot.schema_cache import put_cache
|
||||
from openpilot.starpilot.lateral.learning_preferences import KEY, replace_preference
|
||||
params = ChoiceParams(tmp_path)
|
||||
cp = car.CarParams.new_message(brand='hyundai', carFingerprint='HYUNDAI_IONIQ_6')
|
||||
params.put('IsOffroad', b'1' if parked else b'0')
|
||||
params.put(KEY, replace_preference(None, cp, enabled))
|
||||
if parked:
|
||||
put_cache(params, 'CarParamsPersistent', cp)
|
||||
else:
|
||||
params.put('CarParams', cp.to_bytes())
|
||||
return params, cp
|
||||
|
||||
|
||||
@pytest.mark.parametrize('parked', (True, False))
|
||||
@pytest.mark.parametrize('enabled', (True, False))
|
||||
def test_choice_is_independent_of_a_missing_or_stopped_learner(tmp_path, parked, enabled):
|
||||
params, _cp = choice_fixture(tmp_path, parked=parked, enabled=enabled)
|
||||
before = {p.name: (p.read_bytes(), p.stat().st_mtime_ns) for p in tmp_path.iterdir()}
|
||||
snapshot = FlmTrainingResults(tmp_path / 'missing-report', params=params).snapshot()
|
||||
assert snapshot['current'] is None
|
||||
assert snapshot['preference'] == {'version': 1, 'valid': True, 'enabled': enabled, 'parked': parked,
|
||||
'vehicle': 'HYUNDAI_IONIQ_6'}
|
||||
assert before == {p.name: (p.read_bytes(), p.stat().st_mtime_ns) for p in tmp_path.iterdir()}
|
||||
|
||||
|
||||
def test_scoped_on_is_not_overridden_by_legacy_off(tmp_path):
|
||||
params, _cp = choice_fixture(tmp_path)
|
||||
params.put('ForceAutoTuneOff', b'1')
|
||||
assert FlmTrainingResults(tmp_path / 'missing', params=params).snapshot()['preference']['enabled'] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key,raw', (('IsOffroad', b'wat'), ('CarParamsPersistent', b'bad'),
|
||||
('SteeringLearningPreferences', b'{'), ('CarParamsPersistent', b'x' * (1024 * 1024 + 1))))
|
||||
def test_unreadable_choice_is_unknown_not_off(tmp_path, key, raw):
|
||||
params, _cp = choice_fixture(tmp_path)
|
||||
params.put(key, raw)
|
||||
actual = FlmTrainingResults(tmp_path / 'missing', params=params).snapshot()['preference']
|
||||
assert actual['valid'] is False and actual['enabled'] is None
|
||||
|
||||
|
||||
def test_driving_without_current_vehicle_does_not_borrow_parked_identity(tmp_path):
|
||||
params, _cp = choice_fixture(tmp_path)
|
||||
params.put('IsOffroad', b'0')
|
||||
actual = FlmTrainingResults(tmp_path / 'missing', params=params).snapshot()['preference']
|
||||
assert actual['valid'] is False and actual['enabled'] is None and actual['parked'] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key', ('IsOffroad', 'SteeringLearningPreferences', 'CarParamsPersistent'))
|
||||
def test_mode_or_choice_change_during_read_is_unknown(tmp_path, monkeypatch, key):
|
||||
from openpilot.starpilot import saved_source
|
||||
params, _cp = choice_fixture(tmp_path)
|
||||
original = saved_source.read_saved
|
||||
calls = 0
|
||||
|
||||
def changed(owner, name, limit):
|
||||
nonlocal calls
|
||||
if name == key:
|
||||
calls += 1
|
||||
if calls == 2:
|
||||
return b'changed-during-read', True
|
||||
return original(owner, name, limit)
|
||||
|
||||
monkeypatch.setattr(saved_source, 'read_saved', changed)
|
||||
actual = FlmTrainingResults(tmp_path / 'missing', params=params).snapshot()['preference']
|
||||
assert actual['valid'] is False and actual['enabled'] is None
|
||||
|
||||
@@ -18,8 +18,8 @@ const app = readFileSync(new URL("../web/js/app.js", import.meta.url), "utf8")
|
||||
assert.match(app, /drivingSettingsPage\(\) \{ return drivingPage\(route\.path\) \}/)
|
||||
assert.match(app, /<SettingsPage[^>]* v-else-if="drivingSettingsPage"[^>]*:initial-page="drivingSettingsPage"/)
|
||||
assert.match(app, /<DrivingPage[^>]* v-else-if="route\.path === '\/driving'"/)
|
||||
assert(app.includes("<div v-else-if=\"['/tuning', '/tuning/plots', '/tuning/flm'].includes(route.path)\""))
|
||||
assert(app.includes('<PlotsPage :mode="state.monitorMode"'))
|
||||
assert(app.includes("<FlmPage v-else-if=\"['/tuning', '/tuning/flm'].includes(route.path)\""))
|
||||
assert(app.includes('<PlotsPage v-else-if="route.path === \'/tuning/plots\'" :mode="state.monitorMode"'))
|
||||
|
||||
for (const page of ["aol", "conditional", "curve", "slc", "torque", "profiles", "traffic"]) {
|
||||
const requests = []
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { compile } from "../web/vendor/vue/vue.esm-browser.js"
|
||||
import { FlmPage } from "../web/js/flm.js"
|
||||
import { SURFACE_NAMES, PID_UNSUPPORTED, fullCoordinates, surfaceRows, validTrainingResults, coordinateText } from "../web/js/flm-training-results.js"
|
||||
import { SURFACE_NAMES, PID_UNSUPPORTED, fullCoordinates, surfaceRows, validTrainingResults, coordinateText, SURFACE_GROUPS, validLearningPreference, tunerStatus } from "../web/js/flm-training-results.js"
|
||||
|
||||
const report = () => ({ version: 1, history: [], current: { fresh: true, ageSeconds: .1, enabled: true,
|
||||
status: "collecting", reason: "capturing_steering_evidence", progress: { samples: 120, blocks: 3, requiredBlocks: 16 },
|
||||
@@ -55,36 +55,37 @@ assert.equal(coordinateText(null), "Not reported")
|
||||
assert.equal(coordinateText(.005), "+0.005")
|
||||
compile(FlmPage.template, { decodeEntities: value => value.replaceAll("&", "&") })
|
||||
assert.doesNotMatch(FlmPage.template, /v-html|Analyze selected|GM Trial|Choose Full Logs/)
|
||||
assert.match(FlmPage.template, /Currently applied/)
|
||||
assert.match(FlmPage.template, /Applied now/)
|
||||
assert.match(FlmPage.template, /PID unsupported/)
|
||||
assert.deepEqual(SURFACE_GROUPS.flatMap(group => group.names).sort(), [...SURFACE_NAMES].sort())
|
||||
const app = readFileSync(new URL("../web/js/app.js", import.meta.url), "utf8")
|
||||
assert(app.includes("['/tuning', '/tuning/plots', '/tuning/flm'].includes(route.path)"))
|
||||
const analysis = app.match(/<div v-else-if="\['\/tuning', '\/tuning\/plots', '\/tuning\/flm'\]\.includes\(route.path\)"[\s\S]*?<\/div>/)?.[0]
|
||||
assert(analysis, 'Existing analysis route and old bookmarks share one page')
|
||||
assert.match(analysis, /<PlotsPage /)
|
||||
assert.match(analysis, /<FlmPage embedded /)
|
||||
assert.doesNotMatch(app, /<FlmPage v-else-if/)
|
||||
compile(analysis.replace("v-else-if", "v-if"), { onError: error => { throw error }, decodeEntities: value => value.replaceAll("&", "&") })
|
||||
assert.doesNotMatch(app, /Recorded-drive analysis/)
|
||||
const settings = readFileSync(new URL("../web/js/settings.js", import.meta.url), "utf8")
|
||||
assert.doesNotMatch(settings, /FLMTrainingResults|View FLM training results/)
|
||||
assert(app.includes("['/tuning', '/tuning/flm'].includes(route.path)"))
|
||||
assert.match(app, /<FlmPage v-else-if/)
|
||||
assert.match(app, /<PlotsPage v-else-if="route.path === '\/tuning\/plots'"/)
|
||||
assert.doesNotMatch(app, /<FlmPage embedded/)
|
||||
const catalog = JSON.parse(readFileSync(new URL("../web/data/catalog.json", import.meta.url), "utf8"))
|
||||
assert(catalog.tools.some(tool => tool.path === "/tuning/plots"))
|
||||
assert(!catalog.tools.some(tool => tool.path === "/tuning/flm"))
|
||||
assert(catalog.tools.some(tool => tool.path === "/tuning" && tool.name === "StarPilot Tuner"))
|
||||
assert.equal(catalog.tools.filter(tool => tool.path.startsWith('/tuning')).length, 1)
|
||||
const plots = readFileSync(new URL('../web/js/plots.js', import.meta.url), 'utf8')
|
||||
assert.doesNotMatch(plots, /href="#\/tuning\/flm"/)
|
||||
const driving = readFileSync(new URL('../web/js/driving.js', import.meta.url), 'utf8')
|
||||
assert.match(driving, /go\('\/tuning'\)/)
|
||||
assert.match(driving, /go\('\/tuning\/plots'\)/)
|
||||
assert.doesNotMatch(driving, /\/tuning\/flm/)
|
||||
assert.match(FlmPage.template, /<section v-if="!embedded"[^>]*><div class="gx-section__header">.*Live steering response/)
|
||||
assert.doesNotMatch(FlmPage.template, /href="#\/tuning\/plots"/)
|
||||
const route = readFileSync(new URL("../web/js/route-workspace.js", import.meta.url), "utf8")
|
||||
assert.doesNotMatch(route, /Analyze driving logs/)
|
||||
assert.doesNotMatch(readFileSync(new URL('../web/js/flm.js', import.meta.url), 'utf8'), /PlotsFeed|api\/plots/)
|
||||
for (const view of ['overview', 'tune', 'activity']) assert(FlmPage.template.includes(`view === '${view}'`))
|
||||
const choice = { version: 1, valid: true, enabled: true, parked: true, vehicle: 'HYUNDAI_IONIQ_6' }
|
||||
assert(validLearningPreference(choice))
|
||||
assert.equal(tunerStatus({ ...value, preference: choice }, false, true).key, 'parked')
|
||||
assert.equal(tunerStatus({ ...value, preference: { ...choice, enabled: false } }, true, true).key, 'off')
|
||||
assert.equal(tunerStatus({ ...value, preference: choice }, false, false).key, 'stale')
|
||||
assert.equal(tunerStatus({ ...value, preference: choice }, true, true, 'offline').key, 'offline')
|
||||
for (const invalid of [null, {}, { ...choice, version: true }, { ...choice, valid: false, enabled: true },
|
||||
{ ...choice, enabled: 'yes' }, { ...choice, parked: 1 }, { ...choice, vehicle: 'x'.repeat(257) }]) {
|
||||
assert(!validLearningPreference(invalid))
|
||||
assert(!validTrainingResults({ ...value, preference: invalid }))
|
||||
}
|
||||
|
||||
function instance() {
|
||||
const vm = { ...FlmPage.data(), activePage: true, mode: "local", unauthorized: () => vm.auth++, auth: 0,
|
||||
feed: { stop() {} }, poller: { stop() {} } }
|
||||
poller: { stop() {} } }
|
||||
for (const [name, method] of Object.entries(FlmPage.methods)) vm[name] = method.bind(vm)
|
||||
for (const [name, method] of Object.entries(FlmPage.computed)) Object.defineProperty(vm, name, { get: () => method.call(vm) })
|
||||
return vm
|
||||
@@ -92,16 +93,11 @@ function instance() {
|
||||
const originalFetch = globalThis.fetch, originalDocument = globalThis.document
|
||||
try {
|
||||
globalThis.document = { hidden: false, addEventListener() {}, removeEventListener() {} }
|
||||
for (const embedded of [true, false]) {
|
||||
const page = { ...FlmPage.data(), embedded, mode: 'local', unauthorized() {}, load() {} }
|
||||
let plotStarts = 0
|
||||
FlmPage.created.call(page)
|
||||
page.feed.start = () => { plotStarts++ }
|
||||
page.feed.stop = () => {}
|
||||
FlmPage.mounted.call(page)
|
||||
assert.equal(plotStarts, embedded ? 0 : 1, 'Embedded results reuse the existing live plots')
|
||||
FlmPage.beforeUnmount.call(page)
|
||||
}
|
||||
const mounted = instance()
|
||||
mounted.load = () => {}
|
||||
FlmPage.mounted.call(mounted)
|
||||
assert.equal(mounted.feed, undefined, 'Tuner does not start a second raw-plot feed')
|
||||
FlmPage.beforeUnmount.call(mounted)
|
||||
const calls = []
|
||||
globalThis.fetch = async (url, options) => { calls.push({ url, options }); return { ok: true, json: async () => value } }
|
||||
const vm = instance(); await vm.load()
|
||||
@@ -113,6 +109,18 @@ try {
|
||||
vm.now = vm.receivedAt + 2100
|
||||
assert(!vm.currentApplied)
|
||||
assert(vm.rows.every(row => row.applied === null))
|
||||
vm.report = { ...value, preference: { ...choice, enabled: false, parked: false } }
|
||||
vm.now = vm.receivedAt
|
||||
assert(!vm.currentApplied, 'Authoritative current OFF suppresses earlier RAM application')
|
||||
vm.report = { ...value, preference: choice }
|
||||
assert(!vm.currentApplied, 'Parked report does not claim a current steering correction')
|
||||
vm.report = value
|
||||
const focused = []
|
||||
const event = { key: 'ArrowRight', preventDefault() {}, currentTarget: { parentElement: { querySelectorAll: () =>
|
||||
['overview', 'tune', 'activity'].map(name => ({ focus: () => focused.push(name) })) } } }
|
||||
vm.tabKey(event); assert.equal(vm.view, 'tune'); assert.deepEqual(focused, ['tune'])
|
||||
event.key = 'End'; vm.tabKey(event); assert.equal(vm.view, 'activity')
|
||||
event.key = 'Home'; vm.tabKey(event); assert.equal(vm.view, 'overview')
|
||||
globalThis.fetch = async () => { throw new TypeError("offline") }
|
||||
await vm.load(); assert(!vm.currentApplied); assert.equal(vm.report, value)
|
||||
globalThis.fetch = async () => ({ ok: false, status: 401, json: async () => ({ error: "Sign in" }) })
|
||||
|
||||
@@ -173,15 +173,14 @@ for (const mutate of [value => value.version = 'unknown', value => value.binding
|
||||
const invalid = report(); invalid.current.trackingPlot = plot(); mutate(invalid.current.trackingPlot)
|
||||
assert(!validTrackingPlot(invalid.current.trackingPlot)); assert(!validTrainingResults(invalid)); assert.equal(trackingPlotView(invalid), null)
|
||||
}
|
||||
assert.match(FlmPage.template, /Latest completed delay-paired steering episode/)
|
||||
assert.match(FlmPage.template, /not actuator-delay aligned/)
|
||||
assert.match(FlmPage.template, /Pending trial target/)
|
||||
assert.match(FlmPage.template, /Accepted baseline/)
|
||||
assert.match(FlmPage.template, /No delay-paired steering episode plot/)
|
||||
assert.match(FlmPage.template, /measured delay/)
|
||||
assert.match(FlmPage.template, /Completed window/)
|
||||
assert.match(FlmPage.template, /Applied now/)
|
||||
assert.match(FlmPage.template, /saving is separate/)
|
||||
assert.match(FlmPage.template, /No interpolation/)
|
||||
compile(FlmPage.template, { onError: error => { throw error }, decodeEntities: value => value.replaceAll('&', '&') })
|
||||
assert.match(FlmPage.template, /tracking\?\.label \|\| summary/)
|
||||
assert.match(FlmPage.template, /Future trial episodes/)
|
||||
assert.match(FlmPage.template, /Passing future checks does not itself confirm/)
|
||||
assert.match(FlmPage.template, /Comparable trial windows/)
|
||||
assert.match(FlmPage.template, /not a confirmed improvement/)
|
||||
assert.doesNotMatch(FlmPage.template, /v-html/)
|
||||
console.log('Optional versioned FLM tracking report, paired observed metrics, honest trial acknowledgments and old report compatibility passed')
|
||||
|
||||
@@ -196,5 +195,5 @@ assert(!liveText.includes('Collection & fitting'), 'Legacy fitter cards must not
|
||||
delete ctx.report.current.tracking
|
||||
const legacyText = text(render.call(ctx,ctx,[]))
|
||||
assert(legacyText.toLowerCase().includes('invalid applied output'))
|
||||
assert(legacyText.includes('Collection & fitting'))
|
||||
assert(legacyText.includes('Collecting driving samples'))
|
||||
console.log('Actual Vue render keeps live learner progress separate and preserves legacy report fallback')
|
||||
|
||||
@@ -1859,3 +1859,139 @@ progress { max-width: 100%; accent-color: var(--primary); }
|
||||
}
|
||||
.gx-starport-checks .gx-starport-check--active { padding: var(--sp-3); border-radius: var(--radius-sm); color: var(--primary); background: color-mix(in srgb, var(--primary) 12%, transparent); font-weight: var(--fw-bold); }
|
||||
.gx-starport-checks .gx-starport-check--active::before { border-right-color: var(--secondary, var(--primary)); }
|
||||
|
||||
/* StarPilot Tuner shares Galaxy surfaces, spacing and source-aware state colors. */
|
||||
.gx-tuner { display: grid; gap: var(--sp-5); min-width: 0; padding-bottom: var(--sp-4); }
|
||||
.gx-tuner h2, .gx-tuner h3, .gx-tuner h4, .gx-tuner p { margin: 0; }
|
||||
.gx-tuner__heading h2 { font-size: clamp(1.8rem, 4vw, 2.3rem); letter-spacing: -.04em; margin: 5px 0; }
|
||||
.gx-tuner__heading > div > p:last-child { color: var(--on-surface-variant); }
|
||||
.gx-tuner__eyebrow { display: flex; align-items: center; gap: 8px; font-size: .66rem; line-height: 1.5; font-weight: 600; letter-spacing: .13em; color: var(--on-surface-variant); }
|
||||
.gx-card.gx-tuner__hero { --tuner-accent: var(--text-muted); display: grid; grid-template-columns: 78px minmax(0, 1fr) auto; align-items: center; gap: var(--sp-5); padding: clamp(20px, 3vw, 32px); background: linear-gradient(115deg, var(--primary-container), transparent 65%), var(--surface); border-color: color-mix(in srgb, var(--primary) 25%, var(--glass-border)); }
|
||||
.gx-tuner__hero[data-tone="active"] { --tuner-accent: var(--primary); }
|
||||
.gx-tuner__hero[data-tone="success"], .gx-tuner__hero[data-tone="ready"] { --tuner-accent: var(--secondary); }
|
||||
.gx-tuner__hero[data-tone="warning"] { --tuner-accent: var(--warning); }
|
||||
.gx-tuner__hero-icon { display: grid; place-items: center; width: 78px; height: 78px; border: 1px solid var(--glass-border); border-radius: 24px; color: var(--tuner-accent); background: var(--surface-container); font-size: 2rem; }
|
||||
.gx-tuner__hero-copy { display: grid; gap: 8px; }
|
||||
.gx-tuner__hero-copy h3 { font-size: clamp(1.3rem, 2.7vw, 1.8rem); line-height: 1.2; letter-spacing: -.03em; }
|
||||
.gx-tuner__hero-copy > p { max-width: 65ch; color: var(--on-surface-variant); font-size: .88rem; line-height: 1.6; }
|
||||
.gx-tuner__status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--tuner-accent); box-shadow: 0 0 12px color-mix(in srgb, var(--tuner-accent) 35%, transparent); }
|
||||
.gx-tuner__hero-meta { display: grid; gap: 7px; max-width: 170px; border-left: 1px solid var(--glass-border); padding-left: 24px; font-size: .75rem; line-height: 1.5; }
|
||||
.gx-tuner__hero-meta small { color: var(--text-muted); }
|
||||
.gx-tuner__current-job { display: flex; gap: 8px; color: var(--on-primary-container); }
|
||||
.gx-tuner__tabs { display: flex; align-items: center; gap: 4px; border-bottom: 1px solid var(--glass-border); }
|
||||
.gx-tuner__tabs > button { position: relative; min-height: 48px; padding: 0 16px 14px; border: 0; background: transparent; color: var(--on-surface-variant); font: inherit; font-size: .86rem; cursor: pointer; white-space: nowrap; }
|
||||
.gx-tuner__tabs > button[aria-selected="true"] { color: var(--on-surface); font-weight: 600; }
|
||||
.gx-tuner__tabs > button[aria-selected="true"]::after { position: absolute; content: ""; bottom: -1px; left: 15px; right: 15px; height: 3px; border-radius: 3px 3px 0 0; background: var(--primary); }
|
||||
.gx-tuner__tabs > button:focus-visible, .gx-tuner__groups > button:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; border-radius: var(--radius-sm); }
|
||||
.gx-tuner__tabs > .gx-tuner__plots-link { margin-left: auto; padding-right: 0; color: var(--on-surface-variant); font-size: .78rem; }
|
||||
.gx-tuner__count { display: inline-block; margin-left: 7px; padding: 1px 6px; border: 1px solid var(--glass-border); border-radius: var(--radius-full); font-size: .62rem; color: var(--on-surface-variant); vertical-align: middle; }
|
||||
.gx-tuner__panel { display: grid; gap: var(--sp-5); min-width: 0; }
|
||||
.gx-tuner__metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--sp-4); }
|
||||
.gx-card.gx-tuner__metric { display: grid; gap: 9px; padding: 20px 24px; min-width: 0; }
|
||||
.gx-tuner__metric > span { font-size: .77rem; color: var(--on-surface-variant); }
|
||||
.gx-tuner__metric > strong { font-size: 1.9rem; line-height: 1.15; font-weight: 500; letter-spacing: -.04em; font-variant-numeric: tabular-nums; }
|
||||
.gx-tuner__metric > strong small { font-size: .8rem; color: var(--text-muted); }
|
||||
.gx-tuner__metric p { color: var(--text-muted); font-size: .68rem; line-height: 1.5; }
|
||||
.gx-tuner__overview-grid { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: var(--sp-4); }
|
||||
.gx-card.gx-tuner__section { padding: clamp(18px, 2.5vw, 24px); min-width: 0; }
|
||||
.gx-tuner__section > p { color: var(--on-surface-variant); font-size: .82rem; line-height: 1.6; margin: 16px 0; }
|
||||
.gx-tuner__section-heading { display: flex; align-items: start; justify-content: space-between; gap: var(--sp-4); margin-bottom: 18px; }
|
||||
.gx-tuner__section-heading h3 { font-size: 1.02rem; font-weight: 600; letter-spacing: -.02em; margin-top: 3px; }
|
||||
.gx-tuner__section-heading p:not(.gx-tuner__eyebrow) { font-size: .8rem; color: var(--on-surface-variant); margin-top: 6px; line-height: 1.5; }
|
||||
.gx-tuner__pill { flex: 0 0 auto; display: inline-flex; align-items: center; padding: 5px 8px; font-size: .6rem; color: var(--on-surface-variant); border: 1px solid var(--glass-border); border-radius: var(--radius-full); white-space: nowrap; }
|
||||
.gx-tuner__response svg, .gx-tuner__disclosure svg { width: 100%; height: auto; min-height: 150px; display: block; margin: 20px 0; }
|
||||
.gx-tuner svg polyline { fill: none; stroke-width: 2.5; vector-effect: non-scaling-stroke; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.gx-tuner .gx-plot-axis { stroke: var(--on-surface-variant); opacity: .35; stroke-width: 1; }
|
||||
.gx-tuner .gx-plot-zero { stroke: var(--on-surface-variant); opacity: .2; stroke-width: 1; stroke-dasharray: 4 4; }
|
||||
.gx-tuner .gx-plot-label { fill: var(--on-surface-variant); font-size: 11px; }
|
||||
.gx-tuner__legend { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; font-size: .73rem; color: var(--on-surface-variant); }
|
||||
.gx-tuner__legend > span::before { content: ""; width: 14px; height: 3px; display: inline-block; border-radius: 2px; margin-right: 7px; vertical-align: middle; }
|
||||
.gx-tuner__requested::before { background: #45a9ff; }
|
||||
.gx-tuner__measured::before { background: #ffa352; }
|
||||
.gx-tuner__legend small { margin-left: auto; color: var(--text-muted); font-size: .65rem; }
|
||||
.gx-tuner p.gx-tuner__caption, .gx-tuner__caption { color: var(--text-muted); font-size: .68rem; line-height: 1.6; margin-top: 13px; }
|
||||
.gx-tuner__empty { display: grid; justify-items: center; align-content: center; gap: 10px; text-align: center; min-height: 215px; padding: 24px; }
|
||||
.gx-tuner__empty > i { font-size: 1.6rem; color: var(--primary); padding-bottom: 6px; }
|
||||
.gx-tuner__empty > h4 { font-size: .88rem; font-weight: 500; }
|
||||
.gx-tuner__empty > p { color: var(--text-muted); font-size: .76rem; line-height: 1.65; max-width: 37ch; }
|
||||
.gx-tuner__comparison { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 18px; margin: 25px 0 0; }
|
||||
.gx-tuner__comparison > div { display: grid; gap: 8px; }
|
||||
.gx-tuner__comparison span { color: var(--text-muted); font-size: .7rem; }
|
||||
.gx-tuner__comparison strong { font-size: 1.15rem; font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
.gx-tuner__comparison > i { color: var(--text-muted); font-size: .85rem; }
|
||||
.gx-tuner p.gx-tuner__observed { font-size: .9rem; color: var(--on-surface); }
|
||||
.gx-tuner__observed small { display: block; margin-top: 4px; font-size: .65rem; color: var(--text-muted); }
|
||||
.gx-tuner__quota { margin-top: 20px; }
|
||||
.gx-tuner__quota > div:first-child { display: flex; justify-content: space-between; gap: 10px; font-size: .7rem; }
|
||||
.gx-tuner__quota strong { font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
.gx-tuner__progress { margin-top: 10px; height: 5px; border-radius: 5px; background: var(--outline); overflow: hidden; }
|
||||
.gx-tuner__progress > span { display: block; height: 100%; background: var(--primary); border-radius: inherit; }
|
||||
.gx-tuner__tune-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); gap: var(--sp-4); align-items: start; }
|
||||
.gx-tuner__groups { display: grid; gap: 8px; }
|
||||
.gx-tuner__groups > button { display: flex; align-items: center; gap: 13px; min-height: 70px; padding: 14px; border: 1px solid var(--glass-border); border-radius: var(--radius-sm); background: var(--surface); color: var(--on-surface-variant); text-align: left; cursor: pointer; font: inherit; font-size: .78rem; }
|
||||
.gx-tuner__groups > button[aria-pressed="true"] { background: var(--primary-container); border-color: var(--primary); color: var(--on-surface); }
|
||||
.gx-tuner__groups > button > i:first-child { width: 22px; text-align: center; font-size: 1rem; color: var(--primary); }
|
||||
.gx-tuner__groups > button > i:last-child { margin-left: auto; font-size: .65rem; }
|
||||
.gx-tuner__groups small { display: block; margin-top: 5px; font-size: .61rem; color: var(--text-muted); }
|
||||
.gx-tuner__parameter-head, .gx-tuner__parameter { display: grid; grid-template-columns: minmax(140px, 1.5fr) repeat(3, minmax(75px, .75fr)); gap: 10px; align-items: center; }
|
||||
.gx-tuner__parameter-head { color: var(--text-muted); font-size: .61rem; padding: 0 0 12px; }
|
||||
.gx-tuner__parameter-head > span:not(:first-child), .gx-tuner__parameter > div:not(:first-child) { text-align: right; }
|
||||
.gx-tuner__parameter { padding: 17px 0; border-top: 1px solid var(--outline-variant); }
|
||||
.gx-tuner__parameter h4 { font-size: .77rem; line-height: 1.5; font-weight: 500; }
|
||||
.gx-tuner__parameter strong { font-size: .72rem; font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
.gx-tuner__parameter small { display: block; font-size: .6rem; color: var(--text-muted); margin-top: 5px; }
|
||||
.gx-tuner__mobile-label { display: none; }
|
||||
.gx-tuner__pending { color: var(--warning); }
|
||||
.gx-tuner__applied { color: var(--secondary); }
|
||||
.gx-tuner__disclosure { margin-top: 16px; color: var(--on-surface-variant); font-size: .77rem; }
|
||||
.gx-tuner__disclosure > summary { padding: 8px 0; min-height: 36px; cursor: pointer; color: var(--on-surface); }
|
||||
.gx-tuner__disclosure > p { margin-top: 12px; font-size: .74rem; line-height: 1.7; }
|
||||
.gx-tuner__timeline { list-style: none; margin: 8px 0 18px; padding: 0; }
|
||||
.gx-tuner__timeline li { position: relative; display: grid; grid-template-columns: 12px 1fr; gap: 18px; padding: 13px 0 20px; }
|
||||
.gx-tuner__timeline li:not(:last-child)::before { content: ""; position: absolute; top: 22px; left: 4px; bottom: -13px; width: 1px; background: var(--glass-border); }
|
||||
.gx-tuner__timeline-dot { position: relative; width: 9px; height: 9px; border-radius: 50%; border: 2px solid var(--primary); margin-top: 4px; background: var(--background); }
|
||||
.gx-tuner__timeline-dot--applied { border-color: var(--secondary); background: var(--secondary); }
|
||||
.gx-tuner__timeline strong { font-size: .85rem; font-weight: 500; }
|
||||
.gx-tuner__timeline p { margin-top: 6px; color: var(--on-surface-variant); font-size: .77rem; line-height: 1.5; }
|
||||
.gx-tuner__timeline small { display: block; margin-top: 7px; color: var(--text-muted); font-size: .64rem; }
|
||||
.gx-tuner__diagnostics { display: grid; grid-template-columns: minmax(100px, 1fr) minmax(0, 2fr); gap: 10px 20px; overflow-wrap: anywhere; margin-top: 16px; }
|
||||
.gx-tuner__diagnostics dt { color: var(--text-muted); }
|
||||
.gx-tuner__diagnostics dd { margin: 0; font-variant-numeric: tabular-nums; }
|
||||
@media (max-width: 900px) {
|
||||
.gx-card.gx-tuner__hero { grid-template-columns: 62px minmax(0, 1fr); gap: 18px; }
|
||||
.gx-tuner__hero-icon { width: 62px; height: 62px; border-radius: 20px; font-size: 1.7rem; }
|
||||
.gx-tuner__hero-meta { grid-column: 2; border: 0; padding: 0; max-width: none; display: flex; flex-wrap: wrap; gap: 8px 16px; }
|
||||
.gx-tuner__overview-grid { grid-template-columns: 1fr; }
|
||||
.gx-tuner__tune-layout { grid-template-columns: 1fr; }
|
||||
.gx-tuner__groups { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.gx-tuner__groups > button { padding: 12px; gap: 9px; }
|
||||
.gx-tuner__groups > button > i:last-child { display: none; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.gx-tuner { gap: 18px; }
|
||||
.gx-tuner__heading { gap: 12px; }
|
||||
.gx-tuner__heading > button { padding: 9px 12px; font-size: .72rem; min-width: 44px; }
|
||||
.gx-tuner__heading > button > i { display: none; }
|
||||
.gx-card.gx-tuner__hero { grid-template-columns: minmax(0, 1fr); }
|
||||
.gx-tuner__hero-icon { display: none; }
|
||||
.gx-tuner__hero-meta { grid-column: 1; font-size: .64rem; }
|
||||
.gx-tuner__tabs > button { padding: 0 11px 12px; font-size: .76rem; }
|
||||
.gx-tuner__tabs > .gx-tuner__plots-link { font-size: .68rem; }
|
||||
.gx-tuner__count { font-size: .56rem; margin-left: 4px; padding: 1px 4px; }
|
||||
.gx-tuner__metrics { gap: 8px; }
|
||||
.gx-card.gx-tuner__metric { padding: 15px 11px; gap: 8px; }
|
||||
.gx-tuner__metric > span { font-size: .75rem; }
|
||||
.gx-tuner__metric > strong { font-size: 1.5rem; }
|
||||
.gx-tuner__metric > strong small { font-size: .6rem; }
|
||||
.gx-tuner__metric p { font-size: .7rem; }
|
||||
.gx-tuner__pill { font-size: .54rem; padding: 4px 6px; }
|
||||
.gx-tuner__groups { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.gx-tuner__parameter-head { display: none; }
|
||||
.gx-tuner__parameter { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px 8px; padding: 15px 0; }
|
||||
.gx-tuner__parameter > div:first-child { grid-column: 1 / -1; }
|
||||
.gx-tuner__parameter > div:not(:first-child) { text-align: left; }
|
||||
.gx-tuner__mobile-label { display: block; color: var(--text-muted); font-size: .59rem; margin-bottom: 6px; }
|
||||
.gx-tuner__parameter strong { font-size: .72rem; }
|
||||
.gx-tuner__diagnostics { grid-template-columns: 1fr; gap: 5px; }
|
||||
.gx-tuner__diagnostics dd { margin-bottom: 12px; }
|
||||
}
|
||||
|
||||
@@ -163,10 +163,10 @@
|
||||
"availability": "local-only"
|
||||
},
|
||||
{
|
||||
"name": "Plots & Analysis",
|
||||
"path": "/tuning/plots",
|
||||
"icon": "bi-sign-turn-right",
|
||||
"description": "Live control plots and StarPilot Auto Learn results",
|
||||
"name": "StarPilot Tuner",
|
||||
"path": "/tuning",
|
||||
"icon": "bi-sliders2",
|
||||
"description": "Automatic steering tuning, measured corrections and live plots",
|
||||
"availability": "local-only"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -267,10 +267,8 @@ createApp({
|
||||
<SettingsPage @nested="ensureBackEntry" ref="activeSettings" v-else-if="drivingSettingsPage" :key="drivingSettingsPage" :mode="state.monitorMode" :unauthorized="sessionExpired" :initial-page="drivingSettingsPage" :title="'Driving settings · ' + drivingSettingsPage.replaceAll('_', ' ')" :return-to="returnToDriving" />
|
||||
<SettingsPage @nested="ensureBackEntry" ref="activeSettings" v-else-if="route.path === '/settings'" :key="state.searchPage || 'hub'" :mode="state.monitorMode" :unauthorized="sessionExpired"
|
||||
:initial-page="state.searchPage || 'hub'" :return-to="state.searchPage ? returnFromSearch : null" />
|
||||
<div v-else-if="['/tuning', '/tuning/plots', '/tuning/flm'].includes(route.path)" class="gx-stack">
|
||||
<PlotsPage :mode="state.monitorMode" :unauthorized="sessionExpired" />
|
||||
<FlmPage embedded :mode="state.monitorMode" :unauthorized="sessionExpired" />
|
||||
</div>
|
||||
<FlmPage v-else-if="['/tuning', '/tuning/flm'].includes(route.path)" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go" />
|
||||
<PlotsPage v-else-if="route.path === '/tuning/plots'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
|
||||
<GxState v-else :title="pageName">This capability is unavailable in this build. No operation was attempted.</GxState>
|
||||
</div></Transition></div></main>
|
||||
<BottomNav :path="route.path" @navigate="go" />
|
||||
|
||||
@@ -51,7 +51,7 @@ export const DrivingPage = {
|
||||
</section>
|
||||
<div class="gx-driving__extras gx-actions"><button type="button" class="gx-btn gx-btn--tonal" @click="go('/driving/longitudinal-curves')">Edit longitudinal curves</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="go('/tuning/plots')">Live plots</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="go('/tuning/plots')">Auto Learn results</button></div>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="go('/tuning')">StarPilot Tuner</button></div>
|
||||
</template>
|
||||
</section>`,
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const detailValue = (value, depth = 0) => value === null || typeof value === "bo
|
||||
Object.entries(value).every(([key, item]) => text(key, 80) && detailValue(item, depth + 1)))
|
||||
export function validTrainingResults(value) {
|
||||
if (!value || value.version !== 1 || !Array.isArray(value.history) || value.history.length > 20) return false
|
||||
if (value.preference !== undefined && !validLearningPreference(value.preference)) return false
|
||||
if (!value.history.every(row => row && text(row.time, 80) && text(row.status, 64) && text(row.reason) && typeof row.applied === "boolean")) return false
|
||||
const row = value.current
|
||||
if (row === null) return true
|
||||
@@ -110,6 +111,41 @@ export function surfaceRows(report, live = false) {
|
||||
export const coordinateText = value => value === null ? "Not reported" :
|
||||
value === 0 ? "0" : Math.abs(value) < 1e-6 ? value.toExponential(3) : (value > 0 ? "+" : "") + value.toFixed(6).replace(/0+$/, "").replace(/\.$/, "")
|
||||
|
||||
export function validLearningPreference(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) && value.version === 1 &&
|
||||
typeof value.valid === "boolean" && [true, false, null].includes(value.enabled) &&
|
||||
[true, false, null].includes(value.parked) && (value.vehicle === null || text(value.vehicle, 256)) &&
|
||||
(value.valid ? typeof value.enabled === "boolean" && typeof value.parked === "boolean" && value.vehicle !== null : value.enabled === null)
|
||||
}
|
||||
|
||||
export const SURFACE_GROUPS = Object.freeze([
|
||||
{ id: "balance", label: "Steering balance", icon: "bi-arrow-left-right", description: "Response to left and right steering requests.", names: ["ff_gain_left", "ff_gain_right", "turn_in_boost_left", "turn_in_boost_right", "unwind_taper_left", "unwind_taper_right"] },
|
||||
{ id: "center", label: "Straight & center", icon: "bi-signpost-split", description: "Small corrections and straight-road steadiness.", names: ["center_taper_max", "highway_center_taper_max", ...SURFACE_NAMES.filter(name => name.startsWith("center_deadband_"))] },
|
||||
{ id: "low", label: "Low speed", icon: "bi-speedometer", description: "Crawl-speed turn-in and angle assistance.", names: ["crawl_turn_in_ff_boost_left", "crawl_turn_in_ff_boost_right", "low_speed_angle_assist_max_torque"] },
|
||||
{ id: "entry", label: "Corner entry", icon: "bi-arrow-up-right", description: "Friction thresholds and turn-in trimming.", names: ["turn_in_threshold_reduction_left", "turn_in_threshold_reduction_right", "curvy_turn_in_trim_left", "curvy_turn_in_trim_right"] },
|
||||
{ id: "exit", label: "Corner exit", icon: "bi-arrow-return-right", description: "Friction and relief as steering unwinds.", names: ["unwind_threshold_increase_left", "unwind_threshold_increase_right", "curvy_unwind_floor_relief_left", "curvy_unwind_floor_relief_right", "curvy_unwind_extra_reduction_left", "curvy_unwind_extra_reduction_right"] },
|
||||
{ id: "speed", label: "Speed ranges", icon: "bi-sliders", description: "Where turn-in and unwind shaping take effect.", names: ["curvy_speed_min", "curvy_speed_max", "curvy_turn_in_trim_speed_min", "curvy_turn_in_trim_speed_max"] },
|
||||
])
|
||||
|
||||
export function tunerStatus(report, live = false, choiceLive = false, error = "") {
|
||||
const preference = choiceLive && validLearningPreference(report?.preference) ? report.preference : null
|
||||
const current = report?.current
|
||||
if (error) return { key: "offline", tone: "muted", title: "Connection interrupted", description: "Earlier results are available below. Current steering activity is unknown." }
|
||||
if (preference?.valid && preference.enabled === false || !preference && live && current?.enabled === false)
|
||||
return { key: "off", tone: "muted", title: "Auto Learn is off", description: "No driving samples are being collected by the learner. Turn it on in steering settings to begin." }
|
||||
if (preference?.valid && preference.enabled && preference.parked)
|
||||
return { key: "parked", tone: "ready", title: "Auto Learn is on", description: "Collection starts while driving. No new correction is being applied while parked." }
|
||||
if (!current) return { key: "waiting", tone: "muted", title: "Waiting for the learner", description: preference?.enabled ? "Auto Learn is on, but no learner report has arrived yet." : preference ? "The device has not identified a current vehicle and Auto Learn setting." : "Connect to your device to see its learning activity and steering tune." }
|
||||
if (!live) return { key: "stale", tone: "muted", title: "No current learner report", description: "These are earlier observations. They do not establish a correction being applied now." }
|
||||
const tracking = trackingView(report, live)
|
||||
if (tracking?.acknowledged && tracking.status === "confirmed") return { key: "confirmed", tone: "success", title: "Correction applied", description: "The comparison passed and the controller acknowledged this correction. Disk saving is a separate result." }
|
||||
if (tracking?.acknowledged && tracking.status === "trial_active") return { key: "testing", tone: "active", title: "Testing a small correction", description: "The controller acknowledged the trial. Comparable new driving windows decide whether to keep it." }
|
||||
if (tracking?.status === "trial_pending") return { key: "pending", tone: "active", title: "Waiting for the controller", description: "A trial was offered. It has not yet been acknowledged as applied." }
|
||||
if (tracking?.status === "rollback") return { key: "rollback", tone: "warning", title: "Returning to the baseline", description: tracking.acknowledged ? "The controller acknowledged the rollback." : "A rollback was requested. Current application is not yet acknowledged." }
|
||||
if (tracking?.status === "paused" || current.status === "unavailable") return { key: "paused", tone: "warning", title: "Learning paused", description: readable(tracking?.reason || current.reason) }
|
||||
return { key: "collecting", tone: "active", title: tracking?.status === "calibrating" ? "Measuring steering response" : "Collecting driving samples", description: readable(tracking?.reason || current.reason) }
|
||||
}
|
||||
|
||||
|
||||
const TRACKING_STATES = ['calibrating', 'collecting', 'not_identifiable', 'ready', 'trial_pending', 'trial_active', 'confirmed', 'rollback', 'paused']
|
||||
const optionalMeasurement = value => value === null || typeof value === 'number' && Number.isFinite(value) && value >= 0
|
||||
|
||||
@@ -2,65 +2,105 @@ import { GxState } from "./state.js"
|
||||
import { GxNotice } from "./notice.js"
|
||||
import { PollTimer, connectionError } from "./polling.js"
|
||||
import { requestJson } from "./startup.js"
|
||||
import { PlotGraph, PlotsFeed, chartGeometry } from "./plots.js"
|
||||
import { validTrainingResults, trainingSummary, readable, detailText, surfaceRows, coordinateText, trackingView, measurementText, validTrackingValues, trackingPlotView, sourceSeconds } from "./flm-training-results.js"
|
||||
import { PlotGraph, chartGeometry } from "./plots.js"
|
||||
import { validTrainingResults, readable, detailText, surfaceRows, coordinateText, trackingView, measurementText, validTrackingValues, trackingPlotView, sourceSeconds, validLearningPreference, SURFACE_GROUPS, tunerStatus } from "./flm-training-results.js"
|
||||
|
||||
const VIEWS = ["overview", "tune", "activity"]
|
||||
const PARAMETER_LABELS = {
|
||||
ff_gain_left: "Left steering gain", ff_gain_right: "Right steering gain",
|
||||
turn_in_boost_left: "Left turn-in boost", turn_in_boost_right: "Right turn-in boost",
|
||||
unwind_taper_left: "Left unwind taper", unwind_taper_right: "Right unwind taper",
|
||||
center_taper_max: "Center taper", highway_center_taper_max: "Highway center taper",
|
||||
center_deadband_crawl_deg: "Crawl center deadband", center_deadband_low_deg: "Low-speed center deadband",
|
||||
center_deadband_mid_deg: "Mid-speed center deadband", center_deadband_fast_deg: "Fast center deadband",
|
||||
center_deadband_highway_deg: "Highway center deadband",
|
||||
turn_in_threshold_reduction_left: "Left turn-in threshold", turn_in_threshold_reduction_right: "Right turn-in threshold",
|
||||
unwind_threshold_increase_left: "Left unwind threshold", unwind_threshold_increase_right: "Right unwind threshold",
|
||||
crawl_turn_in_ff_boost_left: "Left crawl turn-in boost", crawl_turn_in_ff_boost_right: "Right crawl turn-in boost",
|
||||
low_speed_angle_assist_max_torque: "Low-speed angle assistance",
|
||||
curvy_speed_min: "Unwind range · lower speed", curvy_speed_max: "Unwind range · upper speed",
|
||||
curvy_turn_in_trim_speed_min: "Turn-in range · lower speed", curvy_turn_in_trim_speed_max: "Turn-in range · upper speed",
|
||||
curvy_turn_in_trim_left: "Left turn-in trim", curvy_turn_in_trim_right: "Right turn-in trim",
|
||||
curvy_unwind_floor_relief_left: "Left unwind floor relief", curvy_unwind_floor_relief_right: "Right unwind floor relief",
|
||||
curvy_unwind_extra_reduction_left: "Left extra unwind relief", curvy_unwind_extra_reduction_right: "Right extra unwind relief",
|
||||
}
|
||||
|
||||
export const FlmPage = {
|
||||
name: "FlmPage",
|
||||
components: { GxState, GxNotice, PlotGraph },
|
||||
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, embedded: { type: Boolean, default: false } },
|
||||
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
|
||||
go: { type: Function, default: path => { window.location.hash = path } } },
|
||||
data: () => ({ report: null, error: "", loading: false, request: null, receivedAt: 0, now: 0,
|
||||
status: "idle", data: null, history: [], plotError: "", showPlots: true }),
|
||||
created() {
|
||||
if (this.embedded) this.showPlots = false
|
||||
this.feed = new PlotsFeed({ publish: update => {
|
||||
const { error, ...values } = update
|
||||
Object.assign(this.$data, values, { plotError: error })
|
||||
}, unauthorized: this.unauthorized })
|
||||
},
|
||||
view: "overview", group: "balance", showAllHistory: false, groups: SURFACE_GROUPS }),
|
||||
mounted() {
|
||||
this.activePage = true
|
||||
this.poller = new PollTimer({ read: () => this.load(), interval: 1000 })
|
||||
this.visibility = () => {
|
||||
this.now = Date.now()
|
||||
if (document.hidden) { this.request?.abort(); this.feed.stop() }
|
||||
else { this.load(); if (this.showPlots && this.mode === "local") this.feed.start() }
|
||||
if (document.hidden) this.request?.abort()
|
||||
else this.load()
|
||||
}
|
||||
document.addEventListener("visibilitychange", this.visibility)
|
||||
if (this.mode === "local") { this.poller.start(); this.load(); if (this.showPlots && !document.hidden) this.feed.start() }
|
||||
if (this.mode === "local") { this.poller.start(); this.load() }
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.activePage = false; clearTimeout(this.freshnessTimer); this.poller?.stop(); this.request?.abort(); this.request = null; this.feed.stop()
|
||||
this.activePage = false; clearTimeout(this.freshnessTimer); this.poller?.stop(); this.request?.abort(); this.request = null
|
||||
document.removeEventListener("visibilitychange", this.visibility)
|
||||
},
|
||||
computed: {
|
||||
current() { return this.report?.current },
|
||||
live() { return !this.error && !!this.current?.fresh && this.current.ageSeconds + Math.max(0, this.now - this.receivedAt) / 1000 <= 2 },
|
||||
summary() { return trainingSummary(this.report, this.error || (this.current && !this.live ? "stale" : "")) },
|
||||
currentApplied() { return this.live && this.current?.application?.applied === true },
|
||||
preference() { return validLearningPreference(this.report?.preference) ? this.report.preference : null },
|
||||
choiceLive() { return !this.error && !!this.preference && Math.max(0, this.now - this.receivedAt) <= 2000 },
|
||||
live() { return !this.error && !!this.current?.fresh && this.current.ageSeconds + Math.max(0, this.now - this.receivedAt) / 1000 <= 2 &&
|
||||
!(this.choiceLive && (this.preference.enabled === false || this.preference.parked === true)) },
|
||||
state() { return tunerStatus(this.report, this.live, this.choiceLive, this.error) },
|
||||
vehicle() { return this.preference?.vehicle ? this.preference.vehicle.replaceAll("_", " ").toLowerCase().replace(/\b\w/g, c => c.toUpperCase()) : "Your steering, measured" },
|
||||
currentApplied() { return this.live && this.current?.enabled === true && this.current?.application?.applied === true },
|
||||
rows() { return surfaceRows(this.report, this.live) },
|
||||
tracking() { return trackingView(this.report, this.live) },
|
||||
trackingValues() { return validTrackingValues(this.current?.trackingValues) ? this.current.trackingValues : null },
|
||||
trackingPlot() { return trackingPlotView(this.report, this.now - this.receivedAt) },
|
||||
pairedTrackingChart() { return chartGeometry(this.trackingPlot?.points || [], ['desiredForceAccel', 'measuredForceAccel']) },
|
||||
pairedErrorChart() { return chartGeometry(this.trackingPlot?.points || [], ['errorForceAccel']) },
|
||||
readiness() { return this.current?.details?.["Snapshot readiness"] },
|
||||
pairedTrackingChart() { return chartGeometry(this.trackingPlot?.points || [], ["desiredForceAccel", "measuredForceAccel"]) },
|
||||
pairedErrorChart() { return chartGeometry(this.trackingPlot?.points || [], ["errorForceAccel"]) },
|
||||
details() { return Object.entries(this.current?.details || {}) },
|
||||
reportAge() { return this.current ? this.current.ageSeconds + Math.max(0, this.now - this.receivedAt) / 1000 : null },
|
||||
appliedCount() { return this.currentApplied && this.rows.every(row => row.applied !== null) ? this.rows.filter(row => row.applied !== 0).length : null },
|
||||
acceptedCount() { return this.trackingValues ? this.rows.filter(row => row.accepted !== null && row.accepted !== 0).length : null },
|
||||
groupRows() { return this.rows.filter(row => this.selectedGroup.names.includes(row.name)) },
|
||||
selectedGroup() { return this.groups.find(group => group.id === this.group) || this.groups[0] },
|
||||
historyRows() { return this.showAllHistory ? this.report?.history || [] : (this.report?.history || []).slice(0, 6) },
|
||||
improvement() {
|
||||
const before = this.tracking?.baselineErrorRms, after = this.tracking?.futureErrorRms
|
||||
return Number.isFinite(before) && before > 0 && Number.isFinite(after) ? (before - after) / before * 100 : null
|
||||
},
|
||||
quota() {
|
||||
const required = this.tracking?.requiredEpisodes, completed = this.tracking?.futureEpisodes
|
||||
return Number.isSafeInteger(required) && required > 0 ? { required, completed, percent: Math.min(100, completed / required * 100) } : null
|
||||
},
|
||||
losses() {
|
||||
const d = this.current?.details || {}, baseline = d["Fit baseline loss"], learned = d["Fit candidate loss"]
|
||||
return typeof baseline === "number" && Number.isFinite(baseline) && baseline >= 0 &&
|
||||
typeof learned === "number" && Number.isFinite(learned) && learned >= 0 ? { baseline, learned } : null
|
||||
},
|
||||
lateralChart() { return chartGeometry(this.history, ["desiredLateralAccel", "actualLateralAccel"]) },
|
||||
termsChart() { return chartGeometry(this.history, ["lateralP", "lateralI", "lateralD", "lateralF"]) },
|
||||
},
|
||||
methods: {
|
||||
readable, detailText, coordinateText, measurementText, sourceSeconds,
|
||||
amount(value) { return value === null || value === undefined ? "Not reported" : value.toLocaleString() },
|
||||
togglePlots() {
|
||||
this.showPlots = !this.showPlots
|
||||
if (this.showPlots && this.mode === "local" && !document.hidden) this.feed.start()
|
||||
else this.feed.stop()
|
||||
amount(value) { return value === null || value === undefined ? "—" : value.toLocaleString() },
|
||||
parameterLabel(name) { return PARAMETER_LABELS[name] || readable(name) },
|
||||
groupChanges(group) {
|
||||
if (!this.trackingValues) return "Not reported"
|
||||
const count = this.rows.filter(row => group.names.includes(row.name) && row.accepted !== null && row.accepted !== 0).length
|
||||
return count ? `${count} accepted` : "Baseline"
|
||||
},
|
||||
selectView(view) { if (VIEWS.includes(view)) this.view = view },
|
||||
tabKey(event) {
|
||||
const delta = { ArrowRight: 1, ArrowLeft: -1, Home: -VIEWS.length, End: VIEWS.length }[event.key]
|
||||
if (delta === undefined) return
|
||||
event.preventDefault()
|
||||
const index = VIEWS.indexOf(this.view)
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? VIEWS.length - 1 : (index + delta + VIEWS.length) % VIEWS.length
|
||||
this.view = VIEWS[next]
|
||||
event.currentTarget.parentElement.querySelectorAll('[role="tab"]')[next]?.focus()
|
||||
},
|
||||
async load() {
|
||||
this.now = Date.now()
|
||||
@@ -73,8 +113,8 @@ export const FlmPage = {
|
||||
if (!validTrainingResults(value)) throw new Error("The device returned an unsupported training report.")
|
||||
this.report = value; this.error = ""; this.receivedAt = this.now = Date.now()
|
||||
clearTimeout(this.freshnessTimer)
|
||||
if (value.current?.fresh) this.freshnessTimer = setTimeout(() => { this.now = Date.now() },
|
||||
Math.max(0, 2000 - value.current.ageSeconds * 1000) + 1)
|
||||
const age = value.current?.fresh ? value.current.ageSeconds * 1000 : 0
|
||||
this.freshnessTimer = setTimeout(() => { this.now = Date.now() }, Math.max(0, 2000 - age) + 1)
|
||||
} catch (error) {
|
||||
if (this.request !== controller || !this.activePage) return
|
||||
if (error.status === 401 || ["access_unavailable", "setup_required"].includes(error.code)) this.unauthorized()
|
||||
@@ -84,84 +124,56 @@ export const FlmPage = {
|
||||
}
|
||||
},
|
||||
},
|
||||
template: `<div class="gx-view gx-flm gx-stack">
|
||||
<header class="gx-settings__header gx-page-header"><div><h3 v-if="embedded">StarPilot Auto Learn</h3><h2 v-else>StarPilot Auto Learn</h2>
|
||||
<p>Learned steering corrections, collection progress and current application.</p></div>
|
||||
</header>
|
||||
<GxState v-if="mode !== 'local'">Auto Learn results require authenticated local Galaxy access.</GxState>
|
||||
template: `<div class="gx-view gx-tuner">
|
||||
<header class="gx-page-header gx-settings__header gx-tuner__heading"><div><p class="gx-tuner__eyebrow">STEERING · AUTO LEARN</p><h2>StarPilot Tuner</h2><p>{{ vehicle }}</p></div>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="go('/driving/torque')"><i class="bi bi-sliders" aria-hidden="true"></i> Steering settings</button></header>
|
||||
<GxState v-if="mode !== 'local'">Connect to your device to see its steering tune.</GxState>
|
||||
<template v-else>
|
||||
<section class="gx-card gx-flm__panel"><div class="gx-section__header"><i class="bi bi-activity" aria-hidden="true"></i><span class="gx-section__title" role="status">{{ tracking?.label || summary }}</span></div>
|
||||
<GxState v-if="loading && !report" loading>Reading learning results…</GxState>
|
||||
<GxNotice v-if="error" tone="danger">{{ error }}</GxNotice>
|
||||
<template v-if="current"><p v-if="!tracking">{{ readable(current.reason) }}</p>
|
||||
<dl class="gx-flm__facts"><div><dt>Auto Learn</dt><dd>{{ !live ? 'Last reported: ' : '' }}{{ current.enabled === null ? 'Unknown' : current.enabled ? 'On' : 'Off' }}</dd></div>
|
||||
<div v-if="!tracking"><dt>Collection</dt><dd>{{ amount(current.progress.samples) }} samples · {{ amount(current.progress.blocks) }} retained blocks</dd></div>
|
||||
<div><dt>Current application</dt><dd>{{ currentApplied ? 'Correction reported applied' : live && current.application ? 'No correction reported applied' : 'Unknown — waiting for a current report' }}</dd></div>
|
||||
</dl><p class="gx-note">Last controller report: {{ current.ageSeconds.toFixed(1) }} seconds old when read.</p>
|
||||
<p v-if="live && current.application">{{ readable(current.application.reason) }}</p>
|
||||
</template>
|
||||
<template v-if="tracking">
|
||||
<p>{{ readable(tracking.reason) }}</p>
|
||||
<p v-if="tracking.changedParameters">Parameters under evaluation: {{ tracking.changedParameters }}</p>
|
||||
<p>Comparable episodes: {{ tracking.beforeEpisodes }} before · {{ tracking.futureEpisodes }} after<template v-if="tracking.requiredEpisodes !== null"> · {{ tracking.requiredEpisodes }} future episodes required</template>.</p>
|
||||
<p>Steering input checks: {{ readable(tracking.modelStatus) }} · Steering delay: {{ measurementText(tracking.lagMs) }}<template v-if="tracking.lagMs !== null"> ms</template></p>
|
||||
<div class="gx-flm__table-wrap"><table class="gx-flm__table" aria-label="Observed tracking before and after the correction trial">
|
||||
<thead><tr><th scope="col">Observed measurement</th><th scope="col">Before trial</th><th scope="col">Future trial episodes</th></tr></thead>
|
||||
<tbody><tr v-for="row in tracking.measurements" :key="row.label"><th scope="row">{{ row.label }}</th><td data-label="Before trial">{{ measurementText(row.before) }}</td><td data-label="Future trial episodes">{{ measurementText(row.future) }}</td></tr></tbody>
|
||||
</table></div>
|
||||
<div v-if="trackingPlot">
|
||||
<h4>Latest completed delay-paired steering episode</h4>
|
||||
<p>{{ trackingPlot.points.length }} original endpoints<template v-if="trackingPlot.sampled"> selected from {{ trackingPlot.totalEndpoints }}</template> · {{ trackingPlot.age.toFixed(1) }} seconds since the last measured pose · Alignment delay {{ (trackingPlot.lagNs / 1e6).toFixed(1) }} ms.</p>
|
||||
<p v-if="!live || !trackingPlot.bindingCurrent" class="gx-note">Earlier episode only; the current steering context is not established by this plot.</p>
|
||||
<div class="gx-plots__grid"><div><h5>Requested / measured lateral acceleration · m/s²</h5><PlotGraph :chart="pairedTrackingChart" title="Paired requested and measured lateral acceleration" /></div>
|
||||
<div><h5>Delay-paired error: requested minus measured · m/s²</h5><PlotGraph :chart="pairedErrorChart" title="Actual delay-paired steering tracking error" /></div></div>
|
||||
<p class="gx-note">Each requested value is paired by the learner with its genuine later measured pose. The horizontal axis uses those pose timestamps; selected endpoints are not resampled or upsampled. Blue is requested, orange is measured.</p>
|
||||
<details><summary>Episode source timestamps</summary><p>Latest control / applied output / measured pose: {{ sourceSeconds(trackingPlot.points.at(-1).controlMonoTime) }} / {{ sourceSeconds(trackingPlot.points.at(-1).applyMonoTime) }} / {{ sourceSeconds(trackingPlot.points.at(-1).poseMonoTime) }} monotonic seconds. Episode {{ trackingPlot.episodeId }} · Applied profile {{ trackingPlot.profileSha256 }}.</p></details>
|
||||
</div>
|
||||
<p v-else class="gx-note">No delay-paired steering episode plot has been reported yet.</p>
|
||||
<p class="gx-note">The live desired/observed plots show sampled controller telemetry; they are not actuator-delay aligned.</p>
|
||||
<p class="gx-note">Before and after use separate completed, comparable driving episodes. Unmeasured values remain “Not measured”. Passing future checks does not itself confirm controller application or saving; currently applied values require fresh controller evidence.</p>
|
||||
</template>
|
||||
<GxNotice v-if="error" tone="danger">{{ error }}</GxNotice>
|
||||
<section class="gx-card gx-tuner__hero" :data-tone="state.tone" aria-label="Auto Learn status">
|
||||
<div class="gx-tuner__hero-icon" aria-hidden="true"><i class="bi bi-sliders2"></i></div>
|
||||
<div class="gx-tuner__hero-copy"><div class="gx-tuner__eyebrow"><span class="gx-tuner__status-dot"></span>{{ loading && !report ? 'CONNECTING' : state.key === 'off' ? 'DISABLED' : choiceLive || live ? 'DEVICE CONNECTED' : 'WAITING FOR DATA' }}</div>
|
||||
<h3 role="status">{{ loading && !report ? 'Reading your tuner…' : state.title }}</h3><p>{{ state.description }}</p>
|
||||
<p v-if="tracking?.changedParameters && live" class="gx-tuner__current-job"><i class="bi bi-crosshair" aria-hidden="true"></i>{{ tracking.changedParameters }}</p>
|
||||
</div>
|
||||
<div class="gx-tuner__hero-meta"><span>{{ currentApplied ? 'Controller acknowledged' : live ? 'No correction acknowledged' : 'Current application unknown' }}</span><small>{{ reportAge === null ? 'No learner report yet' : 'Learner report ' + reportAge.toFixed(1) + 's old' }}</small></div>
|
||||
</section>
|
||||
<section class="gx-card gx-flm__panel"><div class="gx-section__header"><i class="bi bi-sliders" aria-hidden="true"></i><span class="gx-section__title">Learned parameter corrections</span></div>
|
||||
<p v-if="trackingValues">Each value is an original-effect correction coordinate; baseline 0 means no automatic correction. Accepted baseline values were acknowledged by the controller. A pending trial target is an offer, not proof of application. Currently applied values require fresh controller evidence.</p>
|
||||
<p v-else>Each value is an original-effect correction coordinate. Baseline 0 means no automatic correction. Learned values are the latest evaluated proposal; current values require fresh controller application evidence.</p>
|
||||
<p class="gx-note">Speed-window coordinates multiply their original baseline by (1 + correction). Deadband and angle-assist coordinates use the controller’s physical normalizer. Raw degrees, torque and manual tune values are not published by this report.</p>
|
||||
<div class="gx-flm__table-wrap" tabindex="0" role="region" aria-label="All 30 steering correction parameters"><table class="gx-flm__table">
|
||||
<thead><tr><th scope="col">Parameter</th><th scope="col">Baseline</th><th scope="col">{{ trackingValues ? 'Accepted baseline' : 'Learned proposal' }}</th><th v-if="trackingValues" scope="col">Pending trial target</th><th scope="col">Currently applied</th><th scope="col">Support</th></tr></thead>
|
||||
<tbody><tr v-for="row in rows" :key="row.name"><th scope="row">{{ readable(row.name) }}</th><td data-label="Baseline">{{ coordinateText(row.baseline) }}</td><td :data-label="trackingValues ? 'Accepted baseline' : 'Learned proposal'">{{ coordinateText(trackingValues ? row.accepted : row.learned) }}</td><td v-if="trackingValues" data-label="Pending trial target">{{ coordinateText(row.pending) }}</td><td data-label="Currently applied">{{ coordinateText(row.applied) }}</td><td data-label="Support">{{ row.pidSupported ? 'Torque / torque-actuated PID' : 'Torque; PID unsupported (no friction stage)' }}</td></tr></tbody>
|
||||
</table></div>
|
||||
<p class="gx-note">All 30 parameters have torque stages; 26 have torque-actuated PID stages. The four friction thresholds have no PID friction measurement stage. Support describes controller capability; it does not establish a validated fit. Missing values remain “Not reported”.</p>
|
||||
</section>
|
||||
<div v-if="!tracking" class="gx-plots__grid">
|
||||
<section class="gx-card gx-flm__panel"><div class="gx-section__header"><i class="bi bi-collection" aria-hidden="true"></i><span class="gx-section__title">Collection & fitting</span></div>
|
||||
<p v-if="!current">Collection begins when the device reports eligible steering examples.</p>
|
||||
<template v-if="readiness"><p>{{ readable(readiness.reason) }}</p>
|
||||
<p v-if="readiness.selectedBundles !== undefined">Selected bundles: {{ amount(readiness.selectedBundles) }} / {{ amount(readiness.requiredBundles) }} required · {{ amount(readiness.wholeBundles) }} whole bundles retained.</p>
|
||||
</template>
|
||||
<p v-if="current?.details?.['Fit in progress']">Evaluating the collected examples…</p>
|
||||
<template v-if="current?.fit"><p>{{ readable(current.fit.status) }} · {{ readable(current.fit.reason) }}</p>
|
||||
<p>Training windows: {{ amount(current.fit.trainWindows) }} · Validation windows: {{ amount(current.fit.heldoutWindows) }}</p></template>
|
||||
<p class="gx-note">Collection counts describe retained data. The fitter separately checks physical calibration, complete event support and validation.</p>
|
||||
<nav class="gx-tuner__tabs" role="tablist" aria-label="Tuner views">
|
||||
<button v-for="tab in ['overview', 'tune', 'activity']" :key="tab" type="button" role="tab" :id="'tuner-tab-' + tab" :aria-controls="'tuner-panel-' + tab" :aria-selected="view === tab" :tabindex="view === tab ? 0 : -1" @click="selectView(tab)" @keydown="tabKey">{{ tab === 'overview' ? 'Overview' : tab === 'tune' ? 'Your tune' : 'Activity' }}<span v-if="tab === 'tune'" class="gx-tuner__count">30</span></button>
|
||||
<button type="button" class="gx-tuner__plots-link" @click="go('/tuning/plots')">Live plots <i class="bi bi-arrow-up-right" aria-hidden="true"></i></button>
|
||||
</nav>
|
||||
<section v-if="view === 'overview'" id="tuner-panel-overview" role="tabpanel" aria-labelledby="tuner-tab-overview" class="gx-tuner__panel">
|
||||
<div class="gx-tuner__metrics"><div class="gx-card gx-tuner__metric"><span>Applied parameters</span><strong>{{ amount(appliedCount) }}<small v-if="appliedCount !== null"> / 30</small></strong><p>{{ appliedCount === null ? 'Requires current controller evidence' : 'Nonzero corrections acknowledged now' }}</p></div>
|
||||
<div class="gx-card gx-tuner__metric"><span>Accepted corrections</span><strong>{{ amount(acceptedCount) }}</strong><p>{{ acceptedCount === null ? 'No accepted map reported' : 'Reported accepted map · saving is separate' }}</p></div>
|
||||
<div class="gx-card gx-tuner__metric"><span>Driving samples</span><strong>{{ amount(current?.progress?.samples) }}</strong><p>Samples retained in the learner report</p></div></div>
|
||||
<div class="gx-tuner__overview-grid"><section class="gx-card gx-tuner__section gx-tuner__response"><div class="gx-tuner__section-heading"><div><p class="gx-tuner__eyebrow">STEERING RESPONSE</p><h3>Requested meets measured</h3></div><span class="gx-tuner__pill">{{ trackingPlot ? 'Completed window' : 'Waiting for samples' }}</span></div>
|
||||
<template v-if="trackingPlot"><PlotGraph :chart="pairedTrackingChart" title="Requested and measured steering acceleration, paired using the learner's measured delay" /><div class="gx-tuner__legend"><span class="gx-tuner__requested">Requested</span><span class="gx-tuner__measured">Measured</span><small>m/s² · delay aligned</small></div><p class="gx-tuner__caption">{{ trackingPlot.totalEndpoints }} measured endpoints · {{ trackingPlot.age.toFixed(1) }}s since the last pose</p></template>
|
||||
<div v-else class="gx-tuner__empty"><i class="bi bi-activity" aria-hidden="true"></i><h4>Real measurements go here</h4><p>A completed steering window will show the requested response beside what the car actually did.</p></div>
|
||||
</section>
|
||||
<section class="gx-card gx-flm__panel"><div class="gx-section__header"><i class="bi bi-bar-chart" aria-hidden="true"></i><span class="gx-section__title">Latest fit comparison</span></div>
|
||||
<dl v-if="losses"><dt>Baseline loss</dt><dd>{{ losses.baseline.toPrecision(5) }}</dd><dt>Learned proposal loss</dt><dd>{{ losses.learned.toPrecision(5) }}</dd></dl>
|
||||
<p v-else>No fit comparison has been reported yet.</p>
|
||||
<p class="gx-note">These are fitter-reported losses for the latest proposal. The live control plots are separate observations and do not measure a before/after learning benefit.</p>
|
||||
</section>
|
||||
</div>
|
||||
<section v-if="!embedded" class="gx-card gx-flm__panel"><div class="gx-section__header"><i class="bi bi-graph-up" aria-hidden="true"></i><span class="gx-section__title">Live steering response</span>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :aria-expanded="showPlots" @click="togglePlots">{{ showPlots ? 'Pause live plots' : 'Show live plots' }}</button></div>
|
||||
<template v-if="showPlots"><p role="status">{{ status === 'current' ? 'Live' : status === 'stale' ? 'Last received window — reconnecting' : 'Waiting for steering telemetry' }}</p>
|
||||
<GxNotice v-if="plotError" tone="danger">{{ plotError }}</GxNotice>
|
||||
<div class="gx-plots__grid"><section><h4>Desired / actual lateral acceleration · m/s²</h4><PlotGraph :chart="lateralChart" title="Desired and actual lateral acceleration" /></section>
|
||||
<section><h4>Steering controller terms · controller units</h4><PlotGraph :chart="termsChart" title="Steering controller P I D and F terms" terms /><p class="gx-note">P · I · D · F; absent terms leave gaps.</p></section></div>
|
||||
<p class="gx-note">Blue is desired response; orange is actual. Existing live control telemetry; missing samples leave gaps.</p>
|
||||
</template>
|
||||
<section class="gx-card gx-tuner__section"><div class="gx-tuner__section-heading"><div><p class="gx-tuner__eyebrow">{{ live ? 'THE CURRENT EXPERIMENT' : 'LAST REPORTED EXPERIMENT' }}</p><h3>{{ tracking?.acknowledged && tracking.status === 'trial_active' ? 'Does the correction help?' : 'One change. A fair comparison.' }}</h3></div></div>
|
||||
<p v-if="tracking">{{ readable(tracking.reason) }}</p><p v-else>Comparable driving windows establish the baseline before a small correction can be evaluated.</p>
|
||||
<div class="gx-tuner__comparison"><div><span>Before</span><strong>{{ measurementText(tracking?.baselineErrorRms ?? null) }}</strong></div><i class="bi bi-arrow-right" aria-hidden="true"></i><div><span>Trial</span><strong>{{ measurementText(tracking?.futureErrorRms ?? null) }}</strong></div></div><p class="gx-tuner__caption">Tracking error RMS · m/s² · lower is better</p>
|
||||
<p v-if="improvement !== null" class="gx-tuner__observed">{{ Math.abs(improvement).toFixed(1) }}% {{ improvement >= 0 ? 'lower' : 'higher' }} observed error<small>{{ state.key === 'confirmed' ? 'Comparison passed; controller acknowledged' : 'Observation so far, not a confirmed improvement' }}</small></p>
|
||||
<div v-if="quota" class="gx-tuner__quota"><div><span>Comparable trial windows</span><strong>{{ quota.completed }} / {{ quota.required }}</strong></div><div class="gx-tuner__progress" role="progressbar" aria-label="Comparable trial windows" :aria-valuenow="quota.completed" :aria-valuemax="quota.required" aria-valuemin="0"><span :style="{ width: quota.percent + '%' }"></span></div></div>
|
||||
<p class="gx-tuner__caption">A worse or interrupted trial rolls back. A candidate, an applied correction and a saved tune are different results.</p>
|
||||
</section></div>
|
||||
</section>
|
||||
<section class="gx-card gx-flm__panel"><div class="gx-section__header"><i class="bi bi-clock-history" aria-hidden="true"></i><span class="gx-section__title">Recent findings</span></div><p v-if="!report?.history.length">No learning history has been reported yet.</p>
|
||||
<ol v-else><li v-for="(row, index) in report.history" :key="index">{{ row.time }} — {{ readable(row.status) }}: {{ readable(row.reason) }}<template v-if="row.applied"> · Correction applied at that time</template></li></ol>
|
||||
<details v-if="details.length"><summary>Report provenance and details</summary><dl><template v-for="[key, value] in details" :key="key"><dt>{{ readable(key) }}</dt><dd>{{ detailText(value) }}</dd></template></dl></details>
|
||||
<section v-if="view === 'tune'" id="tuner-panel-tune" role="tabpanel" aria-labelledby="tuner-tab-tune" class="gx-tuner__panel"><div class="gx-tuner__section-heading"><div><h3>Your steering tune</h3><p>Thirty correction parameters, organized around how the car steers.</p></div><span class="gx-tuner__pill">Read-only · automatic corrections</span></div>
|
||||
<div class="gx-tuner__tune-layout"><nav class="gx-tuner__groups" aria-label="Steering parameter groups"><button v-for="item in groups" :key="item.id" type="button" :aria-pressed="group === item.id" @click="group = item.id"><i :class="['bi', item.icon]" aria-hidden="true"></i><span>{{ item.label }}<small>{{ item.names.length }} parameters · {{ groupChanges(item) }}</small></span><i class="bi bi-chevron-right" aria-hidden="true"></i></button></nav>
|
||||
<section class="gx-card gx-tuner__section gx-tuner__parameters"><div class="gx-tuner__section-heading"><div><h3>{{ selectedGroup.label }}</h3><p>{{ selectedGroup.description }}</p></div></div>
|
||||
<div class="gx-tuner__parameter-head" aria-hidden="true"><span>Parameter</span><span>{{ trackingValues ? 'Accepted' : 'Proposal' }}</span><span>Trial</span><span>Applied now</span></div>
|
||||
<div v-for="row in groupRows" :key="row.name" class="gx-tuner__parameter"><div><h4>{{ parameterLabel(row.name) }}</h4><small v-if="!row.pidSupported">Torque only · PID unsupported</small></div><div><span class="gx-tuner__mobile-label">{{ trackingValues ? 'Accepted' : 'Proposal' }}</span><strong>{{ coordinateText(trackingValues ? row.accepted : row.learned) }}</strong></div><div><span class="gx-tuner__mobile-label">Trial</span><strong :class="{ 'gx-tuner__pending': row.pending !== null && row.pending !== row.accepted }">{{ coordinateText(row.pending) }}</strong></div><div><span class="gx-tuner__mobile-label">Applied now</span><strong :class="{ 'gx-tuner__applied': row.applied !== null && row.applied !== 0 }">{{ coordinateText(row.applied) }}</strong></div></div>
|
||||
<details class="gx-tuner__disclosure"><summary>How to read these values</summary><p>Zero is the original baseline. These are correction coordinates, not raw degrees or torque. Speed-window coordinates scale the original speed range; deadband and angle assistance use the controller's physical normalizer. A trial is an offer, not proof of application. All 30 stages exist for torque; 26 exist for torque-actuated PID.</p></details>
|
||||
</section></div>
|
||||
</section>
|
||||
<section v-if="view === 'activity'" id="tuner-panel-activity" role="tabpanel" aria-labelledby="tuner-tab-activity" class="gx-tuner__panel"><section class="gx-card gx-tuner__section"><div class="gx-tuner__section-heading"><div><p class="gx-tuner__eyebrow">LEARNING ACTIVITY</p><h3>What the tuner found</h3></div><span class="gx-tuner__pill">Reported observations</span></div>
|
||||
<ol v-if="historyRows.length" class="gx-tuner__timeline"><li v-for="(row, index) in historyRows" :key="index"><span class="gx-tuner__timeline-dot" :class="{ 'gx-tuner__timeline-dot--applied': row.applied }"></span><div><strong>{{ readable(row.status) }}</strong><p>{{ readable(row.reason) }}</p><small>{{ row.time }}<template v-if="row.applied"> · Applied at that time</template></small></div></li></ol>
|
||||
<div v-else class="gx-tuner__empty"><i class="bi bi-clock-history" aria-hidden="true"></i><h4>No findings reported yet</h4><p>This will show real collection, trial and rollback events as the learner reports them.</p></div>
|
||||
<button v-if="(report?.history.length || 0) > 6" type="button" class="gx-btn gx-btn--tonal" @click="showAllHistory = !showAllHistory">{{ showAllHistory ? 'Show recent activity' : 'Show all reported activity' }}</button>
|
||||
</section>
|
||||
<details class="gx-card gx-tuner__section gx-tuner__disclosure"><summary>Diagnostics & source details</summary><dl class="gx-tuner__diagnostics"><dt>Reported state</dt><dd>{{ readable(current?.status) }}</dd><dt>Reason</dt><dd>{{ readable(current?.reason) }}</dd><dt>Retained blocks</dt><dd>{{ amount(current?.progress?.blocks) }}</dd><template v-if="current?.fit"><dt>Latest fit</dt><dd>{{ readable(current.fit.status) }} · {{ readable(current.fit.reason) }}</dd><dt>Training / held-out windows</dt><dd>{{ amount(current.fit.trainWindows) }} / {{ amount(current.fit.heldoutWindows) }}</dd></template><template v-if="losses"><dt>Baseline / candidate loss</dt><dd>{{ losses.baseline.toPrecision(5) }} / {{ losses.learned.toPrecision(5) }}</dd></template><template v-for="[key, value] in details" :key="key"><dt>{{ readable(key) }}</dt><dd>{{ detailText(value) }}</dd></template></dl>
|
||||
<template v-if="trackingPlot"><h4>Delay-paired error</h4><PlotGraph :chart="pairedErrorChart" title="Delay-paired steering error" /><p class="gx-tuner__caption">Episode {{ trackingPlot.episodeId }} · {{ trackingPlot.sampled ? 'Selected' : 'All' }} original endpoints. No interpolation.</p><p class="gx-tuner__caption">Control / applied output / pose: {{ sourceSeconds(trackingPlot.points.at(-1).controlMonoTime) }} / {{ sourceSeconds(trackingPlot.points.at(-1).applyMonoTime) }} / {{ sourceSeconds(trackingPlot.points.at(-1).poseMonoTime) }} monotonic seconds.</p></template>
|
||||
</details>
|
||||
</section>
|
||||
</template>
|
||||
</div>`,
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"protected_inventory_sha256": "a9f0267198b34ea37835792ba6838da6cbc6ecd63fb7e96fab256692f2e4bede", "schema_version": 1}
|
||||
{"protected_inventory_sha256": "bbc66f14ace8a765de855992d1eea2c26dc6aac72c1e07815711ccc03d1915e9", "schema_version": 1}
|
||||
|
||||
Reference in New Issue
Block a user