Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
86 changes: 80 additions & 6 deletions app/data_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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"}

Expand Down Expand Up @@ -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"}:
Expand Down Expand Up @@ -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)
Expand All @@ -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.")
Expand All @@ -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,
Expand Down Expand Up @@ -1383,13 +1431,24 @@ 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.")
source_id = payload.get("source_id") or None
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
Expand Down Expand Up @@ -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":
Expand All @@ -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"):
Expand All @@ -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")
Expand All @@ -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(
Expand Down Expand Up @@ -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),
}
)

Expand Down
12 changes: 10 additions & 2 deletions app/python_simulations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
)
Expand Down Expand Up @@ -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)

Expand Down
21 changes: 19 additions & 2 deletions app/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions app/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions app/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading