flm update

This commit is contained in:
firestar5683
2026-07-23 10:58:17 -05:00
parent d4a3bc0f5c
commit 66245a5a2e
6 changed files with 321 additions and 35 deletions
@@ -56,6 +56,36 @@
gap: var(--gap-sm);
}
.flmRouteSelection {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
}
.flmSegmentRange {
align-items: center;
color: var(--text-muted);
display: flex;
flex-wrap: wrap;
font-size: var(--font-size-xs);
gap: var(--gap-xs);
padding: 0 var(--padding-sm) var(--padding-xs);
}
.flmSegmentRange input {
background: var(--input-bg);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--border-radius-sm);
color: var(--text-color);
max-width: 5.5rem;
padding: var(--padding-xs);
}
.flmSegmentRange small {
color: var(--text-muted);
flex: 1 1 10rem;
}
.flmRouteItem,
.flmWorkspaceItem {
align-items: flex-start;
@@ -11,6 +11,7 @@ const state = reactive({
error: "",
routes: [],
selectedRoutes: [],
segmentRanges: {},
truncatedRoutes: false,
routeProgress: 0,
routeTotal: 0,
@@ -42,6 +43,15 @@ function formatTimestamp(value) {
return parsed.toLocaleString()
}
function formatReportSegmentRanges(report) {
const ranges = report?.segmentRanges || {}
const entries = Object.entries(ranges)
if (!entries.length) return "Whole selected routes"
return entries
.map(([route, range]) => `${route}: ${range?.start ?? "first"}-${range?.end ?? "last"}`)
.join(", ")
}
function safeCount(value) {
const n = Number(value)
return Number.isFinite(n) ? n : 0
@@ -326,8 +336,36 @@ function toggleRouteSelection(routeName) {
state.selectedRoutes = [...current]
}
function setSegmentRange(routeName, key, value) {
const cleaned = String(value ?? "").replace(/[^\d]/g, "")
state.segmentRanges = {
...state.segmentRanges,
[routeName]: {
...(state.segmentRanges[routeName] || {}),
[key]: cleaned,
},
}
}
function selectedSegmentRanges() {
const ranges = {}
for (const routeName of state.selectedRoutes) {
const selected = state.segmentRanges[routeName] || {}
const start = String(selected.start ?? "").trim()
const end = String(selected.end ?? "").trim()
if (start || end) {
ranges[routeName] = {
start: start || null,
end: end || null,
}
}
}
return ranges
}
function clearSelections() {
state.selectedRoutes = []
state.segmentRanges = {}
}
async function runAnalyze() {
@@ -337,7 +375,10 @@ async function runAnalyze() {
const response = await fetch("/api/flm/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ routes: state.selectedRoutes }),
body: JSON.stringify({
routes: state.selectedRoutes,
segmentRanges: selectedSegmentRanges(),
}),
})
const payload = await response.json()
if (!response.ok) throw new Error(payload.error || "Failed to start tuning analysis.")
@@ -1038,7 +1079,7 @@ export function Tuning() {
<div>
<h3>Local Routes</h3>
<p class="longManeuverMuted">
Pick up to 8 routes from the device. The analyzer prefers rlogs and falls back to qlogs when needed.
Pick up to 8 routes from the device. Optionally limit each route to a segment range. The analyzer prefers rlogs and falls back to qlogs when needed.
</p>
</div>
<button class="longManeuverButton" @click="${clearSelections}">Clear</button>
@@ -1050,23 +1091,46 @@ export function Tuning() {
<div class="flmRouteList">
${() => sortedRoutes().map((route) => html`
<div class="flmRouteRow">
<label class="flmRouteItem">
<input
type="checkbox"
checked="${() => state.selectedRoutes.includes(route.name)}"
@change="${() => toggleRouteSelection(route.name)}" />
<span>
<strong>${route.timestampLabel}</strong>
<small>${route.name}</small>
</span>
</label>
${() => connectRouteUrl(route.name) ? html`
<a
class="flmConnectLink"
href="${connectRouteUrl(route.name)}"
target="_blank"
rel="noopener noreferrer">Connect</a>
<div class="flmRouteSelection">
<div class="flmRouteRow">
<label class="flmRouteItem">
<input
type="checkbox"
checked="${() => state.selectedRoutes.includes(route.name)}"
@change="${() => toggleRouteSelection(route.name)}" />
<span>
<strong>${route.timestampLabel}</strong>
<small>${route.name}</small>
</span>
</label>
${() => connectRouteUrl(route.name) ? html`
<a
class="flmConnectLink"
href="${connectRouteUrl(route.name)}"
target="_blank"
rel="noopener noreferrer">Connect</a>
` : ""}
</div>
${() => state.selectedRoutes.includes(route.name) ? html`
<div class="flmSegmentRange">
<span>Segments</span>
<input
type="number"
min="0"
inputmode="numeric"
placeholder="First"
value="${() => state.segmentRanges[route.name]?.start || ""}"
@input="${event => setSegmentRange(route.name, "start", event.target.value)}" />
<span>to</span>
<input
type="number"
min="0"
inputmode="numeric"
placeholder="Last"
value="${() => state.segmentRanges[route.name]?.end || ""}"
@input="${event => setSegmentRange(route.name, "end", event.target.value)}" />
<small>Blank analyzes the whole route.</small>
</div>
` : ""}
</div>
`)}
@@ -1133,7 +1197,9 @@ export function Tuning() {
<p><strong>Path Choice:</strong> ${state.report.pathSelectionSource === "manual" ? "Manual override" : "Automatic"}</p>
<p><strong>Nonlinear Torque Map:</strong> ${state.report.capabilities?.nonlinearTorqueMap?.type === "siglin" ? (state.report.capabilities.nonlinearTorqueMap.asymmetric ? "Asymmetric left/right siglin" : "Symmetric siglin") : "Not detected"}</p>
<p><strong>Live Learner Refits Map:</strong> ${state.report.capabilities?.nonlinearTorqueMap?.type === "siglin" ? "No" : "Not applicable"}</p>
<p><strong>Segment Selection:</strong> ${formatReportSegmentRanges(state.report)}</p>
<p><strong>Processed Segments:</strong> ${safeCount(state.report.summary?.processedSegments)}</p>
<p><strong>Skipped Segments:</strong> ${safeCount(state.report.summary?.skippedSegments)}</p>
<p><strong>Driver-Override Samples Excluded:</strong> ${safeCount(state.report.summary?.excludedDriverOverrideSamples)}</p>
<p><strong>qlog Fallback:</strong> ${state.report.summary?.usedQlogFallback ? "Yes" : "No"}</p>
<p><strong>Samples:</strong> ${safeCount(state.report.summary?.sampleCount)}</p>
+128 -15
View File
@@ -46,12 +46,17 @@ FLM_ANALYZER_PROCESS = None
FLM_ANALYZER_LOCK = threading.Lock()
FLM_PROGRESS_FILENAME = "progress.json"
FLM_ONROAD_POLL_INTERVAL_SECONDS = 0.25
FLM_SEGMENT_TIMEOUT_SECONDS = 180.0
class FLMAnalysisCancelled(RuntimeError):
pass
class FLMSegmentTimeout(RuntimeError):
pass
TRIAL_PARAM_SPECS = {
"AdvancedLateralTune": "bool",
"ForceAutoTune": "bool",
@@ -133,7 +138,7 @@ FLM_DRIVER_OVERRIDE_PRE_BUFFER_S = 0.35
FLM_DRIVER_OVERRIDE_POST_BUFFER_S = 1.0
@dataclass
@dataclass(slots=True)
class RouteSource:
route: str
footage_path: str
@@ -143,7 +148,7 @@ class RouteSource:
used_qlog: bool
@dataclass
@dataclass(slots=True)
class FLMSample:
route: str
segment: int
@@ -513,12 +518,42 @@ def cancel_flm_if_onroad() -> bool:
return stop_flm_background_analysis(reason="onroad")
def start_flm_background_analysis(route_names: list[str], footage_paths: list[str]) -> bool:
def _optional_segment_number(value: Any) -> int | None:
if value is None or str(value).strip() == "":
return None
number = int(value)
if number < 0:
raise ValueError("Segment numbers cannot be negative.")
return number
def normalize_segment_ranges(route_names: list[str], segment_ranges: Any) -> dict[str, dict[str, int | None]]:
if not isinstance(segment_ranges, dict):
return {}
normalized: dict[str, dict[str, int | None]] = {}
valid_routes = set(route_names)
for route, raw_range in segment_ranges.items():
route_name = str(route).strip()
if route_name not in valid_routes or not isinstance(raw_range, dict):
continue
start = _optional_segment_number(raw_range.get("start"))
end = _optional_segment_number(raw_range.get("end"))
if start is not None and end is not None and start > end:
raise ValueError(f"{route_name}: the first segment cannot be after the last segment.")
if start is not None or end is not None:
normalized[route_name] = {"start": start, "end": end}
return normalized
def start_flm_background_analysis(route_names: list[str], footage_paths: list[str],
segment_ranges: dict[str, dict[str, int | None]] | None = None) -> bool:
global FLM_ANALYZER_PROCESS
route_names = [str(route) for route in route_names if str(route).strip()]
if not route_names:
return False
segment_ranges = normalize_segment_ranges(route_names, segment_ranges)
try:
_require_flm_offroad()
except FLMAnalysisCancelled:
@@ -541,6 +576,11 @@ def start_flm_background_analysis(route_names: list[str], footage_paths: list[st
json.dumps({
"routes": route_names[:FLM_ANALYZER_ROUTE_LIMIT],
"footagePaths": [str(path) for path in footage_paths],
"segmentRanges": {
route: segment_range
for route, segment_range in segment_ranges.items()
if route in route_names[:FLM_ANALYZER_ROUTE_LIMIT]
},
}),
]
log_file = None
@@ -561,6 +601,7 @@ def start_flm_background_analysis(route_names: list[str], footage_paths: list[st
"running": True,
"state": "queued",
"routes": route_names[:FLM_ANALYZER_ROUTE_LIMIT],
"segmentRanges": segment_ranges,
"progress": 0,
"total": len(route_names[:FLM_ANALYZER_ROUTE_LIMIT]),
})
@@ -584,16 +625,26 @@ def _parse_segment_num(segment_name: str) -> int:
return 0
def resolve_route_sources(route_names: list[str], footage_paths: list[str]) -> tuple[list[RouteSource], list[str]]:
def resolve_route_sources(route_names: list[str], footage_paths: list[str],
segment_ranges: dict[str, dict[str, int | None]] | None = None) -> tuple[list[RouteSource], list[str]]:
sources: list[RouteSource] = []
warnings: list[str] = []
segment_ranges = normalize_segment_ranges(route_names, segment_ranges)
for route in route_names:
route_added = False
segment_range = segment_ranges.get(route, {})
segment_start = segment_range.get("start")
segment_end = segment_range.get("end")
for footage_path in footage_paths:
segments = utilities.get_segments_in_route(route, footage_path)
if not segments:
continue
for segment in segments:
segment_num = _parse_segment_num(segment)
if segment_start is not None and segment_num < segment_start:
continue
if segment_end is not None and segment_num > segment_end:
continue
segment_path = Path(footage_path) / segment
rlog_path = None
qlog_path = None
@@ -616,7 +667,7 @@ def resolve_route_sources(route_names: list[str], footage_paths: list[str]) -> t
route=route,
footage_path=str(footage_path),
segment=segment,
segment_num=_parse_segment_num(segment),
segment_num=segment_num,
log_path=str(log_path),
used_qlog=rlog_path is None,
))
@@ -624,7 +675,12 @@ def resolve_route_sources(route_names: list[str], footage_paths: list[str]) -> t
if route_added:
break
if not route_added:
warnings.append(f"{route} could not be resolved to a local route with logs.")
if segment_range:
start_label = segment_start if segment_start is not None else "first"
end_label = segment_end if segment_end is not None else "last"
warnings.append(f"{route} had no local logs in the selected segment range {start_label}-{end_label}.")
else:
warnings.append(f"{route} could not be resolved to a local route with logs.")
sources.sort(key=lambda source: (source.route, source.segment_num))
return sources, warnings
@@ -874,7 +930,7 @@ def _segment_samples(segment_source: RouteSource, params: Params | None = None)
_require_flm_offroad(params)
for msg in LogReader(segment_source.log_path, sort_by_time=True):
for msg in LogReader(segment_source.log_path):
now = time.monotonic()
if now - last_offroad_check >= FLM_ONROAD_POLL_INTERVAL_SECONDS:
_require_flm_offroad(params)
@@ -950,6 +1006,33 @@ def _segment_samples(segment_source: RouteSource, params: Params | None = None)
return samples, car_params, init_data, control_states
def _segment_samples_with_timeout(segment_source: RouteSource, params: Params,
timeout_seconds: float = FLM_SEGMENT_TIMEOUT_SECONDS):
if (
timeout_seconds <= 0.0
or threading.current_thread() is not threading.main_thread()
or not hasattr(signal, "SIGALRM")
or not hasattr(signal, "setitimer")
):
return _segment_samples(segment_source, params=params)
def handle_timeout(_signum, _frame):
raise FLMSegmentTimeout(
f"{segment_source.route} segment {segment_source.segment_num} exceeded the {timeout_seconds:.0f}-second read limit."
)
previous_handler = signal.getsignal(signal.SIGALRM)
signal.signal(signal.SIGALRM, handle_timeout)
previous_timer = signal.setitimer(signal.ITIMER_REAL, timeout_seconds)
try:
return _segment_samples(segment_source, params=params)
finally:
signal.setitimer(signal.ITIMER_REAL, 0.0)
signal.signal(signal.SIGALRM, previous_handler)
if previous_timer[0] > 0.0:
signal.setitimer(signal.ITIMER_REAL, previous_timer[0], previous_timer[1])
def _current_param_state(CP, params: Params) -> dict[str, Any]:
advanced_enabled = params.get_bool("AdvancedLateralTune")
torque_tune = CP.lateralTuning.torque if CP.lateralTuning.which() == "torque" else None
@@ -2133,14 +2216,16 @@ def _render_report_html(report: dict[str, Any]) -> str:
)
def analyze_routes(route_names: list[str], footage_paths: list[str], feedback: dict[str, Any] | None = None, report_id: str | None = None) -> dict[str, Any]:
def analyze_routes(route_names: list[str], footage_paths: list[str], feedback: dict[str, Any] | None = None,
report_id: str | None = None, segment_ranges: dict[str, dict[str, int | None]] | None = None) -> dict[str, Any]:
ensure_flm_workspace()
params = Params(return_defaults=True)
_require_flm_offroad(params)
report_id = report_id or f"flm-{int(time.time())}"
feedback = feedback or {}
segment_ranges = normalize_segment_ranges(route_names, segment_ranges)
sources, warnings = resolve_route_sources(route_names, footage_paths)
sources, warnings = resolve_route_sources(route_names, footage_paths, segment_ranges)
if not sources:
raise RuntimeError("No local routes with qlogs or rlogs were found for the selected routes.")
@@ -2150,6 +2235,7 @@ def analyze_routes(route_names: list[str], footage_paths: list[str], feedback: d
observed_control_states: dict[str, int] = {}
used_qlog = False
processed_segments = 0
skipped_segments = 0
for idx, source in enumerate(sources, start=1):
_require_flm_offroad(params)
_write_flm_status({
@@ -2158,11 +2244,25 @@ def analyze_routes(route_names: list[str], footage_paths: list[str], feedback: d
"running": True,
"state": "analyzing",
"routes": route_names,
"segmentRanges": segment_ranges,
"progress": idx - 1,
"total": len(sources),
"currentSegment": source.segment,
})
segment_samples, segment_car_params, segment_init, segment_control_states = _segment_samples(source, params=params)
try:
segment_samples, segment_car_params, segment_init, segment_control_states = _segment_samples_with_timeout(source, params)
except FLMAnalysisCancelled:
raise
except FLMSegmentTimeout as error:
warnings.append(str(error) + " The segment was skipped.")
skipped_segments += 1
continue
except Exception as error:
warnings.append(
f"{source.route} segment {source.segment_num} could not be read ({type(error).__name__}). The segment was skipped."
)
skipped_segments += 1
continue
_require_flm_offroad(params)
if segment_car_params is not None:
car_params_candidates.append(segment_car_params)
@@ -2293,6 +2393,7 @@ def analyze_routes(route_names: list[str], footage_paths: list[str], feedback: d
"reportId": report_id,
"createdAt": time.time(),
"routeNames": route_names,
"segmentRanges": segment_ranges,
"warnings": warnings,
"feedback": feedback,
"car": {
@@ -2311,6 +2412,7 @@ def analyze_routes(route_names: list[str], footage_paths: list[str], feedback: d
"summary": {
**summary_stats,
"processedSegments": processed_segments,
"skippedSegments": skipped_segments,
"usedQlogFallback": used_qlog,
},
"primaryPathKey": path_decision["primaryPathKey"],
@@ -2342,8 +2444,10 @@ def analyze_routes(route_names: list[str], footage_paths: list[str], feedback: d
"running": False,
"state": "complete",
"routes": route_names,
"progress": processed_segments,
"total": processed_segments,
"segmentRanges": segment_ranges,
"progress": len(sources),
"total": len(sources),
"skippedSegments": skipped_segments,
"reportId": report_id,
})
return report
@@ -2875,6 +2979,7 @@ def run_worker(payload_json: str) -> None:
payload = json.loads(payload_json)
routes = [str(route) for route in payload.get("routes", [])]
footage_paths = [str(path) for path in payload.get("footagePaths", [])]
segment_ranges = normalize_segment_ranges(routes, payload.get("segmentRanges", {}))
ensure_flm_workspace()
_write_flm_status({
"pid": os.getpid(),
@@ -2882,21 +2987,27 @@ def run_worker(payload_json: str) -> None:
"running": True,
"state": "starting",
"routes": routes,
"segmentRanges": segment_ranges,
"progress": 0,
"total": len(routes),
})
threading.Thread(target=_watch_flm_worker_for_onroad, daemon=True).start()
try:
_require_flm_offroad()
report = analyze_routes(routes, footage_paths)
report = analyze_routes(routes, footage_paths, segment_ranges=segment_ranges)
processed_segments = int(report.get("summary", {}).get("processedSegments", 0) or 0)
skipped_segments = int(report.get("summary", {}).get("skippedSegments", 0) or 0)
total_segments = processed_segments + skipped_segments
_write_flm_status({
"pid": os.getpid(),
"startedAt": time.time(),
"running": False,
"state": "complete",
"routes": routes,
"progress": len(routes),
"total": len(routes),
"segmentRanges": segment_ranges,
"progress": total_segments,
"total": total_segments,
"skippedSegments": skipped_segments,
"reportId": report["reportId"],
})
except FLMAnalysisCancelled as error:
@@ -2906,6 +3017,7 @@ def run_worker(payload_json: str) -> None:
"running": False,
"state": "cancelled_onroad",
"routes": routes,
"segmentRanges": segment_ranges,
"progress": 0,
"total": len(routes),
"error": str(error),
@@ -2917,6 +3029,7 @@ def run_worker(payload_json: str) -> None:
"running": False,
"state": "failed",
"routes": routes,
"segmentRanges": segment_ranges,
"progress": 0,
"total": len(routes),
"error": str(error),
@@ -1419,6 +1419,20 @@ def _load_server_module():
return module
def test_troubleshoot_steer_delay_normalizes_vehicle_delay_for_display():
server = _load_server_module()
assert server._normalize_troubleshoot_current_display_value("SteerDelay", 0.1, 0.3) == 0.3
assert server._normalize_troubleshoot_current_display_value("SteerDelay", 0.3, 0.3) == 0.3
def test_troubleshoot_steer_delay_preserves_real_custom_values():
server = _load_server_module()
assert server._normalize_troubleshoot_current_display_value("SteerDelay", 0.2, 0.3) == 0.2
assert server._normalize_troubleshoot_current_display_value("LongitudinalActuatorDelay", 0.1, 0.3) == 0.1
def test_stats_endpoint_keeps_existing_keys_and_adds_dashboard(monkeypatch):
server = _load_server_module()
assert server._import_galaxy_web_symbols()
@@ -2,6 +2,7 @@ import importlib.util
import json
import math
import sys
import time
from pathlib import Path
from types import ModuleType, SimpleNamespace
@@ -210,6 +211,48 @@ def test_effective_control_path_keeps_true_angle_and_mixed_routes_diagnostic(tmp
assert module._effective_control_path(angle_cp, {"angleState": 900, "torqueState": 900}) == ("mixed", "controlsState")
def test_segment_ranges_limit_resolved_route_sources(tmp_path, monkeypatch):
module, _ = _load_flm_workspace_module(tmp_path)
route = "00000001--abcdef1234"
segment_names = [f"{route}--{segment}" for segment in range(12)]
for segment_name in segment_names:
segment_path = tmp_path / segment_name
segment_path.mkdir()
(segment_path / "rlog.zst").write_bytes(b"log")
monkeypatch.setattr(module.utilities, "get_segments_in_route", lambda *_args: segment_names)
sources, warnings = module.resolve_route_sources(
[route],
[str(tmp_path)],
{route: {"start": 4, "end": 9}},
)
assert [source.segment_num for source in sources] == [4, 5, 6, 7, 8, 9]
assert warnings == []
def test_segment_range_rejects_reversed_bounds(tmp_path):
module, _ = _load_flm_workspace_module(tmp_path)
with pytest.raises(ValueError, match="first segment"):
module.normalize_segment_ranges(["route"], {"route": {"start": 9, "end": 4}})
def test_segment_reader_timeout_interrupts_stalled_log(tmp_path, monkeypatch):
module, _ = _load_flm_workspace_module(tmp_path)
source = module.RouteSource(
route="route",
footage_path=str(tmp_path),
segment="route--41",
segment_num=41,
log_path=str(tmp_path / "route--41" / "rlog.zst"),
used_qlog=False,
)
monkeypatch.setattr(module, "_segment_samples", lambda *_args, **_kwargs: time.sleep(0.2))
with pytest.raises(module.FLMSegmentTimeout, match="segment 41"):
module._segment_samples_with_timeout(source, module.Params(), timeout_seconds=0.02)
def test_analysis_is_rejected_while_onroad(tmp_path):
module, fake_params_cls = _load_flm_workspace_module(tmp_path)
fake_params_cls._store = {"IsOnroad": True}
+21 -1
View File
@@ -2916,6 +2916,20 @@ def _resolve_troubleshoot_default_value(key, value_type, default_values):
return _coerce_param_value(default_raw_value, safe_type)
def _normalize_troubleshoot_current_display_value(key, current_value, default_value):
if key != "SteerDelay":
return current_value
try:
full_current_delay = full_lateral_delay(float(current_value))
numeric_default = float(default_value)
except (TypeError, ValueError):
return current_value
if math.isfinite(full_current_delay) and math.isfinite(numeric_default) and math.isclose(full_current_delay, numeric_default, abs_tol=1e-6):
return default_value
return current_value
def _normalize_live_delay_status(status):
status_text = str(status or "").strip().lower()
if status_text in {"estimated", "unestimated", "invalid"}:
@@ -3245,6 +3259,7 @@ def _build_troubleshoot_section_payload(section_definition, value_types, default
try:
current_value = _resolve_troubleshoot_current_value(key, value_type, default_values)
default_value = _resolve_troubleshoot_default_value(key, value_type, default_values)
current_value = _normalize_troubleshoot_current_display_value(key, current_value, default_value)
except Exception:
current_value = "Unavailable"
default_value = "n/a"
@@ -5918,7 +5933,12 @@ def setup(app):
if not route_names:
return jsonify({"error": "No routes were selected."}), 400
started = flm_workspace.start_flm_background_analysis(route_names, FOOTAGE_PATHS)
try:
segment_ranges = flm_workspace.normalize_segment_ranges(route_names, data.get("segmentRanges", {}))
except (TypeError, ValueError) as error:
return jsonify({"error": str(error)}), 400
started = flm_workspace.start_flm_background_analysis(route_names, FOOTAGE_PATHS, segment_ranges)
if not started:
return jsonify({"error": "Failed to start FLM analysis."}), 500