File size: 12,196 Bytes
f915d94 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 | """Gradio app: underdamped step response calculator with an LLM explanation panel."""
from html import escape
import gradio as gr
import numpy as np
import pandas as pd
from matplotlib.figure import Figure
import calculator as C
import explainer as E
import theme as T
MODES = {"Normalised (ωn, ζ)": "normalised", "Mass-spring-damper (m, c, k)": "mass-spring-damper"}
TABLE_COLUMNS = ["Quantity", "Value", "Unit", "Formula or method"]
STALE_NOTE = "_Select **Explain these results** to generate a plain-language explanation of the numbers above._"
def plot_response(record):
"""Response curve with final value, settling band, envelope and key markers."""
cv = record["_curve"]
t = np.linspace(0.0, cv["t_end"], 1500)
c = C.normalised_response(t, cv["zeta"], cv["wn"])
y, yf, band = cv["y_final"] * c, cv["y_final"], cv["band"]
root = (1 - cv["zeta"] ** 2) ** 0.5
env = np.exp(-cv["zeta"] * cv["wn"] * t) / root # decay envelope of the oscillation
fig = Figure(figsize=(7.2, 3.6), dpi=110)
fig.patch.set_facecolor(T.P["surface"])
ax = fig.add_subplot(111)
ax.set_facecolor(T.P["surface"])
ax.axhspan(yf * (1 - band), yf * (1 + band), color=T.P["data"], alpha=0.32, lw=0,
label=f"{record['settling_band']} band")
ax.plot(t, yf * (1 + env), color=T.P["muted"], lw=0.9, ls=":", label="Envelope")
ax.plot(t, yf * (1 - env), color=T.P["muted"], lw=0.9, ls=":")
ax.axhline(yf, color=T.P["ink"], lw=0.8, ls="--", label="Final value")
ax.plot(t, y, color=T.P["accent"], lw=2.2, label="Response")
m = record["metrics"]
tp, yp, ts = m["peak_time"]["value"], m["peak_value"]["value"], m["ts_exact"]["value"]
ax.plot([tp], [yp], "o", color=T.P["accent"], ms=6)
ax.annotate(f"Peak {C.fmt(yp)} at {C.fmt(tp)} s", (tp, yp), xytext=(8, 6),
textcoords="offset points", fontsize=8.5, color=T.P["ink"])
ax.axvline(ts, color=T.P["good"], lw=1.1)
ax.annotate(f"Settled {C.fmt(ts)} s", (ts, yf * (1 - band)), xytext=(4, -14),
textcoords="offset points", fontsize=8.5, color=T.P["good"])
unit = f" ({record['output_unit']})" if record["output_unit"] else ""
ax.set_xlabel("Time (s)", color=T.P["ink"])
ax.set_ylabel(f"{record['output_name'].capitalize()}{unit}", color=T.P["ink"])
lo = min(float(y.min()), 0.0, yf * (1 - band)) # works for negative steps too
hi = max(float(y.max()), 0.0, yf * (1 + band))
pad = 0.06 * (hi - lo)
ax.set_ylim(lo - pad, hi + pad)
ax.set_xlim(0, cv["t_end"])
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("left", "bottom"):
ax.spines[side].set_color(T.P["line"])
ax.tick_params(colors=T.P["muted"], labelsize=8.5)
ax.legend(loc="lower right", fontsize=8, frameon=False, labelcolor=T.P["ink"])
fig.tight_layout()
return fig
def summary_card(record):
"""Headline numbers plus one badge per design check."""
m = record["metrics"]
unit = f" {record['output_unit']}" if record["output_unit"] else ""
title = f"{C.fmt(m['overshoot']['value'])} % overshoot, settled in {C.fmt(m['ts_exact']['value'])} s"
sub = (f"The {escape(record['output_name'])} peaks at {C.fmt(m['peak_value']['value'])}{escape(unit)} "
f"after {C.fmt(m['peak_time']['value'])} s and settles at {C.fmt(m['final']['value'])}{escape(unit)}.")
badges = []
for ch in record["checks"]:
word = "passes" if ch["result"] == "PASS" else "fails"
badges.append(T.badge(f"{ch['name']} {word}", "good" if ch["result"] == "PASS" else "bad"))
body = " ".join(badges) if badges else T.badge("No design limits set", "warn")
foot = " ".join(escape(w) for w in record["warnings"]) or \
"Closed-form values from the standard second-order model; settling time found numerically."
return T.verdict_html(title, sub, f"<p>{body}</p>", foot)
def run_calculation(mode_label, wn, zeta, amplitude, gain, mass, damping, stiffness, force,
band, os_on, os_limit, ts_on, ts_limit):
"""Validate, compute, and render every numerical output. Errors clear stale results."""
try:
record = C.compute(
MODES.get(mode_label, ""), wn=wn, zeta=zeta, amplitude=amplitude, gain=gain,
mass=mass, damping=damping, stiffness=stiffness, force=force, band_name=band,
os_limit=os_limit if os_on else None, ts_limit=ts_limit if ts_on else None,
)
except (C.InputError, TypeError, ValueError) as exc:
card = T.verdict_html("Check the inputs", escape(str(exc)), empty=True)
return (card, None, pd.DataFrame(columns=TABLE_COLUMNS), None, "", {},
"_Fix the inputs above to see results and an explanation._", "")
public = {k: v for k, v in record.items() if not k.startswith("_")} # hide plotting internals
table = pd.DataFrame(C.to_rows(record), columns=TABLE_COLUMNS)
return (summary_card(record), plot_response(record), table, record, C.to_text(record),
public, STALE_NOTE, "")
def run_explanation(record):
"""Ask the LLM for prose, then audit it against the record."""
if record is None:
return "_There are no valid results to explain yet._", ""
text, source = E.explain(record)
report = E.grounding_check(text, record)
if report["grounded"]:
status = T.badge(f"Grounded: all {report['numbers_found']} numbers match the record", "good")
else:
issues = [f"not in record: {', '.join(report['unsupported'])}"] if report["unsupported"] else []
issues += report["contradictions"]
status = T.badge("Check this explanation against the table", "bad") + \
f'<p class="expl-note">{escape("; ".join(issues))}</p>'
status += f'<p class="expl-note">Written by {escape(source)}. The table above is the source of truth.</p>'
return text, status
def toggle_mode(mode_label):
"""Show only the input group that matches the chosen mode."""
physical = MODES.get(mode_label) == "mass-spring-damper"
return gr.update(visible=not physical), gr.update(visible=physical)
EXTRA_CSS = f"""
#explain-panel {{ border-top: 1px solid {T.P['line']}; margin-top: 8px; padding-top: 8px; }}
#explain-text {{ font-size: 16px; line-height: 1.6; max-width: 75ch; }}
.expl-note {{ color: {T.P['muted']}; font-size: 13px; margin: 6px 0 0; }}
"""
with gr.Blocks(title="Step response explainer") as demo:
gr.HTML(T.header_html(
"Step response explainer",
"Enter an underdamped second-order system, see its step response computed from first "
"principles, then get a plain-language explanation checked against the numbers."))
with gr.Row(equal_height=False):
with gr.Column(scale=5, min_width=300):
mode = gr.Radio(list(MODES), value="Normalised (ωn, ζ)", label="Describe the system by",
info="Physical values are converted to ωn = √(k/m) and ζ = c / (2√(km)).")
with gr.Column(visible=True) as norm_group:
wn = gr.Number(2.0, label="Natural frequency ωn (rad/s)", minimum=0.1, maximum=500,
info="Valid range 0.1 to 500 rad/s.")
zeta = gr.Slider(0.05, 0.95, value=0.5, step=0.01, label="Damping ratio ζ",
info="Underdamped only. Below 0.05 or at 1 and above is out of scope.")
with gr.Row():
amplitude = gr.Number(1.0, label="Step size A", info="Nonzero.")
gain = gr.Number(1.0, label="DC gain K", info="Final value is K·A.")
with gr.Column(visible=False) as phys_group:
with gr.Row():
mass = gr.Number(1.0, label="Mass m (kg)", minimum=0.01, maximum=10000,
info="0.01 to 10,000 kg.")
stiffness = gr.Number(100.0, label="Spring stiffness k (N/m)", minimum=1,
maximum=1e7, info="1 to 10⁷ N/m.")
with gr.Row():
damping = gr.Number(4.0, label="Damping c (N·s/m)", minimum=0,
info="Must give 0.05 ≤ ζ ≤ 0.95.")
force = gr.Number(10.0, label="Step force F (N)", info="Nonzero. Output shown in mm.")
band = gr.Radio(list(C.BANDS), value="2%", label="Settling band",
info="Settled means staying within this fraction of the final value.")
with gr.Accordion("Design limits (optional)", open=True):
with gr.Row():
os_on = gr.Checkbox(True, label="Check overshoot")
os_limit = gr.Number(10.0, label="Max overshoot (%)", minimum=0.1, maximum=99)
with gr.Row():
ts_on = gr.Checkbox(True, label="Check settling time")
ts_limit = gr.Number(3.0, label="Max settling time (s)", minimum=0.0001)
with gr.Column(scale=6, min_width=320):
card = gr.HTML()
plot = gr.Plot(label="Step response", show_label=False)
table = gr.Dataframe(headers=TABLE_COLUMNS, label="Numerical results",
interactive=False, wrap=True)
with gr.Column(elem_id="explain-panel"):
gr.HTML(T.section_html("Explain the results", "A small language model reads the structured "
"record and describes it in plain words. It does not do any math."))
explain_btn = gr.Button("Explain these results", variant="primary")
explanation = gr.Markdown(STALE_NOTE, elem_id="explain-text")
grounding = gr.HTML()
with gr.Accordion("What the language model receives", open=False):
llm_input = gr.Textbox(label="Structured record as text", lines=18, interactive=False)
with gr.Accordion("Full structured record (JSON)", open=False):
record_json = gr.JSON(label="Record")
record_state = gr.State(None)
inputs = [mode, wn, zeta, amplitude, gain, mass, damping, stiffness, force,
band, os_on, os_limit, ts_on, ts_limit]
mode.change(toggle_mode, mode, [norm_group, phys_group], api_name=False)
# With no explicit triggers, gr.on runs on load and whenever an input changes
gr.on(fn=run_calculation, inputs=inputs,
outputs=[card, plot, table, record_state, llm_input, record_json, explanation, grounding],
trigger_mode="always_last", concurrency_limit=4, api_name="calculate")
explain_btn.click(run_explanation, record_state, [explanation, grounding],
concurrency_limit=1, api_name="explain")
gr.Examples(
examples=[
["Normalised (ωn, ζ)", 2.0, 0.5, 1.0, 1.0, 1.0, 4.0, 100.0, 10.0, "2%", True, 10.0, True, 3.0],
["Normalised (ωn, ζ)", 5.0, 0.7, 1.0, 1.0, 1.0, 4.0, 100.0, 10.0, "5%", False, 10.0, False, 3.0],
["Mass-spring-damper (m, c, k)", 2.0, 0.5, 1.0, 1.0, 250.0, 1000.0, 16000.0, 2500.0, "2%", True, 25.0, True, 1.5],
["Mass-spring-damper (m, c, k)", 2.0, 0.5, 1.0, 1.0, 0.5, 4.0, 2000.0, 5.0, "2%", True, 20.0, True, 0.5],
["Mass-spring-damper (m, c, k)", 2.0, 0.5, 1.0, 1.0, 5.0, 60.0, 50.0, 20.0, "2%", False, 10.0, False, 3.0],
],
example_labels=[
"Textbook case, fails both limits",
"ζ = 0.7, settles before its peak",
"Quarter-car suspension, 2.5 kN load",
"Lightly damped sensor mount",
"Door closer, overdamped (out of scope)",
],
inputs=inputs, label="Try an example",
)
gr.HTML(T.footer_html([
"Assumes a linear second-order system with no zeros, an ideal step at t = 0, and zero initial "
"conditions. Overdamped and critically damped systems are outside this calculator's scope.",
f'Explanations by <a href="https://huggingface.co/{E.MODEL_ID}">{E.MODEL_ID}</a> at a pinned '
"revision, run on CPU. 24-679 Homework 3 by Jack Stevens.",
]))
if __name__ == "__main__":
demo.launch(theme=T.build_theme(), css=T.CSS + EXTRA_CSS, footer_links=[])
|