Spaces:
Sleeping
Sleeping
Batch B: #12 blowby, #1 split plots, #13 modebar, #2 export
Browse files- murphy_unified/tabs/simulation.py +104 -24
- murphy_unified/theme.py +34 -0
murphy_unified/tabs/simulation.py
CHANGED
|
@@ -269,7 +269,7 @@ def run_simulation(
|
|
| 269 |
)
|
| 270 |
ef = _empty_fig()
|
| 271 |
sim_state["_engine_ok"] = False
|
| 272 |
-
return err_html, params_html, ef, ef, ef, ef, err_html, sim_state
|
| 273 |
|
| 274 |
# ββ Extract scalars (prefer hist dict) βββββββββββββββββββββββββββββββ
|
| 275 |
mdot = hist.get("mdot_kgpm", 0.0)
|
|
@@ -308,6 +308,9 @@ def run_simulation(
|
|
| 308 |
eff_accent = "green" if mass_eff > 0.5 else "amber"
|
| 309 |
kWh_str = f"{kWh_ext:.3f}" if kWh_ext is not None else "N/A"
|
| 310 |
|
|
|
|
|
|
|
|
|
|
| 311 |
metrics_html = (
|
| 312 |
'<div class="metric-row">'
|
| 313 |
+ metric_html("MASS FLOW", f"{mdot:.3f}", "kg/min")
|
|
@@ -317,37 +320,38 @@ def run_simulation(
|
|
| 317 |
+ '<div class="metric-row">'
|
| 318 |
+ metric_html("PEAK PRESSURE", f"{pc_peak:.0f}", "barg")
|
| 319 |
+ metric_html("SPECIFIC ENERGY", kWh_str, "kWh/kg")
|
|
|
|
| 320 |
+ metric_html("CPU TIME", f"{cpu_s:.2f}", "s")
|
| 321 |
+ "</div>"
|
| 322 |
)
|
| 323 |
|
| 324 |
-
# ββ
|
| 325 |
angle = hist.get("angle_deg", np.array([]))
|
| 326 |
-
|
| 327 |
-
|
| 328 |
x=angle, y=hist.get("pc", []),
|
| 329 |
name="Chamber P",
|
| 330 |
line=dict(color=TRACE_COLORS[0], width=1.5),
|
| 331 |
))
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
x=angle, y=hist.get("Tc_K", []),
|
| 334 |
-
name="Chamber T
|
| 335 |
-
yaxis="y2",
|
| 336 |
line=dict(color=TRACE_COLORS[1], width=1.5),
|
| 337 |
))
|
| 338 |
-
|
| 339 |
-
title=f"{actual_engine} @ {Pexit:.0f} barg
|
| 340 |
xaxis_title="Crank angle [deg]",
|
| 341 |
-
yaxis_title="
|
| 342 |
-
|
| 343 |
-
title="Temperature [K]",
|
| 344 |
-
overlaying="y",
|
| 345 |
-
side="right",
|
| 346 |
-
tickfont=dict(family=FONT_MONO, size=10,
|
| 347 |
-
color=TRACE_COLORS[1]),
|
| 348 |
-
),
|
| 349 |
-
height=380,
|
| 350 |
-
legend=dict(x=0.01, y=0.99, bgcolor="rgba(0,0,0,0)"),
|
| 351 |
)
|
| 352 |
|
| 353 |
# ββ Valve dynamics plot ββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -436,8 +440,27 @@ def run_simulation(
|
|
| 436 |
|
| 437 |
status_html = f'<div class="status-bar">{" | ".join(parts)}</div>'
|
| 438 |
|
| 439 |
-
|
| 440 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
|
| 442 |
|
| 443 |
# ββ Builder ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -637,13 +660,30 @@ def build_simulation_tab(sim_params_state: gr.State):
|
|
| 637 |
with gr.Column(scale=3):
|
| 638 |
metrics_out = gr.HTML(label="Metrics")
|
| 639 |
params_out = gr.HTML(label="Parameter Summary")
|
| 640 |
-
|
|
|
|
|
|
|
| 641 |
fig_valves_out = gr.Plot(label="Valve Dynamics")
|
| 642 |
with gr.Row():
|
| 643 |
fig_extra1_out = gr.Plot(label="Extra Plot 1")
|
| 644 |
fig_extra2_out = gr.Plot(label="Extra Plot 2")
|
| 645 |
status_out = gr.HTML(label="Status")
|
| 646 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 647 |
# ββ Wire click event βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 648 |
run_btn.click(
|
| 649 |
fn=run_simulation,
|
|
@@ -666,8 +706,9 @@ def build_simulation_tab(sim_params_state: gr.State):
|
|
| 666 |
# Output selector
|
| 667 |
extras_cb,
|
| 668 |
],
|
| 669 |
-
outputs=[metrics_out, params_out,
|
| 670 |
-
fig_extra1_out, fig_extra2_out,
|
|
|
|
| 671 |
)
|
| 672 |
|
| 673 |
# ββ Widget list (order must match _WIDGET_KEYS_IN_ORDER) βββββββββββββ
|
|
@@ -712,3 +753,42 @@ def build_simulation_tab(sim_params_state: gr.State):
|
|
| 712 |
inputs=_all_widgets,
|
| 713 |
outputs=[sim_params_state],
|
| 714 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
)
|
| 270 |
ef = _empty_fig()
|
| 271 |
sim_state["_engine_ok"] = False
|
| 272 |
+
return err_html, params_html, ef, ef, ef, ef, ef, err_html, sim_state, None
|
| 273 |
|
| 274 |
# ββ Extract scalars (prefer hist dict) βββββββββββββββββββββββββββββββ
|
| 275 |
mdot = hist.get("mdot_kgpm", 0.0)
|
|
|
|
| 308 |
eff_accent = "green" if mass_eff > 0.5 else "amber"
|
| 309 |
kWh_str = f"{kWh_ext:.3f}" if kWh_ext is not None else "N/A"
|
| 310 |
|
| 311 |
+
bb_kgpm = hist.get("bb_kgpm")
|
| 312 |
+
bb_str = f"{bb_kgpm:.4f}" if bb_kgpm is not None else "N/A"
|
| 313 |
+
|
| 314 |
metrics_html = (
|
| 315 |
'<div class="metric-row">'
|
| 316 |
+ metric_html("MASS FLOW", f"{mdot:.3f}", "kg/min")
|
|
|
|
| 320 |
+ '<div class="metric-row">'
|
| 321 |
+ metric_html("PEAK PRESSURE", f"{pc_peak:.0f}", "barg")
|
| 322 |
+ metric_html("SPECIFIC ENERGY", kWh_str, "kWh/kg")
|
| 323 |
+
+ metric_html("BLOWBY", bb_str, "kg/min")
|
| 324 |
+ metric_html("CPU TIME", f"{cpu_s:.2f}", "s")
|
| 325 |
+ "</div>"
|
| 326 |
)
|
| 327 |
|
| 328 |
+
# ββ Pressure plot (issue #1: separate plots, not cluttered overlay) ββ
|
| 329 |
angle = hist.get("angle_deg", np.array([]))
|
| 330 |
+
fig_pressure = go.Figure()
|
| 331 |
+
fig_pressure.add_trace(go.Scatter(
|
| 332 |
x=angle, y=hist.get("pc", []),
|
| 333 |
name="Chamber P",
|
| 334 |
line=dict(color=TRACE_COLORS[0], width=1.5),
|
| 335 |
))
|
| 336 |
+
fig_pressure.update_layout(
|
| 337 |
+
title=f"Chamber Pressure β {actual_engine} @ {Pexit:.0f} barg",
|
| 338 |
+
xaxis_title="Crank angle [deg]",
|
| 339 |
+
yaxis_title="Pressure [barg]",
|
| 340 |
+
height=320,
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
# ββ Temperature plot ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 344 |
+
fig_temp = go.Figure()
|
| 345 |
+
fig_temp.add_trace(go.Scatter(
|
| 346 |
x=angle, y=hist.get("Tc_K", []),
|
| 347 |
+
name="Chamber T",
|
|
|
|
| 348 |
line=dict(color=TRACE_COLORS[1], width=1.5),
|
| 349 |
))
|
| 350 |
+
fig_temp.update_layout(
|
| 351 |
+
title=f"Chamber Temperature β {actual_engine} @ {Pexit:.0f} barg",
|
| 352 |
xaxis_title="Crank angle [deg]",
|
| 353 |
+
yaxis_title="Temperature [K]",
|
| 354 |
+
height=320,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
)
|
| 356 |
|
| 357 |
# ββ Valve dynamics plot ββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 440 |
|
| 441 |
status_html = f'<div class="status-bar">{" | ".join(parts)}</div>'
|
| 442 |
|
| 443 |
+
# Store results for export (issue #2)
|
| 444 |
+
results_bundle = {
|
| 445 |
+
"scalars": {
|
| 446 |
+
"mdot_kgpm": mdot,
|
| 447 |
+
"mass_eff": mass_eff,
|
| 448 |
+
"Tc_peak_K": Tc_peak,
|
| 449 |
+
"pc_peak_barg": pc_peak,
|
| 450 |
+
"kWh_extend": kWh_ext,
|
| 451 |
+
"bb_kgpm": hist.get("bb_kgpm"),
|
| 452 |
+
"cpu_s": cpu_s,
|
| 453 |
+
"steps": steps,
|
| 454 |
+
"engine": actual_engine,
|
| 455 |
+
},
|
| 456 |
+
"time_series": {k: v for k, v in hist.items()
|
| 457 |
+
if isinstance(v, np.ndarray)},
|
| 458 |
+
"params": {k: v for k, v in sim_state.items()
|
| 459 |
+
if not k.startswith("_")},
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
return (metrics_html, params_html, fig_pressure, fig_temp, fig_valves,
|
| 463 |
+
extra_figs[0], extra_figs[1], status_html, sim_state, results_bundle)
|
| 464 |
|
| 465 |
|
| 466 |
# ββ Builder ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 660 |
with gr.Column(scale=3):
|
| 661 |
metrics_out = gr.HTML(label="Metrics")
|
| 662 |
params_out = gr.HTML(label="Parameter Summary")
|
| 663 |
+
with gr.Row():
|
| 664 |
+
fig_pressure_out = gr.Plot(label="Chamber Pressure")
|
| 665 |
+
fig_temp_out = gr.Plot(label="Chamber Temperature")
|
| 666 |
fig_valves_out = gr.Plot(label="Valve Dynamics")
|
| 667 |
with gr.Row():
|
| 668 |
fig_extra1_out = gr.Plot(label="Extra Plot 1")
|
| 669 |
fig_extra2_out = gr.Plot(label="Extra Plot 2")
|
| 670 |
status_out = gr.HTML(label="Status")
|
| 671 |
|
| 672 |
+
# ββ Results export (issue #2) βββββββββββββββββββββββββββββββ
|
| 673 |
+
with gr.Accordion("Export Results", open=False):
|
| 674 |
+
export_sections_cb = gr.CheckboxGroup(
|
| 675 |
+
choices=["Key Results", "Time Series", "Parameters"],
|
| 676 |
+
value=["Key Results", "Time Series"],
|
| 677 |
+
label="Sections to include",
|
| 678 |
+
)
|
| 679 |
+
export_results_btn = gr.DownloadButton(
|
| 680 |
+
"Export selected sections (CSV)",
|
| 681 |
+
size="sm",
|
| 682 |
+
)
|
| 683 |
+
|
| 684 |
+
# ββ Hidden state for last run results βββββββββββββββββββββββββββββββ
|
| 685 |
+
sim_results_state = gr.State(None)
|
| 686 |
+
|
| 687 |
# ββ Wire click event βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 688 |
run_btn.click(
|
| 689 |
fn=run_simulation,
|
|
|
|
| 706 |
# Output selector
|
| 707 |
extras_cb,
|
| 708 |
],
|
| 709 |
+
outputs=[metrics_out, params_out, fig_pressure_out, fig_temp_out,
|
| 710 |
+
fig_valves_out, fig_extra1_out, fig_extra2_out,
|
| 711 |
+
status_out, sim_params_state, sim_results_state],
|
| 712 |
)
|
| 713 |
|
| 714 |
# ββ Widget list (order must match _WIDGET_KEYS_IN_ORDER) βββββββββββββ
|
|
|
|
| 753 |
inputs=_all_widgets,
|
| 754 |
outputs=[sim_params_state],
|
| 755 |
)
|
| 756 |
+
|
| 757 |
+
# ββ Results export handler (issue #2) ββββββββββββββββββββββββββββββββ
|
| 758 |
+
import pandas as pd
|
| 759 |
+
|
| 760 |
+
def _export_results(results: dict | None, sections: list[str]) -> str | None:
|
| 761 |
+
if results is None:
|
| 762 |
+
return None
|
| 763 |
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
| 764 |
+
path = os.path.join(tempfile.gettempdir(), f"murphy_results_{stamp}.xlsx")
|
| 765 |
+
with pd.ExcelWriter(path, engine="openpyxl") as writer:
|
| 766 |
+
if "Key Results" in sections and "scalars" in results:
|
| 767 |
+
s = results["scalars"]
|
| 768 |
+
df = pd.DataFrame([
|
| 769 |
+
{"Metric": k, "Value": v} for k, v in s.items()
|
| 770 |
+
if v is not None
|
| 771 |
+
])
|
| 772 |
+
df.to_excel(writer, sheet_name="Key Results", index=False)
|
| 773 |
+
if "Time Series" in sections and "time_series" in results:
|
| 774 |
+
ts = results["time_series"]
|
| 775 |
+
max_len = max((len(v) for v in ts.values()), default=0)
|
| 776 |
+
ts_df = pd.DataFrame({
|
| 777 |
+
k: np.pad(v, (0, max_len - len(v)),
|
| 778 |
+
constant_values=np.nan)
|
| 779 |
+
for k, v in ts.items()
|
| 780 |
+
})
|
| 781 |
+
ts_df.to_excel(writer, sheet_name="Time Series", index=False)
|
| 782 |
+
if "Parameters" in sections and "params" in results:
|
| 783 |
+
p = results["params"]
|
| 784 |
+
df = pd.DataFrame([
|
| 785 |
+
{"Parameter": k, "Value": str(v)} for k, v in p.items()
|
| 786 |
+
])
|
| 787 |
+
df.to_excel(writer, sheet_name="Parameters", index=False)
|
| 788 |
+
return path
|
| 789 |
+
|
| 790 |
+
export_results_btn.click(
|
| 791 |
+
fn=_export_results,
|
| 792 |
+
inputs=[sim_results_state, export_sections_cb],
|
| 793 |
+
outputs=[export_results_btn],
|
| 794 |
+
)
|
murphy_unified/theme.py
CHANGED
|
@@ -50,6 +50,11 @@ CSS = """
|
|
| 50 |
--body: 'DM Sans', sans-serif;
|
| 51 |
}
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
/* ββ body / dark-mode override ββββββββββββββββββββββββββββββββββββββββ */
|
| 54 |
body, .dark {
|
| 55 |
background: var(--bg) !important;
|
|
@@ -259,6 +264,17 @@ FORCE_DARK = """
|
|
| 259 |
function FORCE_DARK() {
|
| 260 |
document.body.classList.add('dark');
|
| 261 |
document.documentElement.style.colorScheme = 'dark';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
}
|
| 263 |
"""
|
| 264 |
|
|
@@ -328,8 +344,26 @@ _template = go.layout.Template(
|
|
| 328 |
yaxis=_axis,
|
| 329 |
colorway=TRACE_COLORS,
|
| 330 |
margin=dict(l=56, r=16, t=44, b=44),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
)
|
| 332 |
)
|
| 333 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
pio.templates["murphy"] = _template
|
| 335 |
pio.templates.default = "murphy"
|
|
|
|
| 50 |
--body: 'DM Sans', sans-serif;
|
| 51 |
}
|
| 52 |
|
| 53 |
+
/* ββ Plotly modebar β always visible on hover (issue #13) ββββββββββββ */
|
| 54 |
+
.js-plotly-plot .modebar { opacity: 0.7 !important; }
|
| 55 |
+
.js-plotly-plot:hover .modebar { opacity: 1 !important; }
|
| 56 |
+
.js-plotly-plot .modebar-btn { font-size: 16px; }
|
| 57 |
+
|
| 58 |
/* ββ body / dark-mode override ββββββββββββββββββββββββββββββββββββββββ */
|
| 59 |
body, .dark {
|
| 60 |
background: var(--bg) !important;
|
|
|
|
| 264 |
function FORCE_DARK() {
|
| 265 |
document.body.classList.add('dark');
|
| 266 |
document.documentElement.style.colorScheme = 'dark';
|
| 267 |
+
|
| 268 |
+
/* Issue #13: ensure Plotly modebar (download/copy buttons) is visible */
|
| 269 |
+
const observer = new MutationObserver(() => {
|
| 270 |
+
document.querySelectorAll('.js-plotly-plot').forEach(plot => {
|
| 271 |
+
if (plot._fullLayout && !plot._modebarPatched) {
|
| 272 |
+
Plotly.relayout(plot, {'modebar.orientation': 'v'});
|
| 273 |
+
plot._modebarPatched = true;
|
| 274 |
+
}
|
| 275 |
+
});
|
| 276 |
+
});
|
| 277 |
+
observer.observe(document.body, {childList: true, subtree: true});
|
| 278 |
}
|
| 279 |
"""
|
| 280 |
|
|
|
|
| 344 |
yaxis=_axis,
|
| 345 |
colorway=TRACE_COLORS,
|
| 346 |
margin=dict(l=56, r=16, t=44, b=44),
|
| 347 |
+
modebar=dict(
|
| 348 |
+
bgcolor="rgba(0,0,0,0)",
|
| 349 |
+
color=TEXT_DIM,
|
| 350 |
+
activecolor=ACCENT,
|
| 351 |
+
orientation="v",
|
| 352 |
+
),
|
| 353 |
)
|
| 354 |
)
|
| 355 |
|
| 356 |
+
# Plotly config applied to all figures β ensures modebar with download/copy
|
| 357 |
+
# button is visible (issue #13).
|
| 358 |
+
PLOTLY_CONFIG = {
|
| 359 |
+
"displayModeBar": True,
|
| 360 |
+
"modeBarButtonsToAdd": ["toImage"],
|
| 361 |
+
"toImageButtonOptions": {
|
| 362 |
+
"format": "png",
|
| 363 |
+
"filename": "murphy_plot",
|
| 364 |
+
"scale": 2,
|
| 365 |
+
},
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
pio.templates["murphy"] = _template
|
| 369 |
pio.templates.default = "murphy"
|