diff --git a/starpilot/system/the_galaxy/assets/components/router.js b/starpilot/system/the_galaxy/assets/components/router.js index 978a2c976..33e7f8a54 100644 --- a/starpilot/system/the_galaxy/assets/components/router.js +++ b/starpilot/system/the_galaxy/assets/components/router.js @@ -19,7 +19,7 @@ import { ModelManager } from "/assets/components/tools/model_manager.js?v=202603 import { LivePlots } from "/assets/components/tools/plots.js" import { ThemeMaker } from "/assets/components/tools/theme_maker.js" import { TestingGround } from "/assets/components/tools/testing_ground.js" -import { Tuning } from "/assets/components/tools/tuning.js?v=ftm-workspace-4" +import { Tuning } from "/assets/components/tools/tuning.js?v=ftm-workspace-5" import { Troubleshoot } from "/assets/components/tools/troubleshoot.js" import { TmuxLog } from "/assets/components/tools/tmux.js" import { ToggleControl } from "/assets/components/tools/toggles.js" diff --git a/starpilot/system/the_galaxy/assets/components/tools/tuning.js b/starpilot/system/the_galaxy/assets/components/tools/tuning.js index 745f15652..433920327 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/tuning.js +++ b/starpilot/system/the_galaxy/assets/components/tools/tuning.js @@ -378,6 +378,30 @@ async function applyProfile(profileId) { } } +async function selectPath(pathKey) { + if (!state.report?.reportId || !pathKey || state.runningAction) return + if (pathKey === (state.report.selectedPathKey || state.report.primaryPathKey)) return + + state.runningAction = true + try { + const response = await fetch(`/api/ftm/report/${encodeURIComponent(state.report.reportId)}/path`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pathKey }), + }) + const payload = await response.json() + if (!response.ok) throw new Error(payload.error || "Failed to select tuning path.") + state.report = payload.report + syncFeedbackState(state.report) + showSnackbar(payload.message || "Tuning path selected.") + } catch (error) { + state.error = error?.message || "Failed to select tuning path." + showSnackbar(state.error, "error") + } finally { + state.runningAction = false + } +} + async function revertProfile() { if (state.runningAction) return state.runningAction = true @@ -422,7 +446,7 @@ async function saveFeedback() { }) const payload = await response.json() if (!response.ok) throw new Error(payload.error || "Failed to save tuning feedback.") - state.report = { + state.report = payload.report || { ...state.report, feedback: payload.feedback, profiles: payload.profiles || state.report.profiles, @@ -480,7 +504,8 @@ function reportPaths() { function primaryPath() { const paths = reportPaths() - return paths.find((path) => path.isPrimary) || paths[0] || null + const selectedPathKey = state.report?.selectedPathKey || state.report?.primaryPathKey + return paths.find((path) => path.key === selectedPathKey) || paths.find((path) => path.isPrimary) || paths[0] || null } function renderProfile(profile) { @@ -586,13 +611,22 @@ function renderSuggestion(suggestion) { } function renderPathSummary(path) { + const selected = path.key === (state.report?.selectedPathKey || state.report?.primaryPathKey) return html`

${path.title}

-

${path.isPrimary ? "Recommended path" : "Alternate path"}

+

+ ${path.isPrimary ? "Analyzer recommended" : "Alternate path"}${selected ? " / Active" : ""} +

+

${path.description || ""}

Why this path: ${path.whySelected || "No path note available."}

@@ -758,7 +792,11 @@ export function Tuning() {

Car: ${state.report.car?.carFingerprint || "Unknown"}

Control Path: ${state.report.car?.controlPath || "unknown"}

Friction Family: ${state.report.capabilities?.frictionFamily || "standard"}

-

Recommended Path: ${primaryPath()?.title || "Recommendations"}

+

Analyzer Recommended: ${reportPaths().find((path) => path.isPrimary)?.title || "Recommendations"}

+

Active Path: ${primaryPath()?.title || "Recommendations"}

+

Path Choice: ${state.report.pathSelectionSource === "manual" ? "Manual override" : "Automatic"}

+

Nonlinear Torque Map: ${state.report.capabilities?.nonlinearTorqueMap?.asymmetric ? "Asymmetric left/right siglin" : (state.report.capabilities?.nonlinearTorqueMap ? "Symmetric siglin" : "Not detected")}

+

Live Learner Refits Map: ${state.report.capabilities?.nonlinearTorqueMap ? "No" : "Not applicable"}

Processed Segments: ${safeCount(state.report.summary?.processedSegments)}

qlog Fallback: ${state.report.summary?.usedQlogFallback ? "Yes" : "No"}

Samples: ${safeCount(state.report.summary?.sampleCount)}

@@ -790,7 +828,7 @@ export function Tuning() {
-

Recommended Findings: ${primaryPath()?.title || "Recommendations"}

+

Active Findings: ${primaryPath()?.title || "Recommendations"}

${primaryPath()?.whySelected || "Mark the dimensions that match what the driver felt."}

diff --git a/starpilot/system/the_galaxy/ftm_workspace.py b/starpilot/system/the_galaxy/ftm_workspace.py index 2dc20351a..7e9a5c2d5 100644 --- a/starpilot/system/the_galaxy/ftm_workspace.py +++ b/starpilot/system/the_galaxy/ftm_workspace.py @@ -581,6 +581,38 @@ def _current_param_state(CP, params: Params) -> dict[str, Any]: } +def _nonlinear_torque_map(CP) -> dict[str, Any]: + if str(getattr(CP, "brand", "") or "") != "gm": + return {} + + try: + from opendbc.car.gm.interface import NON_LINEAR_TORQUE_PARAMS + except (ImportError, AttributeError): + return {} + + raw_params = NON_LINEAR_TORQUE_PARAMS.get(CP.carFingerprint) + if raw_params is None: + return {} + + if isinstance(raw_params, dict): + left = [float(value) for value in raw_params.get("left", [])] + right = [float(value) for value in raw_params.get("right", [])] + else: + left = [float(value) for value in raw_params] + right = list(left) + + if len(left) != 4 or len(right) != 4: + return {} + + return { + "type": "siglin", + "left": left, + "right": right, + "asymmetric": any(not math.isclose(left[idx], right[idx], abs_tol=1e-9) for idx in range(4)), + "learnedByLiveTorque": False, + } + + def _baseline_family_curve(family: str) -> list[float]: getter = { "gm": get_gm_base_friction_threshold, @@ -855,6 +887,9 @@ def _primary_delta_from_summary(summary: dict[str, Any], capabilities: dict[str, supports_curvy_speed_max = _rich_profile_supports_knob(capabilities, "curvy_speed_max") supports_curvy_unwind_extra = _rich_profile_supports_knob(capabilities, f"curvy_unwind_extra_reduction_{side}") supports_curvy_unwind_floor = _rich_profile_supports_knob(capabilities, f"curvy_unwind_floor_relief_{side}") + supports_ff_gain = _rich_profile_supports_knob(capabilities, f"ff_gain_{side}") + nonlinear_map = capabilities.get("nonlinearTorqueMap", {}) + asymmetric_nonlinear_map = bool(isinstance(nonlinear_map, dict) and nonlinear_map.get("asymmetric")) if bucket == "model_limited": return None @@ -889,12 +924,20 @@ def _primary_delta_from_summary(summary: dict[str, Any], capabilities: dict[str, } if bucket in ("understeer", "late_turn_in", "saturation_limited"): + if asymmetric_nonlinear_map and direction in ("left", "right") and supports_ff_gain: + adjustment = _vehicle_knob_adjustment(f"{rich_profile}.ff_gain_{side}", 0.025 * severity, current) + if adjustment is not None: + return adjustment current_value = float(current["SteerLatAccel"]) scale = 0.04 if bucket == "saturation_limited" else 0.03 suggested_value = round(_clamp(current_value + max(scale, current_value * scale * severity), 0.5, 5.0), 4) return {"type": "generic_param", "paramKey": "SteerLatAccel", "current": current_value, "suggested": suggested_value, "delta": round(suggested_value - current_value, 4)} if bucket in ("oversteer", "early_turn_in"): + if asymmetric_nonlinear_map and direction in ("left", "right") and supports_ff_gain: + adjustment = _vehicle_knob_adjustment(f"{rich_profile}.ff_gain_{side}", -0.025 * severity, current) + if adjustment is not None: + return adjustment current_value = float(current["SteerLatAccel"]) suggested_value = round(_clamp(current_value - max(0.03, current_value * 0.03 * severity), 0.5, 5.0), 4) return {"type": "generic_param", "paramKey": "SteerLatAccel", "current": current_value, "suggested": suggested_value, "delta": round(suggested_value - current_value, 4)} @@ -1039,6 +1082,8 @@ def _likely_interpretation(summary: dict[str, Any], adjustment: dict[str, Any]) return "This looks more like a friction-threshold problem than a whole-tune problem; the controller is busy around center and needs a calmer deadzone slope." if adjustment["type"] == "vehicle_knob": symbol = adjustment["symbol"] + if "ff_gain_" in symbol: + return "This car has a directional nonlinear torque map, and the mismatch is concentrated on one side. Correct that side's feedforward layer before moving global authority." if "low_speed_angle_assist_max_torque" in symbol: return "The main torque path is waking up too late below about 8 mph, so the low-speed assist layer needs a little more authority." if "crawl_turn_in_ff_boost" in symbol: @@ -1067,6 +1112,8 @@ def _why_this_knob(adjustment: dict[str, Any]) -> str: return "This changes the threshold that maps small lateral-accel error into friction compensation without pretending the whole torque slope is wrong." if adjustment["type"] == "vehicle_knob": symbol = adjustment["symbol"] + if "ff_gain_" in symbol: + return "This compensates the affected side without flattening the car's separate left/right nonlinear torque response into one global value." if "low_speed_angle_assist_max_torque" in symbol: return "This directly raises the crawl-speed assist ceiling that fills the gap before the normal torque path wakes up." if "crawl_turn_in_ff_boost" in symbol: @@ -1474,7 +1521,10 @@ def build_recommendation_paths(report_id: str, summaries: list[dict[str, Any]], def _render_report_html(report: dict[str, Any]) -> str: report_paths = [path for path in report.get("paths", []) if isinstance(path, dict)] - primary_path = next((path for path in report_paths if path.get("isPrimary")), report_paths[0] if report_paths else {}) + selected_path_key = str(report.get("selectedPathKey") or report.get("primaryPathKey") or "") + primary_path = next((path for path in report_paths if path.get("key") == selected_path_key), None) + if primary_path is None: + primary_path = next((path for path in report_paths if path.get("isPrimary")), report_paths[0] if report_paths else {}) findings_html = [] for suggestion in report.get("suggestions", []): evidence = suggestion.get("evidence", {}) @@ -1508,7 +1558,12 @@ def _render_report_html(report: dict[str, Any]) -> str: path_html = [] for path in report_paths: - badge = "Recommended" if path.get("isPrimary") else "Alternate" + badges = [] + if path.get("isPrimary"): + badges.append("Analyzer recommended") + if path.get("key") == selected_path_key: + badges.append("Active") + badge = " / ".join(badges) or "Alternate" path_html.append( "
" f"

{path.get('title', 'Path')}

" @@ -1566,10 +1621,11 @@ def _render_report_html(report: dict[str, Any]) -> str: f"

{report['car']['carFingerprint']} | {report['car'].get('gitBranch', '')} {report['car'].get('gitCommit', '')}

" f"

Routes

{', '.join(report.get('routeNames', []))}

" f"

Control Path

{report['car'].get('controlPath', 'unknown')}

" - f"

Friction Family

{report['capabilities'].get('frictionFamily', 'standard')}

" + f"

Friction Family

{report['capabilities'].get('frictionFamily', 'standard')}

" + f"

Nonlinear Torque Map

{'Asymmetric left/right siglin' if report['capabilities'].get('nonlinearTorqueMap', {}).get('asymmetric') else ('Symmetric siglin' if report['capabilities'].get('nonlinearTorqueMap') else 'Not detected')}

" f"{''.join(path_html)}" f"{start_here_html}" - f"

Recommended Findings: {primary_path.get('title', 'Recommendations')}

" + f"

Active Findings: {primary_path.get('title', 'Recommendations')}

" f"{findings_block}" "

Trial Profiles

" f"{profiles_block}" @@ -1624,6 +1680,8 @@ def analyze_routes(route_names: list[str], footage_paths: list[str], feedback: d hyundai_canfd=hyundai_canfd, torque_control=torque_control, ) + capabilities = dict(capabilities) + capabilities["nonlinearTorqueMap"] = _nonlinear_torque_map(car_params) current_params = _current_param_state(car_params, params) if torque_control: @@ -1698,6 +1756,8 @@ def analyze_routes(route_names: list[str], footage_paths: list[str], feedback: d "usedQlogFallback": used_qlog, }, "primaryPathKey": path_decision["primaryPathKey"], + "selectedPathKey": path_decision["primaryPathKey"], + "pathSelectionSource": "auto", "pathDecision": path_decision, "paths": paths_payload, "findings": summaries, @@ -1737,6 +1797,31 @@ def load_report(report_id: str) -> dict[str, Any]: return report +def select_report_path(report_id: str, path_key: str) -> dict[str, Any]: + paths = ensure_ftm_workspace() + report = load_report(report_id) + report_paths = [path for path in report.get("paths", []) if isinstance(path, dict)] + selected_path = next((path for path in report_paths if path.get("key") == path_key), None) + if selected_path is None: + raise ValueError(f"Unknown FTM path: {path_key}") + + report["selectedPathKey"] = path_key + report["pathSelectionSource"] = "manual" + report["suggestions"] = list(selected_path.get("suggestions", [])) + report["addTheseParametersAndStartHere"] = _add_parameters_start_here( + report.get("capabilities", {}), + report["suggestions"], + path_key, + ) + report.pop("html", None) + (paths["reports"] / f"{report_id}.html").write_text(_render_report_html(report), encoding="utf-8") + _write_json(paths["reports"] / f"{report_id}.json", report) + return { + "message": f"Using {selected_path.get('title', path_key)} for this report.", + "report": load_report(report_id), + } + + def list_workspace() -> dict[str, Any]: paths = ensure_ftm_workspace() reports = [] @@ -1910,6 +1995,7 @@ def record_feedback(report_id: str, feedback: dict[str, Any]) -> dict[str, Any]: report = load_report(report_id) report["feedback"] = normalized if isinstance(report.get("paths"), list) and report.get("paths"): + selected_path_key = str(report.get("selectedPathKey") or report.get("primaryPathKey") or "") flattened_profiles = [] for path in report["paths"]: if not isinstance(path, dict): @@ -1924,7 +2010,7 @@ def record_feedback(report_id: str, feedback: dict[str, Any]) -> dict[str, Any]: ) path["profiles"] = profiles flattened_profiles.extend(profiles) - if path.get("isPrimary"): + if path.get("key") == selected_path_key: report["suggestions"] = list(path.get("suggestions", [])) report["profiles"] = flattened_profiles else: @@ -1937,6 +2023,7 @@ def record_feedback(report_id: str, feedback: dict[str, Any]) -> dict[str, Any]: "message": "Saved FTM feedback.", "feedback": normalized, "profiles": report["profiles"], + "report": load_report(report_id), } diff --git a/starpilot/system/the_galaxy/tests/test_ftm_workspace.py b/starpilot/system/the_galaxy/tests/test_ftm_workspace.py index 2182404fb..22c5145f5 100644 --- a/starpilot/system/the_galaxy/tests/test_ftm_workspace.py +++ b/starpilot/system/the_galaxy/tests/test_ftm_workspace.py @@ -105,6 +105,8 @@ def _install_ftm_import_stubs(tmp_path): get_ftm_capabilities=lambda *args, **kwargs: {"richProfileKey": "hyundai_ioniq_6", "frictionFamily": "hkg_canfd"}, get_ftm_rich_profile_key=lambda *args, **kwargs: "hyundai_ioniq_6", get_ftm_supported_vehicle_knobs=lambda: { + "hyundai_ioniq_6.ff_gain_left": {"min": 0.0, "max": 0.6, "precision": 0.001, "defaultValue": 0.1, "profile": "hyundai_ioniq_6"}, + "hyundai_ioniq_6.ff_gain_right": {"min": 0.0, "max": 0.6, "precision": 0.001, "defaultValue": 0.12, "profile": "hyundai_ioniq_6"}, "hyundai_ioniq_6.turn_in_boost_left": {"min": 0.4, "max": 2.8, "precision": 0.001, "defaultValue": 1.64, "profile": "hyundai_ioniq_6"}, "hyundai_ioniq_6.unwind_taper_left": {"min": 0.0, "max": 1.2, "precision": 0.001, "defaultValue": 0.4, "profile": "hyundai_ioniq_6"}, "hyundai_ioniq_6.low_speed_angle_assist_max_torque": {"min": 0.0, "max": 0.8, "precision": 0.001, "defaultValue": 0.46, "profile": "hyundai_ioniq_6"}, @@ -231,6 +233,36 @@ def test_build_suggestions_baseline_prefers_generic_lat_accel_for_understeer(tmp assert adjustment["suggested"] > adjustment["current"] +def test_build_suggestions_baseline_respects_asymmetric_nonlinear_map(tmp_path): + module, _ = _load_ftm_workspace_module(tmp_path) + summary = { + "bucket": "understeer", + "dimensionId": "understeer:right:mid", + "direction": "right", + "speedBand": "mid", + "severity": 1.0, + "evidence": {"speedBand": "mid", "directionBias": "right", "eventCount": 3, "segments": [{"label": "route/2"}]}, + "plotSvg": "", + } + capabilities = { + "richProfileKey": "hyundai_ioniq_6", + "frictionFamily": "gm", + "nonlinearTorqueMap": { + "type": "siglin", + "left": [2.6, 1.1, 0.19, 0.0], + "right": [2.7, 1.0, 0.15, 0.0], + "asymmetric": True, + }, + } + current = {"SteerLatAccel": 1.8, "SteerFriction": 0.2} + + suggestions = module.build_suggestions([summary], capabilities, current, strategy="baseline") + adjustment = suggestions[0]["primaryAdjustmentRaw"] + assert adjustment["type"] == "vehicle_knob" + assert adjustment["symbol"] == "hyundai_ioniq_6.ff_gain_right" + assert adjustment["suggested"] > adjustment["current"] + + def test_build_suggestions_rebases_rich_knob_against_active_override(tmp_path): module, _ = _load_ftm_workspace_module(tmp_path) summary = { @@ -481,3 +513,45 @@ def test_delete_report_removes_saved_artifacts(tmp_path): assert not (workspace["profiles"] / f"{report_id}.json").exists() assert not (workspace["feedback"] / f"{report_id}.json").exists() assert not (workspace["snapshots"] / f"{report_id}-recommended.json").exists() + + +def test_select_report_path_persists_manual_override(tmp_path): + module, _ = _load_ftm_workspace_module(tmp_path) + workspace = module.ensure_ftm_workspace() + report_id = "report-path" + suggestion_base = { + "evidence": {"speedBand": "mixed", "directionBias": "center", "eventCount": 0, "segments": []}, + "currentVsSuggested": None, + "observedBehavior": "test", + "likelyInterpretation": "test", + "primaryAdjustment": "test", + "whatNotToTouchYet": "test", + "ifThatWasWrong": "test", + "plotSvg": "", + } + cleanup_suggestion = {**suggestion_base, "dimensionId": "cleanup", "bucket": "model_limited"} + baseline_suggestion = {**suggestion_base, "dimensionId": "baseline", "bucket": "understeer"} + report = { + "reportId": report_id, + "routeNames": ["route"], + "car": {"carFingerprint": "TEST", "controlPath": "torque", "gitBranch": "", "gitCommit": ""}, + "capabilities": {"frictionFamily": "standard", "richProfileKey": "hyundai_ioniq_6", "nonlinearTorqueMap": {}}, + "primaryPathKey": "cleanup_pass", + "selectedPathKey": "cleanup_pass", + "pathSelectionSource": "auto", + "paths": [ + {"key": "cleanup_pass", "title": "Cleanup Pass", "isPrimary": True, "suggestions": [cleanup_suggestion], "profiles": []}, + {"key": "baseline_fix", "title": "Baseline Fix", "isPrimary": False, "suggestions": [baseline_suggestion], "profiles": []}, + ], + "suggestions": [cleanup_suggestion], + "profiles": [], + "addTheseParametersAndStartHere": [], + } + (workspace["reports"] / f"{report_id}.json").write_text(json.dumps(report), encoding="utf-8") + + result = module.select_report_path(report_id, "baseline_fix") + selected = result["report"] + assert selected["selectedPathKey"] == "baseline_fix" + assert selected["pathSelectionSource"] == "manual" + assert selected["primaryPathKey"] == "cleanup_pass" + assert selected["suggestions"] == [baseline_suggestion] diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index c3557d7f4..295fc76ea 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -5702,6 +5702,20 @@ def setup(app): except RuntimeError as error: return jsonify({"error": str(error)}), 409 + @app.route("/api/ftm/report//path", methods=["POST"]) + def select_ftm_report_path(report_id): + data = request.get_json(silent=True) or {} + path_key = str(data.get("pathKey") or "").strip() + if not path_key: + return jsonify({"error": "pathKey is required."}), 400 + + try: + return jsonify(ftm_workspace.select_report_path(report_id, path_key)), 200 + except FileNotFoundError: + return jsonify({"error": "FTM report not found."}), 404 + except ValueError as error: + return jsonify({"error": str(error)}), 400 + @app.route("/api/ftm/workspace", methods=["GET"]) def get_ftm_workspace(): return jsonify(ftm_workspace.list_workspace()), 200