diff --git a/snippets/1023_AIAssistedPIDTuning/brainstorm.md b/snippets/1023_AIAssistedPIDTuning/brainstorm.md new file mode 100644 index 0000000..2047a34 --- /dev/null +++ b/snippets/1023_AIAssistedPIDTuning/brainstorm.md @@ -0,0 +1,80 @@ +# Brainstorm: AI-Assisted PID Tuning for a Simple Thermal Plant + +--- + +## Overview + +**Topic:** AI-Assisted PID Tuning for a Simple Thermal Plant + +**Why a thermal system?** +- Intuitive — everyone understands heating something to a target temperature +- No control engineering background needed to follow along +- Simple first-order dynamics +- Relatable real-world application (ovens, heated chambers, industrial processes) + +**What is PID Tuning?** +A PID controller adjusts a system output (e.g. heater power) to match a desired setpoint (e.g. target temperature) using three terms: +- **P (Proportional):** reacts to current error +- **I (Integral):** corrects accumulated past error +- **D (Derivative):** anticipates future error + +Tuning = finding the right Kp, Ki, Kd values so the system responds well (fast, stable, minimal overshoot). + +--- + +## Plant Model + +**First-order thermal system with dead time:** + +G(s) = K · e^(-θs) / (τs + 1) + +**Parameters:** +- **K** — steady-state gain (°C per % heater power), e.g. 1.5 +- **τ (tau)** — time constant (system sluggishness), e.g. 200 seconds +- **θ (theta)** — dead time (delay before response), e.g. 20 seconds + +Exact parameter values to be finalized during the planning phase. + +--- + +## Tool Split + +| Role | Tool | Mode | +|------|------|------| +| Planning, outlining, clarifying questions | DeepSeek-V4-Pro | Expert mode, DeepThink enabled | +| Code generation, implementation | Claude Sonnet 4.6 | Medium Extended Thinking | + +--- + +## Implementation Phases + +1. **DeepSeek:** Model the thermal plant, define transfer function parameters +2. **DeepSeek:** Plan the full implementation structure, ask clarifying questions +3. **Claude:** Simulate open-loop step response +4. **Claude:** Implement closed-loop PID controller, plot response vs. setpoint reference line +5. **Claude:** Implement automatic tuning algorithm (Claude decides the method) +6. **Claude:** Build interactive UI with configurable Kp, Ki, Kd and performance metrics display + +--- + +## Simulation & Visualization + +**Plot will include:** +- Flat horizontal reference line at the setpoint +- PID controller temperature response curve tracking the setpoint + +**Performance metrics to display:** +- Overshoot (%) +- Rise time +- Settling time + +**Interactive UI:** +- Configurable Kp, Ki, Kd inputs/sliders +- Plot updates on parameter change +- Performance metrics update accordingly + +--- + +## Important Framing Note + +"AI-assisted" in this context means the AI wrote the code — not that the tuning algorithm itself is intelligent. diff --git a/snippets/1023_AIAssistedPIDTuning/image/UI_ZN_streamlit.png b/snippets/1023_AIAssistedPIDTuning/image/UI_ZN_streamlit.png new file mode 100644 index 0000000..b675f86 Binary files /dev/null and b/snippets/1023_AIAssistedPIDTuning/image/UI_ZN_streamlit.png differ diff --git a/snippets/1023_AIAssistedPIDTuning/image/block_diagram_standard_control_loop.png b/snippets/1023_AIAssistedPIDTuning/image/block_diagram_standard_control_loop.png new file mode 100644 index 0000000..500d6c7 Binary files /dev/null and b/snippets/1023_AIAssistedPIDTuning/image/block_diagram_standard_control_loop.png differ diff --git a/snippets/1023_AIAssistedPIDTuning/image/closed_loop_pid_response.png b/snippets/1023_AIAssistedPIDTuning/image/closed_loop_pid_response.png new file mode 100644 index 0000000..2070d75 Binary files /dev/null and b/snippets/1023_AIAssistedPIDTuning/image/closed_loop_pid_response.png differ diff --git a/snippets/1023_AIAssistedPIDTuning/image/open_loop_response.png b/snippets/1023_AIAssistedPIDTuning/image/open_loop_response.png new file mode 100644 index 0000000..256ab6e Binary files /dev/null and b/snippets/1023_AIAssistedPIDTuning/image/open_loop_response.png differ diff --git a/snippets/1023_AIAssistedPIDTuning/image/tuning_comparison.png b/snippets/1023_AIAssistedPIDTuning/image/tuning_comparison.png new file mode 100644 index 0000000..c09a4e7 Binary files /dev/null and b/snippets/1023_AIAssistedPIDTuning/image/tuning_comparison.png differ diff --git a/snippets/1023_AIAssistedPIDTuning/image/zn_tuned_response.png b/snippets/1023_AIAssistedPIDTuning/image/zn_tuned_response.png new file mode 100644 index 0000000..0cc8674 Binary files /dev/null and b/snippets/1023_AIAssistedPIDTuning/image/zn_tuned_response.png differ diff --git a/snippets/1023_AIAssistedPIDTuning/implementation_phases/phase1_setup_and_openloop.md b/snippets/1023_AIAssistedPIDTuning/implementation_phases/phase1_setup_and_openloop.md new file mode 100644 index 0000000..71aa953 --- /dev/null +++ b/snippets/1023_AIAssistedPIDTuning/implementation_phases/phase1_setup_and_openloop.md @@ -0,0 +1,99 @@ +# Phase 1: Project Setup & Open-Loop Step Response Simulation + +## Objective +Set up the project environment, implement the thermal plant model as a transfer function, and simulate its open-loop step response. The result is a plot showing how the plant’s temperature naturally evolves when the heater is turned on to full power, with no controller. + +--- + +## 1. Project Structure +Create the following directory layout: + + thermal_pid_tuner/ + ├── plant.py # Thermal plant model + ├── utils.py # Performance metric functions (for later phases) + ├── main.py # Entry point for open-loop simulation + ├── requirements.txt # Python dependencies + └── plots/ # Directory for saved figures + +--- + +## 2. Dependencies (`requirements.txt`) + + numpy>=1.24 + scipy>=1.10 + matplotlib>=3.7 + control>=0.9 + +All simulations and control objects will use the `control` library (also known as `python-control`). + +--- + +## 3. Thermal Plant Model (`plant.py`) + +### Plant Parameters + +| Parameter | Symbol | Value | Unit | +|-----------|--------|--------|------------------| +| Steady‑state gain | K | 1.5 | °C / % heater | +| Time constant | τ | 200.0 | seconds | +| Dead time | θ | 20.0 | seconds | +| Ambient temperature | T_amb | 20.0 | °C | + +The transfer function from heater power deviation (Δu in %) to temperature deviation (ΔT in °C) is: + + K · e^(-θs) + G(s) = ─────────────── + τ s + 1 + +The absolute temperature is: +`T(t) = T_amb + output of G(s) for a given heater input u(t).` + +Heater input is bounded between 0 % and 100 %. + +### Implementation + +Create a class `ThermalPlant` with: +- **`__init__(self, K=1.5, tau=200.0, theta=20.0, T_amb=20.0)`** – stores parameters. +- **`transfer_function(self)`** – returns a `control.TransferFunction` object using a **2nd‑order Padé approximation** for the dead time. +- **`step_response(self, u_step=100.0, t_end=800.0, dt=0.5)`** – simulates the step response (heater jumps from 0 to `u_step` at t=0) and returns `(t, T)` where `T` is the absolute temperature. + +**Important details:** + +- The `control` library does not directly support pure dead time. Use `control.pade(θ, n=2)` to obtain numerator/denominator polynomials, then multiply the rational part `K / (τ s + 1)` by the Padé approximation. The `control.series()` function can help. +- For simulation, use `control.step_response()` or `control.forced_response()` with a step input signal. +- Ensure the initial temperature is `T_amb` (output starts at 0 deviation, then add `T_amb`). +- The time vector should span 0 to `t_end` with step `dt`; ensure the output array covers the full simulation. + +--- + +## 4. Main Script (`main.py`) + +The script should: +1. Import `ThermalPlant` from `plant.py`. +2. Instantiate the plant with default parameters. +3. Call `step_response(u_step=100.0, t_end=800.0)`. +4. Create a publication‑quality plot: + - X‑axis: Time (s) + - Y‑axis: Temperature (°C) + - Title: **Open‑Loop Step Response (Heater 0→100%)** + - Grid on, tight layout. + - Include a dashed horizontal line at the steady‑state temperature (ambient + K·100) as a visual reference. +5. Save the figure as `plots/open_loop_response.png` (the `plots` directory must exist; create it if necessary). +6. Print a summary line: `"Steady-state temperature: X °C"` + +--- + +## 5. Acceptance Criteria + +- Running `python main.py` produces no errors. +- The plot shows a smooth, delayed S‑shaped rise from 20 °C to the steady‑state value. +- The delay (~20 s before any noticeable change) and the time constant (~200 s for 63% of the rise) are visually coherent. +- The steady‑state temperature equals 20 + 1.5×100 = 170 °C; the dashed line matches this value. + +--- + +## 6. Notes for the Coding Assistant + +- Use `control.pade(theta, 2)` → returns `(num_pade, den_pade)` as 1D arrays of polynomial coefficients (descending powers). Then create `control.TransferFunction` objects for the Padé part and the first‑order lag, and multiply them. +- Ensure all vectors (time, input, output) are plain NumPy arrays or lists that `matplotlib` can handle directly. +- Avoid complex numbers leaking into the temperature output – the Padé approximation yields real coefficients, so output should remain real. \ No newline at end of file diff --git a/snippets/1023_AIAssistedPIDTuning/implementation_phases/phase2_closed_loop_pid.md b/snippets/1023_AIAssistedPIDTuning/implementation_phases/phase2_closed_loop_pid.md new file mode 100644 index 0000000..0a0e530 --- /dev/null +++ b/snippets/1023_AIAssistedPIDTuning/implementation_phases/phase2_closed_loop_pid.md @@ -0,0 +1,121 @@ +# Phase 2: Closed-Loop PID Control Simulation + +## Objective +Implement a PID controller as a transfer function, close the feedback loop with the thermal plant, simulate the step response to a 60 °C setpoint, plot the temperature tracking, and compute basic performance metrics. + +--- + +## 1. PID Controller Module (`controller.py`) + +Create a new file `controller.py` in the project root with a class `PIDController`. + +### Class Interface + +- **`__init__(self, Kp=1.0, Ki=0.0, Kd=0.0)`** – stores the three gains. +- **`transfer_function(self)`** – returns a `control.TransferFunction` object representing the ideal PID controller in the Laplace domain: + + Gc(s) = Kp + Ki/s + Kd*s + +Use the `control.TransferFunction` constructor directly. For example: + + control.TransferFunction([Kd, Kp, Ki], [1, 0]) + +gives numerator `Kd s^2 + Kp s + Ki` and denominator `s`. + +- No derivative filtering and no anti‑windup at this stage — pure ideal PID. + +--- + +## 2. Performance Metrics (`utils.py`) + +Replace the stubs in the existing `utils.py` with fully implemented functions. All functions must accept NumPy arrays for time `t` and output `y`, and return a single float. + +### Required Functions + +1. **`overshoot(y, setpoint)`** + - Returns `(max(y) - setpoint) / setpoint * 100` (percentage). + - If the maximum is less than or equal to the setpoint, return 0.0. + +2. **`rise_time(t, y, setpoint, low=0.1, high=0.9)`** + - Assumes the system starts at an initial temperature below the setpoint and rises. + - Finds the first time `y` crosses `setpoint * low` and the first time it crosses `setpoint * high`, then returns `t_high - t_low`. + - If either bound is never reached, return `float('inf')`. + +3. **`settling_time(t, y, setpoint, band=0.02)`** + - Returns the smallest time `t_s` such that for all `t >= t_s`, + `|y - setpoint| <= band * setpoint`. + - If the signal never settles within the simulation time, return `float('inf')`. + - Search backwards from the end of the array for efficiency. + +All functions should handle the case where `y` is a 1D array. Use NumPy indexing and boolean masking. + +--- + +## 3. Closed-Loop Simulation Script (`closed_loop.py`) + +Create a new script `closed_loop.py` that ties everything together. + +### 3.1 Imports +Import the following: +- `numpy as np` +- `matplotlib.pyplot as plt` +- `control` (python‑control) +- `plant` (ThermalPlant) +- `controller` (PIDController) +- `utils` (all three metric functions) + +### 3.2 Parameters +Define: +- `SETPOINT = 60.0` (°C) +- `T_AMBIENT = 20.0` (°C) +- Placeholder PID gains: + `Kp = 1.8` + `Ki = 0.008` + `Kd = 30.0` +- Simulation end time: 800 s. + +### 3.3 Build the Closed‑Loop System +1. Instantiate `ThermalPlant` with default parameters → `Gp = plant.transfer_function()`. +2. Instantiate `PIDController` with the placeholder gains → `Gc = pid.transfer_function()`. +3. Form the open‑loop series: `G_ol = Gc * Gp` (using `control.series()` or `*`). +4. Build the closed‑loop transfer function from reference (setpoint deviation) to output deviation: + `G_cl = control.feedback(G_ol, 1, sign=-1)` + (unity negative feedback). +5. The step magnitude from ambient to setpoint is `SETPOINT - T_AMBIENT = 40.0`. + +### 3.4 Simulate Step Response +- Use `control.step_response(G_cl, T=800)` which returns `(t, y_dev)` — `y_dev` is the **temperature deviation** for a unit step input. +- Multiply `y_dev` by 40 to obtain the deviation for a 40 °C command, then add ambient: + `T_abs = T_AMBIENT + 40.0 * y_dev` +- Ensure `t` has enough points; the default from `step_response` is usually fine, but you can specify a `T_num` parameter for smoothness. + +### 3.5 Plot +- Create a figure (size 10×6 recommended). +- Plot `t` vs `T_abs` with a solid line (label "Temperature"). +- Add a dashed horizontal line at `SETPOINT` (label "Setpoint 60 °C", color grey or red). +- Label axes: `Time (s)` and `Temperature (°C)`. +- Title: `Closed-Loop PID Response to 60°C Setpoint`. +- Enable grid, use `tight_layout()`. +- Save the plot to `plots/closed_loop_pid_response.png` (create the directory if needed). + +### 3.6 Compute and Print Metrics +- `overshoot_val = utils.overshoot(T_abs, SETPOINT)` +- `rise_t = utils.rise_time(t, T_abs, SETPOINT)` +- `settle_t = utils.settling_time(t, T_abs, SETPOINT)` +- Print one metric per line, formatted to two decimal places: + - `Overshoot: X.XX %` + - `Rise time: X.XX s` + - `Settling time: X.XX s` + +### 3.7 Run +The script must be executable with `python closed_loop.py`. + +--- + +## 4. Acceptance Criteria + +- `python closed_loop.py` runs without errors. +- The plot shows the temperature rising from 20 °C, approaching 60 °C, possibly overshooting, then settling near the setpoint. +- The reference line at 60 °C is visible. +- The three metrics are printed and are physically plausible (rise time between 50–400 s, overshoot 0–50 %, settling time ≤800 s). +- The plot is saved to `plots/closed_loop_pid_response.png`. \ No newline at end of file diff --git a/snippets/1023_AIAssistedPIDTuning/implementation_phases/phase3.5_cohen_coon.md b/snippets/1023_AIAssistedPIDTuning/implementation_phases/phase3.5_cohen_coon.md new file mode 100644 index 0000000..ad42b27 --- /dev/null +++ b/snippets/1023_AIAssistedPIDTuning/implementation_phases/phase3.5_cohen_coon.md @@ -0,0 +1,140 @@ +# Phase 3.5: Cohen‑Coon Auto‑Tuning & Comparison with Ziegler‑Nichols + +## Objective +Implement a second automatic tuning method — Cohen‑Coon — directly from the plant’s FOPDT parameters, then compare the closed‑loop performance of Ziegler‑Nichols and Cohen‑Coon on the same plot, with a printed metric comparison table. + +--- + +## 1. Extend `tuner.py` with Cohen‑Coon + +Add a new public function to the existing `tuner.py` module: + + def cohen_coon_tune(plant): + ... + +### 1.1 Plant Parameter Access +Our `ThermalPlant` stores `K`, `tau`, `theta`. These should be accessed via the plant instance, e.g. `plant.K`, `plant.tau`, `plant.theta`. If these aren't already instance attributes, add them to `plant.py` by setting them in `__init__`: + + self.K = K + self.tau = tau + self.theta = theta + +(This is a minimal, non‑breaking change — the plant should still work exactly as before.) + +### 1.2 Cohen‑Coon PID Formulas (Ideal Form) +Given `Kp_plant = plant.K`, `tau = plant.tau`, `theta = plant.theta`: + + r = theta / tau + + Kc = (1.0 / Kp_plant) * (1.35 * (tau / theta) + 0.27) + tauI = tau * ( (32.0 + 6.0 * r) / (13.0 + 8.0 * r) ) + tauD = tau * ( 4.0 / (11.0 + 2.0 * r) ) + +Then for an ideal PID: `Gc(s) = Kc (1 + 1/(tauI s) + tauD s)`, which is equivalent to: + + Kp = Kc + Ki = Kc / tauI + Kd = Kc * tauD + +### 1.3 Return Value +Return a dictionary containing `Kp`, `Ki`, `Kd` (floats), along with the intermediate values `Kc`, `tauI`, `tauD` for diagnostic prints. + + return { + 'Kp': Kp, + 'Ki': Ki, + 'Kd': Kd, + 'Kc': Kc, + 'tauI': tauI, + 'tauD': tauD + } + +### 1.4 Edge Cases +If `theta` is zero or very small (not our case), return sensible defaults to avoid division by zero. For our plant (θ=20), this is fine. + +--- + +## 2. Comparison Script: `compare_tuning.py` + +Create a new script `compare_tuning.py` that runs both tuners and compares their closed‑loop responses. + +### 2.1 Imports + + import numpy as np + import matplotlib.pyplot as plt + import control + from plant import ThermalPlant + from controller import PIDController + from tuner import ziegler_nichols_tune, cohen_coon_tune + from utils import overshoot, rise_time, settling_time + +### 2.2 Parameters + + SETPOINT = 60.0 + T_AMBIENT = 20.0 + T_SIM = 800.0 + +### 2.3 Tune with Both Methods + + plant = ThermalPlant() + zn = ziegler_nichols_tune(plant) + cc = cohen_coon_tune(plant) + +Print the gains for both: + + print("Ziegler-Nichols PID: Kp=..., Ki=..., Kd=...") + print("Cohen-Coon PID: Kp=..., Ki=..., Kd=...") + +### 2.4 Build Closed‑Loop Systems and Simulate +Write a small helper function to avoid code duplication: + + def simulate_closed_loop(Kp, Ki, Kd): + pid = PIDController(Kp, Ki, Kd) + Gp = plant.transfer_function() + Gc = pid.transfer_function() + G_ol = Gc * Gp + G_cl = control.feedback(G_ol, 1, sign=-1) + t, y_dev = control.step_response(G_cl, T=T_SIM) + T_abs = T_AMBIENT + (SETPOINT - T_AMBIENT) * y_dev + return t, T_abs + +Then: + + t_zn, T_zn = simulate_closed_loop(zn['Kp'], zn['Ki'], zn['Kd']) + t_cc, T_cc = simulate_closed_loop(cc['Kp'], cc['Ki'], cc['Kd']) + +### 2.5 Plot Both Responses + +- Figure size (10, 6). +- Plot `t_zn`, `T_zn` with label "Ziegler‑Nichols". +- Plot `t_cc`, `T_cc` with label "Cohen‑Coon". +- Add dashed horizontal line at 60 °C, label "Setpoint". +- Grid, axis labels, title "ZN vs Cohen‑Coon Tuning Comparison". +- Legend. +- Save to `plots/tuning_comparison.png`. + +### 2.6 Compute and Print Metrics Table + +For each method, compute overshoot, rise time, and settling time. Print a formatted table: + + Metric Ziegler‑Nichols Cohen‑Coon + -------------------------------------------------- + Overshoot (%) X.XX Y.YY + Rise time (s) X.XX Y.YY + Settling time (s) X.XX Y.YY + +All numbers formatted to two decimal places. + +### 2.7 Run +The script must be executable with `python compare_tuning.py`. + +--- + +## 3. Acceptance Criteria + +- `python compare_tuning.py` runs without errors. +- The terminal output clearly shows both sets of gains and the metrics comparison table. +- The plot displays two distinct response curves (different rise times and overshoots). +- Cohen‑Coon typically yields lower overshoot than ZN for this plant (though both are acceptable). +- All rise times and settling times ≤ 800 s. +- Plot saved to `plots/tuning_comparison.png`. +- Existing `python auto_tune.py` still works after the `tuner.py` extension. \ No newline at end of file diff --git a/snippets/1023_AIAssistedPIDTuning/implementation_phases/phase3_auto_tuning.md b/snippets/1023_AIAssistedPIDTuning/implementation_phases/phase3_auto_tuning.md new file mode 100644 index 0000000..cf1a62e --- /dev/null +++ b/snippets/1023_AIAssistedPIDTuning/implementation_phases/phase3_auto_tuning.md @@ -0,0 +1,111 @@ +# Phase 3: Automatic PID Tuning (Ziegler–Nichols Ultimate Gain Method) + +## Objective +Implement the Ziegler–Nichols closed-loop tuning algorithm by extracting the ultimate gain and ultimate period from the plant’s frequency response, then compute PID gains and simulate the resulting closed-loop performance. + +--- + +## 1. New Module: `tuner.py` + +Create `tuner.py` in the project root. It must contain one public function: + + def ziegler_nichols_tune(plant): + ... + +### 1.1 Algorithm Steps + +- Obtain the plant’s transfer function `Gp = plant.transfer_function()` (a `control.TransferFunction` object). +- Compute the **gain margin** and **phase crossover frequency** using `control.margin(Gp)`. + - `gm, pm, w_p, w_g = control.margin(Gp)` + - `gm` is the gain margin in linear scale (not dB). `w_p` is the frequency where the phase is –180° (rad/s). +- If `gm` is infinite or NaN (meaning the system cannot be made unstable by proportional gain alone), fall back to a conservative default: + - `Ku = 5.0`, `Tu = 200.0`, and print a warning: "Gain margin infinite; using default Ku=5, Tu=200 for tuning." +- Otherwise, set: + - Ultimate gain: `Ku = gm` + - Ultimate period: `Tu = 2 * np.pi / w_p` +- Compute PID parameters using the classic Ziegler–Nichols rules: + - `Kp = 0.6 * Ku` + - `Ki = 2 * Kp / Tu` + - `Kd = Kp * Tu / 8` +- Return a dictionary with keys `Ku`, `Tu`, `Kp`, `Ki`, `Kd`. + +### 1.2 Imports + +The function should import: +- `numpy as np` +- `control` + +No other dependencies. + +### 1.3 Notes + +- The `control.margin` function expects a SISO LTI system. Our plant (with Padé delay) qualifies. +- The returned `Kp`, `Ki`, `Kd` are the final controller gains; these can be used directly with the existing `PIDController` class. +- Ensure all values are standard Python floats, not NumPy arrays of size 1. + +--- + +## 2. Demonstration Script: `auto_tune.py` + +Create a new script `auto_tune.py` that ties together the plant, the tuner, and the closed-loop simulation to validate the auto-tuned PID gains. + +### 2.1 Imports + + import numpy as np + import matplotlib.pyplot as plt + import control + from plant import ThermalPlant + from controller import PIDController + from tuner import ziegler_nichols_tune + from utils import overshoot, rise_time, settling_time + +### 2.2 Set Up Plant and Tune + + plant = ThermalPlant() + tuning = ziegler_nichols_tune(plant) + Kp, Ki, Kd = tuning['Kp'], tuning['Ki'], tuning['Kd'] + print(f"Ultimate Gain: {tuning['Ku']:.3f}, Ultimate Period: {tuning['Tu']:.2f} s") + print(f"ZN PID Gains: Kp={Kp:.3f}, Ki={Ki:.5f}, Kd={Kd:.3f}") + +### 2.3 Build and Simulate Closed Loop + +Use the same structure as `closed_loop.py`: +- `Gp = plant.transfer_function()` +- `pid = PIDController(Kp, Ki, Kd)`, `Gc = pid.transfer_function()` +- `G_ol = Gc * Gp` +- `G_cl = control.feedback(G_ol, 1, sign=-1)` +- `t, y_dev = control.step_response(G_cl, T=800)` +- `T_abs = 20.0 + 40.0 * y_dev` (setpoint = 60 °C, ambient = 20 °C) + +### 2.4 Plot + +- Figure size (10, 6). +- Plot `t` vs `T_abs`, label "Temperature (ZN Tuned)". +- Dashed grey line at 60 °C, label "Setpoint". +- Grid, axis labels, title "Closed-Loop Response with Ziegler–Nichols Tuning". +- Save to `plots/zn_tuned_response.png`. + +### 2.5 Compute and Print Metrics + +- `overshoot_val = overshoot(T_abs, 60.0)` +- `rise_t = rise_time(t, T_abs, 60.0)` +- `settle_t = settling_time(t, T_abs, 60.0)` +- Print one per line, formatted to two decimal places: + - `Overshoot: X.XX %` + - `Rise time: X.XX s` + - `Settling time: X.XX s` + +### 2.6 Run + +The script must be executable with `python auto_tune.py`. + +--- + +## 3. Acceptance Criteria + +- `python auto_tune.py` runs without errors. +- The script prints `Ku` and `Tu` (both positive finite numbers) and the three derived PID gains. +- The plot shows a temperature response that rises to 60 °C, possibly with some overshoot, and settles. +- Rise time and settling time are within 0–800 s. +- Overshoot is consistent with ZN tuning (typically 10–40% for such a system). +- The plot is saved to `plots/zn_tuned_response.png`. \ No newline at end of file diff --git a/snippets/1023_AIAssistedPIDTuning/implementation_phases/phase4_fix.md b/snippets/1023_AIAssistedPIDTuning/implementation_phases/phase4_fix.md new file mode 100644 index 0000000..5492744 --- /dev/null +++ b/snippets/1023_AIAssistedPIDTuning/implementation_phases/phase4_fix.md @@ -0,0 +1,130 @@ +# Phase 4 Fix: Slider Bugs & Auto-Tune Button Errors + +## Problem Summary + +Two issues observed during testing of `app.py`: + +1. **Sliders unresponsive** — sometimes moving a slider doesn't register the new value; the slider snaps back to its previous position. + +2. **Auto-tune buttons throw StreamlitAPIException**: "st.session_state.Kp_slider cannot be modified after the widget with key Kp_slider is instantiated." This happens for both "Load Ziegler–Nichols" and "Load Cohen–Coon" buttons. The error occurs because the button handler tries to write directly to a widget's bound session state key after that widget has already been rendered. Streamlit forbids this. The interesting side effect (slider shows auto-tuned values after the error) confirms the values are being set but via an invalid path. + +--- + +## Root Cause Analysis + +**Issue 2 — the crash:** + +The current button handler writes to widget keys directly: + st.session_state['Kp_slider'] = zn['Kp'] # ILLEGAL in Streamlit + +Streamlit widget keys (ending in `_slider`) are managed internally. You may read them, but you may **not** write to them from outside the widget's own `value=` parameter. The correct approach is: + +- Use **separate** session state keys (e.g. `kp_val`, `ki_val`, `kd_val`) as the "master" source of truth. +- The slider's `value=` parameter reads from these master keys. +- Button handlers write to these master keys, then call `st.rerun()` if needed (or let the natural rerun pick up the change). + +**Issue 1 — slider unresponsiveness:** + +Likely a secondary symptom of the same root cause: if the code ever tries to overwrite the widget key, Streamlit's internal state tracking gets confused, and subsequent slider drags may not register correctly. Fixing issue 2 will likely resolve issue 1. Additionally, ensure `value=` for each slider is explicitly bound to the session state key so that slider position and state stay synchronised. + +--- + +## Required Changes to `app.py` + +### 1. Initialise Master Gain Keys + +At the top of the script (after constants, before sliders), initialise the "master" session state keys if they don't exist: + + # Initialise master gain keys once + if 'kp_val' not in st.session_state: + st.session_state['kp_val'] = 1.8 + if 'ki_val' not in st.session_state: + st.session_state['ki_val'] = 0.008 + if 'kd_val' not in st.session_state: + st.session_state['kd_val'] = 30.0 + +### 2. Rewrite Sliders to Use Master Keys + +Replace the existing slider definitions with: + + Kp = st.sidebar.slider( + "Kp (Proportional)", min_value=0.0, max_value=20.0, + value=st.session_state['kp_val'], step=0.1, format="%.1f", + key="Kp_slider" + ) + # Sync slider's live value back to the master key on every change + st.session_state['kp_val'] = Kp + + Ki = st.sidebar.slider( + "Ki (Integral)", min_value=0.0, max_value=0.5, + value=st.session_state['ki_val'], step=0.001, format="%.3f", + key="Ki_slider" + ) + st.session_state['ki_val'] = Ki + + Kd = st.sidebar.slider( + "Kd (Derivative)", min_value=0.0, max_value=100.0, + value=st.session_state['kd_val'], step=1.0, format="%.1f", + key="Kd_slider" + ) + st.session_state['kd_val'] = Kd + +This pattern: +- Reads the slider's starting position from the master key. +- Immediately writes back whatever value the slider produces (user drag or programmatic change) to the master key, keeping them in sync. +- Keeps the widget `key` distinct from the master value key. + +### 3. Rewrite Button Handlers to Write to Master Keys Only + +Replace the button blocks with: + + if st.sidebar.button("Load Ziegler–Nichols Gains"): + zn = ziegler_nichols_tune(plant) + st.session_state['kp_val'] = float(np.clip(zn['Kp'], 0.0, 20.0)) + st.session_state['ki_val'] = float(np.clip(zn['Ki'], 0.0, 0.5)) + st.session_state['kd_val'] = float(np.clip(zn['Kd'], 0.0, 100.0)) + st.sidebar.success(f"ZN loaded: Kp={zn['Kp']:.2f}, Ki={zn['Ki']:.4f}, Kd={zn['Kd']:.1f}") + st.rerun() + + if st.sidebar.button("Load Cohen–Coon Gains"): + cc = cohen_coon_tune(plant) + st.session_state['kp_val'] = float(np.clip(cc['Kp'], 0.0, 20.0)) + st.session_state['ki_val'] = float(np.clip(cc['Ki'], 0.0, 0.5)) + st.session_state['kd_val'] = float(np.clip(cc['Kd'], 0.0, 100.0)) + st.sidebar.success(f"CC loaded: Kp={cc['Kp']:.2f}, Ki={cc['Ki']:.4f}, Kd={cc['Kd']:.1f}") + st.rerun() + +Key points: +- Write **only** to `kp_val`, `ki_val`, `kd_val` — never to `Kp_slider`, `Ki_slider`, `Kd_slider`. +- Use `np.clip` to keep gains within slider bounds (prevents visual mismatch). +- Call `st.rerun()` to force an immediate re-render with the new values. (This is safe because the slider `value=` parameter reads from the master keys, which now hold the new values.) + +### 4. Remove Any Leftover Widget-Key Writes + +Search the entire `app.py` for any assignments to `st.session_state['Kp_slider']`, `st.session_state['Ki_slider']`, or `st.session_state['Kd_slider']` and delete them all. The only assignments to `*_slider` keys should be the ones done automatically by Streamlit when the slider widget renders. + +### 5. Keep the Rest Unchanged + +The following sections of `app.py` require **no changes**: +- Imports +- `st.set_page_config`, title, markdown header +- Constants (`SETPOINT`, `T_AMBIENT`, `T_SIM`) +- `get_plant()` with `st.cache_resource` +- Simulation logic (transfer function, step response, `T_abs`) +- Matplotlib plot and `st.pyplot(fig)` +- Metrics in three columns +- Footer + +The only edits are in the gain initialisation, slider definitions, and button handlers. + +--- + +## Acceptance Criteria + +- `streamlit run app.py` launches without errors. +- Dragging any slider smoothly updates the plot and metrics in real time; no "snap back" behaviour. +- Clicking "Load Ziegler–Nichols Gains" immediately moves all three sliders to ZN values, updates the plot, and shows a success message — with **no StreamlitAPIException**. +- Clicking "Load Cohen–Coon Gains" does the same for CC values, including the detuning warning if applicable. +- Repeatedly alternating between ZN and CC buttons works correctly. +- Manually dragging sliders after using an auto-tune button works correctly. +- All four existing terminal scripts (`main.py`, `closed_loop.py`, `auto_tune.py`, `compare_tuning.py`) still run unchanged. \ No newline at end of file diff --git a/snippets/1023_AIAssistedPIDTuning/implementation_phases/phase4_interactive_ui.md b/snippets/1023_AIAssistedPIDTuning/implementation_phases/phase4_interactive_ui.md new file mode 100644 index 0000000..6842869 --- /dev/null +++ b/snippets/1023_AIAssistedPIDTuning/implementation_phases/phase4_interactive_ui.md @@ -0,0 +1,163 @@ +# Phase 4: Interactive Streamlit Dashboard + +## Objective +Build an interactive web app using Streamlit that lets users adjust Kp, Ki, Kd with sliders, see the closed-loop temperature response update in real time, and view the three performance metrics (overshoot, rise time, settling time). Include one-click loading of Ziegler–Nichols and Cohen–Coon auto-tuned gains. + +--- + +## 1. New File: `app.py` + +Create `app.py` in the project root. This is the Streamlit application, launched with: + + streamlit run app.py + +### 1.1 Imports + + import streamlit as st + import numpy as np + import matplotlib.pyplot as plt + import control + from plant import ThermalPlant + from controller import PIDController + from tuner import ziegler_nichols_tune, cohen_coon_tune + from utils import overshoot, rise_time, settling_time + +### 1.2 Page Configuration + +At the top of the script, set: + + st.set_page_config(page_title="PID Tuner — Thermal Plant", layout="wide") + st.title("AI-Assisted PID Tuning for a Thermal Plant") + st.markdown("Adjust the PID gains below and observe the closed-loop response to a 60°C setpoint.") + +### 1.3 Constants + + SETPOINT = 60.0 + T_AMBIENT = 20.0 + T_SIM = 800.0 + +### 1.4 Plant Initialisation + +Use `st.cache_resource` to instantiate the plant once: + + @st.cache_resource + def get_plant(): + return ThermalPlant() + + plant = get_plant() + +### 1.5 Sidebar — Gain Controls + +Place all controls in `st.sidebar`. + +#### Sliders + +Three sliders for manual tuning: + + Kp = st.sidebar.slider("Kp (Proportional)", min_value=0.0, max_value=20.0, value=1.8, step=0.1, format="%.1f") + Ki = st.sidebar.slider("Ki (Integral)", min_value=0.0, max_value=0.5, value=0.008, step=0.001, format="%.3f") + Kd = st.sidebar.slider("Kd (Derivative)", min_value=0.0, max_value=100.0, value=30.0, step=1.0, format="%.1f") + +Initial values match the Phase 2 placeholder gains. + +#### Auto-Tune Buttons + + st.sidebar.markdown("---") + st.sidebar.markdown("### Auto-Tuning Methods") + + if st.sidebar.button("Load Ziegler–Nichols Gains"): + zn = ziegler_nichols_tune(plant) + st.session_state['Kp'] = zn['Kp'] + st.session_state['Ki'] = zn['Ki'] + st.session_state['Kd'] = zn['Kd'] + st.sidebar.success(f"ZN loaded: Kp={zn['Kp']:.2f}, Ki={zn['Ki']:.4f}, Kd={zn['Kd']:.1f}") + + if st.sidebar.button("Load Cohen–Coon Gains"): + cc = cohen_coon_tune(plant) + st.session_state['Kp'] = cc['Kp'] + st.session_state['Ki'] = cc['Ki'] + st.session_state['Kd'] = cc['Kd'] + st.sidebar.success(f"CC loaded: Kp={cc['Kp']:.2f}, Ki={cc['Ki']:.4f}, Kd={cc['Kd']:.1f}") + +Note: Streamlit sliders don't natively bind to `st.session_state` values set by buttons unless you use a workaround. The simplest approach is: + +- After a button click, store the desired gains in `st.session_state` (e.g. `st.session_state['Kp_zn']`). +- Read slider values from session state with defaults: + + Kp = st.sidebar.slider("Kp ...", value=st.session_state.get('Kp_default', 1.8)) + +Then on button press, update the relevant session state key. An alternative is to use `st.number_input` for tighter control, but sliders were specified. Implement whichever pattern is cleanest — the essential behaviour is that clicking a button updates the displayed slider values and the plot re-runs. + +### 1.6 Main Area — Plot and Metrics + +#### 1.6.1 Build and Simulate + + pid = PIDController(Kp, Ki, Kd) + Gp = plant.transfer_function() + Gc = pid.transfer_function() + G_ol = Gc * Gp + G_cl = control.feedback(G_ol, 1, sign=-1) + t, y_dev = control.step_response(G_cl, T=T_SIM) + T_abs = T_AMBIENT + (SETPOINT - T_AMBIENT) * y_dev + +#### 1.6.2 Plot + +Use `st.pyplot` with a Matplotlib figure: + + fig, ax = plt.subplots(figsize=(10, 5)) + ax.plot(t, T_abs, 'b-', linewidth=2, label='Temperature') + ax.axhline(y=SETPOINT, color='grey', linestyle='--', linewidth=1.5, label=f'Setpoint {SETPOINT}°C') + ax.set_xlabel('Time (s)') + ax.set_ylabel('Temperature (°C)') + ax.set_title('Closed-Loop Step Response') + ax.legend() + ax.grid(True, alpha=0.3) + fig.tight_layout() + st.pyplot(fig) + +#### 1.6.3 Performance Metrics + +Compute and display in three columns: + + col1, col2, col3 = st.columns(3) + + overshoot_val = overshoot(T_abs, SETPOINT) + rise_t = rise_time(t, T_abs, SETPOINT) + settle_t = settling_time(t, T_abs, SETPOINT) + + with col1: + st.metric("Overshoot", f"{overshoot_val:.2f} %") + with col2: + st.metric("Rise Time (10→90%)", f"{rise_t:.2f} s" if rise_t < float('inf') else ">800 s") + with col3: + st.metric("Settling Time (±2%)", f"{settle_t:.2f} s" if settle_t < float('inf') else ">800 s") + +Format `inf` values as `">800 s"` to keep the display clean. + +### 1.7 Footer + + st.sidebar.markdown("---") + st.sidebar.caption("Built for AI-Assisted PID Tuning demo. Plant: FOPDT with Padé delay approximation.") + +--- + +## 2. Acceptance Criteria + +- `streamlit run app.py` opens a browser tab with the dashboard. +- The sidebar contains three sliders (Kp, Ki, Kd) and two auto-tune buttons. +- Dragging any slider immediately updates the plot and all three metrics. +- Clicking "Load Ziegler–Nichols Gains" sets the sliders to the ZN values from Phase 3 and updates the plot. +- Clicking "Load Cohen–Coon Gains" sets the sliders to the CC values from Phase 3.5 and updates the plot. +- The plot shows a dashed horizontal reference line at 60°C. +- The three metrics are displayed prominently with correct units. +- The app does not crash when extreme slider values are chosen (e.g. very high Kd); if the system becomes unstable, the plot will show large oscillations — that is acceptable behaviour. +- All existing scripts (`python main.py`, `python closed_loop.py`, `python auto_tune.py`, `python compare_tuning.py`) continue to work unchanged. + +--- + +## 3. Notes for the Coding Assistant + +- Streamlit re‑runs the entire script on every widget interaction; `st.cache_resource` for the plant avoids re‑creating the Padé approximation on every slider move. +- Keep the transfer‑function creation and simulation inside the main body — they need to re‑execute when gains change. +- If the slider‑button synchronisation proves tricky, a practical solution is to replace `st.slider` with `st.number_input` for the auto‑tune buttons, or use `st.session_state` keys to hold "requested" values that the slider reads from on each run. +- The spec uses `control.step_response` which returns arrays; ensure these are 1D before plotting. \ No newline at end of file diff --git a/snippets/1023_AIAssistedPIDTuning/index.md b/snippets/1023_AIAssistedPIDTuning/index.md new file mode 100644 index 0000000..9b6f3bc --- /dev/null +++ b/snippets/1023_AIAssistedPIDTuning/index.md @@ -0,0 +1,254 @@ +--- +layout: default +codename: AIAssistedPIDTuning +title: AI-assisted PID Tuning for a Simple Plant +tags: snippets mieset +authors: Charaf Mohamad +--- + +# AI-assisted PID Tuning for a Simple Plant +--- +## Problem Statement +Generally speaking, Control Engineering is the discipline of making a system reach and hold a desired, safe state (e.g., making a thermal system reach and maintain a desired temperature). Typically, we refer to control loop diagrams to represent the whole control system, shown in the following image: + +![General Control Loop Diagram](image/block_diagram_standard_control_loop.png) + +The components of this diagram represent the following: +- The **"Actuator"**, such as heaters, valves or dampers, is the device that directly affects the state of the controlled system. It receives a controller output signal 'y' that changes the way the actuator behaves (e.g., increase heating of the heater, decrease speed of the motor). +- The **"Controlled System"** – also known as the "Plant" – is the physical process that is being regulated (e.g., the air inside a house), and often, in the real world, an external disturbance signal 'z' interferes with the state of the system (e.g., opening the window while the heating system is activated, which allows cold air to enter the house). +- The **"Measuring Device"** measures the actual output of the plant 'x' and feeds it back to the input as a signal 'r', so that it is compared to the ideal (desired) signal 'w' (e.g., a temperature sensor measures an output of 26°C, but the desired temperature is 25°C, so the error signal 'e' represents the 1°C difference). +- The **"Controller"** is the device that takes the error signal 'e' and computes a corrective signal 'y' that is fed to the Actuator. + +The most used industrial controller is called a PID controller. They can be found in thermostats, ovens, factory processes, robotic arm positioning, cruise control, etc... PID is an acronym, and it is made up of three terms, each having their own role/contribution in producing the corrective signal. Each PID term carries its own adjustable weight, called a gain – $K_p$, $K_i$, and $K_d$ – which sets how strongly that term influences the final corrective signal. **Tuning** a PID controller means choosing the right values for these three gains. The following table generally explains this: +| Term | Reacts To | Role | Gain | Effect if Too High | Effect if Too Low | +|------|-----------|------|------|---------------------|--------------------| +| **P** (Proportional) | Current error | Drives an immediate correction proportional to the present error | $K_p$ | Shaky, overcorrects, swings past the target | Sluggish, slow response | +| **I** (Integral) | Accumulated past error | Eliminates lingering steady-state error that P alone cannot remove | $K_i$ | Overcorrects, takes a long time to settle down | Never reaches the desired target precisely | +| **D** (Derivative) | Rate of change of error | Anticipates future error and dampens overshoot | $K_d$ | Too sensitive to small fluctuations, output gets jumpy | Little smoothing, more overshoot | + +So how do control engineers pick the "correct" $K_p$, $K_i$, and $K_d$ gain values? Traditionally, this was done using manual trial and error based on the operator intuition and experience from observing system behavior. It later evolved into systematic heuristic methods like Ziegler–Nichols autotuning, and eventually into model-based, adaptive, and optimization-driven techniques supported by digital computation and modern control theory. +Across all of these eras – manual, heuristic, model-based, or optimization-driven – the ***design*** of the tuning method itself has always required a human control engineer to derive, select, or implement it. So what happens when that role is handed to an AI coding assistant instead? + +## Task Description +The end goal of this project is to have an interactive UI that allows users to configure the gain values of a PID controller for a ***thermal system*** (i.e., the temperature was controlled using the PID controller). The in-between checkpoints include modeling the plant using a transfer function, simulating the step response of the system with and without the controller and implementing controller autotuning methods using AI. To tackle this complex task, two AI models are used: DeepSeek-v4-Pro in Expert mode with DeepThink Enabled and Claude's Sonnet 4.6 model with Medium effort. The former (DeepSeek model) took the role of the planner, while the latter took that of the implementer. Before starting the task, I created a session with Claude's Sonnet 4.6 (Medium Effort) purely to brainstorm ideas and create an outline; the session resulted in a [brainstorm.md](brainstorm.md) file that explains the task, phases of implementation and extra notes. This file was then given to the DeepSeek model as the first prompt. One of the most important sections of the "brainstorm" file is the "Implementation Phases" which includes the following: + +| Phase | Description | AI Model | +|--------|-------------|--------| +| 1 | Model the thermal plant, define transfer function parameters | DeepSeek-v4-Pro | +| 2 | Plan the full implementation structure, ask clarifying questions | DeepSeek-v4-Pro | +| 3 | Simulate open-loop step response | Sonnet 4.6 | +| 4 | Implement closed-loop PID controller, plot response vs. setpoint reference line | Sonnet 4.6 | +| 5 | Implement automatic tuning algorithm | Sonnet 4.6 | +| 6 | Build interactive UI with configurable Kp, Ki, Kd and performance metrics display | Sonnet 4.6 | + +> Initial Role of DeepSeek-v4-Pro (before generating markdown files): Choosing Plant/Simulation parameters, selecting performance metrics, choosing the suitable implementation language and libraries modeling the controlled system, selecting interactive UI framework and **choosing the automatic tuning method**. + +After the initial prompts, the project was executed using the two models as follows: +| Model | Role | +|--------|-------------| +| **DeepSeek-v4-Pro** | For each phase, generate a markdown file which is fed into Claude's Sonnet 4.6 and check the feedback from Sonnet 4.6 after it is done | +| **Sonnet 4.6** | Receive a markdown file for a phase of the project, implement the phase, summarize what was built and check acceptance criteria | + +> The markdown file generated by DeepSeek-v4-Pro included the phases' objective, implementation details, notes for Sonnet 4.6 and acceptance criteria. +> My only role – other than fixes and extra features – was to hand off the markdown file generated by DeepSeek-v4-Pro to Sonnet 4.6 and return the feedback generated by Sonnet 4.6 to DeepSeek-v4-Pro. +--- +## Lessons Learned +- **DeepSeek-v4-Pro has trouble with markdown files** – throughout the whole session, every time I asked the model to proceed to the next phase and generate a markdown file, a lot of the content, which should be placed inside the copyable and downloadable markdown box (i.e., where the content should be), "leaks" outside the markdown box. To correct this, I had to explicitly mark which blocks of text where outside the markdown box, and the only time it was able to generate a markdown file correctly inside the markdown box, was when I explicitly stated: +```text +Stop having trouble with the text being placed in the md block. Most of the text is outside the md block. +``` +- **Two-model architecture is powerful for complex tasks** – by assigning each model a job for the project completion, I increased autonomy and decreased the effort a person has to make throughout the whole process. By doing so, the progression of the project accelerated relative to using 1 model for everything. +- **Understand what the model is good at** – my choice of the models is not arbitrary. Through experience, I have understood that models like DeepSeek-v4-Pro tend to be better at planning, outlining tasks and understanding what the user's needs are, especially in Expert mode and DeepThink enabled. On the other hand, Claude's Sonnet 4.6 model was sufficient for generating the necessary files and excels when provided with markdown files that include specifications on what task to complete. +- **Saving tokens through projects is key** – for Claude's Sonnet 4.6 model, instead of attaching the markdown files generated by DeepSeek-v4-Pro directly into the chat, I added the chat to a project and attached the markdown files there for the model to read. This way, I save more tokens compared to when files are dumped into the chat directly and especially for complex tasks like this one. +- **One role per model policy is efficient** – in total, I had 3 active sessions with 2 different models and each had a separate role. This ensured that each model strictly focused to a single goal, decreasing the chances of hallucination or general mistakes: + +| Session | Model | Role | +|---|-------------|-------------| +| 1 | Sonnet 4.6 | **Brainstorming** | +| 2 | Sonnet 4.6 | **Implementing** | +| 3 | DeepSeek-v4-Pro | **Planning** | + +- **Use markdown files with Claude models** – whatever the task is, Claude models work more efficiently when given a markdown file that includes directions and specifications on what the task is. This was evident when Sonnet 4.6 was able to follow markdown file directions accurately throughout the whole session. +- **DeepSeek-v4-Pro doesn't accept attachments** – this is a significant limitation when it comes to complex tasks, since the model was able to supervise the implementation of Sonnet 4.6 only through text feedback generated by Sonnet. +- **Sonnet 4.6 with "Medium" effort can miss specifications** – in one of the phases, the model accepted a result that explicitly contradicted an acceptance criterion in the markdown file it used as a reference. This might come from the fact that only "Medium" effort was set for the model to save tokens. +--- +## Using the Result + +Each of the four implementation phases implemented by Claude Sonnet 4.6 (i.e., phases 3-6 in the table above) can be run simply by following the "Setup" and "How to Run Each Phase" sections of the [README.md](thermal_pid_tuner/README.md) file generated by DeepSeek-v4-Pro, and the images outputted as a result of the first 3 phases can be accessed from the "[plots](./thermal_pid_tuner/plots/)" directory. +The markdown files generated by the DeepSeek model for phases of implementation done by Sonnet 4.6 can be accessed in the "[implementation_phases](./implementation_phases/)" directory. + +--- +## Workflow Details +The project began with the [brainstorm.md](brainstorm.md) outline being handed to DeepSeek as the first prompt. From there, to maintain a clear separation of responsibilities throughout the project, each phase followed a consistent loop: DeepSeek generated a markdown file outlining the phase's objective and implementation details, which was then handed off to Claude for implementation and acceptance checking. + +### Establishing Context and Constraints +The DeepSeek-v4-Pro model session started with the following prompt: +#### ***User*** +```text +Here is the outline for the project: + +... [Here is where the brainsotrm.md file was placed] ... + +Your job is to decompose this project into detailed phases and for each phase produce a structured .md file that a separate AI coding assistant can use to implement the task. Do not move forward with anything before asking me. Ask clarifying questions. +```` + +#### ***DeepSeek*** + +```text +Before I break the project into implementation phases and produce the .md guides, I have several clarifying questions: +... +``` + +Since the model proceeded by asking multiple technical clarifying questions about parameters, metrics, the programming language to be used etc..., I decided to leave the choice up to the model, and focus on ***why*** it made that choice to check whether it was suitable for this complex task. +Notably, it chose Python as the programming language, explaining that it's the "most natural fit" for AI-assisted coding. Also, the UI framework "Streamlit" was chosen since, according to the model, it's the quickest way to implement a "live-updating" dashboard with sliders and plots, it requires no front-end knowledge, integrates well with libraries like matplotlib(used for plotting), and produces a clean shareable web app which is ideal in the case of AI-assisted implementation. More importantly, it chose a well-established autotuning method called "Ziegler–Nichols closed‑loop method". + +Before attaching any of the markdown files, I asked the DeepSeek model to generate an initial prompt that I can give to the Sonnet 4.6 model session. +#### ***User*** +```text +What short introductory prompt should I give to the AI coding assistant before attaching the file in a place where it can read it. +``` + +#### ***DeepSeek*** +```text +... +I'm working on a project to simulate a thermal plant and later tune a PID controller. + +I'll paste the specification for Phase 1 below. Please read it carefully and generate all required Python files exactly as described. + +Output each file's complete code in separate code blocks, clearly labeled with the filename (e.g., # plant.py). Follow the directory structure and acceptance criteria precisely. +... +``` +This simulates how a two-model approach can be powerful, since by generating an initial prompt for the second model using the first model, which already has context on what the project is and its specifications, we ensure synchronization between both ongoing sessions. So, along with the markdown files being sent to Sonnet 4.6 from DeepSeek and feedbacks being sent in the opposite direction, DeepSeek has an idea of how the implementation model is behaving. + +### Phase-by-Phase Breakdown +In this subsection, we will emphasize on the phases of **implementation** by Sonnet 4.6 and how it connects to the planning model (DeepSeek-v4-Pro). These are the phases 3-5 mentioned in the table provided in the [Task Description](#task-description) section. Phase 4 of implementation (phase 6 in the table provided at the beginning) is discussed in the following [UI Implementation](#ui-implementation) section. In each of these phases, the structure of phase progression was identical: +1. Generating the specification markdown file using DeepSeek-v4-Pro. +2. Implementing the phase using Sonnet 4.6 based on the markdown file. +3. Evaluating the implementation using the summary of Sonnet 4.6 as feedback for DeepSeek-v4-Pro. +4. Manual checking of the functionality of the generated files. + +#### **Phase 1 of Implementation**: Simulate open-loop step response +As mentioned, the phase starts with the DeepSeek-v4-Pro model generating a markdown file: [phase 1](implementation_phases/phase1_setup_and_openloop.md). It is worth noting that the model generated accurate specifications in the file, but struggled with placing all the contents in one markdown block, so it required multiple iterations to successfully generate it. + +##### *First Iteration* +###### ***DeepSeek*** +```text +Here is the Phase 1 .md file content. Copy everything below into a file named phase1_setup_and_openloop.md. +... [first unsuccessful attempt to generate all content in one markdown block] ... +``` +###### ***User*** +```text +Some of the text you wrote is outside the md file box and it separated the content. Here is the text that is outside the box: +... +``` + +##### *Second Iteration* +###### ***DeepSeek*** +```text +Here’s the corrected Phase 1 .md content, with all parts inside a single copyable code block. The nested triple backticks have been replaced with indented code blocks to prevent splitting. +... [second unsuccessful attempt to generate all content in one markdown block] ... +``` +###### ***User*** +```text +This part is still outside the md block: +thermal_pid_tuner/ +├── plant.py # Thermal plant model +├── utils.py # Performance metric functions (for later phases) +├── main.py # Entry point for open-loop simulation +├── requirements.txt # Python dependencies +└── plots/ # Directory for saved figures +``` + +##### *Third Iteration* +###### ***DeepSeek*** +```text +I apologize — the inner triple backticks were indeed breaking the outer code block. Here is the corrected Phase 1 .md content, with all code examples (directory tree, requirements) using indented code blocks so nothing escapes the outer fence. +... [Third successful attempt to generate all content in one markdown block] ... +``` + +##### *Implementation* +Now that the markdown file is ready, I created a Project and added the Sonnet 4.6 chat to it. The markdown file was then attached to the project for the model to read. + +###### ***User*** +```text +Read the phase1_setup_and_openloop.md file attached to the project +``` +###### ***Sonnet*** +Using the markdown file as a reference, the model was able to fulfill all the specifications accurately. Above all, it checked the acceptance criteria including running the main python file. After completion, Sonnet 4.6 created a short summary of what it had done, which was used as feedback for DeepSeek. +```text +All files generated, verified, and passing all acceptance criteria. Here's a summary of what was built: +Project structure — thermal_pid_tuner/ +plant.py — ThermalPlant class with: + +__init__ storing K=1.5, τ=200 s, θ=20 s, T_amb=20 °C +transfer_function() composing the first-order lag K/(τs+1) in series with a 2nd-order Padé approximation for dead time via control.pade(theta, n=2) +step_response() using control.forced_response() and returning absolute temperature T_amb + ΔT + +main.py — Runs the simulation, plots the response with a dashed steady-state reference line, saves to plots/open_loop_response.png, and prints the summary. +utils.py — Stubbed with IAE, ISE, ITAE, overshoot, and settling-time functions ready for later phases. +requirements.txt — numpy, scipy, matplotlib, control pinned to spec versions. +Acceptance criteria check: + +✅ python main.py runs with no errors +✅ Steady-state temperature = 170.0 °C (20 + 1.5×100) +✅ Plot shows a smooth, S-shaped delayed rise from 20 °C with visible ~20 s dead time and ~200 s time constant +✅ Dashed reference line at 170 °C +``` + +> At this point, DeepSeek mentioned that Claude's verification was strong, but recommended running the main.py file for further confirmation + +The first phase resulted in a clean plot representing the step response of system if there was no controller involved: +![Open Loop Step Response](image/open_loop_response.png) + +#### **Phase 2 of Implementation**: Implement closed-loop PID controller, plot response vs. setpoint reference line +In this phase, the goal was to introduce a PID controller to the system and simulated its step response. DeepSeek was able to generate the markdown file successfully in the second attempt. The phase was implemented successfully, and the result was generated: + +![Closed-loop Step Response](image/closed_loop_pid_response.png) + +It can be observed that at the beginning of the step response, there is a "dip", which is not realistic for real physical systems. DeepSeek-v4-Pro was not able to recognize the "anomaly" because the feedback it received is only through text (since it can't accept attachments). So, this issue was mentioned to the Sonnet 4.6 model: +###### ***User*** +```text +Feedback: The result looks excellent. + +I have a question: If you look back at the closed_loop_pid_response.png, we can see that at the beginning of the step response, there is an undershoot or a "dip" before the step response increases to the setpoint. Is that what you expected or is that what should happen? +``` +###### ***Sonnet*** +```text +Great observation! That "dip" is actually expected and physically correct — it's a well-known artifact of two things working together in this system: +... +... +``` +The model explained that this comes from the mathematical representation of the "dead time" (i.e., time delay between input and output response) and the $K_d$ gain value, so it is not an issue and wouldn't appear in a real physical system. + +#### **Phase 3 of Implementation**: Implement automatic tuning algorithm +The goal here was to introduce an autotuning method, which was chosen by DeepSeek to be "Ziegler-Nichols" and implemented by Claude's Sonnet model. DeepSeek was able to generate the markdown file successfully in the third attempt. The phase was implemented successfully, and the result was generated: + +![Ziegler–Nichols Closed‑loop Response](image/zn_tuned_response.png) + +Comparing this result to the previous phase's closed-loop response, we can observe that using this autotuning method written by Sonnet 4.6, the time to reach the set point steady state was halved. On the other hand, we can observe an overshoot. +I introduced a "Phase 3.5" which implements a second autotuning method written by Sonnet 4.6 and called "Cohen-Coon" (a.k.a., the CC method). The results obtained shows a comparison of both methods: +![Autotuning Methods Step Response Comparison](image/tuning_comparison.png) + +The benefit of using this second method (highlighted in orange)is that it yields a much smaller overshoot relative to the ZN method. The issue here is that, using the CC method written by Sonnet 4.6, the response never truly reaches the set point value. After bringing up this issue to the model, it claimed that it had left it this way since the acceptance criteria only required: +- no errors +- two distinct curves +- CC typically yields lower overshoot than ZN + +Looking back at the specification markdown file [phase 3.5](implementation_phases/phase3.5_cohen_coon.md), it also included an "All rise times and settling times ≤ 800 s." criterion, which means the model should have rejected the result. + +### UI Implementation (Phase 4 of Implementation) +For the UI implementation, the same steps were taken: DeepSeek successfully generated a markdown file in its second attempt. Sonnet implemented the app.py runnable file (check [README](thermal_pid_tuner/README.md) for how to run) and can be observed below: + +![UI](image/UI_ZN_streamlit.png) + +On the first iteration, the gain values $K_p$, $K_i$ and $K_d$ were configurable using the sliders and every time a value was changed, the plot updates correctly. After using the UI, two issues could be observed: + 1. The sliders were buggy: sometimes when changing a gain value using the slider, it doesn't register the change and keeps the old value. + 2. The autotuning method buttons result in an error. + +This time, the problems were presented to DeepSeek. The model generated another markdown file that was then read by Sonnet. In this second iteration, Sonnet successfully fixed the autotuning method buttons so that no error shows up, and the plots are updated accordingly, but the sliders, although improved, remained a little buggy. + +## Summary & Conclusion +The two-model approach proved to be a suitable technique to tackle complex tasks such as this one. Evidently, the choice of the models is one of the more important aspects for this method, since as we saw, the performance of the models, especially Sonnet 4.6 with "Medium" Effort, was dependent on its ability to handle complex tasks. A practical solution could be to use a "stronger" model for implementation like Opus 4.8. Overall, the project showed that AI can take a control engineering task from modeling to a working tuned UI with minimal manual coding, but the Cohen-Coon and UI bugs are a reminder human verification is still necessary. + +**Author:** Charaf Mohamad + diff --git a/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/README.md b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/README.md new file mode 100644 index 0000000..c3d82b4 --- /dev/null +++ b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/README.md @@ -0,0 +1,103 @@ +# AI-Assisted PID Tuning for a Simple Thermal Plant + +A Python simulation and interactive dashboard for PID tuning of a first-order-plus-dead-time thermal system. Built as a demonstration of AI-assisted coding — the AI wrote the code; the tuning methods themselves are classical control engineering. + +--- + +## Project Structure + + thermal_pid_tuner/ + ├── plant.py # Thermal plant model (FOPDT with Padé approximation) + ├── controller.py # Ideal PID controller transfer function + ├── tuner.py # Auto-tuning: Ziegler-Nichols & Cohen-Coon + ├── utils.py # Performance metrics (overshoot, rise time, settling time) + ├── main.py # Phase 1: Open-loop step response + ├── closed_loop.py # Phase 2: Closed-loop PID with hand-tuned gains + ├── auto_tune.py # Phase 3: Ziegler-Nichols auto-tuned response + ├── compare_tuning.py # Phase 3.5: ZN vs Cohen-Coon comparison + ├── app.py # Phase 4: Interactive Streamlit dashboard + ├── requirements.txt # Python dependencies + ├── plots/ # Saved figures from each script + └── README.md # This file + +--- + +## Plant Model + +| Parameter | Symbol | Value | Unit | +|--------------------|--------|---------|-------------------| +| Steady-state gain | K | 1.5 | °C / % heater | +| Time constant | τ | 200.0 | seconds | +| Dead time | θ | 20.0 | seconds | +| Ambient temperature| T_amb | 20.0 | °C | + +The transfer function is **K·e^(−θs) / (τs + 1)**, approximated with a 2nd-order Padé for the delay. + +The setpoint for all closed-loop simulations is **60°C**. + +--- + +## Setup + + py -m pip install -r requirements.txt + +Dependencies: `numpy`, `scipy`, `matplotlib`, `control`, and `streamlit` (for the dashboard). + +--- + +## How to Run Each Phase + +### Phase 1 — Open-Loop Step Response + py main.py + +Heater jumps 0→100%. Plots the plant's natural S-shaped rise from 20°C to 170°C. +Output: `plots/open_loop_response.png` + +### Phase 2 — Closed-Loop PID (Hand-Tuned) + py closed_loop.py + +Manual PID gains: Kp=1.8, Ki=0.008, Kd=30. Simulates tracking the 60°C setpoint with unity negative feedback. Prints overshoot, rise time, and settling time. +Output: `plots/closed_loop_pid_response.png` + +### Phase 3 — Ziegler–Nichols Auto-Tuning + py auto_tune.py + +Extracts ultimate gain and period from the plant's frequency response via `control.margin`, then applies ZN rules. Prints Ku, Tu, and the derived PID gains plus performance metrics. +Output: `plots/zn_tuned_response.png` + +### Phase 3.5 — ZN vs. Cohen–Coon Comparison + py compare_tuning.py + +Runs both Ziegler–Nichols and Cohen–Coon tuning, plots both responses on the same axes, and prints a side-by-side metrics table. +Output: `plots/tuning_comparison.png` + +### Phase 4 — Interactive Dashboard + py -m streamlit run app.py + +Opens a browser tab with sliders for Kp, Ki, Kd, one-click loading of ZN and CC gains, and live updating plot + metrics. Move any slider to see the response change instantly. + +--- + +## Performance Metrics + +| Metric | Definition | +|---------------|---------------------------------------------------------------| +| Overshoot | (peak − setpoint) / setpoint × 100% | +| Rise time | Time from 10% to 90% of the setpoint step | +| Settling time | Time to enter and remain within ±2% of the setpoint | + +--- + +## Auto-Tuning Methods + +**Ziegler–Nichols (Ultimate Gain)** — Uses the gain margin and phase crossover frequency from the plant's frequency response to compute ultimate gain Ku and ultimate period Tu, then applies the classic ZN PID rules. + +**Cohen–Coon** — A model-based method designed specifically for FOPDT processes. Uses the plant parameters (K, τ, θ) directly. In this simulation, raw CC gains are automatically detuned when necessary to maintain stability with the Padé-approximated plant. + +--- + +## Notes + +- The dead time is approximated with a 2nd-order Padé; this is a standard technique but introduces non-minimum-phase zeros that can interact with very high derivative gains. +- The PID controller is ideal form (no derivative filtering, no anti-windup) — appropriate for a linear simulation. +- "AI-assisted" refers to the code generation process, not the tuning algorithms themselves. All tuning methods are classical control engineering. \ No newline at end of file diff --git a/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/app.py b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/app.py new file mode 100644 index 0000000..6c17958 --- /dev/null +++ b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/app.py @@ -0,0 +1,241 @@ +""" +app.py +------ +Phase 4: Interactive Streamlit dashboard for PID tuning. + +Launch with: + streamlit run app.py + +Features +-------- +- Three sidebar sliders (Kp, Ki, Kd) — update the plot on every change. +- "Load Ziegler-Nichols Gains" button — writes ZN gains into session state, + sliders snap to those values on the next re-run. +- "Load Cohen-Coon Gains" button — same for CC gains. +- Main area: Matplotlib step-response plot + three st.metric tiles. + +Slider-button synchronisation pattern (post Phase-4-fix) +---------------------------------------------------------- +Streamlit forbids writing to a widget's own state key (e.g. 'Kp_slider') +after that widget has been instantiated — doing so raises +StreamlitAPIException. The correct pattern, used here, is: + + 1. Separate "master" session-state keys — 'kp_val', 'ki_val', 'kd_val' — + are the single source of truth for the gains. + 2. Each slider reads its starting position from the master key via + `value=st.session_state['kp_val']`, and uses a *different* key + (`key='Kp_slider'`) purely for Streamlit's internal widget tracking. + 3. Immediately after each slider call, the live value is written back + to the master key — this keeps slider position and master key in + sync on manual drags. + 4. Button handlers write ONLY to the master keys (never to the + '*_slider' widget keys) and then call `st.rerun()` to force an + immediate re-render, which causes the sliders to read their new + `value=` from the updated master keys. +""" + +import numpy as np +import matplotlib +matplotlib.use("Agg") # must be set before pyplot import +import matplotlib.pyplot as plt +import control +import streamlit as st + +from plant import ThermalPlant +from controller import PIDController +from tuner import ziegler_nichols_tune, cohen_coon_tune +from utils import overshoot, rise_time, settling_time + +# ====================================================================== +# Page configuration (must be the first Streamlit call) +# ====================================================================== +st.set_page_config(page_title="PID Tuner — Thermal Plant", layout="wide") + +# ====================================================================== +# Constants +# ====================================================================== +SETPOINT = 60.0 +T_AMBIENT = 20.0 +T_SIM = 800.0 +STEP_MAG = SETPOINT - T_AMBIENT # 40 °C + +# ====================================================================== +# Plant — cached so the Pade TF is not rebuilt on every slider move +# ====================================================================== +@st.cache_resource +def get_plant() -> ThermalPlant: + return ThermalPlant() + +plant = get_plant() + +# ====================================================================== +# Initialise master gain keys once (Phase-2 placeholder defaults) +# ====================================================================== +if "kp_val" not in st.session_state: + st.session_state["kp_val"] = 1.8 +if "ki_val" not in st.session_state: + st.session_state["ki_val"] = 0.008 +if "kd_val" not in st.session_state: + st.session_state["kd_val"] = 30.0 + +# ====================================================================== +# Sidebar — title & sliders (read from / write back to master keys) +# ====================================================================== +st.sidebar.header("PID Gain Controls") + +Kp = st.sidebar.slider( + "Kp (Proportional)", min_value=0.0, max_value=20.0, + value=st.session_state["kp_val"], step=0.1, format="%.1f", + key="Kp_slider", +) +# Sync slider's live value back to the master key on every change +st.session_state["kp_val"] = Kp + +Ki = st.sidebar.slider( + "Ki (Integral)", min_value=0.0, max_value=0.5, + value=st.session_state["ki_val"], step=0.001, format="%.3f", + key="Ki_slider", +) +st.session_state["ki_val"] = Ki + +Kd = st.sidebar.slider( + "Kd (Derivative)", min_value=0.0, max_value=100.0, + value=st.session_state["kd_val"], step=1.0, format="%.1f", + key="Kd_slider", +) +st.session_state["kd_val"] = Kd + +# ====================================================================== +# Sidebar — auto-tune buttons (write ONLY to master keys, then rerun) +# ====================================================================== +st.sidebar.markdown("---") +st.sidebar.markdown("### Auto-Tuning Methods") + +if st.sidebar.button("Load Ziegler\u2013Nichols Gains"): + zn = ziegler_nichols_tune(plant) + st.session_state["kp_val"] = float(np.clip(zn["Kp"], 0.0, 20.0)) + st.session_state["ki_val"] = float(np.clip(zn["Ki"], 0.0, 0.5)) + st.session_state["kd_val"] = float(np.clip(zn["Kd"], 0.0, 100.0)) + st.sidebar.success( + f"ZN loaded: Kp={zn['Kp']:.2f}, Ki={zn['Ki']:.4f}, Kd={zn['Kd']:.1f}" + ) + st.rerun() + +if st.sidebar.button("Load Cohen\u2013Coon Gains"): + cc = cohen_coon_tune(plant) + st.session_state["kp_val"] = float(np.clip(cc["Kp"], 0.0, 20.0)) + st.session_state["ki_val"] = float(np.clip(cc["Ki"], 0.0, 0.5)) + st.session_state["kd_val"] = float(np.clip(cc["Kd"], 0.0, 100.0)) + st.sidebar.success( + f"CC loaded: Kp={cc['Kp']:.2f}, Ki={cc['Ki']:.4f}, Kd={cc['Kd']:.1f}" + ) + st.rerun() + +# ====================================================================== +# Sidebar — footer +# ====================================================================== +st.sidebar.markdown("---") +st.sidebar.caption( + "Built for AI-Assisted PID Tuning demo. " + "Plant: FOPDT with Pad\u00e9 delay approximation." +) + +# ====================================================================== +# Main area — header +# ====================================================================== +st.title("AI-Assisted PID Tuning for a Thermal Plant") +st.markdown( + "Adjust the PID gains in the sidebar and observe the closed-loop " + "response to a **60 °C** setpoint. " + "Use the auto-tune buttons to load pre-computed gains instantly." +) + +# Display the current gains prominently +g1, g2, g3 = st.columns(3) +g1.markdown(f"**Kp** = `{Kp:.3f}`") +g2.markdown(f"**Ki** = `{Ki:.4f}`") +g3.markdown(f"**Kd** = `{Kd:.3f}`") + +st.markdown("---") + +# ====================================================================== +# Simulation — runs on every re-render (gains may have changed) +# ====================================================================== +try: + pid = PIDController(Kp, Ki, Kd) + Gp = plant.transfer_function() + Gc = pid.transfer_function() + G_ol = control.series(Gc, Gp) + G_cl = control.feedback(G_ol, 1, sign=-1) + t, y_dev = control.step_response(G_cl, T=T_SIM, T_num=2000) + + # Ensure 1-D real arrays for plotting and metrics + t = np.real(np.asarray(t).flatten()) + y_dev = np.real(np.asarray(y_dev).flatten()) + T_abs = T_AMBIENT + STEP_MAG * y_dev + + sim_ok = True + sim_error = None +except Exception as exc: + sim_ok = False + sim_error = str(exc) + +# ====================================================================== +# Plot +# ====================================================================== +fig, ax = plt.subplots(figsize=(10, 5)) + +if sim_ok: + ax.plot(t, T_abs, "b-", linewidth=2, label="Temperature") +else: + ax.text( + 0.5, 0.5, + f"Simulation error:\n{sim_error}", + ha="center", va="center", transform=ax.transAxes, + color="red", fontsize=11, + ) + +ax.axhline( + y=SETPOINT, color="grey", linestyle="--", + linewidth=1.5, label=f"Setpoint {SETPOINT:.0f} \u00b0C", +) +ax.set_xlabel("Time (s)", fontsize=12) +ax.set_ylabel("Temperature (\u00b0C)", fontsize=12) +ax.set_title("Closed-Loop Step Response", fontsize=13, fontweight="bold") +ax.legend(fontsize=11) +ax.grid(True, alpha=0.3) +fig.tight_layout() + +st.pyplot(fig) +plt.close(fig) + +# ====================================================================== +# Performance metrics +# ====================================================================== +st.markdown("### Performance Metrics") +col1, col2, col3 = st.columns(3) + +if sim_ok: + overshoot_val = overshoot(T_abs, SETPOINT) + rise_t = rise_time(t, T_abs, SETPOINT) + settle_t = settling_time(t, T_abs, SETPOINT) + + with col1: + st.metric("Overshoot", f"{overshoot_val:.2f} %") + with col2: + st.metric( + "Rise Time (10\u219290%)", + f"{rise_t:.2f} s" if rise_t < float("inf") else ">800 s", + ) + with col3: + st.metric( + "Settling Time (\u00b12%)", + f"{settle_t:.2f} s" if settle_t < float("inf") else ">800 s", + ) +else: + with col1: + st.metric("Overshoot", "N/A") + with col2: + st.metric("Rise Time (10\u219290%)", "N/A") + with col3: + st.metric("Settling Time (\u00b12%)", "N/A") diff --git a/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/auto_tune.py b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/auto_tune.py new file mode 100644 index 0000000..2e2075a --- /dev/null +++ b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/auto_tune.py @@ -0,0 +1,94 @@ +""" +auto_tune.py +------------ +Phase 3: Automatic PID tuning via Ziegler–Nichols ultimate gain method. + +1. Extracts Ku and Tu from the plant's frequency response. +2. Computes ZN PID gains. +3. Simulates the closed-loop step response to a 60 °C setpoint. +4. Plots and saves the result; prints performance metrics. +""" + +import os +import numpy as np +import matplotlib +matplotlib.use("Agg") # Non-interactive backend for file output +import matplotlib.pyplot as plt +import control + +from plant import ThermalPlant +from controller import PIDController +from tuner import ziegler_nichols_tune +from utils import overshoot, rise_time, settling_time + +# ---------------------------------------------------------------------- +# 1. Set up plant and run auto-tuner +# ---------------------------------------------------------------------- +plant = ThermalPlant() +tuning = ziegler_nichols_tune(plant) + +Kp = tuning["Kp"] +Ki = tuning["Ki"] +Kd = tuning["Kd"] + +print(f"Ultimate Gain: {tuning['Ku']:.3f}, Ultimate Period: {tuning['Tu']:.2f} s") +print(f"ZN PID Gains: Kp={Kp:.3f}, Ki={Ki:.5f}, Kd={Kd:.3f}") + +# ---------------------------------------------------------------------- +# 2. Build closed-loop system +# ---------------------------------------------------------------------- +Gp = plant.transfer_function() +pid = PIDController(Kp, Ki, Kd) +Gc = pid.transfer_function() + +G_ol = control.series(Gc, Gp) # open-loop: Gc(s) · Gp(s) +G_cl = control.feedback(G_ol, 1, sign=-1) # unity negative feedback + +# ---------------------------------------------------------------------- +# 3. Simulate step response +# y_dev is the deviation response to a unit step; +# scale by 40 °C (setpoint - ambient) and shift to absolute temperature. +# ---------------------------------------------------------------------- +T_AMBIENT = 20.0 +SETPOINT = 60.0 +STEP_MAG = SETPOINT - T_AMBIENT # 40 °C + +t, y_dev = control.step_response(G_cl, T=800, T_num=2000) +T_abs = T_AMBIENT + STEP_MAG * np.real(y_dev) + +# ---------------------------------------------------------------------- +# 4. Plot +# ---------------------------------------------------------------------- +fig, ax = plt.subplots(figsize=(10, 6)) + +ax.plot(t, T_abs, linewidth=2, color="#1f77b4", label="Temperature (ZN Tuned)") +ax.axhline(SETPOINT, color="grey", linewidth=1.5, + linestyle="--", label="Setpoint") + +ax.set_xlabel("Time (s)", fontsize=13) +ax.set_ylabel("Temperature (°C)", fontsize=13) +ax.set_title("Closed-Loop Response with Ziegler–Nichols Tuning", + fontsize=14, fontweight="bold") +ax.legend(fontsize=11) +ax.grid(True, linestyle="--", alpha=0.6) +ax.set_xlim(t[0], t[-1]) + +fig.tight_layout() + +plots_dir = os.path.join(os.path.dirname(__file__), "plots") +os.makedirs(plots_dir, exist_ok=True) +output_path = os.path.join(plots_dir, "zn_tuned_response.png") +fig.savefig(output_path, dpi=150) +plt.close(fig) + +# ---------------------------------------------------------------------- +# 5. Performance metrics +# ---------------------------------------------------------------------- +overshoot_val = overshoot(T_abs, SETPOINT) +rise_t = rise_time(t, T_abs, SETPOINT) +settle_t = settling_time(t, T_abs, SETPOINT) + +print(f"Overshoot: {overshoot_val:.2f} %") +print(f"Rise time: {rise_t:.2f} s") +print(f"Settling time: {settle_t:.2f} s") +print(f"Plot saved to: {output_path}") diff --git a/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/closed_loop.py b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/closed_loop.py new file mode 100644 index 0000000..4dbf855 --- /dev/null +++ b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/closed_loop.py @@ -0,0 +1,95 @@ +""" +closed_loop.py +-------------- +Phase 2: Closed-loop PID control simulation. + +Builds the feedback loop r → [PID] → [Plant] → y and simulates a +step from ambient (20 °C) to the target setpoint (60 °C). +Plots the response, computes performance metrics, and saves the figure. +""" + +import os +import numpy as np +import matplotlib +matplotlib.use("Agg") # Non-interactive backend for file output +import matplotlib.pyplot as plt +import control + +from plant import ThermalPlant +from controller import PIDController +import utils + +# ---------------------------------------------------------------------- +# Parameters +# ---------------------------------------------------------------------- +SETPOINT = 60.0 # °C – target temperature +T_AMBIENT = 20.0 # °C – starting / ambient temperature +STEP_MAG = SETPOINT - T_AMBIENT # 40 °C command step in deviation space + +# Placeholder PID gains (to be optimised in later phases) +Kp = 1.8 +Ki = 0.008 +Kd = 30.0 + +T_END = 800 # s – simulation horizon + +# ---------------------------------------------------------------------- +# 1. Build plant and controller transfer functions +# ---------------------------------------------------------------------- +thermal_plant = ThermalPlant() # K=1.5, tau=200, theta=20, T_amb=20 +pid = PIDController(Kp=Kp, Ki=Ki, Kd=Kd) + +Gp = thermal_plant.transfer_function() +Gc = pid.transfer_function() + +# ---------------------------------------------------------------------- +# 2. Form open-loop and close the feedback loop +# ---------------------------------------------------------------------- +G_ol = control.series(Gc, Gp) # Gc(s) · Gp(s) +G_cl = control.feedback(G_ol, 1, sign=-1) # unity negative feedback + +# ---------------------------------------------------------------------- +# 3. Simulate step response (unit step in deviation space) +# ---------------------------------------------------------------------- +t, y_dev = control.step_response(G_cl, T=T_END, T_num=2000) + +# Convert deviation output → absolute temperature +# y_dev is the response to a unit step; scale by STEP_MAG then shift +T_abs = T_AMBIENT + STEP_MAG * np.real(y_dev) + +# ---------------------------------------------------------------------- +# 4. Plot +# ---------------------------------------------------------------------- +fig, ax = plt.subplots(figsize=(10, 6)) + +ax.plot(t, T_abs, linewidth=2, color="#1f77b4", label="Temperature") +ax.axhline(SETPOINT, color="#d62728", linewidth=1.5, + linestyle="--", label=f"Setpoint {SETPOINT:.0f} °C") + +ax.set_xlabel("Time (s)", fontsize=13) +ax.set_ylabel("Temperature (°C)", fontsize=13) +ax.set_title("Closed-Loop PID Response to 60°C Setpoint", + fontsize=14, fontweight="bold") +ax.legend(fontsize=11) +ax.grid(True, linestyle="--", alpha=0.6) +ax.set_xlim(t[0], t[-1]) + +fig.tight_layout() + +plots_dir = os.path.join(os.path.dirname(__file__), "plots") +os.makedirs(plots_dir, exist_ok=True) +output_path = os.path.join(plots_dir, "closed_loop_pid_response.png") +fig.savefig(output_path, dpi=150) +plt.close(fig) + +# ---------------------------------------------------------------------- +# 5. Performance metrics +# ---------------------------------------------------------------------- +overshoot_val = utils.overshoot(T_abs, SETPOINT) +rise_t = utils.rise_time(t, T_abs, SETPOINT) +settle_t = utils.settling_time(t, T_abs, SETPOINT) + +print(f"Overshoot: {overshoot_val:.2f} %") +print(f"Rise time: {rise_t:.2f} s") +print(f"Settling time: {settle_t:.2f} s") +print(f"Plot saved to: {output_path}") diff --git a/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/compare_tuning.py b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/compare_tuning.py new file mode 100644 index 0000000..07fae59 --- /dev/null +++ b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/compare_tuning.py @@ -0,0 +1,121 @@ +""" +compare_tuning.py +----------------- +Phase 3.5: Cohen-Coon auto-tuning and comparison with Ziegler-Nichols. + +1. Tunes the plant with both ZN and Cohen-Coon. +2. Simulates closed-loop step responses for each. +3. Overlays both responses on a single plot. +4. Prints a formatted metrics comparison table. +""" + +import os +import numpy as np +import matplotlib +matplotlib.use("Agg") # Non-interactive backend for file output +import matplotlib.pyplot as plt +import control + +from plant import ThermalPlant +from controller import PIDController +from tuner import ziegler_nichols_tune, cohen_coon_tune +from utils import overshoot, rise_time, settling_time + +# ---------------------------------------------------------------------- +# Parameters +# ---------------------------------------------------------------------- +SETPOINT = 60.0 +T_AMBIENT = 20.0 +T_SIM = 800.0 +STEP_MAG = SETPOINT - T_AMBIENT # 40 degrees C + +# ---------------------------------------------------------------------- +# 1. Instantiate plant and run both tuners +# ---------------------------------------------------------------------- +plant = ThermalPlant() + +zn = ziegler_nichols_tune(plant) +cc = cohen_coon_tune(plant) + +print("=" * 60) +print(f"Ziegler-Nichols PID: Kp={zn['Kp']:.4f}, Ki={zn['Ki']:.5f}, Kd={zn['Kd']:.4f}") +print(f"Cohen-Coon PID: Kp={cc['Kp']:.4f}, Ki={cc['Ki']:.5f}, Kd={cc['Kd']:.4f}") +print(f" (Kc={cc['Kc']:.4f}, tauI={cc['tauI']:.2f} s, tauD={cc['tauD']:.2f} s," + f" detuning factor={cc['detune_f']:.4f})") +print("=" * 60) + +# ---------------------------------------------------------------------- +# 2. Helper: build closed loop and simulate +# ---------------------------------------------------------------------- +def simulate_closed_loop(Kp, Ki, Kd): + """ + Build a unity-negative-feedback closed-loop system with the given + PID gains and simulate a step from ambient to setpoint. + + Returns + ------- + t : np.ndarray Time vector [s] + T_abs : np.ndarray Absolute temperature [degrees C] + """ + pid = PIDController(Kp, Ki, Kd) + Gp = plant.transfer_function() + Gc = pid.transfer_function() + G_ol = control.series(Gc, Gp) + G_cl = control.feedback(G_ol, 1, sign=-1) + t, y_dev = control.step_response(G_cl, T=T_SIM, T_num=2000) + T_abs = T_AMBIENT + STEP_MAG * np.real(y_dev) + return t, T_abs + + +t_zn, T_zn = simulate_closed_loop(zn["Kp"], zn["Ki"], zn["Kd"]) +t_cc, T_cc = simulate_closed_loop(cc["Kp"], cc["Ki"], cc["Kd"]) + +# ---------------------------------------------------------------------- +# 3. Plot both responses on the same axes +# ---------------------------------------------------------------------- +fig, ax = plt.subplots(figsize=(10, 6)) + +ax.plot(t_zn, T_zn, linewidth=2, color="#1f77b4", label="Ziegler-Nichols") +ax.plot(t_cc, T_cc, linewidth=2, color="#ff7f0e", label="Cohen-Coon") +ax.axhline(SETPOINT, color="grey", linewidth=1.5, + linestyle="--", label=f"Setpoint {SETPOINT:.0f} deg C") + +ax.set_xlabel("Time (s)", fontsize=13) +ax.set_ylabel("Temperature (deg C)", fontsize=13) +ax.set_title("ZN vs Cohen-Coon Tuning Comparison", + fontsize=14, fontweight="bold") +ax.legend(fontsize=11) +ax.grid(True, linestyle="--", alpha=0.6) +ax.set_xlim(t_zn[0], t_zn[-1]) + +fig.tight_layout() + +plots_dir = os.path.join(os.path.dirname(__file__), "plots") +os.makedirs(plots_dir, exist_ok=True) +output_path = os.path.join(plots_dir, "tuning_comparison.png") +fig.savefig(output_path, dpi=150) +plt.close(fig) + +print(f"Plot saved to: {output_path}") + +# ---------------------------------------------------------------------- +# 4. Compute metrics for both methods +# ---------------------------------------------------------------------- +os_zn = overshoot(T_zn, SETPOINT) +rt_zn = rise_time(t_zn, T_zn, SETPOINT) +st_zn = settling_time(t_zn, T_zn, SETPOINT) + +os_cc = overshoot(T_cc, SETPOINT) +rt_cc = rise_time(t_cc, T_cc, SETPOINT) +st_cc = settling_time(t_cc, T_cc, SETPOINT) + +# ---------------------------------------------------------------------- +# 5. Print formatted comparison table +# ---------------------------------------------------------------------- +col_w = 18 +print() +print(f"{'Metric':<24} {'Ziegler-Nichols':>{col_w}} {'Cohen-Coon':>{col_w}}") +print("-" * (24 + col_w * 2 + 2)) +print(f"{'Overshoot (%)':<24} {os_zn:>{col_w}.2f} {os_cc:>{col_w}.2f}") +print(f"{'Rise time (s)':<24} {rt_zn:>{col_w}.2f} {rt_cc:>{col_w}.2f}") +print(f"{'Settling time (s)':<24} {st_zn:>{col_w}.2f} {st_cc:>{col_w}.2f}") diff --git a/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/controller.py b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/controller.py new file mode 100644 index 0000000..2edb5a8 --- /dev/null +++ b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/controller.py @@ -0,0 +1,44 @@ +""" +controller.py +------------- +Ideal PID controller as a Laplace-domain transfer function. + + Ki +Gc(s) = Kp + ── + Kd·s = (Kd·s² + Kp·s + Ki) / s + s + +No derivative filter and no anti-windup — pure ideal PID. +""" + +import control + + +class PIDController: + """ + Ideal PID controller. + + Parameters + ---------- + Kp : float Proportional gain (default 1.0) + Ki : float Integral gain (default 0.0) + Kd : float Derivative gain (default 0.0) + """ + + def __init__(self, Kp: float = 1.0, Ki: float = 0.0, Kd: float = 0.0) -> None: + self.Kp = Kp + self.Ki = Ki + self.Kd = Kd + + def transfer_function(self) -> control.TransferFunction: + """ + Return the ideal PID transfer function: + + Gc(s) = (Kd·s² + Kp·s + Ki) / s + + Returns + ------- + control.TransferFunction + """ + numerator = [self.Kd, self.Kp, self.Ki] # Kd·s² + Kp·s + Ki + denominator = [1.0, 0.0] # s + return control.TransferFunction(numerator, denominator) diff --git a/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/main.py b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/main.py new file mode 100644 index 0000000..cb04b43 --- /dev/null +++ b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/main.py @@ -0,0 +1,71 @@ +""" +main.py +------- +Entry point for the Phase 1 open-loop step response simulation. + +Instantiates the ThermalPlant, runs a 0→100 % heater step, and saves +a publication-quality plot to plots/open_loop_response.png. +""" + +import os +import numpy as np +import matplotlib +matplotlib.use("Agg") # Non-interactive backend for file output +import matplotlib.pyplot as plt + +from plant import ThermalPlant + + +def main() -> None: + # ------------------------------------------------------------------ + # 1. Instantiate plant with default parameters + # ------------------------------------------------------------------ + plant = ThermalPlant(K=1.5, tau=200.0, theta=20.0, T_amb=20.0) + + # ------------------------------------------------------------------ + # 2. Run open-loop step response: heater 0 → 100 % + # ------------------------------------------------------------------ + t, T = plant.step_response(u_step=100.0, t_end=800.0, dt=0.5) + + # ------------------------------------------------------------------ + # 3. Steady-state reference + # ------------------------------------------------------------------ + T_ss = plant.T_amb + plant.K * 100.0 # 20 + 1.5 * 100 = 170 °C + + # ------------------------------------------------------------------ + # 4. Publication-quality plot + # ------------------------------------------------------------------ + fig, ax = plt.subplots(figsize=(10, 5)) + + ax.plot(t, T, linewidth=2, color="#1f77b4", label="Plant output T(t)") + ax.axhline(T_ss, color="#d62728", linewidth=1.5, + linestyle="--", label=f"Steady-state {T_ss:.1f} °C") + + ax.set_xlabel("Time (s)", fontsize=13) + ax.set_ylabel("Temperature (°C)", fontsize=13) + ax.set_title("Open-Loop Step Response (Heater 0→100%)", fontsize=14, fontweight="bold") + ax.legend(fontsize=11) + ax.grid(True, linestyle="--", alpha=0.6) + ax.set_xlim(t[0], t[-1]) + ax.set_ylim(plant.T_amb - 5, T_ss + 10) + + fig.tight_layout() + + # ------------------------------------------------------------------ + # 5. Save figure + # ------------------------------------------------------------------ + plots_dir = os.path.join(os.path.dirname(__file__), "plots") + os.makedirs(plots_dir, exist_ok=True) + output_path = os.path.join(plots_dir, "open_loop_response.png") + fig.savefig(output_path, dpi=150) + plt.close(fig) + + # ------------------------------------------------------------------ + # 6. Summary + # ------------------------------------------------------------------ + print(f"Steady-state temperature: {T_ss:.1f} °C") + print(f"Plot saved to: {output_path}") + + +if __name__ == "__main__": + main() diff --git a/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/plant.py b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/plant.py new file mode 100644 index 0000000..2f0f47a --- /dev/null +++ b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/plant.py @@ -0,0 +1,96 @@ +""" +plant.py +-------- +Thermal plant model as a first-order-plus-dead-time (FOPDT) transfer function. + +Transfer function (deviation form): + K * e^(-theta * s) + G(s) = ───────────────────── + tau * s + 1 + +Dead time is approximated with a 2nd-order Padé expansion. +Absolute temperature: T(t) = T_amb + G(s) * u(t) +""" + +import numpy as np +import control + + +class ThermalPlant: + """ + First-order thermal plant with dead time. + + Parameters + ---------- + K : float Steady-state gain [°C / % heater power] + tau : float Time constant [s] + theta : float Dead time [s] + T_amb : float Ambient temperature [°C] + """ + + def __init__(self, K: float = 1.5, tau: float = 200.0, + theta: float = 20.0, T_amb: float = 20.0) -> None: + self.K = K + self.tau = tau + self.theta = theta + self.T_amb = T_amb + + # ------------------------------------------------------------------ + def transfer_function(self) -> control.TransferFunction: + """ + Build and return the full transfer function G(s), with the dead + time represented by a 2nd-order Padé approximation. + + Returns + ------- + control.TransferFunction + """ + # --- First-order lag: K / (tau*s + 1) --- + lag = control.TransferFunction([self.K], [self.tau, 1.0]) + + # --- 2nd-order Padé approximation for e^(-theta*s) --- + num_pade, den_pade = control.pade(self.theta, n=2) + pade_tf = control.TransferFunction(num_pade, den_pade) + + # --- Series combination: G(s) = lag * pade --- + G = control.series(lag, pade_tf) + return G + + # ------------------------------------------------------------------ + def step_response(self, u_step: float = 100.0, + t_end: float = 800.0, + dt: float = 0.5): + """ + Simulate the open-loop step response: heater jumps from 0 to + *u_step* at t = 0. + + Parameters + ---------- + u_step : float Heater power step size [%] (clamped to [0, 100]) + t_end : float Simulation end time [s] + dt : float Time step [s] + + Returns + ------- + t : np.ndarray Time vector [s] + T : np.ndarray Absolute temperature [°C] + """ + # Clamp heater command + u_step = float(np.clip(u_step, 0.0, 100.0)) + + # Build time vector + t = np.arange(0.0, t_end + dt, dt) + + # Step input signal (deviation from 0 %) + u = np.full_like(t, u_step) + + # Get transfer function + G = self.transfer_function() + + # Simulate forced response + t_out, y_out = control.forced_response(G, T=t, U=u) + + # Convert to absolute temperature; keep only real part (Padé is real) + T = self.T_amb + np.real(y_out) + + return t_out, T diff --git a/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/plots/closed_loop_pid_response.png b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/plots/closed_loop_pid_response.png new file mode 100644 index 0000000..2070d75 Binary files /dev/null and b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/plots/closed_loop_pid_response.png differ diff --git a/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/plots/open_loop_response.png b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/plots/open_loop_response.png new file mode 100644 index 0000000..256ab6e Binary files /dev/null and b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/plots/open_loop_response.png differ diff --git a/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/plots/tuning_comparison.png b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/plots/tuning_comparison.png new file mode 100644 index 0000000..c09a4e7 Binary files /dev/null and b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/plots/tuning_comparison.png differ diff --git a/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/plots/zn_tuned_response.png b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/plots/zn_tuned_response.png new file mode 100644 index 0000000..0cc8674 Binary files /dev/null and b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/plots/zn_tuned_response.png differ diff --git a/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/requirements.txt b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/requirements.txt new file mode 100644 index 0000000..993f168 --- /dev/null +++ b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/requirements.txt @@ -0,0 +1,4 @@ +numpy>=1.24 +scipy>=1.10 +matplotlib>=3.7 +control>=0.9 diff --git a/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/tuner.py b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/tuner.py new file mode 100644 index 0000000..8877d1d --- /dev/null +++ b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/tuner.py @@ -0,0 +1,213 @@ +""" +tuner.py +-------- +Automatic PID tuning methods. + +Functions +--------- +ziegler_nichols_tune(plant) + Ziegler-Nichols ultimate gain method -- uses the plant's frequency + response (gain margin / phase-crossover frequency) to derive Ku and Tu, + then applies the classic ZN rules. + +cohen_coon_tune(plant) + Cohen-Coon method -- uses the plant's FOPDT parameters (K, tau, theta) + directly to compute PID gains. Includes an automatic robustness check: + if the Pade-approximated closed loop is unstable (a known artifact of + pure-derivative terms interacting with Pade RHP zeros), the gains are + scaled down iteratively until the closed loop is stable with adequate + gain margin. +""" + +import numpy as np +import control + + +# ====================================================================== +# Ziegler-Nichols Ultimate Gain Method +# ====================================================================== + +def ziegler_nichols_tune(plant) -> dict: + """ + Compute Ziegler-Nichols PID gains for *plant*. + + Parameters + ---------- + plant : ThermalPlant + An instance of ThermalPlant (or any object with a + ``transfer_function()`` method returning a SISO LTI system). + + Returns + ------- + dict with keys: + Ku - ultimate (critical) gain [float] + Tu - ultimate period [float, seconds] + Kp - proportional gain [float] + Ki - integral gain [float] + Kd - derivative gain [float] + """ + # ------------------------------------------------------------------ + # 1. Obtain plant transfer function and compute stability margins + # ------------------------------------------------------------------ + Gp = plant.transfer_function() + + # control.margin returns (gm, pm, w_p, w_g) + # gm - gain margin (linear, not dB) + # pm - phase margin (degrees) + # w_p - phase-crossover frequency, where angle(G) = -180 [rad/s] + # w_g - gain-crossover frequency, where |G| = 1 [rad/s] + gm, pm, w_p, w_g = control.margin(Gp) + + # ------------------------------------------------------------------ + # 2. Guard: infinite or NaN gain margin -> fallback defaults + # ------------------------------------------------------------------ + if gm is None or not np.isfinite(gm) or np.isnan(gm): + print("Gain margin infinite; using default Ku=5, Tu=200 for tuning.") + Ku = 5.0 + Tu = 200.0 + else: + Ku = float(gm) + Tu = float(2.0 * np.pi / w_p) + + # ------------------------------------------------------------------ + # 3. Ziegler-Nichols PID rules + # ------------------------------------------------------------------ + Kp = 0.6 * Ku + Ki = 2.0 * Kp / Tu + Kd = Kp * Tu / 8.0 + + return { + "Ku": Ku, + "Tu": Tu, + "Kp": float(Kp), + "Ki": float(Ki), + "Kd": float(Kd), + } + + +# ====================================================================== +# Cohen-Coon Method +# ====================================================================== + +def cohen_coon_tune(plant) -> dict: + """ + Compute Cohen-Coon PID gains for *plant* using its FOPDT parameters. + + The Cohen-Coon method works directly from the plant's steady-state + gain (K), time constant (tau), and dead time (theta), making it + independent of frequency-response calculations. It typically produces + lower overshoot than Ziegler-Nichols for FOPDT processes. + + Formulas (ideal PID form) + ------------------------- + r = theta / tau + Kc = (1/K) * (1.35 * (tau/theta) + 0.27) + tauI = tau * (32 + 6r) / (13 + 8r) + tauD = tau * 4 / (11 + 2r) + + Converting to standard gains: + Kp = Kc + Ki = Kc / tauI + Kd = Kc * tauD + + Robustness note + --------------- + When the plant dead time is approximated by a Pade expansion (as in + this project), the resulting RHP zeros can interact with a large Kd + and destabilise the closed loop -- even though the Cohen-Coon formulas + are mathematically correct for the true FOPDT plant. This function + therefore performs a closed-loop stability check after computing the + raw gains and, if necessary, scales all three gains down by a common + factor f until the system is stable with an adequate gain margin. + The scaling preserves the ratio Kp:Ki:Kd (i.e. tauI and tauD are + unchanged), so the closed-loop character remains Cohen-Coon in nature. + + Parameters + ---------- + plant : ThermalPlant + Must expose ``plant.K``, ``plant.tau``, and ``plant.theta``. + + Returns + ------- + dict with keys: + Kp - proportional gain [float] + Ki - integral gain [float] + Kd - derivative gain [float] + Kc - raw Cohen-Coon controller gain (before any detuning) + tauI - integral time constant [float, seconds] + tauD - derivative time constant [float, seconds] + detune_f - detuning factor applied [float] (1.0 = no detuning) + """ + from controller import PIDController + + K_plant = float(plant.K) + tau = float(plant.tau) + theta = float(plant.theta) + + # ------------------------------------------------------------------ + # Edge-case guard: avoid division by zero if theta is negligibly small + # ------------------------------------------------------------------ + if theta < 1e-9: + print("Warning: dead time theta ~= 0; Cohen-Coon requires theta > 0. " + "Returning conservative defaults.") + return {"Kp": 1.0, "Ki": 0.01, "Kd": 0.0, + "Kc": 1.0, "tauI": 100.0, "tauD": 0.0, "detune_f": 1.0} + + # ------------------------------------------------------------------ + # Cohen-Coon PID formulas (ideal form) + # ------------------------------------------------------------------ + r = theta / tau + + Kc = (1.0 / K_plant) * (1.35 * (tau / theta) + 0.27) + tauI = tau * (32.0 + 6.0 * r) / (13.0 + 8.0 * r) + tauD = tau * 4.0 / (11.0 + 2.0 * r) + + Kp_raw = float(Kc) + Ki_raw = float(Kc / tauI) + Kd_raw = float(Kc * tauD) + + # ------------------------------------------------------------------ + # Robustness check: verify closed-loop stability with Pade plant. + # If unstable, binary-search for the largest detuning factor f in + # (0, 1] that yields a stable closed loop, then apply a 15 % safety + # margin (f_use = f_boundary * 0.85). + # ------------------------------------------------------------------ + Gp = plant.transfer_function() + + def _is_stable(f: float) -> bool: + pid = PIDController(Kp_raw * f, Ki_raw * f, Kd_raw * f) + Gc = pid.transfer_function() + G_cl = control.feedback(control.series(Gc, Gp), 1, sign=-1) + poles = control.poles(G_cl) + return all(p.real <= 0 for p in poles) + + if _is_stable(1.0): + # Raw CC gains are stable -- use them directly + detune_f = 1.0 + else: + # Binary search for stability boundary + print("Cohen-Coon: raw gains unstable with Pade plant model; " + "applying automatic detuning to restore stability.") + lo_f, hi_f = 0.0, 1.0 + for _ in range(60): # 60 iterations -> precision ~1e-18 + mid = (lo_f + hi_f) / 2.0 + if _is_stable(mid): + lo_f = mid + else: + hi_f = mid + # Apply 15 % safety margin below the stability boundary + detune_f = lo_f * 0.85 + + Kp = Kp_raw * detune_f + Ki = Ki_raw * detune_f + Kd = Kd_raw * detune_f + + return { + "Kp": float(Kp), + "Ki": float(Ki), + "Kd": float(Kd), + "Kc": float(Kc), + "tauI": float(tauI), + "tauD": float(tauD), + "detune_f": float(detune_f), + } diff --git a/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/utils.py b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/utils.py new file mode 100644 index 0000000..a9586cf --- /dev/null +++ b/snippets/1023_AIAssistedPIDTuning/thermal_pid_tuner/utils.py @@ -0,0 +1,114 @@ +""" +utils.py +-------- +Performance metric functions for closed-loop PID evaluation. + +All functions accept 1-D NumPy arrays and return a single float. +""" + +import numpy as np + + +def overshoot(y: np.ndarray, setpoint: float) -> float: + """ + Percentage overshoot relative to the setpoint. + + Returns (max(y) - setpoint) / setpoint * 100. + Returns 0.0 if the output never exceeds the setpoint. + + Parameters + ---------- + y : np.ndarray 1-D output signal + setpoint : float Target value + + Returns + ------- + float Overshoot in % + """ + y = np.asarray(y, dtype=float) + peak = float(np.max(y)) + if peak <= setpoint: + return 0.0 + return (peak - setpoint) / setpoint * 100.0 + + +def rise_time(t: np.ndarray, y: np.ndarray, setpoint: float, + low: float = 0.1, high: float = 0.9) -> float: + """ + Time for the output to rise from (low * setpoint) to (high * setpoint). + + Finds the first crossing of each threshold and returns t_high - t_low. + Returns float('inf') if either threshold is never crossed. + + Parameters + ---------- + t : np.ndarray Time vector [s] + y : np.ndarray 1-D output signal + setpoint : float Target value + low : float Lower threshold as a fraction of setpoint (default 0.1) + high : float Upper threshold as a fraction of setpoint (default 0.9) + + Returns + ------- + float Rise time in seconds + """ + t = np.asarray(t, dtype=float) + y = np.asarray(y, dtype=float) + + low_val = setpoint * low + high_val = setpoint * high + + # First index where y >= low_val + low_idx = np.where(y >= low_val)[0] + if len(low_idx) == 0: + return float("inf") + + # First index where y >= high_val + high_idx = np.where(y >= high_val)[0] + if len(high_idx) == 0: + return float("inf") + + t_low = float(t[low_idx[0]]) + t_high = float(t[high_idx[0]]) + return t_high - t_low + + +def settling_time(t: np.ndarray, y: np.ndarray, + setpoint: float, band: float = 0.02) -> float: + """ + Smallest time t_s such that |y(t) - setpoint| <= band * setpoint + for all t >= t_s. + + Searches backwards from the end of the array for efficiency. + Returns float('inf') if the signal never settles within the simulation. + + Parameters + ---------- + t : np.ndarray Time vector [s] + y : np.ndarray 1-D output signal + setpoint : float Target value + band : float Tolerance as a fraction of setpoint (default 0.02 = 2 %) + + Returns + ------- + float Settling time in seconds + """ + t = np.asarray(t, dtype=float) + y = np.asarray(y, dtype=float) + + threshold = band * abs(setpoint) + within_band = np.abs(y - setpoint) <= threshold + + # Walk backwards to find the last index where signal is OUTSIDE the band + outside_indices = np.where(~within_band)[0] + if len(outside_indices) == 0: + # Always within band — settled from the start + return float(t[0]) + + last_outside = outside_indices[-1] + + # If the very last sample is still outside, it never settled + if last_outside >= len(t) - 1: + return float("inf") + + return float(t[last_outside + 1]) diff --git a/snippets/1026_AIforGeneratingRobotModels/image/3dof_arm.png b/snippets/1026_AIforGeneratingRobotModels/image/3dof_arm.png new file mode 100644 index 0000000..8137811 Binary files /dev/null and b/snippets/1026_AIforGeneratingRobotModels/image/3dof_arm.png differ diff --git a/snippets/1026_AIforGeneratingRobotModels/image/6dof_arm.png b/snippets/1026_AIforGeneratingRobotModels/image/6dof_arm.png new file mode 100644 index 0000000..2388d01 Binary files /dev/null and b/snippets/1026_AIforGeneratingRobotModels/image/6dof_arm.png differ diff --git a/snippets/1026_AIforGeneratingRobotModels/image/diffrobot_model_rviz.png b/snippets/1026_AIforGeneratingRobotModels/image/diffrobot_model_rviz.png new file mode 100644 index 0000000..9756d3e Binary files /dev/null and b/snippets/1026_AIforGeneratingRobotModels/image/diffrobot_model_rviz.png differ diff --git a/snippets/1026_AIforGeneratingRobotModels/image/mobile_manipulator.png b/snippets/1026_AIforGeneratingRobotModels/image/mobile_manipulator.png new file mode 100644 index 0000000..01c14f5 Binary files /dev/null and b/snippets/1026_AIforGeneratingRobotModels/image/mobile_manipulator.png differ diff --git a/snippets/1026_AIforGeneratingRobotModels/image/mobile_robot_camera.png b/snippets/1026_AIforGeneratingRobotModels/image/mobile_robot_camera.png new file mode 100644 index 0000000..f126774 Binary files /dev/null and b/snippets/1026_AIforGeneratingRobotModels/image/mobile_robot_camera.png differ diff --git a/snippets/1026_AIforGeneratingRobotModels/image/mobile_robot_lidar.png b/snippets/1026_AIforGeneratingRobotModels/image/mobile_robot_lidar.png new file mode 100644 index 0000000..fd98541 Binary files /dev/null and b/snippets/1026_AIforGeneratingRobotModels/image/mobile_robot_lidar.png differ diff --git a/snippets/1026_AIforGeneratingRobotModels/index.md b/snippets/1026_AIforGeneratingRobotModels/index.md new file mode 100644 index 0000000..18c0d01 --- /dev/null +++ b/snippets/1026_AIforGeneratingRobotModels/index.md @@ -0,0 +1,195 @@ +--- +layout: default +codename: AIforGeneratingRobotModels +title: AI for Generating Robot Models +tags: snippets mieset +authors: Charaf Mohamad +--- + +# AI for Generating Robot Models +--- +## Problem Statement +In robotics engineering, before a robot is simulated, visualized, or planned around, its physical structure must be formally described to the software tools that operate on it. The Unified Robot Description Format (URDF) is the standard most widely used for this task: an XML-based schema used throughout the Robot Operating System (ROS) ecosystem – a widely used open-source framework for building robotics software – and tools built around it to define a robot's links, joints, visual and collision geometry, and inertial properties. It essentially encodes the robot's entire kinematic skeleton (i.e., how each rigid body connects to the next, along which axis it can move, and within what limits) whether it's a mobile robot – an automated machine capable of moving through its environment to perform tasks independently, rather than being fixed to one location – or a robot manipulator – a robotic arm made up of a series of rigid links connected by joints, designed to move and position an end-effector (like a gripper or tool) in space to perform tasks such as picking, placing, welding, or assembling – or a mobile robot manipulator (i.e., a mix of both). However, hand-writing a URDF file is a meticulous and error-prone process, because each link requires a precisely defined coordinate frame, and each joint requires a correctly specified type and axis. This means that, a URDF file may load without errors, but behave incorrectly. Hence, and since URDF is a well-documented and highly standardized format, this provides the opportunity to investigate the generation of URDF robot models using AI models. +![Simple Mobile Robot Model](image/diffrobot_model_rviz.png) + +## Task Description +For this task, Claude Sonnet 4.6 was used with "Medium" effort set and "Thinking" enabled. The model was tasked with generating five robot URDF models across three increasingly complex groups each designed to test a different aspect of the model's ability to handle robot kinematics correctly: + +| Robot No. | Type | Description | Complexity | +|--------|-------------|--------|------------| +| **1.1** | Mobile Robot | A 4-wheeled differential drive robot, with a LiDAR sensor mounted on top | Simple | +| **1.2** | Mobile Robot | A 4-wheeled differential drive robot, with a camera mounted on the front | Simple | +| **2.1** | Robot Manipulator | A 3-DOF (degrees of freedom) robot arm | Moderate | +| **2.2** | Robot Manipulator | A 6-DOF (degrees of freedom) robot arm with a gripper on its tool center point (i.e., the same 2.1 robot, but with a gripper) | Moderate-Complex | +| **3** | Mobile Robot Manipulator | A 4-wheeled differential drive robot, with a 6-DOF robot arm, a LiDAR sensor mounted on top (back side) and a camera mounted to the front | Moderate-Complex | + +The first tier is a simple differential drive mobile robot consisting of a basic chassis with 4 wheels: one with a LiDAR on top and the other with a camera mounter to the front. Both test whether the model can produce a correct link/joint tree with appropriately typed joints (e.g., continuous joints for the wheels) and a sensibly placed, fixed-joint sensor mount. The second tier's base manipulator is a 3-DOF robot arm, testing whether the model correctly defines joint axes, joint limits, and the chained coordinate transforms required for a multi-link kinematic arm. The base manipulator was then extended by adding a gripper as the end effector, which added 3 DOFs to the robot, thus checking whether Sonnet is able to dynamically add an end effector to an already existing link chain, without disrupting functionality. Finally, the third tier group is a single mobile robot manipulator, consisting of the Tier 1 Mobile robot and the Tier 2 Mobile Manipulator. This tests the model's ability to adapt and generate different sub-systems into a single, coherent robot, without naming collisions or proportion mismatches. +The main focus was the evaluation of structural and kinematic correctness, validated using a web-based URDF visualizer. + +--- +## Lessons Learned +- **Giving strict specifications is important** – Sonnet 4.6 was able to general XML code, but some parameter values like the limit range within which joints can move were questionable. Since the model is not aware of how robot looks like, it can only predict within reason, the suitable values for the joint limits. So, either the correct robot value specifications must be given prior to the file generation, or a person must validate the values using a visualization tool. +- **Sonnet 4.6 can adapt to changes correctly** – the tasks involved multiple modifications to the URDF files, which the model handled correctly. It was able to dynamically add and remove sensors (e.g., it was able to replace a LiDAR with a camera) and change proportions of the manipulator when asked. +- **Setting requirements is key to smooth sessions** – by adding a "Requirements" list to the initial prompts, we ensured that the model uses the correct format while maintaining readability and minimal structure. The model was also able to follow naming conventions and understand what the files will be used for (i.e., visualization only). +- **Step-by-step progression is important for consistency** – when dealing with tasks that are on the complex side of the spectrum, such as the generating a URDF file for a mobile robot manipulator, models like Sonnet 4.6 will be sufficient if the task is decomposed into steps and/or sub-tasks. Because I started with the mobile robot first, then the robot manipulator while solving each of the problems that came with each separately, the model had minimal issues when integrating both subsystems into one. +- **Always ask a model for improvement suggestions** – after generating the first version of the mobile robot manipulator, the model was able to suggest solid improvement points that not only involved practical suggestions on positioning, stability and risks, but also involved naming convention fixes and mounting advices. +- **Sonnet is able to model basic robots** – for the structural aspect of each of the robot models, Sonnet 4.6 generated the files with no issues regarding the correct placement of the links and joints. + +--- +## Using the Result +Each of the robot models are accessible in the [Robot Models](robot_models) directory. To test the models: +1. Navigate to the https://viewer.robotsfan.com/ web-based visualization tool. +2. Pick one of the models from the [Robot Models](robot_models) directory. +3. Drag and Drop the chosen URDF file into the website. +4. Click the "Joint Axes" button on top of the page to see the axes of rotation (make sure it is highlighted). +5. Use the sliders provided in the "Joints" tab located in the bottom left of the page to change the angle of a joint (e.g., to rotate a wheel, move the robot arm) + +In the "Structure" tab, you can see the "TF tree" of the robot, which shows the links of the robot connected by joints of different types. + +--- +## Workflow Details +A "URDF generation" project was created, and within it, I held three separate sessions using Sonnet 4.6, where each session involved generating one of the tiers mentioned before: +- **Session 1**: Tier 1 robots (Robot 1.1 and robot 1.2). +- **Session 2**: Tier 2 robots (Robot 2.1 and robot 2.2). +- **Session 3**: Tier 3 robots (Robot 3). + + +### Establishing Context and Constraints +I provided each session with identical initial prompts giving the specifications and the requirements to ensure step-by-step and smooth progression. +#### ***User*** +```text +You are a URDF generation assistant focused only on robot visuals and structure. +Generate clean, valid URDF XML using: + +- links +- joints +- visual geometry +- materials/colors + +Ignore or omit: +- inertia +- mass +- collision geometry +- physics accuracy +- dynamics and transmissions + +Requirements: +- Output only URDF XML. +- Keep the structure minimal and readable. +- Use standard URDF conventions. +- Generate visually coherent robot models suitable for rendering and visualization only. + + Before you move on to anything, ask clarifying questions, then move ahead after i confirm everything is okay. +``` + +### Implementation +#### Session 1: Mobile Robot URDF +I answered the model's clarifying questions that specify implementation details for the mobile robot: +- Type: Mobile differential-drive robot. +- Links/joints: 4-wheeled base with a decently-sized chassis. +- Joint types: mixed. +- Basic geometry preference: No preference. +- Color scheme: No preference. +- Root link name: base_link + +The robot was successfully able to generate the XML code for the file, but decided to add a LiDAR sensor on top of the robot, which it should have asked about in the clarifying questions. The reason behind this is that I filled the "Joint types" field with "mixed", and since the joints of the wheels are all type "continuous", it had to add an extra link, which had a joint of a different type to fulfill the requirement I set. + +![Mobile Robot with LiDAR](image/mobile_robot_lidar.png) + +Since the model was able to easily generate the basic mobile robot, so I decided to test its ability to modify already existing URDF files. + + +##### ***User*** +```text +Replace the lidar sensor on top with a camera at the front. +``` + +Structurally, the model handled the modification well, but it made the decision to make the lens of the camera rotate, which is not usually implemented this way. It should have either kept the camera fully fixed or made the camera body rotate around the z-axis. After this issue was addressed, it proceeded by generating the same file but keeping the whole camera fixed. + +![Mobile Robot with Camera](image/mobile_robot_camera.png) + +#### Session 2: Robot Manipulator URDF +I answered the model's clarifying questions that specify implementation details only by stating that I want a simple 3-DOF robot manipulator, while the rest of the specifications including structure, dimensions, colors, etc... was up to the model's best judgment. +Sonnet was successfully able to perform the task, but since I specified the degrees of freedom to be only 3, the manipulator was basic and had a "dummy" end effector as a placeholder: + +![3-DOF Robot Manipulator](image/3dof_arm.png) + +So, the next natural step was to add an end effector to the tool center point of the manipulator and resizing it to later fit the mobile robot. +##### ***User*** +```text +Feedback: +- Make the overall size of the manipulator smaller +- Use a better end effector, even if that means adding DOF's to the robot. +``` + +![6-DOF Robot Manipulator](image/6dof_arm.png) + +There were two main issues that I observed: +1. The manipulator was able to collide with itself easily by increasing the angle of joint2 to 90 degrees +2. The fingers of the gripper were able to open, but move outside the range of the gripper mount. +So, these problems were addressed in addition to resizing the manipulator: +##### ***User*** +```text +Feedback: +- Make the robot a bit smaller +- joint2 allows the robot to collide with itself, fix it. +- The 2 end effector finger joints are able to move outside the range of the gripper mount radius, fix it. +``` +In the first iteration, the model fixed the second issue, but for the first issue, instead of lowering the upper angle limit from 90 degrees, it increased the lower limit. After this mistake was addressed, Sonnet generated the corrected version in the second iteration. + +#### Session 3: Mobile Robot Manipulator URDF +For the third session, I attached the generated files for the mobile robot and robot manipulator to the project and provided the prompt to give it context on what its task is: +##### ***User*** +```text +Feedback: +I have provided the 2 URDF files for the mobile robot and robot manipulator. Your job is to combine the two robots. If you think it is necessary to alter the URDF files, feel free to do so, but let me know. +``` + +The model was successfully able to mark a naming conflict and then proceeded by asking questions regarding implementation: +##### ***User*** +```text +Q: Where should the arm be mounted on the mobile robot chassis? +A: Front-mounted on top (near camera side) + +Q: Should the arm face the same direction as the camera (forward, +X), or a different orientation? +A: Same as camera (forward) + +Q: The mobile base top surface is at z=0.06 (half of 0.12 box height). Should the arm's base sit flush on top of that, or with a small mounting riser/gap? +A: You decide a sensible default +``` +##### ***Sonnet 4.6*** +```text +The change I made: renamed the arm's base_link to arm_base_link (the only conflict between the two files) and connected it to the mobile robot's base_link with a new fixed joint, joint_arm_mount, at xyz="0.10 0 0.065" with no rotation. Everything else — wheels, camera, and all 6-DOF arm links/joints/limits — is unchanged from your originals. +``` + +Although it successfully generated the file, I asked it for improvement suggestions, and it replied with a list including: +- Workspace and collision risk: It brought up concerns about the manipulator being too close to the camera and suggested either pulling the arm mount back slightly, or repositioning the camera to the side. +- Stability/tipping: Since the manipulator is shifted to the front, the center of mass of the whole robot is also shifting forward, which is worth considering for stability. +- Reachability: The robot manipulator's workspace is too small for tasks like picking objects off the floor +- Sensor/utility gaps: For mobile manipulators, the mount on the camera should usually be able to pan-tilt. Mobile manipulators also usually depend on a 2D LiDAR sensor +##### ***User*** +```text +Integrate all these improvements while staying within the requirements set in the initial prompt. You can change whatever you need to change, but mention the changes after you're done. +``` +##### ***Sonnet 4.6*** +```text +Validated (well-formed XML, all joint parent/child references resolve, no duplicate link names — 18 links, 17 joints). Changes made: +Collision/clearance risk — moved the arm mount from x=0.10 to x=0.05 (closer to chassis center), increasing horizontal clearance from the camera at x=0.21 from 0.11m to 0.16m. +Stability — same x=0.05 repositioning also pulls the arm's reach envelope more over the wheelbase center rather than hanging off the front edge. +Floor-reach limitation — left as-is per scope; addressing this for real would mean changing arm/base dimensions, which felt out of bounds for "integrate improvements" without your sign-off, so flagging it rather than silently resizing the arm. +Naming convention — arm chain renamed to a consistent scheme: arm_base_link, arm_link1-3, arm_wrist_link, arm_gripper_base, arm_finger_right/left, with joints joint_arm_1, joint_arm_2, joint_arm_3, joint_arm_4_wrist_roll, etc. Wheel and camera joints already followed a clean joint__ pattern, so I extended that pattern rather than changing it. +TF tree root — added base_footprint as a geometry-less root link, connected via a fixed joint_base_footprint to base_link. This is the standard ROS navigation-stack convention and makes the file easier to drop into RViz/move_base/Nav2 setups later. +Camera — converted from fully fixed to a pan-tilt mount: new camera_pan_link (revolute, ±60° about z) carries camera_body (revolute, ±45° about y) which still carries camera_lens exactly as before. This lets the camera track the arm/gripper or scan independently of chassis heading. +LIDAR — added a new lidar_link (simple cylinder, sensor_black material) fixed to the rear-top of the chassis at x=-0.10, z=0.08, giving the navigation sensor an unobstructed scan plane clear of the arm. +``` + +The model could detect weaknesses in the robot structure and implement its improvement suggestions successfully. It was also able to resize the robot manipulator for reachability. In addition, it implemented a virtual link called "base_footprint" which is needed in a various robotics applications like mapping using "SLAM". + +![Mobile Manipulator](image/mobile_manipulator.png) + +--- + +## Summary & Conclusion +All things considered, Sonnet 4.6 (Medium effort, with Thinking enabled) produced structurally and kinematically sound URDF files across all three complexity tiers, handling link/joint trees, joint typing, and – most importantly – combining two independently generated sub-systems into a single cohesive mobile manipulator without naming collisions or broken transforms. The model's ability to self-diagnose weaknesses (such as workspace reachability, stability, and sensor placement) and translate that feedback into structural fixes – such as adopting standard ROS conventions like the base_footprint root link without explicit instruction – was its strongest demonstration. However, the model consistently struggled with judgment at the parameter level rather than structure: joint limit ranges, self-collision boundaries, and end-effector travel ranges all needed to be manually inspected and corrected because the model lacks an understanding of how a particular robot should physically appear or move in relation to its components. + +**Author:** Charaf Mohamad \ No newline at end of file diff --git a/snippets/1026_AIforGeneratingRobotModels/robot_models/01_mobile_robot_lidar.urdf b/snippets/1026_AIforGeneratingRobotModels/robot_models/01_mobile_robot_lidar.urdf new file mode 100644 index 0000000..3b22148 --- /dev/null +++ b/snippets/1026_AIforGeneratingRobotModels/robot_models/01_mobile_robot_lidar.urdf @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/snippets/1026_AIforGeneratingRobotModels/robot_models/02_mobile_robot_camera.urdf b/snippets/1026_AIforGeneratingRobotModels/robot_models/02_mobile_robot_camera.urdf new file mode 100644 index 0000000..9df682c --- /dev/null +++ b/snippets/1026_AIforGeneratingRobotModels/robot_models/02_mobile_robot_camera.urdf @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/snippets/1026_AIforGeneratingRobotModels/robot_models/03_3dof_arm.urdf b/snippets/1026_AIforGeneratingRobotModels/robot_models/03_3dof_arm.urdf new file mode 100644 index 0000000..7e76da2 --- /dev/null +++ b/snippets/1026_AIforGeneratingRobotModels/robot_models/03_3dof_arm.urdf @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/snippets/1026_AIforGeneratingRobotModels/robot_models/04_6dof_arm.urdf b/snippets/1026_AIforGeneratingRobotModels/robot_models/04_6dof_arm.urdf new file mode 100644 index 0000000..063be47 --- /dev/null +++ b/snippets/1026_AIforGeneratingRobotModels/robot_models/04_6dof_arm.urdf @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/snippets/1026_AIforGeneratingRobotModels/robot_models/05_6dof_arm_improved.urdf b/snippets/1026_AIforGeneratingRobotModels/robot_models/05_6dof_arm_improved.urdf new file mode 100644 index 0000000..07fda24 --- /dev/null +++ b/snippets/1026_AIforGeneratingRobotModels/robot_models/05_6dof_arm_improved.urdf @@ -0,0 +1,181 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/snippets/1026_AIforGeneratingRobotModels/robot_models/06_mobile_robot_6dof_manipulator.urdf b/snippets/1026_AIforGeneratingRobotModels/robot_models/06_mobile_robot_6dof_manipulator.urdf new file mode 100644 index 0000000..54857ed --- /dev/null +++ b/snippets/1026_AIforGeneratingRobotModels/robot_models/06_mobile_robot_6dof_manipulator.urdf @@ -0,0 +1,392 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +