diff --git a/app/README.md b/app/README.md
index 94bbf510..6d97ed89 100644
--- a/app/README.md
+++ b/app/README.md
@@ -70,6 +70,7 @@ Removing a simulation keeps its saved runs.
- `http_utils.py`: small HTTP parsing helpers.
- `storage.py`: canonical folder layout for shipped app assets and mutable user
data.
+- `tracks.py`: track CSV import and boundary previews.
- `tire_eval.py`: MF52 tire-load and curve payload generation for the UI.
- `modelica_generator.py`: vehicle YAML to BobLib Modelica generation.
- `desktop.py`: desktop/webview wrapper for packaged builds.
diff --git a/app/data_services.py b/app/data_services.py
index 315a9718..f04ea646 100644
--- a/app/data_services.py
+++ b/app/data_services.py
@@ -1118,6 +1118,51 @@ def _ggv_link_signature() -> str:
)["fingerprint"])
+def _simulation_line_input(item: dict[str, Any], items: list[dict[str, Any]], key: str) -> dict[str, Any]:
+ """## Racing-Line Connection
+
+ Parameters
+ ----------
+ item : dict
+ Lap analysis with a source ID.
+ items : list of dict
+ Analyses in this vehicle workspace.
+ key : str
+ Workspace key.
+
+ Returns
+ -------
+ dict
+ Latest result and its compatibility with current track and vehicle width.
+ """
+ source_id = item.get("racing_line_source_id")
+ result: dict[str, Any] = {"source_id": source_id, "status": "Unavailable"}
+ source = next((entry for entry in items
+ if entry["id"] == source_id and entry["workflow_id"] == "racing-line"), None)
+ if source is None:
+ return {**result, "message": "Connect a racing-line result from this vehicle."}
+ result["name"] = source["name"]
+ try:
+ from simulations.lap.racing_line_sim import line_signature
+ run = next((run for run in saved_results_payload(key)["results"]
+ if (run.get("simulation") or {}).get("id") == source_id), None)
+ if run is None:
+ return {**result, "message": "Run the connected racing-line analysis first."}
+ file = next((file for file in run["files"] if file["label"] == "Racing line"), None)
+ if file is None:
+ return {**result, "message": "The saved racing line is missing. Rerun its analysis."}
+ path = _safe_repo_path(file["path"])
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
+ saved = json.loads(path.read_text())
+ config = yaml.safe_load(source["config_text"])
+ if file.get("sha256") != digest or saved.get("signature") != line_signature(config, ROOT):
+ return {**result, "status": "Stale", "message": "Track, width or clearance changed. Rerun the racing line."}
+ return {**result, "status": "Ready", "message": "Uses the latest successful racing line.",
+ "path": file["path"], "sha256": digest, "run_id": run["id"], "track": config["track"]}
+ except (OSError, ValueError, KeyError, TypeError, yaml.YAMLError) as exc:
+ return {**result, "message": str(exc)}
+
+
def _simulation_ggv_input(item: dict[str, Any], items: list[dict[str, Any]], key: str) -> dict[str, Any]:
"""## GGV Connection
@@ -1137,7 +1182,7 @@ def _simulation_ggv_input(item: dict[str, Any], items: list[dict[str, Any]], key
"""
source_id = item.get("ggv_source_id")
if not source_id:
- return {"mode": "generate", "status": "Local", "message": "Generate with this lap's envelope settings."}
+ return {"mode": "linked", "status": "Unavailable", "message": "Connect a GGV result from this vehicle."}
result: dict[str, Any] = {"mode": "linked", "source_id": source_id, "status": "Unavailable"}
try:
source = next((entry for entry in items if entry["id"] == source_id and entry["workflow_id"] == "ggv"), None)
@@ -1207,6 +1252,7 @@ def _inherit_ggv_fidelity(items: list[dict[str, Any]]) -> None:
dof = int(yaml.safe_load(source["config_text"]).get("generation", {}).get("model_dof", 3))
config = yaml.safe_load(lap["config_text"])
config["model_dof"] = dof
+ config.setdefault("event", {})["drive_power_limit_w"] = None
lap["config_text"] = yaml.safe_dump(config, sort_keys=False)
lap["model"] = {**lap.get("model", {}), "model_dofs": [dof], "fidelity": f"{dof} DOF QSS"}
@@ -1302,6 +1348,8 @@ def simulation_workspace(action: str = "list", payload: dict[str, Any] | None =
def _simulation_analysis_group(workflow_id: str) -> str:
+ if workflow_id == "racing-line":
+ return "track"
if workflow_id == "four-post":
return "kinematic"
if workflow_id in {"steady-state", "ggv", "ymd", "lap-qss"}:
@@ -1330,7 +1378,7 @@ def _simulation_workspace(action: str, payload: dict[str, Any]) -> dict[str, Any
number += 1
child["name"] = f"Signal Explorer {number}"
groups_path = path.with_name("analysis_groups.json")
- group_order = ("kinematic", "steady-state", "transient")
+ group_order = ("track", "kinematic", "steady-state", "transient")
saved_groups = json.loads(groups_path.read_text(encoding="utf-8")) if groups_path.is_file() else []
groups = {group for group in saved_groups if isinstance(group, str) and group in group_order}
groups.update(_simulation_analysis_group(entry["workflow_id"]) for entry in items)
@@ -1340,7 +1388,7 @@ def _simulation_workspace(action: str, payload: dict[str, Any]) -> dict[str, Any
if action in {"add-analysis", "remove-analysis"}:
group = payload.get("group")
if group not in group_order:
- raise ValueError("Choose Kinematic, Steady state or Transient.")
+ raise ValueError("Choose Track, Kinematic, Steady state or Transient.")
if action == "remove-analysis":
if any(_simulation_analysis_group(entry["workflow_id"]) == group for entry in items):
raise ValueError("Remove the simulations in this analysis first.")
@@ -1349,7 +1397,7 @@ def _simulation_workspace(action: str, payload: dict[str, Any]) -> dict[str, Any
groups.add(group)
elif action == "create":
workflow = _workflow_by_id(str(payload.get("workflow_id", "")))
- if workflow.group not in {"standard", "response", "envelope", "lap"}:
+ if workflow.group not in {"standard", "response", "envelope", "lap", "track"}:
raise ValueError("Choose a simulation workflow.")
spec = _sim_config_spec(workflow.id)
item = {"id": uuid4().hex, "workflow_id": workflow.id,
@@ -1383,6 +1431,13 @@ def _simulation_workspace(action: str, payload: dict[str, Any]) -> dict[str, Any
if item is None:
raise ValueError("Simulation not found for this vehicle.")
item["name"] = str(payload.get("name") or item["name"]).strip()[:120] or item["name"]
+ elif action == "set-racing-line":
+ if item is None or item["workflow_id"] != "lap-qss":
+ raise ValueError("Racing-line connections require a QSS lap.")
+ source_id = payload.get("source_id") or None
+ if source_id and not any(entry["id"] == source_id and entry["workflow_id"] == "racing-line" for entry in items):
+ raise ValueError("Choose a racing-line analysis from this vehicle.")
+ item["racing_line_source_id"] = source_id
elif action == "set-ggv":
if item is None or item["workflow_id"] != "lap-qss":
raise ValueError("GGV connections require a QSS lap analysis.")
@@ -1390,6 +1445,10 @@ def _simulation_workspace(action: str, payload: dict[str, Any]) -> dict[str, Any
if source_id and not any(entry["id"] == source_id and entry["workflow_id"] == "ggv" for entry in items):
raise ValueError("Choose a GGV analysis from this vehicle.")
item["ggv_source_id"] = source_id
+ if source_id:
+ data = yaml.safe_load(item["config_text"])
+ data.setdefault("event", {})["drive_power_limit_w"] = None
+ item["config_text"] = yaml.safe_dump(data, sort_keys=False)
if source_id is None:
data = yaml.safe_load(item["config_text"])
data.setdefault("qss", {})["generate_if_missing"] = True
@@ -1464,6 +1523,16 @@ def _simulation_workspace(action: str, payload: dict[str, Any]) -> dict[str, Any
spec = _sim_config_spec(item["workflow_id"])
if action in {"load", "prepare-run"}:
config_text = item["config_text"]
+ if action == "prepare-run" and item["workflow_id"] == "lap-qss":
+ if not item.get("ggv_source_id"):
+ raise ValueError("Connect a GGV result before running this lap.")
+ line_input = _simulation_line_input(item, items, key)
+ if line_input["status"] != "Ready":
+ raise ValueError(line_input["message"])
+ data = yaml.safe_load(config_text)
+ data["linked_racing_line"] = {key: line_input[key] for key in ("path", "sha256", "source_id", "run_id")}
+ data["track"] = line_input["track"]
+ config_text = yaml.safe_dump(data, sort_keys=False)
if action == "prepare-run" and item.get("ggv_source_id"):
dependency = _simulation_ggv_input(item, items, key)
if dependency["status"] != "Ready":
@@ -1483,6 +1552,9 @@ def _simulation_workspace(action: str, payload: dict[str, Any]) -> dict[str, Any
item["model"] = workflow_model(_workflow_by_id(item["workflow_id"]), root=ROOT)
config_text = _config_file_path(spec).read_text(encoding="utf-8")
data = yaml.safe_load(config_text)
+ if data.pop("linked_racing_line", None):
+ data["track"] = yaml.safe_load(item["config_text"])["track"]
+ config_text = yaml.safe_dump(data, sort_keys=False)
if data.get("qss", {}).pop("linked_ggv", None):
original = yaml.safe_load(item["config_text"]).get("qss", {})
for name in ("ggv_csv", "generate_if_missing"):
@@ -1501,7 +1573,7 @@ def _simulation_workspace(action: str, payload: dict[str, Any]) -> dict[str, Any
groups.update(_simulation_analysis_group(entry["workflow_id"]) for entry in items)
analysis_groups = [group for group in group_order if group in groups]
if action in {"add-analysis", "remove-analysis", "create", "save", "rename", "delete", "duplicate", "set-baseline",
- "set-ggv", "add-visualization", "delete-visualization",
+ "set-ggv", "set-racing-line", "add-visualization", "delete-visualization",
"update-visualization", "rename-visualization"}:
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(items, indent=2), encoding="utf-8")
@@ -1513,6 +1585,7 @@ def _simulation_workspace(action: str, payload: dict[str, Any]) -> dict[str, Any
for summary, entry in zip(summaries, items):
if entry["workflow_id"] == "lap-qss":
summary["ggv_input"] = _simulation_ggv_input(entry, items, key)
+ summary["racing_line_input"] = _simulation_line_input(entry, items, key)
if any(summary.get("visualizations") for summary in summaries):
runs = saved_results_payload(key)["results"]
captures = app_visual.available_runs(ROOT) if any(
@@ -2119,7 +2192,8 @@ def save_active_results(
"kind": output.kind,
"source_path": output.path,
"path": target.relative_to(ROOT).as_posix(),
- "sha256": hashlib.sha256(target.read_bytes()).hexdigest() if workflow_id == "ggv" else None,
+ "sha256": (hashlib.sha256(target.read_bytes()).hexdigest()
+ if workflow_id in {"ggv", "racing-line"} else None),
}
)
diff --git a/app/python_simulations.py b/app/python_simulations.py
index 521eec32..0b7cf8d6 100644
--- a/app/python_simulations.py
+++ b/app/python_simulations.py
@@ -31,6 +31,13 @@ def run(workflow_id: str) -> None:
Ramp steer, step steer, QSS lap or transient lap workflow ID.
"""
root = repo_root()
+ if workflow_id == "racing-line":
+ from simulations.lap.racing_line_sim import run as generate_line
+ config = config_io.resolve("simulations/lap/racing_line_config.yml", root=root)
+ print("Generating racing line from track boundaries and vehicle width.", flush=True)
+ line_result = generate_line(load_yaml(config), root)
+ print(f"Racing line ready: {line_result['length_m']:.1f} m", flush=True)
+ return
if workflow_id == "mbd-step-steer":
from simulations.response.mbd.transient.transient_eval_sim import main as run_mbd_transient
@@ -42,7 +49,7 @@ def run(workflow_id: str) -> None:
data = load_yaml(config)
print(f"DynPy | {data['model_dof']} DOF | {workflow_id}", flush=True)
if data.get("qss", {}).get("linked_ggv"):
- print("Using linked GGV. Solving the racing line and lap speed profile.", flush=True)
+ print("Using linked GGV. Solving the lap speed profile.", flush=True)
summary = run_lap_time_evaluation(
config, scenario="qss" if workflow_id == "lap-qss" else "transient"
)
@@ -114,7 +121,8 @@ def main() -> None:
"""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
- "workflow", choices=("reduced-step-steer", "reduced-ramp-steer", "mbd-step-steer", "lap-qss", "lap-transient")
+ "workflow", choices=("reduced-step-steer", "reduced-ramp-steer", "mbd-step-steer",
+ "lap-qss", "lap-transient", "racing-line")
)
run(parser.parse_args().workflow)
diff --git a/app/registry.py b/app/registry.py
index b818caef..be617275 100644
--- a/app/registry.py
+++ b/app/registry.py
@@ -625,6 +625,15 @@ def _hardpoint_fields(axle: str, label: str) -> tuple[FieldSpec, ...]:
_field("test.directions", "Directions", kind="list", group="Input", choices=DIRECTION_CHOICES),
),
),
+ "racing-line": ConfigSpec(
+ id="racing-line", group="track", label="Racing line",
+ path="simulations/lap/racing_line_config.yml", workflow_id="racing-line", relocatable=True,
+ fields=(
+ _field("track.boundary_csv", "Track", kind="string", group="Track"),
+ _field("track.safety_margin_m", "Edge clearance", kind="number", group="Track", unit="m"),
+ _field("mode", "Line", kind="select", group="Track", choices=("minimum_curvature", "centerline")),
+ ),
+ ),
"reduced-ramp-steer": ConfigSpec(
id="reduced-ramp-steer", group="response", label="Ramp steer",
path="simulations/response/reduced/ramp_steer_config.yml",
@@ -667,7 +676,6 @@ def _hardpoint_fields(axle: str, label: str) -> tuple[FieldSpec, ...]:
fields=(
_field("model_dof", "Model DOF", kind="select", group="Model", choices=("3", "6", "10", "14")),
_field("track.boundary_csv", "Track CSV", kind="string", group="Track"),
- _field("track.vehicle_width_m", "Vehicle width", kind="number", group="Track", unit="m"),
_field("track.safety_margin_m", "Edge clearance", kind="number", group="Track", unit="m"),
_field("track.sample_step_m", "Track sample spacing", kind="number", group="Track", unit="m"),
_field("racing_line.mode", "Racing line", kind="select", group="Track",
@@ -859,7 +867,7 @@ def build_action_specs(root: Path, python: str, python_module_arg: str) -> dict[
label=f"Run {name}",
argv=(*_python_module_argv(python, python_module_arg, "app.python_simulations"), name),
)
- for name in ("reduced-step-steer", "reduced-ramp-steer", "lap-qss", "lap-transient")
+ for name in ("reduced-step-steer", "reduced-ramp-steer", "lap-qss", "lap-transient", "racing-line")
},
"run-ggv": ActionSpec(
id="run-ggv",
@@ -986,6 +994,15 @@ def build_workflows() -> tuple[WorkflowSpec, ...]:
OutputSpec("Metrics", "simulations/mbd/generated_results/step_steer_report_metrics.csv", "csv"),
),
),
+ WorkflowSpec(
+ id="racing-line", group="track", label="Racing line", engine="Track",
+ fidelity="Geometry", runtime="Python", config="simulations/lap/racing_line_config.yml",
+ actions=("run-racing-line",), outputs=(
+ OutputSpec("Report", "app/user_data/results/racing-line/report.pdf", "pdf"),
+ OutputSpec("Racing line", "app/user_data/results/racing-line/line.json", "json"),
+ OutputSpec("Signals", "app/user_data/results/racing-line/signals.csv", "csv"),
+ ),
+ ),
WorkflowSpec(
id="reduced-ramp-steer", group="response", label="Ramp steer",
engine="DynPy", fidelity="Transient", runtime="Python",
diff --git a/app/runtime.py b/app/runtime.py
index f43d2e77..6b808ddb 100644
--- a/app/runtime.py
+++ b/app/runtime.py
@@ -34,6 +34,7 @@
"simulations/response/reduced/reduced_order_eval_config.yml",
"simulations/response/reduced/ramp_steer_config.yml",
"simulations/lap/lap_time_eval_config.yml",
+ "simulations/lap/racing_line_config.yml",
"simulations/lap/lap_validation_config.yml",
"simulations/lap/tracks",
"simulations/envelope/ggv/ggv_config.yml",
diff --git a/app/server.py b/app/server.py
index ade6ff29..cc8ee41e 100644
--- a/app/server.py
+++ b/app/server.py
@@ -330,6 +330,12 @@ def _handle_post(self) -> None:
elif parsed.path == "/api/tires/template":
payload = save_tire_template(str(body.get("name", "")), str(body.get("text", "")))
self._send_json(payload)
+ elif parsed.path == "/api/tracks/import":
+ from app.tracks import import_track
+ self._send_json(import_track(ROOT, str(body.get("name", "")), str(body.get("text", ""))))
+ elif parsed.path == "/api/tracks/preview":
+ from app.tracks import track_payload
+ self._send_json(track_payload(ROOT, str(body.get("path", ""))))
elif parsed.path == "/api/tires/import":
payload = save_tire_template(str(body.get("name", "")), str(body.get("text", "")))
self._send_json(payload)
diff --git a/app/static/app.js b/app/static/app.js
index 3b69bd97..e31e8860 100644
--- a/app/static/app.js
+++ b/app/static/app.js
@@ -628,7 +628,7 @@ function fmtBytes(value) {
}
function standardWorkflows() {
- return (state.status?.workflows || []).filter((workflow) => ["standard", "response", "envelope", "lap"].includes(workflow.group));
+ return (state.status?.workflows || []).filter((workflow) => ["standard", "response", "envelope", "lap", "track"].includes(workflow.group));
}
function activeVehicleWorkspace() {
@@ -727,6 +727,8 @@ function canUseStandardSim() {
function canRunStandardWorkflow(workflow = selectedWorkflow()) {
if (!workflow || !activeVehicleConfigReady() || state.dirtyVehicle) return false;
+ if (workflow.id === "lap-qss" && selectedSimulation() &&
+ [selectedSimulation().ggv_input, selectedSimulation().racing_line_input].some(input => input?.status !== "Ready")) return false;
return (!workflow.requires_mbd || canUseStandardSim()) && workflowAvailable(workflow);
}
@@ -1162,6 +1164,11 @@ function standardWorkflowLockedMessage(workflow = selectedWorkflow()) {
if (!activeVehicleConfigReady()) return "Save this vehicle to your library first.";
if (state.dirtyVehicle) return "Save your vehicle changes first.";
if (workflow?.requires_mbd && !canUseStandardSim()) return simulationLockedMessage();
+ if (workflow?.id === "lap-qss" && selectedSimulation()) {
+ const blocked = [selectedSimulation().ggv_input, selectedSimulation().racing_line_input].find(input => input?.status !== "Ready");
+ if (blocked) return blocked.message;
+ if (!selectedSimulation().ggv_input || !selectedSimulation().racing_line_input) return "Connect GGV and racing-line results.";
+ }
if (!workflowAvailable(workflow)) return workflowUnavailableMessage(workflow);
return "";
}
@@ -4375,10 +4382,10 @@ function openSimulationVisualization(child) {
}
}
-async function openSimulationGgv(id) {
+async function openSimulationGgv(id, kind = "ggv") {
await openSimulation(id);
state.simModalOpen = false;
- state.simulationGgvEditor = true;
+ state.simulationGgvEditor = kind;
renderSimulationWorkspace();
}
@@ -4388,23 +4395,25 @@ function renderSimulationGgv() {
if (!panel) return;
panel.hidden = state.view !== "standard" || !state.simulationGgvEditor;
if (panel.hidden) return;
+ const isLine = state.simulationGgvEditor === "racing-line";
+ const kind = isLine ? "racing-line" : "ggv";
+ const label = isLine ? "Racing line" : "GGV";
+ const sourceId = isLine ? simulation.racing_line_source_id : simulation.ggv_source_id;
+ const input = isLine ? simulation.racing_line_input : simulation.ggv_input;
+ panel.querySelector("h2").textContent = `${label} input`;
const source = document.getElementById("simulation-ggv-source");
- source.innerHTML = 'Generate for this lap ' + (state.simulationWorkspace?.simulations || [])
- .filter(item => item.workflow_id === "ggv").map(item => `${escapeHtml(item.name)} `).join("");
- if (simulation.ggv_source_id && ![...source.options].some(option => option.value === simulation.ggv_source_id)) {
- source.insertAdjacentHTML("beforeend", `Unavailable GGV `);
- }
- const input = simulation.ggv_input;
- document.getElementById("simulation-ggv-status").textContent = `${input?.status || "Local"}: ${input?.message || "Generate with this lap's envelope settings."}`
- + (input?.needs_rerun ? " Rerun the lap to use this result." : "");
+ source.innerHTML = 'Not connected ' + (state.simulationWorkspace?.simulations || [])
+ .filter(item => item.workflow_id === kind).map(item => `${escapeHtml(item.name)} `).join("");
+ document.getElementById("simulation-ggv-status").textContent = `${input?.status || "Unavailable"}: ${input?.message || `Connect a ${label.toLowerCase()} result.`}`;
const open = document.getElementById("simulation-ggv-open");
- open.textContent = simulation.ggv_source_id ? "Open GGV analysis" : "Edit lap envelope settings";
- open.disabled = Boolean(simulation.ggv_source_id && !(state.simulationWorkspace?.simulations || []).some(item => item.id === simulation.ggv_source_id));
- open.title = open.disabled ? "The linked GGV analysis is no longer available." : "";
- open.onclick = () => workspaceAction(() => openSimulation(simulation.ggv_source_id || simulation.id));
+ open.textContent = `Open ${label.toLowerCase()} analysis`;
+ open.disabled = !sourceId || !(state.simulationWorkspace?.simulations || []).some(item => item.id === sourceId);
+ open.title = open.disabled ? "No available analysis is connected." : "";
+ open.onclick = () => workspaceAction(() => openSimulation(sourceId));
source.onchange = () => workspaceAction(async () => {
- await simulationRequest("set-ggv", {id: simulation.id, source_id: source.value || null});
+ await simulationRequest(isLine ? "set-racing-line" : "set-ggv", {id: simulation.id, source_id: source.value || null});
renderSimulationWorkspace();
+ renderJobs();
});
}
@@ -4437,6 +4446,9 @@ function wireSimulationGgvDrag() {
const clear = () => tree.querySelectorAll(".ggv-drop-target").forEach(node => node.classList.remove("ggv-drop-target"));
const targetFor = event => {
const branch = event.target.closest("[data-simulation-branch]");
+ const input = event.target.closest("[data-input-kind]");
+ const source = state.simulationWorkspace?.simulations?.find(item => item.id === sourceId);
+ if (input && input.dataset.inputKind !== source?.workflow_id) return null;
return sourceId && state.simulationWorkspace?.simulations?.some(item =>
item.id === branch?.dataset.simulationBranch && item.workflow_id === "lap-qss") ? branch : null;
};
@@ -4468,8 +4480,9 @@ function wireSimulationGgvDrag() {
const id = target.dataset.simulationBranch;
workspaceAction(async () => {
await saveSimulationEdits();
- await simulationRequest("set-ggv", {id, source_id: source});
- await openSimulationGgv(id);
+ const kind = state.simulationWorkspace.simulations.find(item => item.id === source)?.workflow_id;
+ await simulationRequest(kind === "racing-line" ? "set-racing-line" : "set-ggv", {id, source_id: source});
+ await openSimulationGgv(id, kind);
});
});
}
@@ -4531,7 +4544,7 @@ function wireSimulationContextMenu() {
trigger?.setAttribute("aria-expanded", "false");
trigger = element;
const simulation = state.simulationWorkspace?.simulations?.find(item => item.id === id);
- const envelope = ["ggv", "ymd"].includes(simulation?.workflow_id);
+ const envelope = ["ggv", "ymd", "racing-line"].includes(simulation?.workflow_id);
const lap = simulation?.workflow_id === "lap-qss";
for (const button of menu.querySelectorAll("button")) {
const actions = scope === "group" ? ["add"] : scope === "results" ? ["replay", "signals"] : ["duplicate", "delete"];
@@ -4661,7 +4674,7 @@ function mountSimulationWorkspace() {
clearTimeout(overviewClickTimer);
if (event.target.closest("[data-simulation-home]")) { workspaceAction(openSimulationHome); return; }
const ggv = event.target.closest("[data-simulation-ggv]");
- if (ggv) { workspaceAction(() => openSimulationGgv(ggv.dataset.simulationGgv)); return; }
+ if (ggv) { workspaceAction(() => openSimulationGgv(ggv.dataset.simulationGgv, ggv.dataset.inputKind || "ggv")); return; }
const group = event.target.closest("[data-simulation-add-group]");
if (group) {
workspaceAction(() => showSimulationCatalog(group.dataset.simulationAddGroup));
@@ -4768,10 +4781,10 @@ function renderSimulationWorkspace() {
${simulationTreeIcon("response")}Setup
- ${item.workflow_id === "lap-qss" ? `
${simulationTreeIcon("link")}${item.ggv_source_id ? `GGV → ${escapeHtml(item.ggv_input?.name || "Unavailable")}` : "Link GGV"} ${escapeHtml(item.ggv_input?.status || "Local")} ` : ""}
+ ${item.workflow_id === "lap-qss" ? [["ggv", "GGV", item.ggv_source_id, item.ggv_input], ["racing-line", "Racing line", item.racing_line_source_id, item.racing_line_input]].map(([kind, label, source, input]) => `
${simulationTreeIcon("link")}${source ? `${label} → ${escapeHtml(input?.name || "Unavailable")}` : `Link ${label}`} ${escapeHtml(input?.status || "Unavailable")} `).join("") : ""}
${children.length ? `${simulationTreeIcon("chevron")} ` : ""}
- ${simulationTreeIcon("report")}Results ${runs.length && item.workflow_id === "ggv" ? `${simulationTreeIcon("grip")} ` : ""}
+ ${simulationTreeIcon("report")}Results ${runs.length && ["ggv", "racing-line"].includes(item.workflow_id) ? `${simulationTreeIcon("grip")} ` : ""}
${children.map(child => `
${simulationTreeIcon(child.kind === "replay" ? "scene" : "envelope")}${escapeHtml(child.name)} ×
`).join("")}
@@ -4862,19 +4875,21 @@ function responseModelPicker(workflow) {
let activeSimulationGroup = "transient";
const SIMULATION_GROUPS = [
+ {id: "track", label: "Track", description: "Track boundaries and reusable racing lines."},
{id: "kinematic", label: "Kinematic", description: "Suspension motion and loads."},
{id: "steady-state", label: "Steady state", description: "Choose equilibrium, an envelope or a quasi-steady lap."},
{id: "transient", label: "Transient", description: "Choose a maneuver or a driver-controlled lap."},
];
function simulationAnalysisGroup(workflow) {
+ if (workflow.id === "racing-line") return "track";
if (workflow.id === "four-post") return "kinematic";
if (["steady-state", "ggv", "ymd", "lap-qss"].includes(workflow.id)) return "steady-state";
return "transient";
}
function renderWorkflows() {
- const order = ["four-post", "ramp-steer", "steady-state", "step-steer", "transient", "ggv", "ymd", "lap-qss", "lap-transient"];
+ const order = ["racing-line", "four-post", "ramp-steer", "steady-state", "step-steer", "transient", "ggv", "ymd", "lap-qss", "lap-transient"];
const rank = (workflow) => {
const index = order.indexOf(responseFamily(workflow) || workflow.id);
return index < 0 ? order.length : index;
@@ -5091,7 +5106,7 @@ function simulationVisualHtml(workflow) {
} else {
// Minimum-curvature line solved inside this schematic's 17-unit-wide track.
drawing += '
';
- caption = 'A racing line and speed profile from the track and grip envelope.';
+ caption = id === 'racing-line' ? 'A fixed path generated from paired track boundaries.' : 'A speed profile along the linked racing line.';
}
}
return `
@@ -5389,15 +5404,15 @@ function simulationMethodGuide(workflowId) {
},
"lap-qss": {
"method": [
- "The envelope comes from a linked or supplied GGV, a matching cached result, or generation with the selected DynPy model.",
- "Boundary coordinates define a closed track corridor with allowance for vehicle width and the safety margin.",
- "The line can follow the centerline, minimize curvature, or minimize QSS lap time from the minimum-curvature starting point.",
+ "The linked GGV supplies the acceleration envelope, model fidelity and available vehicle power.",
+ "The Track analysis supplies a saved racing line with vehicle width and edge clearance already applied at its gates.",
+ "The lap uses the saved line samples without re-optimizing the path.",
"At each candidate speed, curvature sets lateral demand. GGV interpolation at that speed and demand supplies the remaining acceleration and braking capacity.",
"The lateral boundary limits corner speed. Alternating forward acceleration and backward braking passes produce a converged closed-lap speed profile. Segment travel times give the lap time."
],
"inputs": [
- "Track boundary CSV, vehicle width, safety margin and line sampling distance.",
- "Line mode and optimizer iteration limits, speed ceiling and optional event drive-power limit.",
+ "A generated racing-line result from the Track analysis.",
+ "A GGV result generated for the current vehicle.",
"A GGV result or envelope-generation settings. A linked GGV supplies the model fidelity and must match the vehicle and power limit."
],
"outputs": [
@@ -5407,7 +5422,7 @@ function simulationMethodGuide(workflowId) {
],
"limits": [
"This is a spatial speed-profile calculation. It does not integrate suspension, wheel or body states around the lap, even when its envelope comes from 14 DOF.",
- "The planar corridor has no elevation or banking. Generated GGV values use path-tangent and path-normal axes. Lookup uses absolute lateral demand and conservative capability when left and right CSV rows differ.",
+ "The planar track has no elevation or banking. Signed lateral demand selects the corresponding left or right GGV branch.",
"Speed is capped by the highest GGV speed and any lower configured ceiling. Envelope and line resolution affect the result.",
"The constrained line optimizer is local and can reach its iteration limit. A minimum-time result is not a guarantee of the global fastest line."
],
@@ -5520,6 +5535,12 @@ function simulationMathHtml(id) {
}
function simulationMethodHtml(workflow) {
+ if (workflow.id === "racing-line") return `
+
Track format CSV coordinates are in metres. Each row pairs two points across the track. Rows follow the circuit in order. Each boundary stays on the same side. The circuit closes automatically.
+ x1,y1,x2,y2
+ (x1, y1) (x2, y2) One row per paired gate
+
Vehicle clearance Nominal width is the larger axle span, measured between the outside tire edges with the wheels straight ahead. Wheel-center positions and tire section widths come from Setup. Edge clearance adds space on each side.
+
Line generation Both boundaries are periodic cubic splines through every CSV point, with a smooth closure at the finish line.
Minimum-curvature generation minimizes integrated squared curvature, with a small smoothness penalty on gate offsets. The line stays within the vehicle-center bounds at the paired gates. These are sampled constraints, not a swept-body clearance check between gates.
The saved line is independent of GGV fidelity. A linked QSS lap uses its original samples without changing the line.
`;
const guide = simulationMethodGuide(workflow.id);
if (!guide) return "";
const dynpy = guide.sources.includes("engines/dynpy/models.py");
@@ -5573,11 +5594,12 @@ function simWorkflowMeta(workflow) {
parameters: ["DOF, speed, steer step, timing and KinPy evaluation"],
outputs: ["PDF report, signals CSV and summary"],
},
+ "racing-line": {title: "Racing line", summary: "A reusable path from track boundaries and vehicle clearance."},
"lap-qss": {
title: "QSS lap",
- summary: "Racing line and speed profile from a vehicle acceleration envelope.",
- sequence: ["GGV validation, racing line and speed profile"],
- parameters: ["DOF, track, racing line and power cap"],
+ summary: "A speed profile along a saved racing line using the linked GGV.",
+ sequence: ["Linked-input validation and speed profile"],
+ parameters: ["Linked GGV and racing-line results"],
outputs: ["PDF report, lap CSV and summary"],
},
"lap-transient": {
@@ -7066,7 +7088,11 @@ function renderSimConfigForm() {
return;
}
const busy = (state.status?.jobs || []).some(job => ["queued", "running"].includes(job.status));
- const fields = (state.simConfigPayload.fields || []).filter(field => !(selectedSimulation()?.ggv_source_id && (field.group === "Envelope" || field.path?.[0] === "model_dof")));
+ const fields = (state.simConfigPayload.fields || []).filter(field => {
+ if (selectedWorkflow()?.id === "lap-qss") return false;
+ if (selectedWorkflow()?.id === "racing-line" && field.path?.join(".") === "track.boundary_csv") return false;
+ return !(selectedSimulation()?.ggv_source_id && (field.group === "Envelope" || field.path?.[0] === "model_dof"));
+ });
const advancedGroups = new Set(["Numerics", "Execution", "Closed loop", "Fit"]);
const physical = fields.filter(field => !advancedGroups.has(field.group));
physical.sort((a, b) => (["Simulation", "Report"].indexOf(a.group) + 1) - (["Simulation", "Report"].indexOf(b.group) + 1));
@@ -7075,6 +7101,10 @@ function renderSimConfigForm() {
${fieldGroupSections(physical, "sim-config")}
${advanced.length ? `Advanced ${fieldGroupSections(advanced, "sim-config")} ` : ""}
`;
+ if (selectedWorkflow()?.id === "lap-qss") {
+ form.innerHTML = ``;
+ }
+ if (selectedWorkflow()?.id === "racing-line") renderTrackImport(form, busy);
if (busy) form.querySelectorAll("button, input, select, textarea").forEach(control => {
control.title = "Simulation settings are locked while a run is active.";
});
@@ -7083,6 +7113,51 @@ function renderSimConfigForm() {
form.onclick = handleArrayEditorClick;
}
+function trackPreviewSvg(payload) {
+ const points = [...payload.left, ...payload.right];
+ const xs = points.map(p => p[0]), ys = points.map(p => -p[1]);
+ const x = Math.min(...xs), y = Math.min(...ys);
+ const width = Math.max(...xs) - x, height = Math.max(...ys) - y;
+ const pad = Math.max(width, height) * .06 || 1;
+ const boundary = points => [...points, points[0]].map(([x, y]) => `${x},${-y}`).join(" ");
+ return `
+
+ `;
+}
+
+function renderTrackImport(form, busy) {
+ const path = state.simConfigPayload.data.track.boundary_csv;
+ const section = document.createElement("section");
+ section.className = "track-import";
+ section.innerHTML = `Import track ${escapeHtml(path.split("/").pop())}
`;
+ form.prepend(section);
+ section.querySelector("button").onclick = () => section.querySelector("input").click();
+ form.querySelectorAll('select option').forEach(option => {
+ option.textContent = option.value === "minimum_curvature" ? "Minimum curvature" : "Centerline";
+ });
+ const preview = payload => {
+ if (!section.isConnected) return;
+ section.querySelector(".track-preview-area").innerHTML = trackPreviewSvg(payload);
+ section.querySelector(".track-status").textContent = `Vehicle width: ${payload.vehicle_width_m.toFixed(3)} m (from Setup)`;
+ };
+ api("/api/tracks/preview", {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({path})})
+ .then(preview).catch(error => { section.querySelector(".track-status").textContent = error.message; });
+ section.querySelector("input").oninput = event => event.stopPropagation();
+ section.querySelector("input").onchange = async event => {
+ event.stopPropagation();
+ const file = event.target.files[0];
+ if (!file) return;
+ try {
+ if (state.dirtySimConfig && !await applySimConfigEdits()) return;
+ const payload = await api("/api/tracks/import", {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({name:file.name,text:await file.text()})});
+ state.simConfigPayload = await api("/api/configs/racing-line", {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({values:{'["track","boundary_csv"]':payload.path}})});
+ await simulationRequest("save", {id: state.selectedSimulationId});
+ state.dirtySimConfig = false;
+ renderSimConfigForm();
+ } catch(error) { section.querySelector(".track-status").textContent = error.message; }
+ };
+}
+
function markSimConfigDirty() {
state.dirtySimConfig = true;
updateSimConfigStatus();
diff --git a/app/static/styles.css b/app/static/styles.css
index dd6564ea..171e44f6 100644
--- a/app/static/styles.css
+++ b/app/static/styles.css
@@ -5101,3 +5101,11 @@ summary:focus-visible {
#signal-explorer .multiple-unit-plots { overflow-y: auto; }
#signal-explorer .multiple-unit-plots canvas { width: 100%; height: 330px; min-height: 330px; flex: 0 0 330px; }
+
+.track-import { display: grid; gap: 12px; }
+.track-import-bar { display: flex; gap: 12px; align-items: center; }
+.track-preview { display: block; width: 100%; height: min(38vh, 340px); }
+.track-preview polyline { fill: none; stroke: var(--blue); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
+.track-name, .track-status { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
+.track-format-diagram { display: block; width: min(100%, 640px); }
+.track-format-diagram text { fill: currentColor; font-size: 14px; }
diff --git a/app/tracks.py b/app/tracks.py
new file mode 100644
index 00000000..b5ec6b4b
--- /dev/null
+++ b/app/tracks.py
@@ -0,0 +1,69 @@
+"""Track import and geometry previews."""
+from pathlib import Path
+import hashlib
+import tempfile
+from typing import Any
+
+from common.vehicle_io import load_yaml
+from simulations.lap.core.track import TrackCorridor
+from simulations.lap.racing_line_sim import vehicle_width_m
+
+
+def track_payload(root: Path, path: str) -> dict[str, Any]:
+ """## Track Preview
+
+ Parameters
+ ----------
+ root : Path
+ Vehicle workspace root.
+ path : str
+ Workspace-relative CSV path.
+
+ Returns
+ -------
+ dict
+ Track boundaries and derived nominal vehicle width.
+ """
+ source = (root / path).resolve()
+ if not source.is_relative_to(root.resolve()) or source.suffix.lower() != ".csv":
+ raise ValueError("Choose a track CSV in this workspace.")
+ track = TrackCorridor.from_csv(source)
+ left, right = track.interpolated_boundaries()
+ return {"path": source.relative_to(root.resolve()).as_posix(), "left": left.tolist(),
+ "right": right.tolist(),
+ "vehicle_width_m": vehicle_width_m(load_yaml(root / "vehicle.yml"), root)}
+
+
+def import_track(root: Path, name: str, text: str) -> dict[str, Any]:
+ """## Track Import
+
+ Parameters
+ ----------
+ root : Path
+ Vehicle workspace root.
+ name : str
+ Original CSV filename.
+ text : str
+ Paired boundary coordinates in metres.
+
+ Returns
+ -------
+ dict
+ Saved CSV path and preview geometry.
+ """
+ if Path(name).suffix.lower() != ".csv" or len(text.encode()) > 2_000_000:
+ raise ValueError("Choose a track CSV smaller than 2 MB.")
+ directory = root / "app/user_data/config/tracks"
+ directory.mkdir(parents=True, exist_ok=True)
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", dir=directory, encoding="utf-8", delete=False) as handle:
+ temporary = Path(handle.name)
+ handle.write(text)
+ try:
+ payload = track_payload(root, temporary.relative_to(root).as_posix())
+ digest = hashlib.sha256(text.encode()).hexdigest()[:16]
+ destination = directory / f"track-{digest}.csv"
+ temporary.replace(destination)
+ payload["path"] = destination.relative_to(root).as_posix()
+ return payload
+ finally:
+ temporary.unlink(missing_ok=True)
diff --git a/common/deploy/deploy.py b/common/deploy/deploy.py
index a4f360bd..22a1c663 100644
--- a/common/deploy/deploy.py
+++ b/common/deploy/deploy.py
@@ -44,6 +44,7 @@
"simulations/response/reduced/reduced_order_eval_config.yml",
"simulations/response/reduced/ramp_steer_config.yml",
"simulations/lap/lap_time_eval_config.yml",
+ "simulations/lap/racing_line_config.yml",
"simulations/lap/lap_validation_config.yml",
"simulations/lap/tracks",
"simulations/envelope/ggv/ggv_config.yml",
diff --git a/common/reporting/racing_line_report.py b/common/reporting/racing_line_report.py
new file mode 100644
index 00000000..6d6530fc
--- /dev/null
+++ b/common/reporting/racing_line_report.py
@@ -0,0 +1,37 @@
+"""Racing-line geometry report."""
+from pathlib import Path
+from typing import Any
+
+from matplotlib.figure import Figure
+from matplotlib.backends.backend_pdf import PdfPages
+import numpy as np
+
+
+def build_racing_line_report(result: dict[str, Any], path: Path) -> None:
+ """## Racing-Line Report
+
+ Parameters
+ ----------
+ result : dict
+ Generated line, track boundaries and clearance inputs.
+ path : Path
+ Destination PDF.
+ """
+ line = result["line"]
+ with PdfPages(path) as pdf:
+ fig = Figure(figsize=(11, 8.5), layout="constrained")
+ ax = fig.subplots()
+ for key, label, color in (("left", "Boundary 1", "#64748b"), ("right", "Boundary 2", "#94a3b8")):
+ points = np.asarray(result[key])
+ points = np.vstack((points, points[0]))
+ ax.plot(points[:, 0], points[:, 1], color=color, label=label)
+ ax.plot([*line["x_m"], line["x_m"][0]], [*line["y_m"], line["y_m"][0]], color="#0284c7", label="Racing line")
+ ax.set(aspect="equal", xlabel="x (m)", ylabel="y (m)", title="Racing line")
+ ax.legend()
+ fig.suptitle(f"Width {result['vehicle_width_m']:.3f} m | Clearance {result['clearance_m']:.3f} m per side")
+ pdf.savefig(fig)
+ fig = Figure(figsize=(11, 8.5), layout="constrained")
+ ax = fig.subplots()
+ ax.plot(line["station_m"], line["curvature_per_m"])
+ ax.set(xlabel="Distance (m)", ylabel="Curvature (1/m)", title="Racing-line curvature")
+ pdf.savefig(fig)
diff --git a/docs/lap-time-simulation.md b/docs/lap-time-simulation.md
index 58d97dd9..824a7c9f 100644
--- a/docs/lap-time-simulation.md
+++ b/docs/lap-time-simulation.md
@@ -55,15 +55,29 @@ make lap-validation-visuals # resume from cached work
## Track format and racing line
+Boundary previews and saved racing-line reports use periodic cubic splines through every boundary point. The optimizer constrains vehicle clearance at the paired gates.
+
**Track input:** closed sequence of paired gates with left/right boundaries (metres):
```csv
-left_x_m,left_y_m,right_x_m,right_y_m
+x1,y1,x2,y2
...
```
-Legal vehicle-center interval = gate midline ± (half vehicle width + safety margin).
+Vehicle width comes from the widest axle spacing plus its tire section width.
+The vehicle-center offset is bounded by half the gate width minus half the
+vehicle width and the requested edge clearance.
+
+Each row pairs two boundary points across the track. Rows follow the circuit
+in order and each boundary stays on the same side. The track closes automatically.
+Legacy `left_x_m,left_y_m,right_x_m,right_y_m` headers are also accepted.
+
+The app has a Track analysis for importing the CSV and generating a racing line.
+QSS Lap requires linked GGV and racing-line results. It uses the saved line
+without re-optimization. Track, clearance or derived-width changes invalidate
+the line connection until it is regenerated. Results include the boundaries,
+line and curvature.
-Periodic cubic splines → arc-length-sampled line with heading, curvature, segment length.
+Periodic cubic splines give the sampled line, heading, curvature and segment length.
**Default track:** `simulations/lap/tracks/endurance_michigan_2019.csv` (2019 FSAE Michigan; metres, provenance in `tracks/README.md`).
diff --git a/simulations/lap/core/racing_line.py b/simulations/lap/core/racing_line.py
index 9a640174..00219d40 100644
--- a/simulations/lap/core/racing_line.py
+++ b/simulations/lap/core/racing_line.py
@@ -7,7 +7,7 @@
import numpy as np
from numpy.typing import NDArray
-from scipy.optimize import minimize
+from scipy.optimize import OptimizeResult, minimize
from simulations.lap.core.qss import GGVMap, QSSLapResult, solve_qss_lap
from simulations.lap.core.track import RacingLine, TrackCorridor
@@ -65,24 +65,9 @@ def optimize_racing_line(
iterations=0,
)
- def curvature_cost(offsets: FloatArray) -> float:
- try:
- line = corridor.line_from_offsets(offsets, sample_step_m=sample_step_m)
- except ValueError:
- return 1e12
- curvature_energy = np.sum(
- line.curvature_per_m**2 * line.segment_length_m
- )
- offset_roughness = np.diff(offsets, n=2, append=offsets[:2])
- return float(curvature_energy + 1e-3 * np.sum(offset_roughness**2))
-
- curvature_solution = minimize(
- curvature_cost,
- center_offsets,
- method="SLSQP",
- bounds=bounds,
- options={"maxiter": curvature_max_iterations, "ftol": 1e-8},
- )
+ curvature_solution = minimize_curvature(corridor, vehicle_width_m=vehicle_width_m,
+ safety_margin_m=safety_margin_m, sample_step_m=sample_step_m,
+ max_iterations=curvature_max_iterations)
curvature_offsets = np.asarray(curvature_solution.x, dtype=float)
curvature_line = corridor.line_from_offsets(
curvature_offsets,
@@ -154,3 +139,51 @@ def lap_time_cost(offsets: FloatArray) -> float:
message=str(time_solution.message),
iterations=int(curvature_solution.nit + time_solution.nit),
)
+
+
+def minimize_curvature(corridor: TrackCorridor, *, vehicle_width_m: float,
+ safety_margin_m: float = 0.15, sample_step_m: float = 1.,
+ max_iterations: int = 200) -> OptimizeResult:
+ """## Minimum-Curvature Line
+
+ Parameters
+ ----------
+ corridor : TrackCorridor
+ Paired track boundaries.
+ vehicle_width_m : float
+ Nominal outside-tire width.
+ safety_margin_m : float
+ Clearance beyond each side of the vehicle.
+ sample_step_m : float
+ Path sampling distance.
+ max_iterations : int
+ Curvature optimizer iteration limit.
+
+ Returns
+ -------
+ scipy.optimize.OptimizeResult
+ Gate offsets and convergence diagnostics.
+ """
+ lower, upper = corridor.offset_bounds(vehicle_width_m=vehicle_width_m, safety_margin_m=safety_margin_m)
+ bounds = list(zip(lower, upper))
+ center_offsets = np.zeros(corridor.gate_count)
+
+ def curvature_cost(offsets: FloatArray) -> float:
+ try:
+ line = corridor.line_from_offsets(offsets, sample_step_m=sample_step_m)
+ except ValueError:
+ return 1e12
+ curvature_energy = np.sum(
+ line.curvature_per_m**2 * line.segment_length_m
+ )
+ offset_roughness = np.diff(offsets, n=2, append=offsets[:2])
+ return float(curvature_energy + 1e-3 * np.sum(offset_roughness**2))
+
+ curvature_solution = minimize(
+ curvature_cost,
+ center_offsets,
+ method="SLSQP",
+ bounds=bounds,
+ options={"maxiter": max_iterations, "ftol": 1e-8},
+ )
+ return curvature_solution
diff --git a/simulations/lap/core/track.py b/simulations/lap/core/track.py
index 95e6c157..926f095b 100644
--- a/simulations/lap/core/track.py
+++ b/simulations/lap/core/track.py
@@ -129,15 +129,94 @@ def __post_init__(self) -> None:
@classmethod
def from_csv(cls, path: str | Path, *, closed: bool = True) -> TrackCorridor:
- """Load columns ``left_x_m,left_y_m,right_x_m,right_y_m``."""
+ """Load ``x1,y1,x2,y2`` or the legacy left/right column names."""
left: list[tuple[float, float]] = []
right: list[tuple[float, float]] = []
- with Path(path).open(newline="", encoding="utf-8") as handle:
- for row in csv.DictReader(handle):
- left.append((float(row["left_x_m"]), float(row["left_y_m"])))
- right.append((float(row["right_x_m"]), float(row["right_y_m"])))
- return cls(np.asarray(left), np.asarray(right), closed=closed)
+ with Path(path).open(newline="", encoding="utf-8-sig") as handle:
+ reader = csv.DictReader(handle)
+ reader.fieldnames = [name.strip() for name in (reader.fieldnames or [])]
+ columns = ("x1", "y1", "x2", "y2") if "x1" in (reader.fieldnames or []) else (
+ "left_x_m", "left_y_m", "right_x_m", "right_y_m")
+ if not set(columns).issubset(reader.fieldnames or []):
+ raise ValueError("Track CSV needs x1,y1,x2,y2 columns in metres.")
+ try:
+ for row in reader:
+ left.append((float(row[columns[0]]), float(row[columns[1]])))
+ right.append((float(row[columns[2]]), float(row[columns[3]])))
+ except (TypeError, ValueError) as exc:
+ raise ValueError(f"Invalid track coordinates on row {reader.line_num}.") from exc
+ if len(left) > 1 and np.linalg.norm(np.asarray(left[0]) - left[-1]) <= 1e-6 \
+ and np.linalg.norm(np.asarray(right[0]) - right[-1]) <= 1e-6:
+ left.pop()
+ right.pop()
+ corridor = cls(np.asarray(left), np.asarray(right), closed=closed)
+ corridor.validate_order()
+ return corridor
+
+ def validate_order(self) -> None:
+ """## Boundary Ordering
+
+ Reject crossed boundaries, duplicate gates and reversed gate pairings.
+ """
+ centers = self.center_points_m
+ tangent = np.roll(centers, -1, axis=0) - np.roll(centers, 1, axis=0)
+ gates = self.left_boundary_m - self.right_boundary_m
+ side = tangent[:, 0] * gates[:, 1] - tangent[:, 1] * gates[:, 0]
+ if np.any(side > 1e-8) and np.any(side < -1e-8):
+ raise ValueError("Track boundary pairs change sides. Keep each boundary in circuit order.")
+ if np.any(np.linalg.norm(np.roll(centers, -1, axis=0) - centers, axis=1) <= 1e-6):
+ raise ValueError("Consecutive track gates must be distinct.")
+ starts = np.vstack((self.left_boundary_m, self.right_boundary_m))
+ ends = np.vstack((np.roll(self.left_boundary_m, -1, axis=0),
+ np.roll(self.right_boundary_m, -1, axis=0)))
+ delta = ends - starts
+ for index, (point, vector) in enumerate(zip(starts, delta)):
+ relative = starts[index + 1:] - point
+ others = delta[index + 1:]
+ denominator = vector[0] * others[:, 1] - vector[1] * others[:, 0]
+ valid = np.abs(denominator) > 1e-10
+ first = np.zeros_like(denominator)
+ second = np.zeros_like(denominator)
+ np.divide(relative[:, 0] * others[:, 1] - relative[:, 1] * others[:, 0],
+ denominator, out=first, where=valid)
+ np.divide(relative[:, 0] * vector[1] - relative[:, 1] * vector[0],
+ denominator, out=second, where=valid)
+ if np.any(valid & (first > 1e-8) & (first < 1. - 1e-8) & (second > 1e-8) & (second < 1. - 1e-8)):
+ raise ValueError("Track boundaries cross. Check the CSV row order and boundary pairing.")
+
+ def interpolated_boundaries(self, sample_step_m: float = 1.0) -> tuple[FloatArray, FloatArray]:
+ """## Boundary Splines
+
+ Interpolate every paired gate with closed, periodic cubic splines.
+
+ Parameters
+ ----------
+ sample_step_m : float
+ Maximum sampling interval along each boundary's chord parameter.
+
+ Returns
+ -------
+ tuple[FloatArray, FloatArray]
+ Boundary samples including every input point and the closing point.
+ """
+ if not self.closed:
+ raise NotImplementedError("Boundary splines require a closed track.")
+ if not np.isfinite(sample_step_m) or sample_step_m <= 0.:
+ raise ValueError("Boundary sample spacing must be positive and finite.")
+ boundaries = []
+ for points in (self.left_boundary_m, self.right_boundary_m):
+ chords = np.linalg.norm(np.roll(points, -1, axis=0) - points, axis=1)
+ if np.any(chords <= 1e-6):
+ raise ValueError("Consecutive boundary points must be distinct.")
+ parameter = np.concatenate(([0.], np.cumsum(chords)))
+ spline = CubicSpline(parameter, np.vstack((points, points[0])), bc_type="periodic")
+ samples = np.concatenate([
+ np.linspace(start, end, max(2, int(np.ceil((end - start) / sample_step_m)) + 1))[:-1]
+ for start, end in zip(parameter[:-1], parameter[1:])
+ ] + [parameter[-1:]])
+ boundaries.append(np.asarray(spline(samples), dtype=float))
+ return boundaries[0], boundaries[1]
@property
def gate_count(self) -> int:
@@ -164,6 +243,10 @@ def offset_bounds(
) -> tuple[FloatArray, FloatArray]:
"""Return lateral center-position bounds after vehicle-footprint clearance."""
+ if not np.isfinite(vehicle_width_m) or vehicle_width_m <= 0.:
+ raise ValueError("Vehicle width must be positive and finite.")
+ if not np.isfinite(safety_margin_m) or safety_margin_m < 0.:
+ raise ValueError("Edge clearance must be nonnegative and finite.")
clearance = 0.5 * float(vehicle_width_m) + float(safety_margin_m)
half_available = 0.5 * self.gate_widths_m - clearance
if np.any(half_available <= 0.0):
diff --git a/simulations/lap/lap_time_eval_config.yml b/simulations/lap/lap_time_eval_config.yml
index 9fd40c25..b51e318b 100644
--- a/simulations/lap/lap_time_eval_config.yml
+++ b/simulations/lap/lap_time_eval_config.yml
@@ -10,7 +10,6 @@ event:
track:
boundary_csv: simulations/lap/tracks/endurance_michigan_2019.csv
- vehicle_width_m: 1.35
safety_margin_m: 0.15
sample_step_m: 1.0
diff --git a/simulations/lap/lap_time_eval_sim.py b/simulations/lap/lap_time_eval_sim.py
index 23313ebc..f7c2c4b2 100644
--- a/simulations/lap/lap_time_eval_sim.py
+++ b/simulations/lap/lap_time_eval_sim.py
@@ -85,23 +85,37 @@ def run_lap_time_evaluation(
qss_config=qss_config,
effective_power_limit_w=vehicle.parameters.peak_drive_power_w,
)
- corridor = TrackCorridor.from_csv(track_path)
- line_mode = cast(LineMode, str(line_config.get("mode", "minimum_time_qss")))
- optimized = optimize_racing_line(
- corridor,
- ggv,
- mode=line_mode,
- vehicle_width_m=float(track_config.get("vehicle_width_m", 1.35)),
- safety_margin_m=float(track_config.get("safety_margin_m", 0.1)),
- sample_step_m=float(track_config.get("sample_step_m", 1.0)),
- curvature_max_iterations=int(line_config.get("curvature_max_iterations", 30)),
- lap_time_max_iterations=int(line_config.get("lap_time_max_iterations", 25)),
- max_speed_mps=(
- float(qss_config["max_speed_mps"])
- if qss_config.get("max_speed_mps") is not None
- else None
- ),
- )
+ from simulations.lap.racing_line_sim import load_line, vehicle_width_m
+ if config.get("linked_racing_line"):
+ from simulations.lap.core.qss import solve_qss_lap
+ from simulations.lap.core.racing_line import LineOptimizationResult
+ link = config["linked_racing_line"]
+ line_path = _root_path(root, link["path"])
+ if hashlib.sha256(line_path.read_bytes()).hexdigest() != link["sha256"]:
+ raise ValueError("Linked racing-line file changed. Reconnect the generated result.")
+ line = load_line(line_path)
+ lap = solve_qss_lap(line, ggv, max_speed_mps=qss_config.get("max_speed_mps"))
+ line_mode = "linked"
+ optimized = LineOptimizationResult("minimum_curvature", line, lap, lap.lap_time_s,
+ lap.lap_time_s, True, "Saved racing line", 0)
+ else:
+ corridor = TrackCorridor.from_csv(track_path)
+ line_mode = cast(LineMode, str(line_config.get("mode", "minimum_time_qss")))
+ optimized = optimize_racing_line(
+ corridor,
+ ggv,
+ mode=line_mode,
+ vehicle_width_m=vehicle_width_m(load_yaml(vehicle_path), root),
+ safety_margin_m=float(track_config.get("safety_margin_m", 0.1)),
+ sample_step_m=float(track_config.get("sample_step_m", 1.0)),
+ curvature_max_iterations=int(line_config.get("curvature_max_iterations", 30)),
+ lap_time_max_iterations=int(line_config.get("lap_time_max_iterations", 25)),
+ max_speed_mps=(
+ float(qss_config["max_speed_mps"])
+ if qss_config.get("max_speed_mps") is not None
+ else None
+ ),
+ )
write_qss_lap_csv(output_directory / "qss_lap.csv", optimized.qss_lap)
summary: dict[str, Any] = {
"scenario": scenario,
@@ -120,9 +134,11 @@ def run_lap_time_evaluation(
and np.max(optimized.qss_lap.speed_mps) >= ggv.max_speed_mps - 1e-6
),
"line_mode": line_mode,
+ "racing_line_source": config.get("linked_racing_line"),
"track_length_m": optimized.line.track_length_m,
- "centerline_qss_lap_time_s": optimized.centerline_lap_time_s,
- "minimum_curvature_qss_lap_time_s": optimized.minimum_curvature_lap_time_s,
+ "centerline_qss_lap_time_s": None if config.get("linked_racing_line") else optimized.centerline_lap_time_s,
+ "minimum_curvature_qss_lap_time_s": (None if config.get("linked_racing_line")
+ else optimized.minimum_curvature_lap_time_s),
"optimized_qss_lap_time_s": optimized.qss_lap.lap_time_s,
"qss_converged": optimized.qss_lap.converged,
"line_optimizer_converged": optimized.success,
diff --git a/simulations/lap/racing_line_config.yml b/simulations/lap/racing_line_config.yml
new file mode 100644
index 00000000..5fa6eeb2
--- /dev/null
+++ b/simulations/lap/racing_line_config.yml
@@ -0,0 +1,4 @@
+track:
+ boundary_csv: simulations/lap/tracks/endurance_michigan_2019.csv
+ safety_margin_m: 0.15
+mode: minimum_curvature
diff --git a/simulations/lap/racing_line_sim.py b/simulations/lap/racing_line_sim.py
new file mode 100644
index 00000000..0567065c
--- /dev/null
+++ b/simulations/lap/racing_line_sim.py
@@ -0,0 +1,147 @@
+"""Vehicle-clearance track geometry and reusable racing-line results."""
+from __future__ import annotations
+
+from dataclasses import asdict
+import hashlib
+import json
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+from common.vehicle_io import load_yaml, parse_tir, repo_root, tire_template_name
+from simulations.lap.core.racing_line import minimize_curvature
+from simulations.lap.core.track import RacingLine, TrackCorridor
+
+
+def vehicle_width_m(vehicle: dict[str, Any], root: Path) -> float:
+ """## Nominal Vehicle Width
+
+ Parameters
+ ----------
+ vehicle : dict
+ Vehicle geometry and axle tire definitions.
+ root : Path
+ Root for relative tire paths.
+
+ Returns
+ -------
+ float
+ Widest nominal outside-tire span in metres. Wheels are straight ahead.
+ """
+ tire_root = root / vehicle.get("paths", {}).get("tire_templates", "common/tire_templates")
+ widths = []
+ for axle in ("front", "rear"):
+ side = vehicle[axle]
+ tire = parse_tir(tire_root / f"{tire_template_name(vehicle, side)}.tir")
+ if "WIDTH" not in tire:
+ raise ValueError(f"The {axle} tire file needs a WIDTH value in metres.")
+ section_width = float(tire["WIDTH"])
+ half_track = abs(float(side["suspension"]["wheel_center_m"][1]))
+ if not np.isfinite(section_width + half_track) or section_width <= 0. or half_track <= 0.:
+ raise ValueError(f"Invalid {axle} wheel spacing or tire WIDTH.")
+ widths.append(2. * half_track + section_width)
+ return max(widths)
+
+
+def line_signature(config: dict[str, Any], root: Path) -> str:
+ """## Racing-Line Input Signature
+
+ Parameters
+ ----------
+ config : dict
+ Track and clearance settings.
+ root : Path
+ Vehicle workspace root.
+
+ Returns
+ -------
+ str
+ Digest of the track, derived width, clearance and generation method.
+ """
+ track = config["track"]
+ values = {"width": vehicle_width_m(load_yaml(root / "vehicle.yml"), root),
+ "track": hashlib.sha256((root / track["boundary_csv"]).read_bytes()).hexdigest(),
+ "clearance": float(track.get("safety_margin_m", .15)),
+ "mode": config.get("mode", "minimum_curvature")}
+ return hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest()
+
+
+def run(config: dict[str, Any], root: Path | None = None) -> dict[str, Any]:
+ """## Racing-Line Generation
+
+ Parameters
+ ----------
+ config : dict
+ Track path, clearance and centerline or minimum-curvature method.
+ root : Path or None
+ Vehicle workspace root.
+
+ Returns
+ -------
+ dict
+ Converged line, original boundaries and input provenance.
+ """
+ root = root or repo_root()
+ signature = line_signature(config, root)
+ track = config["track"]
+ corridor = TrackCorridor.from_csv(root / track["boundary_csv"])
+ width = vehicle_width_m(load_yaml(root / "vehicle.yml"), root)
+ margin = float(track.get("safety_margin_m", .15))
+ corridor.offset_bounds(vehicle_width_m=width, safety_margin_m=margin)
+ mode = config.get("mode", "minimum_curvature")
+ if mode not in {"centerline", "minimum_curvature"}:
+ raise ValueError("Choose centerline or minimum_curvature.")
+ offsets = np.zeros(corridor.gate_count)
+ if mode == "minimum_curvature":
+ solution = minimize_curvature(corridor, vehicle_width_m=width, safety_margin_m=margin)
+ if not solution.success:
+ raise ValueError(f"Racing line did not converge: {solution.message}")
+ offsets = solution.x
+ line = corridor.line_from_offsets(offsets)
+ left, right = corridor.interpolated_boundaries()
+ result = {"schema": "bobsim.racing-line.v1", "signature": signature,
+ "vehicle_width_m": width, "clearance_m": margin, "mode": mode,
+ "track": track["boundary_csv"], "length_m": line.track_length_m,
+ "left": left.tolist(), "right": right.tolist(),
+ "line": {key: value.tolist() if isinstance(value, np.ndarray) else value
+ for key, value in asdict(line).items()}}
+ if signature != line_signature(config, root):
+ raise ValueError("Track or vehicle inputs changed during line generation. Run it again.")
+ output = root / "app/user_data/results/racing-line"
+ output.mkdir(parents=True, exist_ok=True)
+ (output / "line.json").write_text(json.dumps(result), encoding="utf-8")
+ np.savetxt(output / "signals.csv", np.column_stack((line.station_m, line.x_m, line.y_m, line.curvature_per_m)),
+ delimiter=",", header="station_m,x_m,y_m,curvature_per_m", comments="")
+ from common.reporting.racing_line_report import build_racing_line_report
+ build_racing_line_report(result, output / "report.pdf")
+ return result
+
+
+def load_line(path: Path) -> RacingLine:
+ """## Saved Racing Line
+
+ Parameters
+ ----------
+ path : Path
+ Archived racing-line JSON.
+
+ Returns
+ -------
+ RacingLine
+ Original sampled line, without resampling or optimization.
+ """
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ if payload.get("schema") != "bobsim.racing-line.v1":
+ raise ValueError("Unsupported racing-line result.")
+ raw = payload["line"]
+ values: dict[str, Any] = {key: float(value) if key == "track_length_m" else np.asarray(value, dtype=float)
+ for key, value in raw.items()}
+ line = RacingLine(**values)
+ arrays = [line.station_m, line.x_m, line.y_m, line.heading_rad, line.curvature_per_m, line.segment_length_m]
+ if len(line.station_m) < 5 or any(a.shape != line.station_m.shape or not np.all(np.isfinite(a)) for a in arrays):
+ raise ValueError("Invalid racing-line samples.")
+ if line.station_m[0] != 0. or np.any(np.diff(line.station_m) <= 0.) or np.any(line.segment_length_m <= 0.) \
+ or not np.isfinite(line.track_length_m) or not np.isclose(line.segment_length_m.sum(), line.track_length_m):
+ raise ValueError("Invalid racing-line distance coordinates.")
+ return line
diff --git a/simulations/lap/validation_visuals.py b/simulations/lap/validation_visuals.py
index a427bd15..5443c370 100644
--- a/simulations/lap/validation_visuals.py
+++ b/simulations/lap/validation_visuals.py
@@ -303,11 +303,12 @@ def _write_lap_figures(output: Path, corridor: TrackCorridor, qss: Any, transien
finish = np.flatnonzero(transient.unwrapped_progress_m >= qss.line.track_length_m)
count = int(finish[0] + 1) if finish.size else transient.transient.time_s.size
state = transient.transient.signals
+ left, right = corridor.interpolated_boundaries()
series: dict[str, Any] = {
- "left_x": np.append(corridor.left_boundary_m[:, 0], corridor.left_boundary_m[0, 0]),
- "left_y": np.append(corridor.left_boundary_m[:, 1], corridor.left_boundary_m[0, 1]),
- "right_x": np.append(corridor.right_boundary_m[:, 0], corridor.right_boundary_m[0, 0]),
- "right_y": np.append(corridor.right_boundary_m[:, 1], corridor.right_boundary_m[0, 1]),
+ "left_x": left[:, 0],
+ "left_y": left[:, 1],
+ "right_x": right[:, 0],
+ "right_y": right[:, 1],
"qss_x": np.append(qss.line.x_m, qss.line.x_m[0]),
"qss_y": np.append(qss.line.y_m, qss.line.y_m[0]),
"transient_x": transient.transient.state[:count, 0],
@@ -643,18 +644,11 @@ def _write_reference_track_figures(
for name, raw_path in tracks.items():
corridor = TrackCorridor.from_csv(_root_path(root, raw_path))
prefix = str(name)
- series[f"{prefix}_left_x"] = np.append(
- corridor.left_boundary_m[:, 0], corridor.left_boundary_m[0, 0]
- )
- series[f"{prefix}_left_y"] = np.append(
- corridor.left_boundary_m[:, 1], corridor.left_boundary_m[0, 1]
- )
- series[f"{prefix}_right_x"] = np.append(
- corridor.right_boundary_m[:, 0], corridor.right_boundary_m[0, 0]
- )
- series[f"{prefix}_right_y"] = np.append(
- corridor.right_boundary_m[:, 1], corridor.right_boundary_m[0, 1]
- )
+ left, right = corridor.interpolated_boundaries()
+ series[f"{prefix}_left_x"] = left[:, 0]
+ series[f"{prefix}_left_y"] = left[:, 1]
+ series[f"{prefix}_right_x"] = right[:, 0]
+ series[f"{prefix}_right_y"] = right[:, 1]
plots[prefix] = {
"layout": "single",
"title": f"Reference track: {str(name).replace('_', ' ')}",
diff --git a/tests/app_editor_clarity.cjs b/tests/app_editor_clarity.cjs
index 23c93fbb..1d44f3bf 100644
--- a/tests/app_editor_clarity.cjs
+++ b/tests/app_editor_clarity.cjs
@@ -722,7 +722,8 @@ vm.runInContext(`
state.simulationWorkspace.simulations[0].ggv_source_id='ggv';
state.simulationWorkspace.simulations[0].ggv_input={name:'GGV',status:'Ready'};
renderSimulationWorkspace();
- assert.doesNotMatch(elements['simulation-tree'].innerHTML,/ggv-link-empty/);
+ assert.match(elements['simulation-tree'].innerHTML,/Link Racing line/);
+ assert.match(elements['simulation-tree'].innerHTML,/ggv-link-supplied/);
assert.match(elements['simulation-tree'].innerHTML,/GGV → GGV/);
assert.match(elements['simulation-tree'].innerHTML,/ggv-link-supplied/);
assert.match(elements['simulation-tree'].innerHTML,/data-ggv-linked="ggv"/);
@@ -813,7 +814,7 @@ vm.runInContext(`
wireSimulationGgvDrag();
const transfer={setData(type,value){assert.equal(type,'text/plain');assert.equal(value,'ggv');}};
const start=()=>events.dragstart({target:{closest:()=>({dataset:{ggvSource:'ggv'}})},dataTransfer:transfer});
- const drop={target:{closest:()=>lap},dataTransfer:transfer,preventDefault(){this.accepted=true;}};
+ const drop={target:{closest:selector=>selector==='[data-input-kind]' ? null : lap},dataTransfer:transfer,preventDefault(){this.accepted=true;}};
events.dragover(drop); assert.equal(drop.accepted,undefined);
start(); assert.equal(transfer.effectAllowed,'link');
events.dragover(drop); assert.equal(drop.accepted,true); assert.equal(highlighted,true);
diff --git a/tests/test_archive_run_inputs.py b/tests/test_archive_run_inputs.py
index ceca87de..f52b75ec 100644
--- a/tests/test_archive_run_inputs.py
+++ b/tests/test_archive_run_inputs.py
@@ -253,6 +253,10 @@ def linked_ggv_workspace(tmp_path, monkeypatch):
monkeypatch.setattr(app, 'ROOT', tmp_path)
monkeypatch.setattr(data_services, '_ggv_link_signature', lambda: 'current-inputs')
+ monkeypatch.setattr(data_services, '_simulation_line_input', lambda *args: {
+ 'status': 'Ready', 'path': 'line.json', 'sha256': 'line-hash',
+ 'source_id': 'line', 'run_id': 'line-run', 'track': {},
+ })
monkeypatch.setattr(vehicle_yaml, 'project_vehicle_yaml', lambda _: SimpleNamespace(
ggv=SimpleNamespace(max_drive_power=80000.)))
(tmp_path / 'vehicle.yml').write_text('vehicle:\n name: Linked\n')
@@ -261,7 +265,7 @@ def linked_ggv_workspace(tmp_path, monkeypatch):
(tmp_path / ggv_path).write_text(yaml.safe_dump({
'vehicle_template': '../../../vehicle.yml', 'generation': {'model_dof': 14}}))
(tmp_path / 'lap.yml').write_text(yaml.safe_dump({
- 'model_dof': 14, 'qss': {'ggv_csv': 'local.csv', 'generate_if_missing': True}}))
+ 'model_dof': 14, 'track': {}, 'qss': {'ggv_csv': 'local.csv', 'generate_if_missing': True}}))
(tmp_path / 'envelope.csv').write_text('speed_mps,ay_mps2,ax_accel_mps2,ax_brake_mps2\n5,0,10,-10\n')
workflows = (
app.WorkflowSpec('ggv', 'envelope', 'GGV', ggv_path, (),
@@ -306,12 +310,11 @@ def test_linked_ggv_requires_a_saved_compatible_result(linked_ggv_workspace):
with pytest.raises(ValueError, match='deleted'):
app.simulation_workspace('prepare-run', {'id': lap['id']})
app.simulation_workspace('set-ggv', {'id': lap['id'], 'source_id': None})
- app.simulation_workspace('prepare-run', {'id': lap['id']})
- assert yaml.safe_load((root / 'lap.yml').read_text())['qss']['generate_if_missing'] is True
+ with pytest.raises(ValueError, match='Connect a GGV'):
+ app.simulation_workspace('prepare-run', {'id': lap['id']})
@pytest.mark.parametrize('change,message', [
- ('power', 'Power limits differ'),
('settings', 'settings changed'), ('physics', 'current vehicle'),
])
def test_linked_ggv_rejects_stale_or_incompatible_inputs(linked_ggv_workspace, monkeypatch, change, message):
@@ -356,6 +359,7 @@ def test_linked_lap_inherits_source_fidelity(linked_ggv_workspace):
app.simulation_workspace('prepare-run', {'id': lap['id']})
prepared = yaml.safe_load((root / 'lap.yml').read_text())
assert prepared['model_dof'] == prepared['qss']['linked_ggv']['model_dof'] == 6
+ assert prepared['event']['drive_power_limit_w'] is None
app.simulation_workspace('set-ggv', {'id': lap['id'], 'source_id': None})
app.simulation_workspace('load', {'id': lap['id']})
assert yaml.safe_load((root / 'lap.yml').read_text())['model_dof'] == 6
@@ -516,7 +520,7 @@ def test_analysis_groups_are_explicit_unique_and_vehicle_owned(archive_workspace
assert result['analysis_groups'] == ['kinematic', 'transient']
assert result['simulations'] == []
assert app.simulation_workspace()['analysis_groups'] == ['kinematic', 'transient']
- with pytest.raises(ValueError, match='Choose Kinematic'):
+ with pytest.raises(ValueError, match='Choose Track, Kinematic'):
app.simulation_workspace('add-analysis', {'group': 'unknown'})
with pytest.raises(ValueError, match='active vehicle changed'):
app.simulation_workspace('add-analysis', {'group': 'steady-state', 'vehicle_key': 'other'})
diff --git a/tests/test_racing_lines.py b/tests/test_racing_lines.py
new file mode 100644
index 00000000..b0bf7bbb
--- /dev/null
+++ b/tests/test_racing_lines.py
@@ -0,0 +1,115 @@
+from __future__ import annotations
+
+import hashlib
+import json
+
+import numpy as np
+import pytest
+import yaml
+
+from app.tracks import import_track, track_payload
+from simulations.lap.core.track import TrackCorridor
+from simulations.lap.racing_line_sim import line_signature, load_line, run, vehicle_width_m
+
+
+@pytest.fixture
+def track_workspace(tmp_path):
+ vehicle = {axle: {"suspension": {"wheel_center_m": [x, y, .2]}, "tire": {"template": axle}}
+ for axle, x, y in [("front", 0., .6), ("rear", -1.5, .65)]}
+ (tmp_path / "vehicle.yml").write_text(yaml.safe_dump(vehicle))
+ tires = tmp_path / "common/tire_templates"
+ tires.mkdir(parents=True)
+ (tires / "front.tir").write_text("WIDTH = 0.21\n")
+ (tires / "rear.tir").write_text("WIDTH = 0.19\n")
+ angles = np.linspace(0., 2 * np.pi, 16, endpoint=False)
+ points = np.column_stack((20*np.cos(angles), 20*np.sin(angles), 26*np.cos(angles), 26*np.sin(angles)))
+ path = tmp_path / "track.csv"
+ np.savetxt(path, points, delimiter=",", header="x1,y1,x2,y2", comments="")
+ return tmp_path, vehicle, {"track": {"boundary_csv": "track.csv", "safety_margin_m": .15}, "mode": "centerline"}
+
+
+def test_track_import_width_and_generation(track_workspace):
+ root, vehicle, config = track_workspace
+ assert vehicle_width_m(vehicle, root) == pytest.approx(1.49)
+ imported = import_track(root, "test.csv", (root / "track.csv").read_text())
+ assert imported["vehicle_width_m"] == pytest.approx(1.49)
+ assert len(imported["left"]) > 16
+ result = run(config, root)
+ line = load_line(root / "app/user_data/results/racing-line/line.json")
+ np.testing.assert_array_equal(line.x_m, result["line"]["x_m"])
+ assert (root / "app/user_data/results/racing-line/report.pdf").is_file()
+ assert result["signature"] == line_signature(config, root)
+ config["mode"] = "minimum_curvature"
+ assert run(config, root)["mode"] == "minimum_curvature"
+
+
+@pytest.mark.parametrize("change", ["nan", "swapped", "duplicate", "header"])
+def test_invalid_track_import_does_not_save(track_workspace, change):
+ root, _, _ = track_workspace
+ rows = (root / "track.csv").read_text().splitlines()
+ if change == "header":
+ rows[0] = "x,y"
+ else:
+ points = np.loadtxt(root / "track.csv", delimiter=",", skiprows=1)
+ if change == "nan":
+ points[1, 0] = np.nan
+ if change == "swapped":
+ points[3] = points[3, [2, 3, 0, 1]]
+ if change == "duplicate":
+ points[2] = points[1]
+ rows = [rows[0], *(",".join(map(str, point)) for point in points)]
+ with pytest.raises(ValueError):
+ import_track(root, "test.csv", "\n".join(rows))
+ assert not list((root / "app/user_data/config/tracks").glob("*.csv"))
+
+
+def test_track_preview_rejects_path_escape(track_workspace):
+ root, _, _ = track_workspace
+ with pytest.raises(ValueError, match="workspace"):
+ track_payload(root, "../track.csv")
+
+
+def test_legacy_headers_and_repeated_closure(track_workspace):
+ root, _, _ = track_workspace
+ text = (root / "track.csv").read_text()
+ text = text.replace("x1,y1,x2,y2", "left_x_m,left_y_m,right_x_m,right_y_m")
+ (root / "track.csv").write_text(text + text.splitlines()[1] + "\n")
+ assert TrackCorridor.from_csv(root / "track.csv").gate_count == 16
+
+
+def test_racing_line_link_checks_inputs_and_result_integrity(track_workspace, monkeypatch):
+ from app import data_services
+ root, vehicle, config = track_workspace
+ result = run(config, root)
+ path = "app/user_data/results/racing-line/line.json"
+ digest = hashlib.sha256((root / path).read_bytes()).hexdigest()
+ source = {"id": "line", "name": "Line", "workflow_id": "racing-line", "config_text": yaml.safe_dump(config)}
+ lap = {"racing_line_source_id": "line"}
+ monkeypatch.setattr(data_services, "ROOT", root)
+ monkeypatch.setattr(data_services, "_safe_repo_path", lambda p: root / p)
+ monkeypatch.setattr(data_services, "saved_results_payload", lambda _: {"results": [{
+ "id": "run", "simulation": {"id": "line"},
+ "files": [{"label": "Racing line", "path": path, "sha256": digest}],
+ }]})
+ assert data_services._simulation_line_input(lap, [source], "vehicle")["status"] == "Ready"
+ vehicle["rear"]["suspension"]["wheel_center_m"][1] += .1
+ (root / "vehicle.yml").write_text(yaml.safe_dump(vehicle))
+ assert data_services._simulation_line_input(lap, [source], "vehicle")["status"] == "Stale"
+ result["line"]["x_m"][0] += 1.
+ (root / path).write_text(json.dumps(result))
+ assert data_services._simulation_line_input(lap, [source], "vehicle")["status"] == "Stale"
+ assert data_services._simulation_line_input(lap, [], "vehicle")["status"] == "Unavailable"
+
+
+def test_boundary_splines_interpolate_every_gate_and_close(track_workspace):
+ root, _, _ = track_workspace
+ corridor = TrackCorridor.from_csv(root / "track.csv")
+ left, right = corridor.interpolated_boundaries(.5)
+ for original, sampled in ((corridor.left_boundary_m, left), (corridor.right_boundary_m, right)):
+ np.testing.assert_allclose(sampled[0], sampled[-1], atol=1e-12)
+ distances = np.linalg.norm(original[:, None, :] - sampled[None, :, :], axis=2)
+ np.testing.assert_allclose(distances.min(axis=1), 0., atol=1e-12)
+ # A circular fixture distinguishes interpolation from straight gate segments.
+ assert np.max(np.abs(np.linalg.norm(sampled, axis=1) - np.linalg.norm(original[0]))) < .003
+ with pytest.raises(ValueError, match="positive and finite"):
+ corridor.interpolated_boundaries(0.)