Download calculator.py from jackstev/step-response-explainer: direct link, hf CLI and curl.
- Browser
- Download file 14.2 kB
-
https://huggingface.co/spaces/jackstev/step-response-explainer/resolve/main/calculator.py
- Command line
-
hf download hf://spaces/jackstev/step-response-explainer/calculator.py
-
curl -L -o calculator.py https://huggingface.co/spaces/jackstev/step-response-explainer/resolve/main/calculator.py
14.2 kB
| """Deterministic backend: underdamped second-order step response. | |
| Model: y(s)/u(s) = K * wn^2 / (s^2 + 2*zeta*wn*s + wn^2), step of size A at t = 0, | |
| zero initial conditions, 0 < zeta < 1. Normalised response: | |
| c(t) = 1 - exp(-zeta*wn*t) / sqrt(1 - zeta^2) * sin(wd*t + theta) | |
| with wd = wn*sqrt(1 - zeta^2) and theta = atan(sqrt(1 - zeta^2) / zeta) = acos(zeta). | |
| Physical output is y(t) = y_final * c(t), where y_final = K*A (or F/k for a mass-spring-damper). | |
| """ | |
| import math | |
| import numpy as np | |
| VERSION = "1.0" | |
| # Valid input ranges, enforced before any calculation runs | |
| RANGES = { | |
| "zeta": (0.05, 0.95), # underdamped only, away from the singular ends | |
| "wn": (0.1, 500.0), # rad/s | |
| "mass": (0.01, 10_000.0), # kg | |
| "stiffness": (1.0, 1e7), # N/m | |
| "damping": (0.0, 1e6), # N*s/m, zeta range is checked after conversion | |
| "force": (-1e6, 1e6), # N, nonzero | |
| "amplitude": (-1e6, 1e6), # step size in input units, nonzero | |
| "gain": (-1e6, 1e6), # DC gain K, nonzero | |
| "os_limit": (0.1, 99.0), # percent | |
| "ts_limit": (1e-4, 1e5), # s | |
| } | |
| BANDS = {"2%": 0.02, "5%": 0.05} # settling band as a fraction of the final value | |
| class InputError(ValueError): | |
| """Raised with a user-facing message when inputs are out of scope.""" | |
| def _check(name, value, label, unit=""): | |
| """Validate one numeric input against RANGES and return it as float.""" | |
| if value is None: | |
| raise InputError(f"Enter a value for {label}.") | |
| value = float(value) | |
| if not math.isfinite(value): | |
| raise InputError(f"{label} must be a finite number.") | |
| lo, hi = RANGES[name] | |
| if not lo <= value <= hi: | |
| raise InputError(f"{label} must be between {lo:g} and {hi:g} {unit}".strip() + ".") | |
| return value | |
| def normalised_response(t, zeta, wn): | |
| """Closed-form unit step response c(t) of the standard second-order system.""" | |
| root = math.sqrt(1.0 - zeta**2) | |
| wd = wn * root # damped natural frequency | |
| theta = math.acos(zeta) # phase, equals atan(root / zeta) | |
| return 1.0 - np.exp(-zeta * wn * t) / root * np.sin(wd * t + theta) | |
| def _bisect(f, a, b, iters=80): | |
| """Root of f on [a, b] assuming a sign change; plain bisection for determinism.""" | |
| fa = f(a) | |
| for _ in range(iters): | |
| m = 0.5 * (a + b) | |
| fm = f(m) | |
| if (fm > 0) == (fa > 0): | |
| a, fa = m, fm # root lies in the right half | |
| else: | |
| b = m # root lies in the left half | |
| return 0.5 * (a + b) | |
| def _first_crossing(level, zeta, wn, t_max, n=20_000): | |
| """First time c(t) reaches a level, refined by bisection.""" | |
| t = np.linspace(0.0, t_max, n) | |
| c = normalised_response(t, zeta, wn) | |
| idx = int(np.argmax(c >= level)) # first grid point at or above the level | |
| f = lambda x: float(normalised_response(np.array(x), zeta, wn)) - level | |
| return _bisect(f, t[max(idx - 1, 0)], t[idx]) | |
| def _settling_time(band, zeta, wn): | |
| """Last time |c(t) - 1| exceeds the band, searched up to the envelope bound.""" | |
| root = math.sqrt(1.0 - zeta**2) | |
| sigma = zeta * wn | |
| t_env = -math.log(band * root) / sigma # envelope is inside the band after this | |
| t = np.linspace(0.0, t_env, 40_000) | |
| err = np.abs(normalised_response(t, zeta, wn) - 1.0) | |
| outside = np.nonzero(err > band)[0] | |
| last = int(outside[-1]) # last grid sample still outside the band | |
| f = lambda x: abs(float(normalised_response(np.array(x), zeta, wn)) - 1.0) - band | |
| return _bisect(f, t[last], t[min(last + 1, len(t) - 1)]), t_env | |
| def _q(label, value, unit, formula=""): | |
| """One labelled quantity in the structured record.""" | |
| return {"label": label, "value": float(value), "unit": unit, "formula": formula} | |
| def compute(mode, wn=None, zeta=None, amplitude=1.0, gain=1.0, | |
| mass=None, damping=None, stiffness=None, force=None, | |
| band_name="2%", os_limit=None, ts_limit=None): | |
| """Validate inputs, compute every metric, and return a structured record. | |
| mode is "normalised" (wn, zeta, amplitude, gain) or "mass-spring-damper" | |
| (mass, damping, stiffness, force). Design limits are optional; None skips a check. | |
| """ | |
| if band_name not in BANDS: | |
| raise InputError("Choose a settling band of 2% or 5%.") | |
| band = BANDS[band_name] | |
| inputs = {} | |
| if mode == "normalised": | |
| wn = _check("wn", wn, "Natural frequency ωn", "rad/s") | |
| zeta = _check("zeta", zeta, "Damping ratio ζ") | |
| amplitude = _check("amplitude", amplitude, "Step size A") | |
| gain = _check("gain", gain, "DC gain K") | |
| if amplitude == 0 or gain == 0: | |
| raise InputError("Step size and DC gain must be nonzero, otherwise the output never moves.") | |
| y_final, out_unit, out_name = gain * amplitude, "", "output" # dimensionless output | |
| inputs["wn"] = _q("Natural frequency ωn", wn, "rad/s") | |
| inputs["zeta"] = _q("Damping ratio ζ", zeta, "") | |
| inputs["amplitude"] = _q("Step size A", amplitude, "") | |
| inputs["gain"] = _q("DC gain K", gain, "") | |
| elif mode == "mass-spring-damper": | |
| mass = _check("mass", mass, "Mass m", "kg") | |
| stiffness = _check("stiffness", stiffness, "Spring stiffness k", "N/m") | |
| damping = _check("damping", damping, "Damping coefficient c", "N·s/m") | |
| force = _check("force", force, "Step force F", "N") | |
| if force == 0: | |
| raise InputError("Step force must be nonzero, otherwise the mass never moves.") | |
| wn = math.sqrt(stiffness / mass) # rad/s | |
| zeta = damping / (2.0 * math.sqrt(stiffness * mass)) | |
| lo, hi = RANGES["zeta"] | |
| if not lo <= zeta <= hi: | |
| kind = "overdamped or critically damped" if zeta >= 1 else "outside the supported range" | |
| raise InputError( | |
| f"These values give ζ = {zeta:.3f}, which is {kind}. This calculator covers " | |
| f"underdamped systems with {lo} ≤ ζ ≤ {hi}; adjust c, k, or m.") | |
| if not RANGES["wn"][0] <= wn <= RANGES["wn"][1]: | |
| raise InputError(f"These values give ωn = {wn:.3g} rad/s, outside 0.1 to 500 rad/s.") | |
| y_final, out_unit, out_name = force / stiffness * 1000.0, "mm", "displacement" | |
| inputs["mass"] = _q("Mass m", mass, "kg") | |
| inputs["damping"] = _q("Damping coefficient c", damping, "N·s/m") | |
| inputs["stiffness"] = _q("Spring stiffness k", stiffness, "N/m") | |
| inputs["force"] = _q("Step force F", force, "N") | |
| else: | |
| raise InputError("Choose an input mode.") | |
| if os_limit is not None: | |
| os_limit = _check("os_limit", os_limit, "Overshoot limit", "%") | |
| if ts_limit is not None: | |
| ts_limit = _check("ts_limit", ts_limit, "Settling time limit", "s") | |
| # Core closed-form quantities | |
| root = math.sqrt(1.0 - zeta**2) | |
| sigma = zeta * wn # decay rate of the envelope | |
| wd = wn * root # damped natural frequency | |
| theta = math.acos(zeta) # phase angle in the formula | |
| t_peak = math.pi / wd # first peak | |
| os_frac = math.exp(-zeta * math.pi / root) # fractional overshoot | |
| t_rise_0_100 = (math.pi - theta) / wd # first time c(t) = 1 | |
| ts_textbook = (4.0 if band == 0.02 else 3.0) / sigma # classic 4/σ or 3/σ rule | |
| # Numerical quantities, still deterministic | |
| ts_exact, t_env = _settling_time(band, zeta, wn) | |
| t10 = _first_crossing(0.1, zeta, wn, t_peak) | |
| t90 = _first_crossing(0.9, zeta, wn, t_peak) | |
| # Self-check: sampled peak should match the closed-form peak time and height | |
| grid = np.linspace(0.0, 2.0 * t_peak, 200_001) | |
| c = normalised_response(grid, zeta, wn) | |
| i_max = int(np.argmax(c)) | |
| peak_time_err = abs(grid[i_max] - t_peak) | |
| peak_height_err = abs(c[i_max] - (1.0 + os_frac)) | |
| derived = {} | |
| if mode == "mass-spring-damper": | |
| # Only physical inputs need wn and zeta derived; normalised inputs already list them | |
| derived["wn"] = _q("Natural frequency ωn", wn, "rad/s", "√(k/m)") | |
| derived["zeta"] = _q("Damping ratio ζ", zeta, "", "c / (2√(km))") | |
| derived.update({ | |
| "wd": _q("Damped frequency ωd", wd, "rad/s", "ωn√(1−ζ²)"), | |
| "sigma": _q("Envelope decay rate σ", sigma, "1/s", "ζωn"), | |
| "theta": _q("Phase angle θ", math.degrees(theta), "deg", "atan(√(1−ζ²)/ζ)"), | |
| "period": _q("Damped period Td", 2 * math.pi / wd, "s", "2π/ωd"), | |
| }) | |
| metrics = { | |
| "final": _q(f"Final {out_name}", y_final, out_unit, "K·A" if mode == "normalised" else "F/k"), | |
| "overshoot": _q("Percent overshoot", 100 * os_frac, "%", "100·exp(−ζπ/√(1−ζ²))"), | |
| "peak_value": _q(f"Peak {out_name}", y_final * (1 + os_frac), out_unit, "final·(1 + OS)"), | |
| "peak_time": _q("Peak time Tp", t_peak, "s", "π/ωd"), | |
| "rise_10_90": _q("Rise time, 10% to 90%", t90 - t10, "s", "numerical, bisection"), | |
| "rise_0_100": _q("Rise time, 0% to 100%", t_rise_0_100, "s", "(π−θ)/ωd"), | |
| "ts_exact": _q(f"Settling time, {band_name} band", ts_exact, "s", "numerical, last band exit"), | |
| "ts_textbook": _q("Settling time, textbook estimate", ts_textbook, "s", | |
| "4/(ζωn)" if band == 0.02 else "3/(ζωn)"), | |
| } | |
| checks = [] | |
| if os_limit is not None: | |
| # Minimum damping ratio that meets the overshoot limit | |
| ln_os = math.log(os_limit / 100.0) | |
| zeta_req = -ln_os / math.sqrt(math.pi**2 + ln_os**2) | |
| ok = 100 * os_frac <= os_limit | |
| checks.append({ | |
| "name": "Overshoot", "value": 100 * os_frac, "limit": os_limit, "unit": "%", | |
| "result": "PASS" if ok else "FAIL", | |
| "requirement": _q("ζ needed for this overshoot limit", zeta_req, "", "−ln(OS)/√(π²+ln²OS)"), | |
| }) | |
| if ts_limit is not None: | |
| # Envelope-based ωn that settles within the limit at the current ζ | |
| wn_req = -math.log(band * root) / (zeta * ts_limit) | |
| ok = ts_exact <= ts_limit | |
| checks.append({ | |
| "name": "Settling time", "value": ts_exact, "limit": ts_limit, "unit": "s", | |
| "result": "PASS" if ok else "FAIL", | |
| "requirement": _q("ωn needed for this settling limit (envelope bound)", wn_req, "rad/s", | |
| "−ln(band·√(1−ζ²)) / (ζ·Ts,max)"), | |
| }) | |
| warnings = [] | |
| if zeta < 0.2: | |
| warnings.append("Very light damping: many oscillations before settling; small parameter errors change results noticeably.") | |
| if zeta > 0.8: | |
| warnings.append("Close to critical damping: overshoot is tiny and the textbook settling estimate is least accurate here.") | |
| if abs(ts_exact - ts_textbook) / ts_exact > 0.25: | |
| warnings.append("The textbook settling estimate differs from the exact value by more than 25%.") | |
| return { | |
| "calculator": "Underdamped second-order step response", | |
| "version": VERSION, | |
| "mode": mode, | |
| "output_name": out_name, | |
| "output_unit": out_unit, | |
| "settling_band": band_name, | |
| "inputs": inputs, | |
| "derived": derived, | |
| "metrics": metrics, | |
| "checks": checks, | |
| "poles": {"real": -sigma, "imag": wd, "unit": "rad/s"}, | |
| "assumptions": [ | |
| "Linear, time-invariant second-order system with no zeros.", | |
| "Ideal step applied at t = 0 with zero initial position and velocity.", | |
| "Underdamped only: 0.05 ≤ ζ ≤ 0.95.", | |
| "Settling time is the last exit from the band around the final value.", | |
| ], | |
| "warnings": warnings, | |
| "self_check": {"peak_time_error_s": peak_time_err, "peak_height_error": peak_height_err}, | |
| "_curve": {"zeta": zeta, "wn": wn, "y_final": y_final, "band": band, | |
| "t_end": max(1.3 * ts_exact, 3 * t_peak)}, | |
| } | |
| def fmt(value, sig=4): | |
| """Readable number: sig figs, no scientific notation for everyday sizes.""" | |
| if value == 0: | |
| return "0" | |
| mag = math.floor(math.log10(abs(value))) | |
| if -4 <= mag < 6: | |
| decimals = max(sig - 1 - mag, 0) | |
| text = f"{value:.{decimals}f}" | |
| return text.rstrip("0").rstrip(".") if "." in text else text | |
| return f"{value:.{sig - 1}e}" | |
| def to_text(record): | |
| """Human-readable version of the record, the exact text the LLM receives.""" | |
| lines = [f"{record['calculator']} (v{record['version']}, {record['mode']} inputs)", "", "Inputs:"] | |
| for q in record["inputs"].values(): | |
| lines.append(f" {q['label']}: {fmt(q['value'])} {q['unit']}".rstrip()) | |
| lines.append("Derived parameters:") | |
| for q in record["derived"].values(): | |
| lines.append(f" {q['label']}: {fmt(q['value'])} {q['unit']}".rstrip()) | |
| p = record["poles"] | |
| lines.append(f" Poles: {fmt(p['real'])} ± j{fmt(p['imag'])} rad/s") | |
| lines.append("Step response metrics:") | |
| for q in record["metrics"].values(): | |
| lines.append(f" {q['label']}: {fmt(q['value'])} {q['unit']}".rstrip()) | |
| if record["checks"]: | |
| lines.append("Design checks:") | |
| for ch in record["checks"]: | |
| req = ch["requirement"] | |
| lines.append(f" {ch['name']}: {fmt(ch['value'])} {ch['unit']} vs limit {fmt(ch['limit'])} " | |
| f"{ch['unit']} -> {ch['result']}; {req['label']}: {fmt(req['value'])} {req['unit']}".rstrip()) | |
| else: | |
| lines.append("Design checks: none requested") | |
| if record["warnings"]: | |
| lines.append("Warnings:") | |
| lines += [f" {w}" for w in record["warnings"]] | |
| return "\n".join(lines) | |
| def to_rows(record): | |
| """Table rows (quantity, value, unit, formula) for the numerical results panel.""" | |
| rows = [] | |
| for group in ("metrics", "derived"): | |
| for q in record[group].values(): | |
| rows.append([q["label"], fmt(q["value"]), q["unit"], q["formula"]]) | |
| p = record["poles"] | |
| rows.append(["Poles", f"{fmt(p['real'])} ± j{fmt(p['imag'])}", "rad/s", "−ζωn ± jωd"]) | |
| return rows | |