mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-12 07:23:45 +08:00
Show recorded tuning recommendations independently of automatic saving
This commit is contained in:
@@ -21,14 +21,56 @@ class StatusReport:
|
||||
self.clock_ns = clock_ns
|
||||
self.started = self.clock_ns()
|
||||
self.current: dict[str, Any] = {'status': 'collecting', 'reason': 'waiting_for_completed_drive', 'vehicle': ''}
|
||||
self.previous: dict[str, Any] = {}
|
||||
self.next_ns = 0
|
||||
|
||||
def set(self, value):
|
||||
changed = (self.current.get('status'), self.current.get('reason')) != (value.get('status'), value.get('reason'))
|
||||
self.current = dict(value)
|
||||
if value.get('completed_route') and value.get('status') != 'fitting':
|
||||
self.previous = dict(value)
|
||||
if changed:
|
||||
self.next_ns = 0
|
||||
|
||||
def analyzing(self, value):
|
||||
# Historical findings survive a new fit. Never carry a saved/current map,
|
||||
# enabled flag or application evidence forward from a previous report.
|
||||
fields = ('completed_route', 'vehicle', 'analysis_counts', 'recorded_tracking', 'source_sha256', 'assessment_sha256',
|
||||
'recommendations', 'suggestions', 'suggestion_count')
|
||||
previous = {key: self.previous[key] for key in fields if key in self.previous}
|
||||
if previous:
|
||||
previous['retained_decision'] = self.previous.get('reason', 'waiting_for_completed_drive')
|
||||
self.set({**(previous or value), 'status': 'fitting', 'reason': value['reason'], 'retained_analysis': bool(previous)})
|
||||
|
||||
def restore(self, cache):
|
||||
"""Recover historical display data, never a current tune or permission."""
|
||||
from types import SimpleNamespace
|
||||
from openpilot.starpilot.flm.offroad_worker import _recorded_values, recorded_recommendations
|
||||
from openpilot.starpilot.flm.offroad_surface import route_values
|
||||
|
||||
try:
|
||||
latest = cache.latest()
|
||||
if latest is None or type(latest.get('result')) is not dict:
|
||||
return
|
||||
result = dict(latest['result'])
|
||||
if result.get('completed_route') and 'recommendations' not in result:
|
||||
saved = cache.get(result['assessment_sha256'])
|
||||
assessment = saved['assessment']
|
||||
route = assessment['route']
|
||||
if (saved['kind'] != 'completed_assessment' or route['route_id'] != result['completed_route']
|
||||
or route['source_sha256'] != result['source_sha256']):
|
||||
return
|
||||
groups = assessment.get('groups', ())
|
||||
if not groups or any(g['original_metadata']['cp_sha256'] != route['cp_sha256'] for g in groups):
|
||||
return
|
||||
baselines = [route_values(_recorded_values(g['original_metadata'])) for g in groups]
|
||||
if baselines and all(values == baselines[0] for values in baselines):
|
||||
inputs = SimpleNamespace(groups=groups, diagnostic_groups=assessment.get('diagnostic_groups', ()))
|
||||
result['recommendations'] = recorded_recommendations(inputs, baselines[0], assessment)
|
||||
self.previous = result
|
||||
except (OSError, KeyError, ValueError, TypeError, AttributeError):
|
||||
pass # The normal worker reports unreadable inputs; historical display is optional.
|
||||
|
||||
def publish(self):
|
||||
now = self.clock_ns()
|
||||
if now < self.next_ns:
|
||||
@@ -51,6 +93,7 @@ class StatusReport:
|
||||
from openpilot.starpilot.flm.offroad_surface import CURVE_NAMES, route_values
|
||||
|
||||
if 'completed_route' in value:
|
||||
details['Earlier analysis shown while updating'] = value.get('retained_analysis') is True
|
||||
details['Completed drive analyzed'] = value['completed_route']
|
||||
details['Recorded steering observations'] = count('recorded_tracking')
|
||||
details['Joined steering observations'] = count('joined_tracking')
|
||||
@@ -59,7 +102,7 @@ class StatusReport:
|
||||
details['Original controller observations'] = count('original_control_metadata_rows')
|
||||
details['Controller eligibility refusals'] = count('controller_fit_refusals')
|
||||
details['Automatic configuration changed'] = bool(value.get('configuration_changed', False))
|
||||
details['Analysis decision'] = value['reason']
|
||||
details['Analysis decision'] = value.get('retained_decision', value['reason'])
|
||||
details['Saved route adjustment count'] = value.get('saved_adjustments')
|
||||
details['Recorded steering response'] = value.get('recorded_tracking')
|
||||
checks = value.get('qualification_summary')
|
||||
@@ -70,6 +113,18 @@ class StatusReport:
|
||||
details['Recorded route suggestions'] = [[s['parameter'], s['delta'], s['whole_windows'], s['qualification']] for s in suggestions[:16]]
|
||||
details['Recorded suggestion count'] = value.get('suggestion_count', len(suggestions))
|
||||
details['More recorded suggestions omitted'] = value.get('suggestion_count', len(suggestions)) > len(suggestions)
|
||||
if 'recommendations' in value:
|
||||
recommendations = value['recommendations'][:35]
|
||||
details['Recorded coordinate recommendations'] = [
|
||||
[s['parameter'], s['baseline'], s['target'], s['whole_windows'], s['reason']]
|
||||
for s in recommendations
|
||||
]
|
||||
details['Recorded route suggestions'] = [
|
||||
[s['parameter'], s['target'] - s['baseline'], s['whole_windows'], 'controller_native_checks_not_passed']
|
||||
for s in recommendations if s['target'] is not None
|
||||
][:16]
|
||||
details['Recorded suggestion count'] = len(recommendations)
|
||||
details['More recorded suggestions omitted'] = False
|
||||
proposal = value.get('suggestion')
|
||||
if proposal is not None:
|
||||
details['Full surface coordinates'] = [[n, proposal['values'][n]] for n in NAMES]
|
||||
@@ -222,8 +277,8 @@ def main():
|
||||
Paths.log_root(),
|
||||
cache,
|
||||
permitted=permitted,
|
||||
on_analysis=lambda: report.set({'status': 'fitting', 'reason': 'analyzing_last_completed_drive'}),
|
||||
on_assessment=lambda a: report.set(
|
||||
on_analysis=lambda: report.analyzing({'status': 'fitting', 'reason': 'analyzing_last_completed_drive'}),
|
||||
on_assessment=lambda a: report.analyzing(
|
||||
{
|
||||
'status': 'fitting',
|
||||
'reason': 'checking_recorded_steering_recommendations',
|
||||
@@ -233,7 +288,9 @@ def main():
|
||||
'recorded_tracking': a.recorded_tracking,
|
||||
}
|
||||
),
|
||||
on_recommendations=lambda value: report.set({**value, 'status': 'fitting', 'reason': 'checking_recorded_steering_recommendations'}),
|
||||
)
|
||||
report.restore(worker.cache)
|
||||
next_analysis = 0.0
|
||||
while not stopped:
|
||||
if not params.get_bool('IsOffroad'):
|
||||
|
||||
@@ -10,6 +10,7 @@ import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from openpilot.starpilot.flm.offroad_surface import ROUTE_NAMES as NAMES
|
||||
from openpilot.starpilot.flm.learned_lifecycle import decode
|
||||
@@ -18,7 +19,7 @@ from openpilot.starpilot.flm.offroad_checks import qualify
|
||||
from openpilot.starpilot.flm.offroad_processor import analyze_latest, CompletedAssessment
|
||||
from openpilot.starpilot.flm.offroad_route import latest_route
|
||||
from openpilot.starpilot.flm.offroad_runtime import configuration_scope
|
||||
from openpilot.starpilot.flm.offroad_store import RouteStorage, KEY, MAX_BYTES, NAMESPACE
|
||||
from openpilot.starpilot.flm.offroad_store import RouteStorage, KEY, MAX_BYTES, MAX_ROUTE_STEP, NAMESPACE
|
||||
from openpilot.starpilot.flm.offroad_surface import REVISION
|
||||
from openpilot.starpilot.flm.offroad_tracking import TrackingEstimate, compare_observed
|
||||
from openpilot.starpilot.saved_source import read_saved
|
||||
@@ -231,6 +232,57 @@ def assess_next_drive(trial, assessment, scope, *, trial_started=True):
|
||||
}
|
||||
|
||||
|
||||
def recorded_recommendations(assessment, previous, report):
|
||||
"""Publish original candidates independently of automatic qualification.
|
||||
|
||||
These are read-only starting points, never an approval, Params write or a
|
||||
replacement profile. Disagreement retains a finding without inventing a
|
||||
single suggested setting. All original fitting/held-out/native gates remain.
|
||||
"""
|
||||
from openpilot.starpilot.flm.offroad_surface import route_bounds
|
||||
|
||||
found: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def add(proposal, windows, reason):
|
||||
if type(proposal) is not dict or type(windows) is not int or windows <= 0:
|
||||
return
|
||||
values = proposal.get('values')
|
||||
if type(values) is not dict:
|
||||
name = proposal.get('parameter', '')
|
||||
if type(name) is not str:
|
||||
return
|
||||
name = name.removeprefix('stable:')
|
||||
delta = proposal.get('delta')
|
||||
if name not in NAMES or type(delta) not in (int, float) or not math.isfinite(delta):
|
||||
return
|
||||
values = {name: previous[name] + delta}
|
||||
for name in NAMES:
|
||||
base, target = previous[name], values.get(name, previous[name])
|
||||
if type(target) not in (int, float) or not math.isfinite(target):
|
||||
continue
|
||||
low, high = route_bounds(name)
|
||||
if not low <= target <= high or not 1e-12 < abs(target - base) <= MAX_ROUTE_STEP + 1e-12:
|
||||
continue
|
||||
if name in found:
|
||||
row = found[name]
|
||||
row['whole_windows'] = max(windows, row['whole_windows'])
|
||||
if row['target'] is None or abs(row['target'] - target) > 1e-12:
|
||||
row.update(target=None, reason='conflicting_recorded_candidates')
|
||||
else:
|
||||
found[name] = {'parameter': name, 'baseline': base, 'target': target, 'whole_windows': windows, 'reason': reason}
|
||||
|
||||
for group in (*assessment.groups, *assessment.diagnostic_groups):
|
||||
for cohort in group['cohorts']:
|
||||
add(cohort.get('proposal'), cohort.get('whole_windows', 0), 'recorded_candidate_requires_automatic_checks')
|
||||
for proof in (*report.get('stable_profile_reports', ()), *report.get('short_turn_profile_reports', ())):
|
||||
roles = proof.get('roles', {})
|
||||
windows = sum(len(roles.get(role, {}).get('original_windows', ())) for role in ('training', 'heldout'))
|
||||
add(proof.get('frozen_training_profile'), windows, proof.get('reason', 'recorded_candidate_requires_automatic_checks'))
|
||||
for proposal in roles.get('training', {}).get('coordinate_candidates', ()):
|
||||
add(proposal, windows, 'training_candidate_not_independently_confirmed')
|
||||
return [found[name] for name in NAMES if name in found]
|
||||
|
||||
|
||||
def qualification_summary(checks):
|
||||
refusals = {}
|
||||
unperformed = checks.get('unperformed_windows', 0)
|
||||
@@ -283,6 +335,7 @@ class OffroadWorker:
|
||||
scope_for=_current_scope,
|
||||
on_analysis=None,
|
||||
on_assessment=None,
|
||||
on_recommendations=None,
|
||||
prior_provider=None,
|
||||
stable_prior_provider=None,
|
||||
):
|
||||
@@ -294,6 +347,7 @@ class OffroadWorker:
|
||||
self.pending_failure_data = None
|
||||
self.on_analysis = on_analysis
|
||||
self.on_assessment = on_assessment
|
||||
self.on_recommendations = on_recommendations
|
||||
self.prior_provider = prior_provider
|
||||
self.stable_prior_provider = stable_prior_provider
|
||||
|
||||
@@ -378,18 +432,24 @@ class OffroadWorker:
|
||||
raise ValueError('Source or settings changed after fitting')
|
||||
# A qualification failure must not erase completed, source-bound measurements.
|
||||
# Keep their original authority check; no suggestion, save or application is implied.
|
||||
self.pending_failure_data = (
|
||||
authorized,
|
||||
{
|
||||
'completed_route': assessment.receipt.route_id,
|
||||
'source_sha256': assessment.receipt.source_sha256,
|
||||
'vehicle': str(assessment.cp.carFingerprint),
|
||||
'analysis_counts': dict(assessment.counts),
|
||||
'recorded_tracking': assessment.recorded_tracking,
|
||||
},
|
||||
)
|
||||
recorded: dict[str, Any] = {
|
||||
'completed_route': assessment.receipt.route_id,
|
||||
'source_sha256': assessment.receipt.source_sha256,
|
||||
'vehicle': str(assessment.cp.carFingerprint),
|
||||
'analysis_counts': dict(assessment.counts),
|
||||
'recorded_tracking': assessment.recorded_tracking,
|
||||
}
|
||||
self.pending_failure_data = (authorized, recorded)
|
||||
report = assessment.report()
|
||||
report.update(policy=POLICY, current_scope=scope.document(), road_improvement_confirmed=False)
|
||||
current = dict(ZERO) if row is None else dict(row['values'])
|
||||
|
||||
def recommendations_ready():
|
||||
recorded['recommendations'] = recorded_recommendations(assessment, current, report)
|
||||
if self.on_recommendations is not None:
|
||||
self.on_recommendations(dict(recorded))
|
||||
|
||||
recommendations_ready()
|
||||
reason = 'more_independent_eligible_tracking_required'
|
||||
status = 'collecting'
|
||||
saved = False
|
||||
@@ -539,6 +599,7 @@ class OffroadWorker:
|
||||
choices.append((group, {**cohort, 'stable_profile_proof': proof}, alternative))
|
||||
# Freeze input-only ranking; training may select a candidate, validation may not.
|
||||
choices.sort(key=lambda v: (-input_coverage(v[1]), -v[1]['whole_windows'], v[1]['cohort'], v[0]['original_context_sha256']))
|
||||
recommendations_ready()
|
||||
training_seen = set()
|
||||
training_spans = set()
|
||||
for group, cohort, proposal in choices:
|
||||
@@ -708,6 +769,7 @@ class OffroadWorker:
|
||||
'vehicle': str(assessment.cp.carFingerprint),
|
||||
'recorded_tracking': report.get('recorded_tracking'),
|
||||
'suggestion_count': len(diagnostics) + len(profile_suggestions),
|
||||
'recommendations': recorded_recommendations(assessment, current, report),
|
||||
'suggestions': profile_suggestions
|
||||
+ [
|
||||
{'parameter': v['parameter'], 'delta': v['delta'], 'whole_windows': c['whole_windows'], 'qualification': 'controller_native_checks_not_passed'}
|
||||
@@ -727,6 +789,8 @@ class OffroadWorker:
|
||||
if configured is not None:
|
||||
result['configured_values'] = dict(configured['values'])
|
||||
result['configuration_state'] = configured['state']
|
||||
result['recommendations'] = [r for r in result['recommendations'] if r['target'] is None or r['target'] != configured['values'][r['parameter']]]
|
||||
result['recommendation_count'] = len(result['recommendations'])
|
||||
# A new started/changed source cannot be marked processed under its new ID.
|
||||
if not authorized():
|
||||
raise ValueError('Offroad source changed before checkpoint')
|
||||
|
||||
@@ -578,3 +578,208 @@ class TestBoundedInconclusiveTrialLifecycle(unittest.TestCase):
|
||||
self.assertEqual(row['values'], original['values'])
|
||||
self.assertEqual(row['previous'], values())
|
||||
self.assertEqual(cache.get(digest), original)
|
||||
|
||||
|
||||
class TestRecordedAdvisoryVisibility(unittest.TestCase):
|
||||
"""The advisory survives a refused automatic save; no hardware acceptance fixture."""
|
||||
|
||||
@staticmethod
|
||||
def worker(directory):
|
||||
from types import SimpleNamespace
|
||||
from openpilot.starpilot.flm.offroad_cache import AssessmentCache, directory_for_params
|
||||
from openpilot.starpilot.flm.offroad_processor import CompletedAssessment
|
||||
from openpilot.starpilot.flm.offroad_route import RouteReceipt
|
||||
from openpilot.starpilot.flm.offroad_worker import OffroadWorker
|
||||
|
||||
params = Params(directory)
|
||||
cache = AssessmentCache(directory_for_params(params))
|
||||
_, observed = TestComparableNextDriveOutcomes.documents()
|
||||
group = observed.stable_groups[0]
|
||||
group['original_metadata']['route_values'] = list(values().items())
|
||||
group['cohorts'][0]['proposal'] = {'parameter': 'highway_center_taper_max', 'delta': 0.005, 'values': dict(values(), highway_center_taper_max=0.005)}
|
||||
inventory = {'routeId': 'declared-advisory-route'}
|
||||
receipt = RouteReceipt(inventory['routeId'], 'd' * 64, (), 'a' * 64, 1, 2, 100, ())
|
||||
assessment = CompletedAssessment(receipt, (group,), {}, SimpleNamespace(carFingerprint='declared-unit-car'), (), inventory)
|
||||
|
||||
def assessed(params, root, prior, *, permitted):
|
||||
prior(assessment.cp)
|
||||
return assessment
|
||||
|
||||
def refused(*args, **kwargs):
|
||||
return {'qualified': False, 'windows': [], 'policy': 'declared-unit-refusal'}
|
||||
|
||||
worker = OffroadWorker(params, directory, cache, permitted=lambda: True, assessor=assessed,
|
||||
checker=refused, scope_for=lambda cp, params: scope())
|
||||
return worker, params, inventory
|
||||
|
||||
def test_refused_automatic_change_retains_its_per_dial_recommendation(self):
|
||||
from openpilot.starpilot.flm import offroad_worker as module
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
worker, params, inventory = self.worker(directory)
|
||||
with patch.object(module, 'latest_route', return_value=inventory):
|
||||
result = worker.run_once()
|
||||
rows = result.get('recommendations', [])
|
||||
self.assertEqual([r['parameter'] for r in rows], ['highway_center_taper_max'])
|
||||
self.assertEqual(rows[0]['baseline'], 0)
|
||||
self.assertEqual(rows[0]['target'], 0.005)
|
||||
self.assertFalse(result['configuration_changed'])
|
||||
self.assertFalse(result['actual_application_proven'])
|
||||
self.assertIsNone(read_saved(params, KEY, MAX_BYTES)[0], 'An advisory must not save the refused tune')
|
||||
|
||||
|
||||
class TestRecordedRecommendationMetadata(unittest.TestCase):
|
||||
@staticmethod
|
||||
def assessment(proposals=()):
|
||||
from types import SimpleNamespace
|
||||
|
||||
cohorts = [{'whole_windows': 6, 'proposal': proposal} for proposal in proposals]
|
||||
return SimpleNamespace(groups=({'cohorts': cohorts},), diagnostic_groups=())
|
||||
|
||||
def test_captured_unconfirmed_profile_is_visible_without_promoting_it(self):
|
||||
from openpilot.starpilot.flm.offroad_worker import recorded_recommendations
|
||||
|
||||
# Exact four targets from the retained last-drive assessment; no original
|
||||
# route/window/VIN identifiers or invented controller qualification.
|
||||
targets = dict(values(), friction_threshold_low=0.002, friction_threshold_mid=0.004,
|
||||
friction_threshold_fast=0.005, friction_threshold_highway=0.006)
|
||||
proof = {'reason': 'heldout_profile_does_not_support_training_step', 'frozen_training_profile': {'values': targets},
|
||||
'roles': {role: {'original_windows': list(range(8)), 'coordinate_candidates': []} for role in ('training', 'heldout')}}
|
||||
result = recorded_recommendations(self.assessment(), values(), {'stable_profile_reports': [proof]})
|
||||
self.assertEqual({r['parameter']: r['target'] for r in result}, {n: v for n, v in targets.items() if v})
|
||||
self.assertEqual(len(result), 4)
|
||||
self.assertTrue(all(r['whole_windows'] == 16 and r['reason'] == proof['reason'] for r in result))
|
||||
self.assertEqual(proof['frozen_training_profile']['values'], targets)
|
||||
|
||||
def test_all_thirty_five_coordinates_survive_without_a_sixteen_row_limit(self):
|
||||
from openpilot.starpilot.flm.offroad_surface import route_bounds
|
||||
from openpilot.starpilot.flm.offroad_worker import recorded_recommendations
|
||||
|
||||
targets = {name: 0.005 if route_bounds(name)[1] >= 0.005 else -0.005 for name in ROUTE_NAMES}
|
||||
result = recorded_recommendations(self.assessment([{'values': targets}]), values(), {})
|
||||
self.assertEqual([r['parameter'] for r in result], list(ROUTE_NAMES))
|
||||
self.assertEqual(len(result), 35)
|
||||
self.assertTrue(all(r['baseline'] == 0 for r in result))
|
||||
|
||||
def test_conflicting_candidates_do_not_invent_a_best_setting(self):
|
||||
from openpilot.starpilot.flm.offroad_worker import recorded_recommendations
|
||||
|
||||
proposals = [{'values': {'center_taper_max': v}} for v in (0.005, 0.006)]
|
||||
result = recorded_recommendations(self.assessment(proposals), values(), {})
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertIsNone(result[0]['target'])
|
||||
self.assertEqual(result[0]['reason'], 'conflicting_recorded_candidates')
|
||||
self.assertEqual(result[0]['whole_windows'], 6, 'Shared windows cannot be double-counted')
|
||||
|
||||
def test_invalid_or_out_of_envelope_candidates_are_not_suggestions(self):
|
||||
from openpilot.starpilot.flm.offroad_worker import recorded_recommendations
|
||||
|
||||
proposals = [{'values': {'center_taper_max': v}} for v in (True, float('nan'), 0.25)]
|
||||
self.assertEqual(recorded_recommendations(self.assessment(proposals), values(), {}), [])
|
||||
|
||||
def test_training_recipe_without_a_repeated_full_map_is_reported(self):
|
||||
from openpilot.starpilot.flm.offroad_worker import recorded_recommendations
|
||||
|
||||
proof = {'roles': {'training': {'original_windows': ['a', 'b', 'c'],
|
||||
'coordinate_candidates': [{'parameter': 'stable:turn_in_boost_left', 'delta': 0.005}]},
|
||||
'heldout': {'original_windows': ['d', 'e', 'f']}}}
|
||||
result = recorded_recommendations(self.assessment(), values(), {'short_turn_profile_reports': [proof]})
|
||||
self.assertEqual(result[0]['parameter'], 'turn_in_boost_left')
|
||||
self.assertEqual(result[0]['target'], 0.005)
|
||||
self.assertEqual(result[0]['reason'], 'training_candidate_not_independently_confirmed')
|
||||
|
||||
def test_failed_checker_keeps_already_published_candidates(self):
|
||||
from openpilot.starpilot.flm import offroad_worker as module
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
worker, params, inventory = TestRecordedAdvisoryVisibility.worker(directory)
|
||||
snapshots = []
|
||||
worker.on_recommendations = snapshots.append
|
||||
|
||||
def failed(*args, **kwargs):
|
||||
raise ValueError('declared controller-check failure')
|
||||
|
||||
worker.checker = failed
|
||||
with patch.object(module, 'latest_route', return_value=inventory):
|
||||
with self.assertRaisesRegex(ValueError, 'declared controller-check failure') as error:
|
||||
worker.run_once()
|
||||
result = worker.record_failure(error.exception)
|
||||
self.assertIsNotNone(result)
|
||||
assert result is not None
|
||||
self.assertEqual(result['recommendations'][0]['target'], 0.005)
|
||||
self.assertEqual(snapshots[0]['recommendations'][0]['parameter'], 'highway_center_taper_max')
|
||||
self.assertIsNone(read_saved(params, KEY, MAX_BYTES)[0])
|
||||
|
||||
|
||||
class TestRetainedDriveRecommendationReport(unittest.TestCase):
|
||||
def test_another_analysis_keeps_historical_findings_but_never_current_permissions(self):
|
||||
from openpilot.starpilot.flm.offroad_daemon import StatusReport
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'report.json'
|
||||
status = StatusReport(path=path, clock_ns=lambda: 1_000_000_000)
|
||||
status.set({'status': 'collecting', 'reason': 'previous', 'completed_route': 'declared-last-drive', 'vehicle': 'declared-car',
|
||||
'analysis_counts': {'recorded_tracking': 120}, 'configuration_state': 'ready_for_next_drive', 'configured_values': values(0.01),
|
||||
'enabled': True, 'actual_coordinates': list(values(0.01).items()), 'actual_control_ns': 1_000_000_000,
|
||||
'recommendations': [{'parameter': 'ff_gain_left', 'baseline': 0, 'target': 0.005, 'whole_windows': 6, 'reason': 'unconfirmed'}]})
|
||||
status.analyzing({'reason': 'analyzing_last_completed_drive'})
|
||||
status.publish()
|
||||
report = json.loads(path.read_bytes())
|
||||
details = report['current']['details']
|
||||
self.assertTrue(details['Earlier analysis shown while updating'])
|
||||
self.assertEqual(details['Completed drive analyzed'], 'declared-last-drive')
|
||||
self.assertEqual(details['Analysis decision'], 'previous')
|
||||
self.assertEqual(details['Recorded coordinate recommendations'][0][2], 0.005)
|
||||
self.assertNotIn('Saved route configuration state', details)
|
||||
self.assertNotIn('Actual full surface coordinates', details)
|
||||
self.assertFalse(report['current']['application']['applied'])
|
||||
self.assertIsNone(report['current']['enabled'])
|
||||
|
||||
def test_full_recommendation_wire_is_bounded_and_backward_compatible(self):
|
||||
from openpilot.starpilot.flm.offroad_daemon import StatusReport
|
||||
from openpilot.starpilot.flm.training_report import READ_MAX_BYTES, read_report
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'report.json'
|
||||
status = StatusReport(path=path, clock_ns=lambda: 1_000_000_000)
|
||||
status.set({'status': 'collecting', 'reason': 'declared', 'completed_route': 'declared-last-drive',
|
||||
'recommendations': [{'parameter': n, 'baseline': 0, 'target': 0.005, 'whole_windows': 6, 'reason': 'unconfirmed'} for n in ROUTE_NAMES]})
|
||||
status.publish()
|
||||
report = read_report(path, now_ns=1_000_000_000)
|
||||
self.assertIsNotNone(report['current'])
|
||||
assert report['current'] is not None
|
||||
details = report['current']['details']
|
||||
self.assertEqual(len(details['Recorded coordinate recommendations']), 35)
|
||||
self.assertEqual(len(details['Recorded route suggestions']), 16)
|
||||
self.assertEqual(details['Recorded suggestion count'], 35)
|
||||
self.assertFalse(details['More recorded suggestions omitted'])
|
||||
self.assertLess(path.stat().st_size, READ_MAX_BYTES)
|
||||
self.assertFalse(report['current']['application']['applied'])
|
||||
|
||||
def test_startup_recovers_existing_candidates_without_rewriting_the_cache(self):
|
||||
from openpilot.starpilot.flm.offroad_cache import AssessmentCache
|
||||
from openpilot.starpilot.flm.offroad_daemon import StatusReport
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
cache = AssessmentCache(Path(directory) / 'cache')
|
||||
assessment = {'route': {'route_id': 'declared-old-drive', 'source_sha256': 'c' * 64, 'cp_sha256': 'a' * 64},
|
||||
'groups': [{'original_metadata': {'cp_sha256': 'a' * 64, 'route_revision': REVISION, 'route_values': list(values().items())},
|
||||
'cohorts': [{'whole_windows': 148, 'proposal': {'values': dict(values(), highway_center_taper_max=0.005)}}]}]}
|
||||
digest = cache.put({'kind': 'completed_assessment', 'assessment': assessment})
|
||||
cache.checkpoint({'input_fingerprint': 'f' * 64, 'result': {'status': 'collecting', 'completed_route': 'declared-old-drive',
|
||||
'source_sha256': 'c' * 64, 'assessment_sha256': digest, 'reason': 'no_new_native_feasible_training_step'}})
|
||||
before = {p.name: p.read_bytes() for p in cache.directory.iterdir() if p.is_file()}
|
||||
path = Path(directory) / 'report.json'
|
||||
status = StatusReport(path=path, clock_ns=lambda: 1_000_000_000)
|
||||
status.restore(cache)
|
||||
status.analyzing({'reason': 'analyzing_last_completed_drive'})
|
||||
status.publish()
|
||||
report = json.loads(path.read_bytes())
|
||||
self.assertEqual(report['current']['details']['Recorded coordinate recommendations'][0][2], 0.005)
|
||||
self.assertFalse(report['current']['application']['applied'])
|
||||
self.assertEqual({p.name: p.read_bytes() for p in cache.directory.iterdir() if p.is_file()}, before)
|
||||
wrong = StatusReport(path=path, clock_ns=lambda: 1_000_000_000)
|
||||
cache.checkpoint({'input_fingerprint': 'f' * 64, 'result': {'status': 'collecting', 'completed_route': 'wrong-route',
|
||||
'source_sha256': 'c' * 64, 'assessment_sha256': digest, 'reason': 'declared'}})
|
||||
wrong.restore(cache)
|
||||
self.assertEqual(wrong.previous, {}, 'Historical candidate inputs must match their exact completed source')
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import { compile } from '../web/vendor/vue/vue.esm-browser.js'
|
||||
import { FlmPage } from '../web/js/flm.js'
|
||||
import { GalaxySettingRow } from '../web/js/galaxy-setting-row.js'
|
||||
import { SURFACE_NAMES, PID_UNSUPPORTED, validTrainingResults, validTrackingResults, trackingView, surfaceRows, measurementText, validTrackingValues, trackingCoordinates, validTrackingPlot, trackingPlotView, sourceSeconds, tunerStatus } from '../web/js/flm-training-results.js'
|
||||
import { SURFACE_NAMES, FRICTION_NAMES, offroadView, offroadCoordinateEvidence, recommendationText, PID_UNSUPPORTED, validTrainingResults, validTrackingResults, trackingView, surfaceRows, measurementText, validTrackingValues, trackingCoordinates, validTrackingPlot, trackingPlotView, sourceSeconds, tunerStatus } from '../web/js/flm-training-results.js'
|
||||
|
||||
const coordinates = amount => SURFACE_NAMES.map((name, i) => [name, i === 0 ? amount : 0])
|
||||
const tracking = () => ({ version: 1, status: 'trial_active', reason: 'scoring_future_tracking', group: 'feedforward',
|
||||
@@ -357,3 +357,70 @@ for (const reason of ['trial_started_waiting_for_comparable_drive', 'trial_measu
|
||||
assert.equal(ctx.currentApplied, false, 'A pending retry cannot fabricate applied or accepted steering')
|
||||
}
|
||||
console.log('Inconclusive next-drive trials remain visibly provisional without fabricated application')
|
||||
|
||||
// Complete, read-only dial advice is independent of automatic-tune approval.
|
||||
const advisoryNames = [...SURFACE_NAMES, ...FRICTION_NAMES]
|
||||
const advisoryReport = { version: 1, vehicle: 'DECLARED_CAR', history: [], current: { fresh: true, ageSeconds: 0,
|
||||
enabled: null, status: 'collecting', reason: 'no_new_native_feasible_training_step', progress: { samples: 100, blocks: 6, requiredBlocks: 6 },
|
||||
fit: null, application: { applied: false, reason: 'not_applied' }, details: {
|
||||
'Completed drive analyzed': 'declared-last-drive', 'Recorded steering observations': 100,
|
||||
'Analysis decision': 'no_new_native_feasible_training_step', 'Recorded route suggestions': [],
|
||||
'Recorded coordinate recommendations': advisoryNames.map(name => [name, 0, .005, 6, 'training_candidate_not_independently_confirmed']),
|
||||
} } }
|
||||
assert(validTrainingResults(advisoryReport))
|
||||
assert.equal(offroadView(advisoryReport).suggestions.length, 35, 'All thirty dials and five curve points survive the public wire')
|
||||
assert.equal(surfaceRows(advisoryReport).filter(row => row.recommendations.length).length, 30)
|
||||
assert(offroadCoordinateEvidence(advisoryReport, 'ff_gain_left', false, '%').includes('suggested +0.5% (from 0%; change +0.5%)'))
|
||||
assert.equal(advisoryReport.current.application.applied, false)
|
||||
manualContext.report = advisoryReport
|
||||
manualContext.now = 0
|
||||
manualContext.receivedAt = 0
|
||||
manualContext.manualState = { status: 'ready', pending: null, error: '', data: { rows: [{
|
||||
coordinate: 'ff_gain_left', label: 'Steering gain', reason: manualHelp, available: true, action: true,
|
||||
value: 0, defaultValue: 0, resetAvailable: true, minimum: -25, maximum: 25, step: .25, unit: '%',
|
||||
}] } }
|
||||
const advisoryTree = render.call(manualContext, manualContext, [])
|
||||
assert(text(advisoryTree).includes('suggested +0.5%'), 'The suggested slider-scale value must appear beside the actual manual dial')
|
||||
assert(text(advisoryTree).includes('not saved'))
|
||||
const untouchedManual = vnodeRows(advisoryTree).find(node => node.props?.row?.coordinate === 'ff_gain_left')
|
||||
assert.equal(untouchedManual.props.row.value, 0, 'Showing advice cannot move a manual slider or enable an override')
|
||||
const singleAdvisory = structuredClone(advisoryReport)
|
||||
singleAdvisory.current.details['Recorded coordinate recommendations'] = singleAdvisory.current.details['Recorded coordinate recommendations'].slice(0, 1)
|
||||
assert(offroadCoordinateEvidence(singleAdvisory, 'unwind_taper_right').includes('No recommendation from this analyzed drive'))
|
||||
const conflictingAdvisory = structuredClone(singleAdvisory)
|
||||
conflictingAdvisory.current.details['Recorded coordinate recommendations'][0][2] = null
|
||||
conflictingAdvisory.current.details['Recorded coordinate recommendations'][0][4] = 'conflicting_recorded_candidates'
|
||||
assert(validTrainingResults(conflictingAdvisory))
|
||||
assert(recommendationText(offroadView(conflictingAdvisory).suggestions[0]).includes('no single suggested setting'))
|
||||
for (const row of [ ['unknown', 0, .005, 6, 'unconfirmed'], ['ff_gain_left', 0, NaN, 6, 'unconfirmed'],
|
||||
['ff_gain_left', 0, .02, 6, 'unconfirmed'], ['ff_gain_left', true, .005, 6, 'unconfirmed'],
|
||||
['ff_gain_left', 0, .005, -1, 'unconfirmed'], ['ff_gain_left', 0, null, 6, 'unconfirmed'] ]) {
|
||||
const invalid = structuredClone(singleAdvisory)
|
||||
invalid.current.details['Recorded coordinate recommendations'] = [row]
|
||||
assert(!validTrainingResults(invalid), 'Invalid advice cannot pass the public report boundary')
|
||||
assert.equal(offroadView(invalid), null)
|
||||
}
|
||||
const duplicateAdvisory = structuredClone(singleAdvisory)
|
||||
duplicateAdvisory.current.details['Recorded coordinate recommendations'].push(...duplicateAdvisory.current.details['Recorded coordinate recommendations'])
|
||||
assert(!validTrainingResults(duplicateAdvisory))
|
||||
const arbitraryList = structuredClone(advisoryReport)
|
||||
arbitraryList.current.details['Unknown detail'] = Array(17).fill(1)
|
||||
assert(!validTrainingResults(arbitraryList), 'The dedicated recommendation wire must not loosen unrelated telemetry bounds')
|
||||
const historicalAdvisory = structuredClone(singleAdvisory)
|
||||
historicalAdvisory.current.status = 'fitting'
|
||||
historicalAdvisory.current.reason = 'analyzing_last_completed_drive'
|
||||
historicalAdvisory.current.details['Earlier analysis shown while updating'] = true
|
||||
assert(tunerStatus(historicalAdvisory, false, false, '', true).description.includes('Earlier completed-drive findings'))
|
||||
assert(offroadCoordinateEvidence(historicalAdvisory, 'ff_gain_left').startsWith('Earlier analyzed drive'))
|
||||
historicalAdvisory.preference = { version: 1, valid: true, enabled: true, parked: true, vehicle: 'OTHER_CAR' }
|
||||
assert.equal(offroadView(historicalAdvisory), null, 'Earlier advice must not be relabeled for another reported vehicle')
|
||||
console.log('All thirty dial targets, five curve points, honest conflicts and retained findings render without automatic approval or manual writes')
|
||||
|
||||
for (const name of SURFACE_NAMES) assert(recommendationText({ parameter: name, baseline: 0, target: .005, delta: .005 }).includes('+0.5%'), 'Advice must use the same percent scale as TorqueFeature.manual_rows for every dial')
|
||||
const nowSavedAdvisory = structuredClone(advisoryReport)
|
||||
nowSavedAdvisory.current.details['Saved route configuration state'] = 'ready_for_next_drive'
|
||||
nowSavedAdvisory.current.details['Full surface coordinates'] = coordinates(.005)
|
||||
assert.equal(offroadView(nowSavedAdvisory).suggestions.filter(row => row.parameter === 'ff_gain_left').length, 0)
|
||||
assert(offroadCoordinateEvidence(nowSavedAdvisory, 'ff_gain_left', false, '%').includes('Auto Learn saved: +0.5% for the next drive'))
|
||||
assert(!offroadCoordinateEvidence(nowSavedAdvisory, 'ff_gain_left', false, '%').includes('Previous-drive recommendation'))
|
||||
assert.equal(nowSavedAdvisory.current.application.applied, false, 'A saved recommendation is still not current application')
|
||||
|
||||
@@ -53,7 +53,8 @@ export function validTrainingResults(value) {
|
||||
key === 'Applied correction coordinates' && Array.isArray(item) && item.length === 30 ?
|
||||
item.every(number => typeof number === 'number' && Number.isFinite(number) && Math.abs(number) <= .25) :
|
||||
key === 'GLOBAL source observation refusals' ? validSourceRefusals(item) :
|
||||
key === 'Recorded steering response' ? item === null || recordedSteeringView(item) !== null : detailValue(item)))
|
||||
key === 'Recorded steering response' ? item === null || recordedSteeringView(item) !== null :
|
||||
key === 'Recorded coordinate recommendations' ? validCoordinateRecommendations(item) : detailValue(item)))
|
||||
}
|
||||
const labels = { collecting: "Gathering driving examples", unavailable: "Waiting for usable driving data",
|
||||
qualifiedFit: "Fit passed validation", qualified: "Fit passed validation", rejected: "Fit did not pass validation",
|
||||
@@ -149,16 +150,27 @@ const frictionCoordinates = rows => Array.isArray(rows) && rows.length === 5 &&
|
||||
Array.isArray(row) && row.length === 2 && row[0] === FRICTION_NAMES[i] && typeof row[1] === 'number' &&
|
||||
Number.isFinite(row[1]) && Math.abs(row[1]) <= .25) ? Object.fromEntries(rows) : null
|
||||
|
||||
export function offroadCoordinateEvidence(report, name, live = false) {
|
||||
export function recommendationText(row, unit = null) {
|
||||
if (row.target === null) return 'Conflicting recorded candidates; no single suggested setting'
|
||||
if (row.target === undefined) return `change ${coordinateText(row.delta)}`
|
||||
// The setting owner displays every canonical coordinate as percent, including window edges.
|
||||
const suffix = unit ?? '%'
|
||||
const display = value => `${coordinateText(suffix === '%' ? value * 100 : value)}${suffix === 'm/s' ? ' ' : ''}${suffix}`
|
||||
return `suggested ${display(row.target)} (from ${display(row.baseline)}; change ${display(row.delta)})`
|
||||
}
|
||||
|
||||
export function offroadCoordinateEvidence(report, name, live = false, unit = null) {
|
||||
const route = offroadView(report), row = surfaceRows(report, live).find(row => row.name === name)
|
||||
const statements = []
|
||||
const display = value => unit === '%' ? `${coordinateText(value * 100)}%` : coordinateText(value) + (unit || '')
|
||||
if (row?.applied !== null && row?.applied !== undefined && row.applied !== 0)
|
||||
statements.push(`Auto Learn applied now: ${coordinateText(row.applied)}.`)
|
||||
statements.push(`Auto Learn applied now: ${display(row.applied)}.`)
|
||||
const saved = route?.savedValues?.[name]
|
||||
if (saved !== undefined && saved !== 0)
|
||||
statements.push(`Auto Learn saved: ${coordinateText(saved)}${route.configurationState === 'ready_for_next_drive' ? ' for the next drive' : ''}.`)
|
||||
statements.push(`Auto Learn saved: ${display(saved)}${route.configurationState === 'ready_for_next_drive' ? ' for the next drive' : ''}.`)
|
||||
for (const suggestion of route?.suggestions || []) if (suggestion.parameter === name)
|
||||
statements.push(`Previous-drive recommendation: change ${coordinateText(suggestion.delta)}; not saved.`)
|
||||
statements.push(`${route.retained ? 'Earlier analyzed drive' : 'Previous-drive recommendation'}: ${recommendationText(suggestion, unit)}; not saved.${suggestion.reason ? ` ${learningReason(suggestion.reason)}` : ''}`)
|
||||
if (route && !statements.length) statements.push('No recommendation from this analyzed drive for this dial.')
|
||||
return statements.join(' ')
|
||||
}
|
||||
|
||||
@@ -179,6 +191,11 @@ export const SURFACE_GROUPS = Object.freeze([
|
||||
])
|
||||
|
||||
const REASONS = {
|
||||
heldout_profile_does_not_support_training_step: "Part of the drive suggested this setting; separate windows did not confirm it.",
|
||||
independent_support_for_frozen_training_profile: "Separate recorded windows support this starting point. Automatic approval is separate.",
|
||||
training_candidate_not_independently_confirmed: "Training windows suggested this setting; it is not independently confirmed.",
|
||||
recorded_candidate_requires_automatic_checks: "A recorded-drive starting point, not approved for automatic saving.",
|
||||
conflicting_recorded_candidates: "Different windows suggested different settings. No single setting is recommended.",
|
||||
trial_started_waiting_for_comparable_drive: "The trial ran, but this drive lacked matching comparison windows. The same bounded trial will be checked on another drive.",
|
||||
trial_measurement_inconclusive: "The comparison did not establish improvement or worsening. The same bounded trial will be checked on another drive.",
|
||||
trial_inconclusive_drive_limit: "Three trial drives did not establish an improvement. The prior tune was restored.",
|
||||
@@ -230,15 +247,29 @@ const HISTORY_LABELS = { collecting: 'Collecting samples', calibrating: 'Measuri
|
||||
trial_active: 'Evaluating a trial', confirmed: 'Correction confirmed', rollback: 'Returning to baseline', paused: 'Learning paused' }
|
||||
export const historyTitle = status => Object.hasOwn(HISTORY_LABELS, status) ? HISTORY_LABELS[status] : readable(status)
|
||||
|
||||
export function validCoordinateRecommendations(rows) {
|
||||
const names = [...SURFACE_NAMES, ...FRICTION_NAMES]
|
||||
return Array.isArray(rows) && rows.length <= names.length && new Set(rows.map(row => row?.[0])).size === rows.length &&
|
||||
rows.every(row => Array.isArray(row) && row.length === 5 && names.includes(row[0]) &&
|
||||
typeof row[1] === 'number' && Number.isFinite(row[1]) && Math.abs(row[1]) <= 1000 &&
|
||||
(row[2] === null && row[4] === 'conflicting_recorded_candidates' ||
|
||||
typeof row[2] === 'number' && Number.isFinite(row[2]) && Math.abs(row[2]) <= 1000 &&
|
||||
Math.abs(row[2] - row[1]) > 1e-12 && Math.abs(row[2] - row[1]) <= .01 + 1e-12) &&
|
||||
stamp(row[3]) && row[3] > 0 && text(row[4], 240))
|
||||
}
|
||||
|
||||
export function offroadView(report) {
|
||||
const details = report?.current?.details
|
||||
if (!record(details) || !text(details['Completed drive analyzed'], 180) || !details['Completed drive analyzed'] ||
|
||||
!stamp(details['Recorded steering observations']) || !text(details['Analysis decision'])) return null
|
||||
if (report.preference?.valid && report.vehicle && report.preference.vehicle !== report.vehicle) return null
|
||||
const rows = details['Recorded route suggestions'] ?? []
|
||||
if (!Array.isArray(rows) || rows.length > 16 || rows.some(row => !Array.isArray(row) || row.length !== 4 ||
|
||||
!text(row[0], 500) || typeof row[1] !== 'number' || !Number.isFinite(row[1]) || Math.abs(row[1]) > .25 ||
|
||||
!stamp(row[2]) || row[3] !== 'controller_native_checks_not_passed')) return null
|
||||
const observed = recordedSteeringView(details['Recorded steering response'])
|
||||
const recommendations = details['Recorded coordinate recommendations']
|
||||
if (recommendations !== undefined && !validCoordinateRecommendations(recommendations)) return null
|
||||
const savedCount = details['Saved route adjustment count']
|
||||
const checked = details['Controller and encoder check windows'], refused = details['Automatic tune refusal reasons']
|
||||
const checks = Array.isArray(checked) && checked.length === 2 && checked.every(stamp) && checked[0] <= checked[1] && checked[1] <= 36 &&
|
||||
@@ -252,7 +283,11 @@ export function offroadView(report) {
|
||||
reason: details['Analysis decision'], saved: details['Automatic configuration changed'] === true,
|
||||
configurationState, savedValues: savedSurface ? { ...savedSurface, ...(savedFriction || {}) } : null,
|
||||
observed, checks, savedCount: stamp(savedCount) && savedCount <= 35 ? savedCount : null,
|
||||
suggestions: rows.map(([parameter, delta, windows, qualification]) => ({ parameter, delta, windows, qualification })),
|
||||
retained: details['Earlier analysis shown while updating'] === true,
|
||||
suggestions: recommendations === undefined ? rows.map(([parameter, delta, windows, qualification]) => ({ parameter, delta, windows, qualification })) :
|
||||
recommendations.map(([parameter, baseline, target, windows, reason]) => ({ parameter, baseline, target,
|
||||
delta: target === null ? null : target - baseline, windows, reason, qualification: 'controller_native_checks_not_passed' }))
|
||||
.filter(row => row.target === null || row.target !== { ...savedSurface, ...savedFriction }[row.parameter]),
|
||||
moreSuggestions: details['More recorded suggestions omitted'] === true }
|
||||
}
|
||||
|
||||
@@ -280,7 +315,7 @@ export function tunerStatus(report, live = false, choiceLive = false, error = ""
|
||||
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 (reportFresh && current?.status === 'fitting' && ['analyzing_last_completed_drive', 'checking_recorded_steering_recommendations'].includes(current.reason))
|
||||
return { key: 'fitting', tone: 'active', title: 'Analyzing your completed drive', description: learningReason(current.reason) }
|
||||
return { key: 'fitting', tone: 'active', title: 'Analyzing your completed drive', description: learningReason(current.reason) + (offroadView(report)?.retained ? ' Earlier completed-drive findings remain below while this analysis runs.' : '') }
|
||||
const route = reportFresh && (!preference || preference.parked) ? offroadView(report) : null
|
||||
if (route) {
|
||||
if (route.configurationState === 'ready_for_next_drive')
|
||||
|
||||
@@ -6,7 +6,7 @@ import { GalaxySettingRow, settingControl } from "./galaxy-setting-row.js"
|
||||
import { PollTimer, connectionError } from "./polling.js"
|
||||
import { requestJson } from "./startup.js"
|
||||
import { PlotGraph, PlotsPage, chartGeometry } from "./plots.js"
|
||||
import { validTrainingResults, readable, detailText, surfaceRows, coordinateText, trackingView, measurementText, validTrackingValues, trackingPlotView, sourceSeconds, validLearningPreference, SURFACE_GROUPS, tunerStatus, learningReason, sourceCaptureCounts, historyTitle, offroadView, FRICTION_NAMES, offroadCoordinateEvidence } from "./flm-training-results.js"
|
||||
import { validTrainingResults, readable, detailText, surfaceRows, coordinateText, trackingView, measurementText, validTrackingValues, trackingPlotView, sourceSeconds, validLearningPreference, SURFACE_GROUPS, tunerStatus, learningReason, sourceCaptureCounts, historyTitle, offroadView, FRICTION_NAMES, offroadCoordinateEvidence, recommendationText } from "./flm-training-results.js"
|
||||
|
||||
const VIEWS = ["overview", "tune", "activity", "plots"]
|
||||
const PARAMETER_LABELS = {
|
||||
@@ -77,6 +77,7 @@ export const FlmPage = {
|
||||
trackingPlot() { return trackingPlotView(this.report, this.now - this.receivedAt) },
|
||||
offroad() { return offroadView(this.report) },
|
||||
savedAdjustments() { return Object.entries(this.offroad?.savedValues || {}).filter(([, value]) => value !== 0).map(([name, value]) => ({ name, value })) },
|
||||
frictionRecommendations() { return (this.offroad?.suggestions || []).filter(row => FRICTION_NAMES.includes(row.parameter)) },
|
||||
savedFriction() { return this.savedAdjustments.filter(row => FRICTION_NAMES.includes(row.name)) },
|
||||
recordedTrackingChart() { return chartGeometry(this.offroad?.observed?.points || [], ['desired', 'actual']) },
|
||||
pairedTrackingChart() { return chartGeometry(this.trackingPlot?.points || [], ["desiredForceAccel", "measuredForceAccel"]) },
|
||||
@@ -103,8 +104,8 @@ export const FlmPage = {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
readable, detailText, coordinateText, measurementText, sourceSeconds, learningReason, historyTitle,
|
||||
manualEvidence(name) { return offroadCoordinateEvidence(this.report, name, this.live) },
|
||||
readable, detailText, coordinateText, measurementText, sourceSeconds, learningReason, historyTitle, recommendationText,
|
||||
manualEvidence(name, unit) { return offroadCoordinateEvidence(this.report, name, this.live, unit) },
|
||||
detailLabel(key) { return ['Fit attempts', 'Fit results', 'Fit in progress', 'Submitted fit jobs (reporter lifetime)'].includes(key) ? 'Legacy fitter · ' + key : readable(key) },
|
||||
amount(value) { return value === null || value === undefined ? "—" : value.toLocaleString() },
|
||||
parameterLabel(name) { return PARAMETER_LABELS[name] || readable(name) },
|
||||
@@ -202,7 +203,7 @@ export const FlmPage = {
|
||||
<section class="gx-card gx-tuner__section"><div class="gx-tuner__section-heading"><div><p class="gx-tuner__eyebrow">{{ offroad ? 'RECORDED-DRIVE ANALYSIS' : live ? 'THE CURRENT EXPERIMENT' : 'LAST REPORTED EXPERIMENT' }}</p><h3>{{ offroad ? 'Next-drive tuning' : tracking?.acknowledged && tracking.status === 'trial_active' ? 'Trial in progress' : 'Trial results' }}</h3></div></div>
|
||||
<p v-if="tracking">{{ learningReason(tracking.reason) }}</p><p v-else-if="offroad">{{ learningReason(offroad.reason) }}</p><p v-else>Waiting for driving samples. Auto Learn compares steering error before and during a trial.</p>
|
||||
<template v-if="offroad?.checks"><p>{{ offroad.checks.passed }} / {{ offroad.checks.required }} original controller and encoder windows passed.</p><p v-for="(refusal, index) in offroad.checks.refusals" :key="index">{{ refusal.count }} window(s): {{ learningReason(refusal.reason) }}</p></template>
|
||||
<template v-if="offroad"><h4 v-if="savedAdjustments.length">Saved Auto Learn adjustments</h4><ul v-if="savedAdjustments.length"><li v-for="row in savedAdjustments" :key="row.name"><strong>{{ parameterLabel(row.name) }} {{ coordinateText(row.value) }}</strong> · {{ offroad.configurationState === 'ready_for_next_drive' ? 'saved for next drive' : 'saved configuration' }}</li></ul><h4 v-if="offroad.suggestions.length">Other recorded candidates · not saved</h4><ul v-if="offroad.suggestions.length"><li v-for="(suggestion, index) in offroad.suggestions" :key="index"><strong>{{ parameterLabel(suggestion.parameter) }} change {{ coordinateText(suggestion.delta) }}</strong> · {{ amount(suggestion.windows) }} recorded windows · not approved for automatic saving</li></ul><p v-if="offroad.moreSuggestions" class="gx-tuner__caption">Additional candidates are retained in the device's full assessment.</p><p class="gx-tuner__caption">Saved values and unsaved candidates are separate. Current application requires fresh controller evidence.</p></template>
|
||||
<template v-if="offroad"><h4 v-if="savedAdjustments.length">Saved Auto Learn adjustments</h4><ul v-if="savedAdjustments.length"><li v-for="row in savedAdjustments" :key="row.name"><strong>{{ parameterLabel(row.name) }} {{ coordinateText(row.value) }}</strong> · {{ offroad.configurationState === 'ready_for_next_drive' ? 'saved for next drive' : 'saved configuration' }}</li></ul><h4 v-if="offroad.suggestions.length">Other recorded candidates · not saved</h4><ul v-if="offroad.suggestions.length"><li v-for="(suggestion, index) in offroad.suggestions" :key="index"><strong>{{ parameterLabel(suggestion.parameter) }} · {{ recommendationText(suggestion) }}</strong> · {{ amount(suggestion.windows) }} recorded windows · {{ suggestion.reason ? learningReason(suggestion.reason) : 'not approved for automatic saving' }}</li></ul><p v-if="offroad.moreSuggestions" class="gx-tuner__caption">Additional candidates are retained in the device's full assessment.</p><p class="gx-tuner__caption">Saved values and unsaved candidates are separate. Current application requires fresh controller evidence.</p></template>
|
||||
<div class="gx-tuner__comparison"><div><span>{{ offroad ? 'Recorded baseline' : 'Before' }}</span><strong>{{ measurementText(offroad?.observed?.errorRms ?? tracking?.baselineErrorRms ?? null) }}</strong></div><i class="bi bi-arrow-right" aria-hidden="true"></i><div><span>Trial</span><strong>{{ offroad ? offroad.configurationState === 'ready_for_next_drive' ? 'Awaiting next-drive measurement' : 'No pending trial reported' : measurementText(tracking?.futureErrorRms ?? null) }}</strong></div></div><p class="gx-tuner__caption">Tracking error RMS · {{ offroad?.observed?.units ?? '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>
|
||||
@@ -211,25 +212,25 @@ export const FlmPage = {
|
||||
</section>
|
||||
<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 tune</h3><p>Thirty steering parameters, grouped by behavior.</p></div><span class="gx-tuner__pill">Manual + Auto Learn</span></div>
|
||||
<section v-if="savedFriction.length" class="gx-card gx-tuner__section"><h4>Auto Learn friction adjustments</h4><p class="gx-tuner__caption">These five-point curve adjustments are separate from the thirty manual sliders.</p><ul><li v-for="row in savedFriction" :key="row.name">{{ parameterLabel(row.name) }} <strong>{{ coordinateText(row.value) }}</strong> · {{ offroad.configurationState === 'ready_for_next_drive' ? 'saved for next drive' : 'saved configuration' }}</li></ul></section>
|
||||
<section v-if="savedFriction.length || frictionRecommendations.length" class="gx-card gx-tuner__section"><h4>Auto Learn friction adjustments</h4><p class="gx-tuner__caption">These five-point curve adjustments are separate from the thirty manual sliders.</p><ul><li v-for="row in savedFriction" :key="row.name">{{ parameterLabel(row.name) }} <strong>{{ coordinateText(row.value) }}</strong> · {{ offroad.configurationState === 'ready_for_next_drive' ? 'saved for next drive' : 'saved configuration' }}</li></ul><ul v-if="frictionRecommendations.length"><li v-for="row in frictionRecommendations" :key="row.parameter"><strong>{{ parameterLabel(row.parameter) }} · {{ recommendationText(row) }}</strong> · {{ learningReason(row.reason) }} · not saved</li></ul></section>
|
||||
<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>
|
||||
<section class="gx-tuner__manual" aria-label="Manual tuning"><h4>Manual tune</h4><p class="gx-tuner__caption">Parked edits apply to the next drive. Auto Learn pauses while manual overrides are set. Zero removes an override.</p>
|
||||
<GxNotice v-if="manualState.error" tone="danger">{{ manualState.error }}</GxNotice>
|
||||
<p v-if="!manualState.data" class="gx-tuner__caption">{{ manualState.status === 'loading' ? 'Loading saved manual tune…' : 'Manual tuning unavailable. Reconnecting…' }}</p>
|
||||
<p v-else-if="!manualRows.length" class="gx-tuner__caption">{{ manualState.data.rows[0]?.reason || 'Manual tuning is unavailable for this controller.' }}</p>
|
||||
<template v-for="{ row, index } in manualRows" :key="row.coordinate"><GalaxySettingRow :row="{ ...row, label: parameterLabel(row.coordinate) }" :index="index" :busy="manualBusy" :disabled="!!manualState.error" :save-value="saveManual" @reset-default="index => manualFeed.resetDefault(index)" /><p v-if="manualEvidence(row.coordinate)" class="gx-tuner__caption">{{ manualEvidence(row.coordinate) }} Manual override shown above is separate.</p></template>
|
||||
<template v-for="{ row, index } in manualRows" :key="row.coordinate"><GalaxySettingRow :row="{ ...row, label: parameterLabel(row.coordinate) }" :index="index" :busy="manualBusy" :disabled="!!manualState.error" :save-value="saveManual" @reset-default="index => manualFeed.resetDefault(index)" /><p v-if="manualEvidence(row.coordinate, row.unit)" class="gx-tuner__caption">{{ manualEvidence(row.coordinate, row.unit) }} Manual override shown above is separate.</p></template>
|
||||
<GalaxySettingRow v-for="{ row, index } in manualActions" :key="index" :row="row" :index="index" :busy="manualBusy" :disabled="!!manualState.error" :save-value="saveManual" @review="reviewManualReset" />
|
||||
</section>
|
||||
<h4>Auto Learn evidence</h4><p class="gx-tuner__caption">These reported results are separate from your manual settings.</p>
|
||||
<div class="gx-tuner__parameter-head" aria-hidden="true"><span>Parameter</span><span>{{ offroad ? 'Saved' : trackingValues ? 'Accepted' : 'Proposal' }}</span><span>{{ offroad ? 'Recommendation · not saved' : '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">{{ offroad ? 'Saved' : trackingValues ? 'Accepted' : 'Proposal' }}</span><strong>{{ coordinateText(offroad ? row.saved : trackingValues ? row.accepted : row.learned) }}</strong></div><div><span class="gx-tuner__mobile-label">{{ offroad ? 'Recommendation · not saved' : 'Trial' }}</span><template v-if="offroad"><strong v-for="(suggestion, index) in row.recommendations" :key="index">change {{ coordinateText(suggestion.delta) }}</strong><strong v-if="!row.recommendations.length">None reported</strong></template><strong v-else :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>
|
||||
<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">{{ offroad ? 'Saved' : trackingValues ? 'Accepted' : 'Proposal' }}</span><strong>{{ coordinateText(offroad ? row.saved : trackingValues ? row.accepted : row.learned) }}</strong></div><div><span class="gx-tuner__mobile-label">{{ offroad ? 'Recommendation · not saved' : 'Trial' }}</span><template v-if="offroad"><strong v-for="(suggestion, index) in row.recommendations" :key="index">{{ recommendationText(suggestion) }}</strong><strong v-if="!row.recommendations.length">None reported</strong></template><strong v-else :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-show="view === 'plots'" id="tuner-panel-plots" role="tabpanel" aria-labelledby="tuner-tab-plots" class="gx-tuner__panel"><PlotsPage :mode="mode" :unauthorized="unauthorized" :active="view === 'plots'" embedded /></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>
|
||||
<template v-if="offroad"><h4>Completed drive {{ offroad.route }}</h4><p>{{ learningReason(offroad.reason) }}</p><p v-if="offroad.checks">{{ offroad.checks.passed }} / {{ offroad.checks.required }} controller and encoder windows passed.</p><ul v-if="savedAdjustments.length"><li v-for="row in savedAdjustments" :key="row.name">{{ parameterLabel(row.name) }} {{ coordinateText(row.value) }} · {{ offroad.configurationState === 'ready_for_next_drive' ? 'saved for next drive; awaiting measurement' : 'saved configuration' }}</li></ul><ul v-if="offroad.suggestions.length"><li v-for="(suggestion, index) in offroad.suggestions" :key="index">{{ parameterLabel(suggestion.parameter) }} change {{ coordinateText(suggestion.delta) }} · recommendation not saved</li></ul><p class="gx-tuner__caption">Recorded-drive assessment. Current application and road improvement require separate measurements.</p></template>
|
||||
<template v-if="offroad"><h4>{{ offroad.retained ? 'Earlier analyzed drive' : 'Completed drive' }} {{ offroad.route }}</h4><p>{{ learningReason(offroad.reason) }}</p><p v-if="offroad.checks">{{ offroad.checks.passed }} / {{ offroad.checks.required }} controller and encoder windows passed.</p><ul v-if="savedAdjustments.length"><li v-for="row in savedAdjustments" :key="row.name">{{ parameterLabel(row.name) }} {{ coordinateText(row.value) }} · {{ offroad.configurationState === 'ready_for_next_drive' ? 'saved for next drive; awaiting measurement' : 'saved configuration' }}</li></ul><ul v-if="offroad.suggestions.length"><li v-for="(suggestion, index) in offroad.suggestions" :key="index">{{ parameterLabel(suggestion.parameter) }} · {{ recommendationText(suggestion) }} · {{ suggestion.reason ? learningReason(suggestion.reason) : 'recommendation not saved' }}</li></ul><p class="gx-tuner__caption">Recorded-drive assessment. Current application and road improvement require separate measurements.</p></template>
|
||||
<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>{{ historyTitle(row.status) }}</strong><p>{{ learningReason(row.reason) }}</p><small>{{ row.time }}<template v-if="row.applied"> · Applied at that time</template></small></div></li></ol>
|
||||
<div v-else-if="!offroad" 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>
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"protected_inventory_sha256": "c1637af30fb219debc4ae608f0789e09d5be5e0fea63055823ce49c9225eb6d9", "schema_version": 1}
|
||||
{"protected_inventory_sha256": "f8e900a800d10c8abcfda066821d1ed3c59aa2a61839bd79e8753add4182c0cc", "schema_version": 1}
|
||||
|
||||
Reference in New Issue
Block a user