This commit is contained in:
firestar5683
2026-07-11 12:19:35 -05:00
parent e47a836d2d
commit 9dbf5db9ec
5 changed files with 224 additions and 11 deletions
@@ -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"
@@ -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`
<div class="ftmCard">
<div class="ftmCardHeader">
<div>
<h4>${path.title}</h4>
<p class="longManeuverMuted">${path.isPrimary ? "Recommended path" : "Alternate path"}</p>
<p class="longManeuverMuted">
${path.isPrimary ? "Analyzer recommended" : "Alternate path"}${selected ? " / Active" : ""}
</p>
</div>
<button
class="longManeuverButton"
disabled="${() => state.runningAction || selected}"
@click="${() => selectPath(path.key)}">
${selected ? "Active Path" : `Use ${path.title}`}
</button>
</div>
<p>${path.description || ""}</p>
<p><strong>Why this path:</strong> ${path.whySelected || "No path note available."}</p>
@@ -758,7 +792,11 @@ export function Tuning() {
<p><strong>Car:</strong> ${state.report.car?.carFingerprint || "Unknown"}</p>
<p><strong>Control Path:</strong> ${state.report.car?.controlPath || "unknown"}</p>
<p><strong>Friction Family:</strong> ${state.report.capabilities?.frictionFamily || "standard"}</p>
<p><strong>Recommended Path:</strong> ${primaryPath()?.title || "Recommendations"}</p>
<p><strong>Analyzer Recommended:</strong> ${reportPaths().find((path) => path.isPrimary)?.title || "Recommendations"}</p>
<p><strong>Active Path:</strong> ${primaryPath()?.title || "Recommendations"}</p>
<p><strong>Path Choice:</strong> ${state.report.pathSelectionSource === "manual" ? "Manual override" : "Automatic"}</p>
<p><strong>Nonlinear Torque Map:</strong> ${state.report.capabilities?.nonlinearTorqueMap?.asymmetric ? "Asymmetric left/right siglin" : (state.report.capabilities?.nonlinearTorqueMap ? "Symmetric siglin" : "Not detected")}</p>
<p><strong>Live Learner Refits Map:</strong> ${state.report.capabilities?.nonlinearTorqueMap ? "No" : "Not applicable"}</p>
<p><strong>Processed Segments:</strong> ${safeCount(state.report.summary?.processedSegments)}</p>
<p><strong>qlog Fallback:</strong> ${state.report.summary?.usedQlogFallback ? "Yes" : "No"}</p>
<p><strong>Samples:</strong> ${safeCount(state.report.summary?.sampleCount)}</p>
@@ -790,7 +828,7 @@ export function Tuning() {
<section class="ftmCard">
<div class="ftmCardHeader">
<div>
<h3>Recommended Findings: ${primaryPath()?.title || "Recommendations"}</h3>
<h3>Active Findings: ${primaryPath()?.title || "Recommendations"}</h3>
<p class="longManeuverMuted">
${primaryPath()?.whySelected || "Mark the dimensions that match what the driver felt."}
</p>
+92 -5
View File
@@ -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(
"<section class='ftm-card'>"
f"<h3>{path.get('title', 'Path')}</h3>"
@@ -1566,10 +1621,11 @@ def _render_report_html(report: dict[str, Any]) -> str:
f"<p>{report['car']['carFingerprint']} | {report['car'].get('gitBranch', '')} {report['car'].get('gitCommit', '')}</p>"
f"<div class='ftm-grid'><section class='ftm-card'><h3>Routes</h3><p>{', '.join(report.get('routeNames', []))}</p></section>"
f"<section class='ftm-card'><h3>Control Path</h3><p>{report['car'].get('controlPath', 'unknown')}</p></section>"
f"<section class='ftm-card'><h3>Friction Family</h3><p>{report['capabilities'].get('frictionFamily', 'standard')}</p></section></div>"
f"<section class='ftm-card'><h3>Friction Family</h3><p>{report['capabilities'].get('frictionFamily', 'standard')}</p></section>"
f"<section class='ftm-card'><h3>Nonlinear Torque Map</h3><p>{'Asymmetric left/right siglin' if report['capabilities'].get('nonlinearTorqueMap', {}).get('asymmetric') else ('Symmetric siglin' if report['capabilities'].get('nonlinearTorqueMap') else 'Not detected')}</p></section></div>"
f"{''.join(path_html)}"
f"{start_here_html}"
f"<h2>Recommended Findings: {primary_path.get('title', 'Recommendations')}</h2>"
f"<h2>Active Findings: {primary_path.get('title', 'Recommendations')}</h2>"
f"{findings_block}"
"<h2>Trial Profiles</h2>"
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),
}
@@ -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]
+14
View File
@@ -5702,6 +5702,20 @@ def setup(app):
except RuntimeError as error:
return jsonify({"error": str(error)}), 409
@app.route("/api/ftm/report/<report_id>/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