operon-feedback / app.py
coredipper's picture
Upload folder using huggingface_hub
8823e12 verified
"""
Operon Feedback Loop Homeostasis -- Interactive Gradio Demo
===========================================================
Simulate a NegativeFeedbackLoop controlling a value toward a setpoint.
Configure gain, damping, and disturbances to watch convergence, oscillation,
or overdamping in real time.
Run locally:
pip install gradio
python space-feedback/app.py
Deploy to HuggingFace Spaces:
Copy this directory to a new HF Space with sdk=gradio.
"""
import sys
from pathlib import Path
import gradio as gr
# Allow importing operon_ai from the repo root when running locally
_repo_root = Path(__file__).resolve().parent.parent
if str(_repo_root) not in sys.path:
sys.path.insert(0, str(_repo_root))
from operon_ai import NegativeFeedbackLoop
# ── Presets ────────────────────────────────────────────────────────────────
PRESETS: dict[str, dict] = {
"(custom)": {
"description": "Configure your own feedback loop parameters.",
"setpoint": 0.0,
"initial": 10.0,
"gain": 0.5,
"damping": 0.1,
"iterations": 30,
"disturbance_step": 0,
"disturbance_magnitude": 0.0,
},
"Temperature control": {
"description": "Smooth cooling from 85°F toward 72°F setpoint — classic thermostat behavior.",
"setpoint": 72.0,
"initial": 85.0,
"gain": 0.3,
"damping": 0.05,
"iterations": 30,
"disturbance_step": 0,
"disturbance_magnitude": 0.0,
},
"Oscillating convergence": {
"description": "High gain causes overshooting around the setpoint before settling.",
"setpoint": 0.0,
"initial": 10.0,
"gain": 0.8,
"damping": 0.0,
"iterations": 40,
"disturbance_step": 0,
"disturbance_magnitude": 0.0,
},
"Overdamped": {
"description": "Heavy damping — slow, stable approach with no overshoot.",
"setpoint": 50.0,
"initial": 0.0,
"gain": 0.2,
"damping": 0.3,
"iterations": 50,
"disturbance_step": 0,
"disturbance_magnitude": 0.0,
},
"Underdamped": {
"description": "Light damping + high gain — fast oscillations that ring before settling.",
"setpoint": 50.0,
"initial": 0.0,
"gain": 0.9,
"damping": 0.02,
"iterations": 40,
"disturbance_step": 0,
"disturbance_magnitude": 0.0,
},
"Disturbance rejection": {
"description": "System at setpoint gets a -30 disturbance at step 10 — watch recovery.",
"setpoint": 100.0,
"initial": 100.0,
"gain": 0.3,
"damping": 0.05,
"iterations": 40,
"disturbance_step": 10,
"disturbance_magnitude": -30.0,
},
}
def _load_preset(
name: str,
) -> tuple[float, float, float, float, int, int, float]:
"""Return slider values for a preset."""
p = PRESETS.get(name, PRESETS["(custom)"])
return (
p["setpoint"],
p["initial"],
p["gain"],
p["damping"],
p["iterations"],
p["disturbance_step"],
p["disturbance_magnitude"],
)
# ── Core simulation ───────────────────────────────────────────────────────
def run_feedback(
preset_name: str,
setpoint: float,
initial: float,
gain: float,
damping: float,
iterations: int,
disturbance_step: int,
disturbance_magnitude: float,
) -> tuple[str, str, str]:
"""Run the feedback loop simulation.
Returns (convergence_banner_html, timeline_md, analysis_md).
"""
loop = NegativeFeedbackLoop(
setpoint=setpoint,
gain=gain,
damping=damping,
silent=True,
)
current = initial
rows: list[dict] = []
initial_error = abs(initial - setpoint)
for step in range(1, int(iterations) + 1):
note = ""
# Apply disturbance
if disturbance_step > 0 and step == int(disturbance_step):
current += disturbance_magnitude
note = f"Disturbance: {disturbance_magnitude:+.1f}"
error_before = current - setpoint
correction = loop.apply(current)
current = correction # apply() returns the corrected value
error_after = current - setpoint
rows.append({
"step": step,
"value": current,
"error": error_after,
"correction": current - (error_before + setpoint),
"note": note,
})
# ── Convergence analysis ──────────────────────────────────────────
final_error = abs(rows[-1]["error"]) if rows else initial_error
threshold = max(initial_error * 0.01, 0.01) # 1% of initial distance
converged = final_error <= threshold
converge_step = None
for r in rows:
if abs(r["error"]) <= threshold:
converge_step = r["step"]
break
if converged:
color, label = "#22c55e", "CONVERGED"
detail = f"Final error: {final_error:.4f} (within 1% of initial distance {initial_error:.2f})"
if converge_step:
detail += f" — converged at step {converge_step}"
else:
color, label = "#ef4444", "NOT CONVERGED"
detail = f"Final error: {final_error:.4f} (threshold: {threshold:.4f})"
banner = (
f'<div style="padding:12px 16px;border-radius:8px;'
f"background:{color}20;border:2px solid {color};margin-bottom:8px\">"
f'<span style="font-size:1.3em;font-weight:700;color:{color}">'
f"{label}</span><br>"
f'<span style="color:#888;font-size:0.9em">{detail}</span></div>'
)
# ── Timeline table ────────────────────────────────────────────────
lines = ["| Step | Value | Error | Correction | Notes |", "| ---: | ---: | ---: | ---: | :--- |"]
for r in rows:
lines.append(
f"| {r['step']} | {r['value']:.4f} | {r['error']:.4f} "
f"| {r['correction']:.4f} | {r['note']} |"
)
timeline_md = "\n".join(lines)
# ── Analysis ──────────────────────────────────────────────────────
errors = [abs(r["error"]) for r in rows]
max_error = max(errors) if errors else 0
min_error = min(errors) if errors else 0
overshoots = sum(
1
for i in range(1, len(rows))
if (rows[i]["error"] > 0) != (rows[i - 1]["error"] > 0)
)
analysis = f"""### Loop Analysis
| Metric | Value |
| :--- | :--- |
| Setpoint | {setpoint:.2f} |
| Initial value | {initial:.2f} |
| Initial distance | {initial_error:.2f} |
| Final value | {rows[-1]['value']:.4f} if rows else 'N/A' |
| Final error | {final_error:.4f} |
| Max |error| | {max_error:.4f} |
| Min |error| | {min_error:.4f} |
| Zero-crossings | {overshoots} |
| Converge step | {converge_step or 'N/A'} |
### Parameter Guide
- **Gain** ({gain}): Higher gain → faster correction but more oscillation
- **Damping** ({damping}): Higher damping → smoother approach but slower convergence
- **Gain > 0.5 with damping ≈ 0**: Expect oscillation (underdamped)
- **Gain < 0.3 with damping > 0.2**: Expect slow, monotonic approach (overdamped)
"""
return banner, timeline_md, analysis
# ── Gradio UI ──────────────────────────────────────────────────────────────
def build_app() -> gr.Blocks:
with gr.Blocks(title="Feedback Loop Homeostasis") as app:
gr.Markdown(
"# ⚖️ Feedback Loop Homeostasis\n"
"Simulate a **NegativeFeedbackLoop** controlling a value toward a "
"setpoint. Adjust gain, damping, and disturbance to explore "
"convergence dynamics."
)
with gr.Row():
preset_dd = gr.Dropdown(
choices=list(PRESETS.keys()),
value="Temperature control",
label="Preset",
scale=2,
)
run_btn = gr.Button("Run Loop", variant="primary", scale=1)
with gr.Row():
setpoint_sl = gr.Slider(-100, 200, value=72.0, step=0.5, label="Setpoint")
initial_sl = gr.Slider(-100, 200, value=85.0, step=0.5, label="Initial value")
with gr.Row():
gain_sl = gr.Slider(0.01, 1.0, value=0.3, step=0.01, label="Gain")
damping_sl = gr.Slider(0.0, 0.5, value=0.05, step=0.01, label="Damping")
iter_sl = gr.Slider(10, 50, value=30, step=1, label="Iterations")
with gr.Row():
dist_step = gr.Number(value=0, label="Disturbance step (0=none)", precision=0)
dist_mag = gr.Number(value=0.0, label="Disturbance magnitude")
banner_html = gr.HTML(label="Convergence")
with gr.Row():
with gr.Column(scale=2):
timeline_md = gr.Markdown(label="Timeline")
with gr.Column(scale=1):
analysis_md = gr.Markdown(label="Analysis")
# ── Event wiring ──────────────────────────────────────────────
preset_dd.change(
fn=_load_preset,
inputs=[preset_dd],
outputs=[setpoint_sl, initial_sl, gain_sl, damping_sl, iter_sl, dist_step, dist_mag],
)
run_btn.click(
fn=run_feedback,
inputs=[preset_dd, setpoint_sl, initial_sl, gain_sl, damping_sl, iter_sl, dist_step, dist_mag],
outputs=[banner_html, timeline_md, analysis_md],
)
return app
if __name__ == "__main__":
app = build_app()
app.launch(theme=gr.themes.Soft())