Files
StarPilot/openpilot/starpilot/flm/gm_recommend.py
T
firestar5683 a82935f326 User interfaces and Android Auto
Integrate onroad views, configurable widgets, local settings, steering-profile contracts, Bluetooth and Android Auto with their device and runtime services.

Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com>
Co-authored-by: Daniel Koepping <elkoled@gmail.com>
Co-authored-by: Danny <dannyirwin31@yahoo.com>
Co-authored-by: DimitriChrysafis <Dimitri.chrysafis@gmail.com>
Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com>
Co-authored-by: Harald Schäfer <harald.the.engineer@gmail.com>
Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu>
Co-authored-by: Shane Smiskol <shane@smiskol.com>
Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com>
Co-authored-by: stef <19478336+stefpi@users.noreply.github.com>
Co-authored-by: whoisdomi <dcantu83@gmail.com>

Co-authored-by: Intelli <contact@intelli.software>
Co-authored-by: pharmacomaniac <blittle65@gmail.com>

Co-authored-by: prabhaavp <prabhaav@msu.edu>
2026-10-14 20:02:39 -05:00

338 lines
18 KiB
Python

"""Exact selected-GM adapters for original evidence-derived trial profiles.
Recommendations are human-selected trials, never a fitted learner, actuation
permission or evidence that a physical vehicle received a command.
"""
import hashlib
import json
import math
from opendbc.car.lateral import FRICTION_THRESHOLD
from openpilot.starpilot.flm.torque_surface import GmSurface, _GM_BOUNDS, _GM_RICH_BOUNDS
_PRECISIONS = {
'torque_universal.ff_gain_left': 0.001,
'torque_universal.ff_gain_right': 0.001,
'torque_universal.turn_in_boost_left': 0.001,
'torque_universal.turn_in_boost_right': 0.001,
'torque_universal.unwind_taper_left': 0.001,
'torque_universal.unwind_taper_right': 0.001,
'torque_universal.center_taper_max': 0.001,
'torque_universal.highway_center_taper_max': 0.001,
'torque_universal.center_deadband_crawl_deg': 0.005,
'torque_universal.center_deadband_low_deg': 0.005,
'torque_universal.center_deadband_mid_deg': 0.005,
'torque_universal.center_deadband_fast_deg': 0.005,
'torque_universal.center_deadband_highway_deg': 0.005,
'torque_universal.turn_in_threshold_reduction_left': 0.001,
'torque_universal.turn_in_threshold_reduction_right': 0.001,
'torque_universal.unwind_threshold_increase_left': 0.001,
'torque_universal.unwind_threshold_increase_right': 0.001,
'torque_universal.crawl_turn_in_ff_boost_left': 0.001,
'torque_universal.crawl_turn_in_ff_boost_right': 0.001,
'torque_universal.low_speed_angle_assist_max_torque': 0.001,
'torque_universal.curvy_speed_min': 0.1,
'torque_universal.curvy_speed_max': 0.1,
'torque_universal.curvy_turn_in_trim_speed_min': 0.1,
'torque_universal.curvy_turn_in_trim_speed_max': 0.1,
'torque_universal.curvy_turn_in_trim_left': 0.001,
'torque_universal.curvy_turn_in_trim_right': 0.001,
'torque_universal.curvy_unwind_floor_relief_left': 0.001,
'torque_universal.curvy_unwind_floor_relief_right': 0.001,
'torque_universal.curvy_unwind_extra_reduction_left': 0.001,
'torque_universal.curvy_unwind_extra_reduction_right': 0.001,
'gm_bolt_2022_2023.ff_gain_left': 0.001,
'gm_bolt_2022_2023.ff_gain_right': 0.001,
'gm_bolt_2022_2023.turn_in_boost_left': 0.001,
'gm_bolt_2022_2023.turn_in_boost_right': 0.001,
'gm_bolt_2022_2023.unwind_taper_left': 0.001,
'gm_bolt_2022_2023.unwind_taper_right': 0.001,
'gm_bolt_2022_2023.center_taper_max': 0.001,
'gm_bolt_2022_2023.turn_in_threshold_reduction_left': 0.001,
'gm_bolt_2022_2023.turn_in_threshold_reduction_right': 0.001,
'gm_bolt_2022_2023.unwind_threshold_increase_left': 0.001,
'gm_bolt_2022_2023.unwind_threshold_increase_right': 0.001,
}
FLM_FRICTION_SPEED_KNOTS = (0., 5., 10., 15., 25.)
def get_standard_friction_threshold(speed):
return FRICTION_THRESHOLD
def get_gm_base_friction_threshold(speed):
return GmSurface('torque_universal', {}).base_threshold(speed)
def get_hkg_canfd_base_friction_threshold(speed):
raise ValueError('This recommendation owner is exact GM only')
def get_flm_supported_vehicle_knobs():
result = {}
for profile in ('torque_universal', 'gm_bolt_2022_2023'):
source = GmSurface(profile, {})
limits = dict(_GM_BOUNDS)
limits.update(_GM_RICH_BOUNDS if profile == 'gm_bolt_2022_2023' else {'ff_gain_left': (-.4, .6), 'ff_gain_right': (-.4, .6)})
for name, (low, high) in limits.items():
symbol = f'{profile}.{name}'
result[symbol] = {'min': low, 'max': high, 'precision': _PRECISIONS.get(symbol, _PRECISIONS[f'torque_universal.{name}']),
'defaultValue': source.knobs[name]}
return result
def normalize_flm_overrides(value):
if type(value) is not dict or set(value) != {'schemaVersion', 'baseFrictionThresholds', 'vehicleKnobs'} or value['schemaVersion'] != 1:
raise ValueError('Malformed recommendation surface')
curves, knobs = value['baseFrictionThresholds'], value['vehicleKnobs']
if type(curves) is not dict or set(curves) - {'gm', 'standard'} or type(knobs) is not dict or len(knobs) > 60:
raise ValueError('Wrong recommendation surface family')
supported = get_flm_supported_vehicle_knobs()
for symbol, number in knobs.items():
if symbol not in supported or type(number) not in (int, float) or not math.isfinite(number):
raise ValueError('Malformed recommendation knob')
limits = supported[symbol]
if not limits['min'] <= number <= limits['max']:
raise ValueError('Out-of-range recommendation knob')
for curve in curves.values():
if type(curve) is not dict or set(curve) != {'speedKnots', 'values'} or curve['speedKnots'] != list(FLM_FRICTION_SPEED_KNOTS):
raise ValueError('Wrong recommendation curve source')
GmSurface('torque_universal', {}, tuple(curve['values']))
return {'schemaVersion': 1, 'baseFrictionThresholds': curves, 'vehicleKnobs': knobs}
def cp_signature(cp):
def binary(value):
if type(value) is bytes:
return {'bytes': value.hex()}
raise TypeError('Unsupported CarParams value')
return hashlib.sha256(json.dumps(cp.to_dict(), sort_keys=True, allow_nan=False,
separators=(',', ':'), default=binary).encode()).hexdigest()
def source_context(cp, controller, params, token, profile):
from openpilot.starpilot.flm.live import gm_capability
from openpilot.starpilot.flm.manual_trial import manual_snapshot, manual_preconditions
from openpilot.starpilot.lateral.gm_geometry_runtime import geometry_basis
from openpilot.starpilot.lateral.torque_runtime import factor_edit_supported
from openpilot.starpilot.saved_source import read_saved
capability = gm_capability(cp, controller)
if capability is None:
raise ValueError('Unsupported GM recommendation owner')
advanced, readable = read_saved(params, 'AdvancedLateralTune', 64)
off, off_readable = read_saved(params, 'ForceAutoTuneOff', 1)
if not readable or advanced not in (None, b'0', b'1') or not off_readable or off not in (None, b'0', b'1'):
raise ValueError('Unreadable recommendation guard')
manual_preconditions(params, cp, controller, profile) # Reject changed gain/geometry basis, never reinterpret it.
selected = profile.flm.selected() if profile.flm is not None else None
surface = selected or GmSurface(capability['profile'], {})
if surface.profile != capability['profile']:
raise ValueError('Review changed surface before training')
context = {'version': 1, 'capability': capability, 'manual': manual_snapshot(profile, capability['fingerprint']),
'surface': surface.document(), 'geometryBasis': geometry_basis(cp), 'cpSignature': cp_signature(cp),
'sourceToken': token, 'advancedEnabled': advanced == b'1', 'forceOff': off == b'1',
'factorSupported': factor_edit_supported(cp, controller), 'gainTable': gain_table(controller),
'cleanupProgress': profile.flm is not None and profile.flm.cleanup_progress}
return json.loads(json.dumps(context, allow_nan=False))
def validate_context(value):
import re
from openpilot.starpilot.flm.torque_surface import GmFlmBinding
from openpilot.starpilot.flm.manual_trial import manual_profile
from openpilot.starpilot.lateral.torque_supported import GM_VEHICLES
expected = {'version', 'capability', 'manual', 'surface', 'geometryBasis', 'cpSignature', 'sourceToken',
'advancedEnabled', 'forceOff', 'factorSupported', 'gainTable', 'cleanupProgress'}
if type(value) is not dict or set(value) != expected or type(value['version']) is not int or value['version'] != 1:
raise ValueError('Malformed GM analysis context')
cap = value['capability']
if type(cap) is not dict or set(cap) != {'fingerprint', 'controller', 'policy', 'basis', 'profile'} or cap['fingerprint'] not in GM_VEHICLES:
raise ValueError('Wrong GM analysis vehicle')
owner = GmFlmBinding(cap['controller'], cap['policy'], tuple(cap['basis']))
surface = GmSurface.from_document(value['surface'])
if surface.profile != cap['profile']:
raise ValueError('Wrong GM analysis surface')
manual_profile(value['manual'], cap['fingerprint'], owner.basis)
if value['gainTable'] != [list(row) for row in gain_table(cap['controller'])]:
raise ValueError('Wrong selected gain source')
for key in ('cpSignature', 'sourceToken'):
if type(value[key]) is not str or re.fullmatch(r'[a-f0-9]{64}', value[key]) is None:
raise ValueError('Malformed GM analysis source')
if any(type(value[key]) is not bool for key in ('advancedEnabled', 'forceOff', 'factorSupported', 'cleanupProgress')):
raise ValueError('Malformed GM analysis guard')
geometry = value['geometryBasis']
if type(geometry) is not list or len(geometry) != 2 or any(type(n) not in (int, float) or not math.isfinite(n) or n <= 0 for n in geometry):
raise ValueError('Malformed GM geometry source')
return value
def matches_recording(cp, context):
from openpilot.starpilot.flm.live import gm_capability
context = validate_context(context)
capability = gm_capability(cp, context['capability']['controller'])
return capability is not None and json.loads(json.dumps(capability)) == context['capability'] and cp_signature(cp) == context['cpSignature']
def gain_table(controller):
if controller == 'starpilot':
return ((0,), (.6,))
if controller != 'standard':
raise ValueError('Wrong recommendation controller')
from openpilot.selfdrive.controls.lib.latcontrol_torque import INTERP_SPEEDS, KP_INTERP
return tuple(INTERP_SPEEDS), tuple(KP_INTERP)
def current_values(context):
from openpilot.starpilot.flm.manual_trial import manual_profile
context = validate_context(context)
cap = context['capability']
profile = manual_profile(context['manual'], cap['fingerprint'], tuple(cap['basis']))
geometry = profile.geometry
force_auto = geometry is not None and geometry.learning == 'force_auto' and not context['forceOff']
force_off = context['forceOff'] or geometry is not None and geometry.learning == 'force_off'
def number(choice, source, suppressed=False):
return choice.custom_value if choice.mode == 'custom' and not suppressed else source
from openpilot.starpilot.lateral.torque_supported import BOLT_VEHICLES
gain_suppressed = cap['fingerprint'] in BOLT_VEHICLES and not context['advancedEnabled']
ratio, delay = context['geometryBasis']
surface = GmSurface.from_document(context['surface'])
family = 'standard' if cap['controller'] == 'standard' else 'gm'
return {'AdvancedLateralTune': True, 'ForceAutoTune': force_auto, 'ForceAutoTuneOff': force_off,
'UseAutoSteerDelay': geometry is None or geometry.automatic_delay,
'SteerLatAccel': number(profile.factor, cap['basis'][0], force_auto),
'SteerFriction': number(profile.friction, cap['basis'][2], force_auto),
'SteerKP': number(profile.proportional_gain, context['gainTable'][1][-1], gain_suppressed),
'SteerRatio': ratio if geometry is None else number(geometry.ratio, ratio, force_auto),
'SteerDelay': delay if geometry is None else number(geometry.full_delay, delay),
'FLMActiveOverrides': {'schemaVersion': 1, 'baseFrictionThresholds': {} if surface.base_values is None else
{family: {'speedKnots': list(FLM_FRICTION_SPEED_KNOTS), 'values': list(surface.base_values)}},
'vehicleKnobs': {f'{surface.profile}.{key}': value for key, value in surface.knobs.items()}}}
def recommendation_capabilities(context):
cap = context['capability']
return {'torqueControl': True, 'richProfileKey': cap['profile'],
'frictionFamily': 'standard' if cap['controller'] == 'standard' else 'gm',
'nonlinearTorqueMap': {} if context['factorSupported'] else {'type': 'fixed_source_conversion', 'asymmetric': False}}
def canonical_trial(context, generated):
"""Translate original numerical intent to one exact-vehicle document, never global writes."""
from dataclasses import replace
from openpilot.starpilot.flm.manual_trial import manual_profile, manual_snapshot
from openpilot.starpilot.lateral.torque_settings import FieldChoice, GainBasis, GeometryProfile
cap = context['capability']
profile = manual_profile(context['manual'], cap['fingerprint'], tuple(cap['basis']))
delta = generated['genericParams']
known = {'AdvancedLateralTune', 'ForceAutoTune', 'ForceAutoTuneOff', 'UseAutoSteerDelay',
'SteerDelay', 'SteerFriction', 'SteerKP', 'SteerLatAccel', 'SteerRatio'}
if set(delta) - known:
raise ValueError('Unsupported original manual choice')
if 'SteerLatAccel' in delta:
if not context['factorSupported']:
raise ValueError('The selected fixed conversion does not consume a custom factor')
profile = replace(profile, factor=FieldChoice('custom', delta['SteerLatAccel']))
if 'SteerFriction' in delta:
profile = replace(profile, friction=FieldChoice('custom', delta['SteerFriction']))
if 'SteerKP' in delta:
speed_row, gain_row = context['gainTable']
basis = GainBasis(cap['controller'], (tuple(speed_row), tuple(gain_row)), tuple(cap['basis']))
profile = replace(profile, proportional_gain=FieldChoice('custom', delta['SteerKP']), gain_basis=basis)
geometry = profile.geometry or GeometryProfile(tuple(context['geometryBasis']))
if any(key in delta for key in ('SteerRatio', 'SteerDelay', 'UseAutoSteerDelay', 'ForceAutoTune', 'ForceAutoTuneOff')):
learning = geometry.learning
if delta.get('ForceAutoTuneOff') is True:
learning = 'force_off'
elif delta.get('ForceAutoTune') is True:
learning = 'force_auto'
elif 'ForceAutoTune' in delta or 'ForceAutoTuneOff' in delta:
learning = 'source'
geometry = replace(geometry,
ratio=FieldChoice('custom', delta['SteerRatio']) if 'SteerRatio' in delta else geometry.ratio,
full_delay=FieldChoice('custom', delta['SteerDelay']) if 'SteerDelay' in delta else geometry.full_delay,
automatic_delay=delta.get('UseAutoSteerDelay', geometry.automatic_delay), learning=learning)
profile = replace(profile, geometry=geometry)
manual = manual_snapshot(profile, cap['fingerprint'])
# Reparse enforces the existing exact selected basis and manual numeric bounds.
manual_profile(manual, cap['fingerprint'], tuple(cap['basis']))
raw = generated['flmOverrides']
if not raw:
raw = {'schemaVersion': 1, 'baseFrictionThresholds': {}, 'vehicleKnobs': {}}
raw = normalize_flm_overrides(raw)
source = GmSurface.from_document(context['surface'])
prefix = cap['profile'] + '.'
if any(not key.startswith(prefix) for key in raw['vehicleKnobs']):
raise ValueError('Wrong selected surface profile')
knobs = dict(source.knobs)
knobs.update({key[len(prefix):]: value for key, value in raw['vehicleKnobs'].items()})
curves = raw['baseFrictionThresholds']
family = 'standard' if cap['controller'] == 'standard' else 'gm'
if set(curves) - {family}:
raise ValueError('Wrong selected friction family')
surface = GmSurface(cap['profile'], knobs, tuple(curves[family]['values']) if family in curves else source.base_values)
return {'manual': manual, 'surface': surface.document()}
def build_report(context, summaries, stats, *, feedback=None):
from openpilot.starpilot.flm.gm_evidence import build_recommendation_paths
context = validate_context(context)
feedback = {} if feedback is None else feedback
allowed = {row['dimensionId'] for row in summaries}
if type(feedback) is not dict or set(feedback) - {'acceptedDimensions', 'ignoredDimensions'}:
raise ValueError('Malformed evidence feedback')
for choices in feedback.values():
if type(choices) is not list or len(choices) > 128 or any(type(key) is not str or key not in allowed for key in choices):
raise ValueError('Unknown evidence dimension')
identity = hashlib.sha256(json.dumps({'source': context['sourceToken'], 'summaries': summaries, 'stats': stats},
sort_keys=True, allow_nan=False).encode()).hexdigest()[:16]
paths, decision = build_recommendation_paths(identity, summaries, stats, recommendation_capabilities(context),
current_values(context), feedback, cleanup_progress_locked=context['cleanupProgress'])
for path in paths:
for generated in path['profiles']:
try:
generated['canonical'] = canonical_trial(context, generated)
generated['unavailableReason'] = None
except (ValueError, TypeError, OverflowError) as error:
generated['canonical'] = None
generated['unavailableReason'] = str(error)
return {'context': context, 'summaries': summaries, 'stats': stats, 'feedback': feedback,
'paths': paths, 'decision': decision, 'fit': False, 'vehicleQualification': False}
def classify_groups(groups):
"""Original classifier runs separately on each real contiguous joined epoch."""
from openpilot.starpilot.flm.gm_evidence import _build_event_summaries
summaries = []
stats = {'sampleCount': 0, 'excludedDriverOverrideSamples': 0, 'qlogFallback': False,
'meanDesiredAbs': 0., 'meanErrorAbs': 0., 'leftBias': 0., 'rightBias': 0.,
'highwayStraightAngleP2P': 0., 'meanOutputAbs': 0.}
for group in groups:
rows, measured = _build_event_summaries(group)
count = measured['sampleCount']
if not count:
continue
summaries.extend(rows)
stats['sampleCount'] += count
stats['excludedDriverOverrideSamples'] += measured['excludedDriverOverrideSamples']
for key in ('meanDesiredAbs', 'meanErrorAbs', 'leftBias', 'rightBias', 'meanOutputAbs'):
stats[key] += measured[key] * count
stats['highwayStraightAngleP2P'] = max(stats['highwayStraightAngleP2P'], measured['highwayStraightAngleP2P'])
if stats['sampleCount']:
for key in ('meanDesiredAbs', 'meanErrorAbs', 'leftBias', 'rightBias', 'meanOutputAbs'):
stats[key] /= stats['sampleCount']
stats['continuousGroups'] = len(groups)
stats['summariesTruncated'] = len(summaries) > 128
return sorted(summaries, key=lambda row: row['severity'], reverse=True)[:128], stats
def same_numerical_context(current, recorded):
# Saving another inactive preset or recording monotonic cleanup progress
# changes the document token, not its selected controller/manual/surface law.
# Current exact token still guards the eventual atomic write. Every numerical,
# CP, controller and explicit winning-global guard remains identical.
if recorded['cleanupProgress'] and not current['cleanupProgress']:
return False
return {key: value for key, value in current.items() if key not in ('sourceToken', 'cleanupProgress')} == {
key: value for key, value in recorded.items() if key not in ('sourceToken', 'cleanupProgress')}