Skip to content

Commit 170dfed

Browse files
authored
Merge pull request #1 from edrivedesign/copilot/create-control-system-model
Add steam engine governor simulation example with interactive GUI
2 parents 7d5019e + 1d43b32 commit 170dfed

1 file changed

Lines changed: 310 additions & 0 deletions

File tree

examples/steam_engine_governor.py

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
#!/usr/bin/env python3
2+
"""Steam engine governor model with a simple input/output GUI."""
3+
4+
import os
5+
6+
if 'PYCONTROL_TEST_EXAMPLES' in os.environ:
7+
import matplotlib
8+
matplotlib.use('Agg')
9+
10+
import control as ct
11+
import matplotlib.pyplot as plt
12+
import numpy as np
13+
14+
try:
15+
import tkinter as tk
16+
from tkinter import ttk
17+
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
18+
except ImportError:
19+
tk = None
20+
ttk = None
21+
FigureCanvasTkAgg = None
22+
23+
24+
DEFAULTS = {
25+
'kp': 1.0,
26+
'ti': 2.0,
27+
'tg': 0.1,
28+
'ts': 0.2,
29+
'kt': 2.0,
30+
'j': 4.5,
31+
'b': 1.2,
32+
'ref_step': 0.08,
33+
'load_step': 0.10,
34+
'load_time': 5.0,
35+
'sim_time': 25.0,
36+
}
37+
SETTLING_TOLERANCE = 0.02
38+
MIN_SETTLING_TOLERANCE = 1e-3
39+
NUM_DOMINANT_POLES = 2
40+
41+
42+
def create_governor_system(params):
43+
"""Create a linearized steam engine governor model."""
44+
kp = params['kp']
45+
ti = params['ti']
46+
tg = params['tg']
47+
ts = params['ts']
48+
kt = params['kt']
49+
inertia = params['j']
50+
damping = params['b']
51+
for name, value in (
52+
('ti', ti), ('tg', tg), ('ts', ts), ('kt', kt), ('j', inertia)):
53+
if value <= 0:
54+
raise ValueError(f'{name} must be positive')
55+
if damping < 0:
56+
raise ValueError('b must be nonnegative')
57+
ki = kp / ti
58+
59+
a_matrix = np.array([
60+
[0, 0, 0, -1],
61+
[ki / tg, -1 / tg, 0, -kp / tg],
62+
[0, kt / ts, -1 / ts, 0],
63+
[0, 0, 1 / inertia, -damping / inertia],
64+
])
65+
b_matrix = np.array([
66+
[1, 0],
67+
[kp / tg, 0],
68+
[0, 0],
69+
[0, -1 / inertia],
70+
])
71+
c_matrix = np.array([
72+
[0, 0, 0, 1],
73+
[0, 1, 0, 0],
74+
[0, 0, 1, 0],
75+
[0, 0, 0, -1],
76+
])
77+
d_matrix = np.array([
78+
[0, 0],
79+
[0, 0],
80+
[0, 0],
81+
[1, 0],
82+
])
83+
84+
return ct.ss(
85+
a_matrix, b_matrix, c_matrix, d_matrix,
86+
states=['integral_error', 'valve', 'steam_torque', 'speed'],
87+
inputs=['reference', 'load_torque'],
88+
outputs=['speed', 'valve', 'steam_torque', 'error'],
89+
name='steam_governor',
90+
)
91+
92+
93+
def simulate_governor(params, samples=600):
94+
"""Simulate the governor response for the requested settings."""
95+
system = create_governor_system(params)
96+
time = np.linspace(0, params['sim_time'], samples)
97+
reference = np.full_like(time, params['ref_step'])
98+
load = np.where(time >= params['load_time'], params['load_step'], 0.0)
99+
response = ct.input_output_response(system, time, [reference, load])
100+
return system, time, np.asarray(response.outputs), reference, load
101+
102+
103+
def settling_time(time, signal, target, tolerance):
104+
"""Return the first time after which the response stays in band."""
105+
error = np.abs(signal - target)
106+
indices = np.nonzero(error > tolerance)[0]
107+
if indices.size == 0:
108+
return time[0]
109+
last_outside = indices[-1]
110+
if last_outside >= len(time) - 1:
111+
return np.nan
112+
return time[last_outside + 1]
113+
114+
115+
def summarize_response(system, time, outputs, target):
116+
"""Create summary metrics for the speed response."""
117+
speed = outputs[0]
118+
final_speed = speed[-1]
119+
peak_speed = np.max(speed)
120+
minimum_speed = np.min(speed)
121+
tolerance = max(
122+
SETTLING_TOLERANCE * max(abs(target), abs(final_speed)),
123+
MIN_SETTLING_TOLERANCE)
124+
if abs(target) < 1e-9:
125+
overshoot = 0.0
126+
elif target > 0:
127+
overshoot = max(0.0, (peak_speed - target) / abs(target) * 100)
128+
else:
129+
overshoot = max(0.0, (target - minimum_speed) / abs(target) * 100)
130+
steady_state_error = target - final_speed
131+
poles = list(ct.poles(system))
132+
# Use the least stable (rightmost) poles as the dominant dynamics.
133+
dominant_poles = sorted(
134+
poles, key=lambda pole: pole.real)[-min(NUM_DOMINANT_POLES, len(poles)):]
135+
damping_ratios = []
136+
for pole in dominant_poles:
137+
magnitude = abs(pole)
138+
if magnitude > 0:
139+
damping_ratios.append(max(0.0, min(1.0, -pole.real / magnitude)))
140+
141+
return {
142+
'settling_time': settling_time(time, speed, target, tolerance),
143+
'overshoot': overshoot,
144+
'steady_state_error': steady_state_error,
145+
'dominant_poles': dominant_poles,
146+
'damping_ratios': damping_ratios,
147+
}
148+
149+
150+
def summary_text(metrics):
151+
"""Format the response metrics for display."""
152+
poles = ', '.join(
153+
f'{pole.real:.2f}{pole.imag:+.2f}j' for pole in metrics['dominant_poles'])
154+
if metrics['damping_ratios']:
155+
damping = ', '.join(f'{ratio:.2f}' for ratio in metrics['damping_ratios'])
156+
else:
157+
damping = 'n/a'
158+
settling = (
159+
f"{metrics['settling_time']:.2f} s"
160+
if np.isfinite(metrics['settling_time']) else 'not settled'
161+
)
162+
return '\n'.join([
163+
f'Settling time ({SETTLING_TOLERANCE:.0%} band): {settling}',
164+
f'Overshoot: {metrics["overshoot"]:.2f}%',
165+
f'Steady-state error: {metrics["steady_state_error"]:.4f}',
166+
f'Dominant poles: {poles}',
167+
f'Damping ratios: {damping}',
168+
])
169+
170+
171+
def plot_results(figure, time, outputs, reference, load, metrics):
172+
"""Draw the response plots for the governor simulation."""
173+
speed, valve, torque, error = outputs
174+
figure.clf()
175+
axes = figure.subplots(3, 1, sharex=True)
176+
177+
axes[0].plot(time, speed, label='Speed deviation')
178+
axes[0].plot(time, reference, '--', label='Reference change')
179+
axes[0].set_ylabel('Speed')
180+
axes[0].set_title('Steam engine governor response')
181+
axes[0].grid(True, linestyle='dotted')
182+
axes[0].legend(loc='best')
183+
184+
axes[1].plot(time, valve, label='Valve position')
185+
axes[1].plot(time, torque, label='Steam torque')
186+
axes[1].set_ylabel('Actuation')
187+
axes[1].grid(True, linestyle='dotted')
188+
axes[1].legend(loc='best')
189+
190+
axes[2].plot(time, load, label='Load torque disturbance')
191+
axes[2].plot(time, error, '--', label='Speed error')
192+
axes[2].set_xlabel('Time [s]')
193+
axes[2].set_ylabel('Disturbance / error')
194+
axes[2].grid(True, linestyle='dotted')
195+
axes[2].legend(loc='best')
196+
197+
figure.text(
198+
0.99, 0.98, summary_text(metrics), va='top', ha='right',
199+
bbox={'boxstyle': 'round', 'facecolor': 'white', 'alpha': 0.9},
200+
)
201+
figure.tight_layout(rect=[0, 0, 0.86, 1])
202+
203+
204+
class GovernorApp:
205+
"""GUI for adjusting governor inputs and viewing outputs."""
206+
207+
field_specs = (
208+
('Governor proportional gain (Kp)', 'kp'),
209+
('Integral time (Ti) [s]', 'ti'),
210+
('Actuator time constant (Tg) [s]', 'tg'),
211+
('Steam lag (Ts) [s]', 'ts'),
212+
('Steam torque gain (Kt)', 'kt'),
213+
('Equivalent inertia (J)', 'j'),
214+
('Damping coefficient (B)', 'b'),
215+
('Reference step', 'ref_step'),
216+
('Load torque step', 'load_step'),
217+
('Load step time [s]', 'load_time'),
218+
('Simulation time [s]', 'sim_time'),
219+
)
220+
221+
def __init__(self, root):
222+
self.root = root
223+
self.root.title('Steam Engine Governor')
224+
self.variables = {
225+
key: tk.StringVar(value=str(value)) for key, value in DEFAULTS.items()
226+
}
227+
self.status = tk.StringVar(
228+
value='Adjust the model inputs and click Simulate.')
229+
230+
main = ttk.Frame(root, padding=10)
231+
main.pack(fill='both', expand=True)
232+
controls = ttk.LabelFrame(main, text='Model inputs', padding=10)
233+
controls.pack(side='left', fill='y')
234+
plots = ttk.Frame(main)
235+
plots.pack(side='right', fill='both', expand=True)
236+
237+
for row, (label, key) in enumerate(self.field_specs):
238+
ttk.Label(controls, text=label).grid(
239+
row=row, column=0, sticky='w', padx=(0, 8), pady=3)
240+
ttk.Entry(controls, textvariable=self.variables[key], width=12).grid(
241+
row=row, column=1, sticky='ew', pady=3)
242+
243+
ttk.Button(controls, text='Simulate', command=self.update_plots).grid(
244+
row=len(self.field_specs), column=0, columnspan=2, sticky='ew',
245+
pady=(10, 4))
246+
ttk.Label(
247+
controls, textvariable=self.status, justify='left', wraplength=260
248+
).grid(row=len(self.field_specs) + 1, column=0, columnspan=2, sticky='w')
249+
250+
self.figure = plt.Figure(figsize=(10, 7))
251+
self.canvas = FigureCanvasTkAgg(self.figure, master=plots)
252+
self.canvas.get_tk_widget().pack(fill='both', expand=True)
253+
254+
self.update_plots()
255+
256+
def read_params(self):
257+
"""Read and validate values from the input fields."""
258+
params = {key: float(variable.get()) for key, variable in self.variables.items()}
259+
for key in ('ti', 'tg', 'ts', 'kt', 'j', 'sim_time'):
260+
if params[key] <= 0:
261+
raise ValueError(f'{key} must be positive')
262+
if params['b'] < 0:
263+
raise ValueError('b must be nonnegative')
264+
if params['load_time'] < 0:
265+
raise ValueError('load_time must be nonnegative')
266+
if params['load_time'] > params['sim_time']:
267+
raise ValueError('load_time must not exceed sim_time')
268+
return params
269+
270+
def update_plots(self):
271+
"""Recompute the simulation and refresh the output plots."""
272+
try:
273+
system, time, outputs, reference, load = simulate_governor(
274+
self.read_params())
275+
metrics = summarize_response(system, time, outputs, reference[-1])
276+
except Exception as exc:
277+
self.status.set(f'Unable to simulate: {exc}')
278+
return
279+
280+
plot_results(self.figure, time, outputs, reference, load, metrics)
281+
self.canvas.draw()
282+
self.status.set(summary_text(metrics))
283+
284+
285+
def run_headless_demo():
286+
"""Exercise the model and plotting code without starting the GUI."""
287+
figure = plt.figure(figsize=(10, 7))
288+
system, time, outputs, reference, load = simulate_governor(DEFAULTS)
289+
metrics = summarize_response(system, time, outputs, reference[-1])
290+
plot_results(figure, time, outputs, reference, load, metrics)
291+
figure.canvas.draw()
292+
plt.close(figure)
293+
294+
295+
def main():
296+
"""Start the GUI unless the example test harness is active."""
297+
if 'PYCONTROL_TEST_EXAMPLES' in os.environ:
298+
run_headless_demo()
299+
return
300+
301+
if tk is None or FigureCanvasTkAgg is None:
302+
raise SystemExit('Tkinter support is required to run this GUI example.')
303+
304+
root = tk.Tk()
305+
GovernorApp(root)
306+
root.mainloop()
307+
308+
309+
if __name__ == '__main__':
310+
main()

0 commit comments

Comments
 (0)