AutoPumping 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- auto_pumping/__init__.py +116 -0
- auto_pumping/__main__.py +35 -0
- auto_pumping/config.py +351 -0
- auto_pumping/dummy.py +49 -0
- auto_pumping/executor.py +178 -0
- auto_pumping/graph.py +358 -0
- auto_pumping/safety.py +83 -0
- auto_pumping/ui.py +691 -0
- autopumping-0.1.0.dist-info/METADATA +72 -0
- autopumping-0.1.0.dist-info/RECORD +13 -0
- autopumping-0.1.0.dist-info/WHEEL +5 -0
- autopumping-0.1.0.dist-info/licenses/LICENSE +28 -0
- autopumping-0.1.0.dist-info/top_level.txt +1 -0
auto_pumping/ui.py
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
from collections import deque
|
|
2
|
+
from threading import Lock
|
|
3
|
+
from time import time
|
|
4
|
+
|
|
5
|
+
from nicegui import background_tasks, run, ui
|
|
6
|
+
|
|
7
|
+
from .config import PumpState, ValveState
|
|
8
|
+
|
|
9
|
+
POLL_INTERVAL = 1.0
|
|
10
|
+
# How much gauge history the live plot keeps, in seconds.
|
|
11
|
+
HISTORY_WINDOW = 600.0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GaugeHistory:
|
|
15
|
+
"""A bounded, thread-safe history of gauge readings shared by all pages.
|
|
16
|
+
|
|
17
|
+
Sampling is driven by each page's poll, so a browser tab opened later sees
|
|
18
|
+
the history collected so far instead of starting from a blank plot.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, gauges, window=HISTORY_WINDOW, min_interval=POLL_INTERVAL / 2):
|
|
22
|
+
self._gauges = list(gauges)
|
|
23
|
+
self._window = window
|
|
24
|
+
self._min_interval = min_interval
|
|
25
|
+
self._samples = deque()
|
|
26
|
+
self._last_sample = None
|
|
27
|
+
# `record` is called from `run.io_bound` worker threads, one per page.
|
|
28
|
+
self._lock = Lock()
|
|
29
|
+
|
|
30
|
+
def record(self, readings, now=None):
|
|
31
|
+
"""Store a set of readings, ignoring samples that arrive too close together.
|
|
32
|
+
|
|
33
|
+
Every open tab polls independently, so without the interval check two
|
|
34
|
+
tabs would record duplicate samples for the same instant.
|
|
35
|
+
"""
|
|
36
|
+
now = time() if now is None else now
|
|
37
|
+
with self._lock:
|
|
38
|
+
if (
|
|
39
|
+
self._last_sample is not None
|
|
40
|
+
and now - self._last_sample < self._min_interval
|
|
41
|
+
):
|
|
42
|
+
return False
|
|
43
|
+
self._last_sample = now
|
|
44
|
+
self._samples.append((now, dict(readings)))
|
|
45
|
+
cutoff = now - self._window
|
|
46
|
+
while self._samples and self._samples[0][0] < cutoff:
|
|
47
|
+
self._samples.popleft()
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
def series(self):
|
|
51
|
+
"""Return `{gauge: [[timestamp_ms, value], ...]}` ready for ECharts.
|
|
52
|
+
|
|
53
|
+
Timestamps are milliseconds because that is what an ECharts `time` axis
|
|
54
|
+
expects. Readings that a log axis cannot show (<= 0, or missing) become
|
|
55
|
+
`None` so the line is drawn with a gap rather than implying a reading
|
|
56
|
+
that was never taken.
|
|
57
|
+
"""
|
|
58
|
+
with self._lock:
|
|
59
|
+
samples = list(self._samples)
|
|
60
|
+
series = {gauge: [] for gauge in self._gauges}
|
|
61
|
+
for timestamp, readings in samples:
|
|
62
|
+
milliseconds = int(timestamp * 1000)
|
|
63
|
+
for gauge in self._gauges:
|
|
64
|
+
value = readings.get(gauge)
|
|
65
|
+
if value is None or value <= 0:
|
|
66
|
+
value = None
|
|
67
|
+
series[gauge].append([milliseconds, value])
|
|
68
|
+
return series
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class UI:
|
|
72
|
+
def __init__(self, config, pumping_system, diagram=None):
|
|
73
|
+
self._pumping_system = pumping_system
|
|
74
|
+
self._config = config
|
|
75
|
+
history = GaugeHistory(config.gauges)
|
|
76
|
+
|
|
77
|
+
@ui.page("/")
|
|
78
|
+
def _index():
|
|
79
|
+
# Each connecting client gets its own `_Page` instance so that
|
|
80
|
+
# widget references, callbacks, and the polling timer are never
|
|
81
|
+
# shared between browser tabs/reconnections.
|
|
82
|
+
_Page(config, pumping_system, diagram, history).build()
|
|
83
|
+
|
|
84
|
+
def run(self):
|
|
85
|
+
ui.run(reload=False)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _Page:
|
|
89
|
+
def __init__(self, config, pumping_system, diagram, history=None):
|
|
90
|
+
self._config = config
|
|
91
|
+
self._pumping_system = pumping_system
|
|
92
|
+
self._diagram = diagram
|
|
93
|
+
self._history = history if history is not None else GaugeHistory(config.gauges)
|
|
94
|
+
self._gauge_chart = None
|
|
95
|
+
self._auto_switch = None
|
|
96
|
+
self._valve_switches = {}
|
|
97
|
+
self._pump_switches = {}
|
|
98
|
+
self._pump_labels = {}
|
|
99
|
+
self._named_state_buttons = {}
|
|
100
|
+
self._stage_buttons = {}
|
|
101
|
+
self._gauge_labels = {}
|
|
102
|
+
self._confirm_dialog = None
|
|
103
|
+
self._confirm_content = None
|
|
104
|
+
self._shown_confirmation = None
|
|
105
|
+
self._plan_description = None
|
|
106
|
+
self._unknown_dialog = None
|
|
107
|
+
self._unknown_state = None
|
|
108
|
+
self._unknown_valve_switches = {}
|
|
109
|
+
self._unknown_pump_switches = {}
|
|
110
|
+
self._unknown_stage_toggle = None
|
|
111
|
+
self._unknown_submit_button = None
|
|
112
|
+
|
|
113
|
+
def build(self):
|
|
114
|
+
config = self._config
|
|
115
|
+
diagram = self._diagram
|
|
116
|
+
# A flex child can only fill leftover vertical space if every ancestor
|
|
117
|
+
# has a definite height, so pin the page content to the viewport.
|
|
118
|
+
ui.query(".nicegui-content").classes("h-screen flex-nowrap gap-2 p-2")
|
|
119
|
+
with ui.row().classes('w-full shrink-0'):
|
|
120
|
+
self._auto_switch = ui.switch(
|
|
121
|
+
"Auto Mode", value=True, on_change=lambda: self._on_auto_mode_change()
|
|
122
|
+
).classes('flex-shrink-0')
|
|
123
|
+
if config.named_states:
|
|
124
|
+
with ui.button_group().props('outline').classes('flex-1 ml-4 bg-transparent'):
|
|
125
|
+
for named_state in config.named_states:
|
|
126
|
+
button = ui.button(
|
|
127
|
+
named_state,
|
|
128
|
+
on_click=lambda _, name=named_state: self._on_named_state_click(name),
|
|
129
|
+
).props('outline color=primary').classes('flex-1')
|
|
130
|
+
self._named_state_buttons[named_state] = button
|
|
131
|
+
with ui.row().classes('w-full grow min-h-0 items-stretch flex-nowrap'):
|
|
132
|
+
with ui.column().classes('w-fit item-stretch shrink min-w-0 self-start'):
|
|
133
|
+
if config.gauges:
|
|
134
|
+
with ui.card().tight().classes('w-full'):
|
|
135
|
+
with ui.card_section():
|
|
136
|
+
ui.label("Guages")
|
|
137
|
+
ui.separator()
|
|
138
|
+
with ui.card_section():
|
|
139
|
+
for gauge in config.gauges:
|
|
140
|
+
self._gauge_labels[gauge] = ui.label()
|
|
141
|
+
if config.valves:
|
|
142
|
+
with ui.card().tight().classes('w-full'):
|
|
143
|
+
with ui.card_section():
|
|
144
|
+
ui.label("Valves")
|
|
145
|
+
ui.separator()
|
|
146
|
+
with ui.card_section():
|
|
147
|
+
with ui.grid(columns=4):
|
|
148
|
+
for valve in config.valves:
|
|
149
|
+
ui.label(valve)
|
|
150
|
+
ui.label("Closed")
|
|
151
|
+
switch = ui.switch(
|
|
152
|
+
value=None,
|
|
153
|
+
on_change=lambda e, valve=valve: self._on_valve_change(e, valve),
|
|
154
|
+
)
|
|
155
|
+
ui.label("Open")
|
|
156
|
+
self._valve_switches[valve] = switch
|
|
157
|
+
self._build_plan()
|
|
158
|
+
with ui.column().classes('grow min-h-0 min-w-0 h-full flex-nowrap'):
|
|
159
|
+
if config.stage:
|
|
160
|
+
with ui.card().tight().classes('w-full shrink-0'):
|
|
161
|
+
with ui.card_section():
|
|
162
|
+
ui.label("Stage")
|
|
163
|
+
ui.separator()
|
|
164
|
+
with ui.card_section():
|
|
165
|
+
with ui.button_group().props('outline').classes('flex-1 ml-4 bg-transparent'):
|
|
166
|
+
for position in config.stage:
|
|
167
|
+
button = ui.button(
|
|
168
|
+
position,
|
|
169
|
+
on_click=lambda _, position=position: self._on_stage_click(position),
|
|
170
|
+
).props('outline color=primary').classes('flex-1')
|
|
171
|
+
self._stage_buttons[position] = button
|
|
172
|
+
for pump in config.pumps:
|
|
173
|
+
with ui.card().tight().classes('w-full shrink-0'):
|
|
174
|
+
with ui.card_section():
|
|
175
|
+
ui.label(pump)
|
|
176
|
+
ui.separator()
|
|
177
|
+
with ui.card_section():
|
|
178
|
+
with ui.row():
|
|
179
|
+
ui.label("Off")
|
|
180
|
+
switch = ui.switch(
|
|
181
|
+
on_change=lambda e, pump=pump: self._on_pump_change(e, pump),
|
|
182
|
+
)
|
|
183
|
+
ui.label("On")
|
|
184
|
+
current_label = ui.label()
|
|
185
|
+
voltage_label = ui.label()
|
|
186
|
+
flow_label = ui.label()
|
|
187
|
+
speed_label = ui.label()
|
|
188
|
+
self._pump_switches[pump] = switch
|
|
189
|
+
self._pump_labels[pump] = {
|
|
190
|
+
"current": current_label,
|
|
191
|
+
"voltage": voltage_label,
|
|
192
|
+
"flow": flow_label,
|
|
193
|
+
"speed": speed_label,
|
|
194
|
+
}
|
|
195
|
+
with ui.row().classes('w-full grow min-h-0 min-w-0 flex-nowrap'):
|
|
196
|
+
if config.gauges:
|
|
197
|
+
with ui.card().tight().classes('grow min-h-0 min-w-0 h-full p-2'):
|
|
198
|
+
self._gauge_chart = ui.echart(
|
|
199
|
+
self._gauge_chart_options()
|
|
200
|
+
).classes('w-full h-full')
|
|
201
|
+
if diagram:
|
|
202
|
+
with ui.card().tight().classes('h-full w-auto shrink-0 p-2'):
|
|
203
|
+
# A q-img has no intrinsic width, so `w-auto` would
|
|
204
|
+
# collapse it; a native <img> derives its width from
|
|
205
|
+
# the aspect ratio once the height is pinned.
|
|
206
|
+
ui.image(diagram).props('tag=img').classes(
|
|
207
|
+
'h-full w-auto object-contain'
|
|
208
|
+
)
|
|
209
|
+
self._build_confirm_dialog()
|
|
210
|
+
self._build_unknown_dialog()
|
|
211
|
+
ui.timer(POLL_INTERVAL, self._refresh)
|
|
212
|
+
# The timer's first tick is a full interval away, so refresh once now to
|
|
213
|
+
# avoid the page rendering blank (and the unknown-state prompt appearing
|
|
214
|
+
# late) on load.
|
|
215
|
+
background_tasks.create(self._refresh())
|
|
216
|
+
|
|
217
|
+
def _gauge_chart_options(self):
|
|
218
|
+
gauges = list(self._config.gauges)
|
|
219
|
+
return {
|
|
220
|
+
"tooltip": {"trigger": "axis"},
|
|
221
|
+
# A single gauge labels itself via the axis name; a legend only
|
|
222
|
+
# earns its space once there is more than one line to tell apart.
|
|
223
|
+
"legend": {"show": len(gauges) > 1, "data": gauges},
|
|
224
|
+
"grid": {
|
|
225
|
+
"left": 70,
|
|
226
|
+
"right": 20,
|
|
227
|
+
"top": 40 if len(gauges) > 1 else 20,
|
|
228
|
+
"bottom": 40,
|
|
229
|
+
},
|
|
230
|
+
"xAxis": {"type": "time"},
|
|
231
|
+
"yAxis": {
|
|
232
|
+
"type": "log",
|
|
233
|
+
"name": "Pressure",
|
|
234
|
+
# A leading ':' marks the value as JavaScript, so ECharts gets a
|
|
235
|
+
# real formatter function. Vacuum pressures span many decades,
|
|
236
|
+
# so plain decimals would be unreadable. Avoid arrow syntax:
|
|
237
|
+
# the options are embedded in HTML, where '>' gets escaped.
|
|
238
|
+
"axisLabel": {
|
|
239
|
+
":formatter": "function (value) { return Number(value).toExponential(0); }"
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
"series": [
|
|
243
|
+
{
|
|
244
|
+
"name": gauge,
|
|
245
|
+
"type": "line",
|
|
246
|
+
# 600 points per line; markers would just be noise.
|
|
247
|
+
"showSymbol": False,
|
|
248
|
+
"data": [],
|
|
249
|
+
}
|
|
250
|
+
for gauge in gauges
|
|
251
|
+
],
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
def _apply_gauge_series(self, series):
|
|
255
|
+
if self._gauge_chart is None:
|
|
256
|
+
return
|
|
257
|
+
changed = False
|
|
258
|
+
for entry in self._gauge_chart.options["series"]:
|
|
259
|
+
data = series.get(entry["name"], [])
|
|
260
|
+
if entry["data"] != data:
|
|
261
|
+
entry["data"] = data
|
|
262
|
+
changed = True
|
|
263
|
+
if changed:
|
|
264
|
+
self._gauge_chart.update()
|
|
265
|
+
|
|
266
|
+
@staticmethod
|
|
267
|
+
def _get_unknown_state(config, pumping_system):
|
|
268
|
+
"""Return the valves, pumps and stage whose state the system cannot report."""
|
|
269
|
+
return {
|
|
270
|
+
"valves": [
|
|
271
|
+
valve
|
|
272
|
+
for valve in config.valves
|
|
273
|
+
if pumping_system.get_valve_state(valve) is None
|
|
274
|
+
],
|
|
275
|
+
"pumps": [
|
|
276
|
+
pump
|
|
277
|
+
for pump in config.pumps
|
|
278
|
+
if pumping_system.get_pump_state(pump) is None
|
|
279
|
+
],
|
|
280
|
+
"stage": bool(config.stage)
|
|
281
|
+
and pumping_system.get_stage_position() not in config.stage,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
@staticmethod
|
|
285
|
+
def _has_unknown_state(unknown):
|
|
286
|
+
return bool(unknown["valves"] or unknown["pumps"] or unknown["stage"])
|
|
287
|
+
|
|
288
|
+
def _build_unknown_dialog(self):
|
|
289
|
+
# Non-escapable: the rest of the UI is unusable until the operator tells
|
|
290
|
+
# us what the hardware is actually doing.
|
|
291
|
+
with ui.dialog().props(
|
|
292
|
+
"persistent no-esc-dismiss no-backdrop-dismiss"
|
|
293
|
+
) as dialog, ui.card():
|
|
294
|
+
self._build_unknown_content()
|
|
295
|
+
self._unknown_dialog = dialog
|
|
296
|
+
|
|
297
|
+
@ui.refreshable
|
|
298
|
+
def _build_unknown_content(self):
|
|
299
|
+
unknown = self._unknown_state or {"valves": [], "pumps": [], "stage": False}
|
|
300
|
+
self._unknown_valve_switches = {}
|
|
301
|
+
self._unknown_pump_switches = {}
|
|
302
|
+
self._unknown_stage_toggle = None
|
|
303
|
+
ui.label("Specify current state").classes("text-lg font-bold")
|
|
304
|
+
ui.label(
|
|
305
|
+
"The system cannot determine the state of the following items. "
|
|
306
|
+
"Set each one to its current state to continue."
|
|
307
|
+
).classes("text-sm")
|
|
308
|
+
if unknown["valves"]:
|
|
309
|
+
ui.label("Valves").classes("font-bold mt-2")
|
|
310
|
+
with ui.grid(columns=4):
|
|
311
|
+
for valve in unknown["valves"]:
|
|
312
|
+
ui.label(valve)
|
|
313
|
+
ui.label("Closed")
|
|
314
|
+
# `value=None` renders Quasar's indeterminate position, so an
|
|
315
|
+
# untouched switch is visually distinct from one set to closed.
|
|
316
|
+
self._unknown_valve_switches[valve] = ui.switch(
|
|
317
|
+
value=None, on_change=self._update_unknown_submit_state
|
|
318
|
+
)
|
|
319
|
+
ui.label("Open")
|
|
320
|
+
if unknown["pumps"]:
|
|
321
|
+
ui.label("Pumps").classes("font-bold mt-2")
|
|
322
|
+
with ui.grid(columns=4):
|
|
323
|
+
for pump in unknown["pumps"]:
|
|
324
|
+
ui.label(pump)
|
|
325
|
+
ui.label("Off")
|
|
326
|
+
self._unknown_pump_switches[pump] = ui.switch(
|
|
327
|
+
value=None, on_change=self._update_unknown_submit_state
|
|
328
|
+
)
|
|
329
|
+
ui.label("On")
|
|
330
|
+
if unknown["stage"]:
|
|
331
|
+
ui.label("Stage").classes("font-bold mt-2")
|
|
332
|
+
self._unknown_stage_toggle = ui.toggle(
|
|
333
|
+
list(self._config.stage),
|
|
334
|
+
value=None,
|
|
335
|
+
on_change=self._update_unknown_submit_state,
|
|
336
|
+
)
|
|
337
|
+
with ui.card_actions().classes("justify-end w-full"):
|
|
338
|
+
self._unknown_submit_button = ui.button(
|
|
339
|
+
"Submit", on_click=self._on_unknown_submit
|
|
340
|
+
)
|
|
341
|
+
self._update_unknown_submit_state()
|
|
342
|
+
|
|
343
|
+
def _unknown_selection_complete(self):
|
|
344
|
+
if any(
|
|
345
|
+
switch.value is None for switch in self._unknown_valve_switches.values()
|
|
346
|
+
):
|
|
347
|
+
return False
|
|
348
|
+
if any(switch.value is None for switch in self._unknown_pump_switches.values()):
|
|
349
|
+
return False
|
|
350
|
+
if self._unknown_stage_toggle is not None and (
|
|
351
|
+
self._unknown_stage_toggle.value is None
|
|
352
|
+
):
|
|
353
|
+
return False
|
|
354
|
+
return True
|
|
355
|
+
|
|
356
|
+
def _update_unknown_submit_state(self):
|
|
357
|
+
if self._unknown_submit_button is not None:
|
|
358
|
+
# Declaring a wrong state could drive an unsafe action, so require an
|
|
359
|
+
# explicit choice for every item rather than defaulting.
|
|
360
|
+
self._unknown_submit_button.enabled = self._unknown_selection_complete()
|
|
361
|
+
|
|
362
|
+
async def _on_unknown_submit(self):
|
|
363
|
+
if not self._unknown_selection_complete():
|
|
364
|
+
return
|
|
365
|
+
valves = {
|
|
366
|
+
valve: ValveState.OPEN if switch.value else ValveState.CLOSED
|
|
367
|
+
for valve, switch in self._unknown_valve_switches.items()
|
|
368
|
+
}
|
|
369
|
+
pumps = {
|
|
370
|
+
pump: PumpState.ON if switch.value else PumpState.OFF
|
|
371
|
+
for pump, switch in self._unknown_pump_switches.items()
|
|
372
|
+
}
|
|
373
|
+
stage = (
|
|
374
|
+
self._unknown_stage_toggle.value
|
|
375
|
+
if self._unknown_stage_toggle is not None
|
|
376
|
+
else None
|
|
377
|
+
)
|
|
378
|
+
await run.io_bound(self._declare_state, valves, pumps, stage)
|
|
379
|
+
self._unknown_state = None
|
|
380
|
+
self._unknown_dialog.close()
|
|
381
|
+
await self._refresh()
|
|
382
|
+
|
|
383
|
+
def _declare_state(self, valves, pumps, stage):
|
|
384
|
+
"""Record the operator-declared state in the pumping system.
|
|
385
|
+
|
|
386
|
+
Uses the raw setters rather than the safety-checked wrappers: the
|
|
387
|
+
operator is describing the state the hardware is already in, not
|
|
388
|
+
commanding a change, and the safety check would need the very state
|
|
389
|
+
being declared. The safety machine's change timestamps are deliberately
|
|
390
|
+
left alone since nothing physically moved.
|
|
391
|
+
"""
|
|
392
|
+
for valve, state in valves.items():
|
|
393
|
+
self._pumping_system.actuate_valve(valve, state)
|
|
394
|
+
for pump, state in pumps.items():
|
|
395
|
+
self._pumping_system.set_pump(pump, state)
|
|
396
|
+
if stage is not None:
|
|
397
|
+
self._pumping_system.move_stage(stage)
|
|
398
|
+
|
|
399
|
+
def _build_confirm_dialog(self):
|
|
400
|
+
# A safety prompt must get an explicit answer, so the dialog cannot be
|
|
401
|
+
# dismissed by clicking the backdrop or pressing Escape.
|
|
402
|
+
with ui.dialog().props("persistent") as dialog, ui.card():
|
|
403
|
+
ui.label("Confirm action").classes("text-lg font-bold")
|
|
404
|
+
self._confirm_content = ui.label()
|
|
405
|
+
with ui.card_actions().classes("justify-end w-full"):
|
|
406
|
+
ui.button("Cancel", on_click=lambda: self._on_confirm_response(False)).props(
|
|
407
|
+
"outline color=primary"
|
|
408
|
+
)
|
|
409
|
+
ui.button("Confirm", on_click=lambda: self._on_confirm_response(True))
|
|
410
|
+
self._confirm_dialog = dialog
|
|
411
|
+
|
|
412
|
+
def _on_confirm_response(self, approved):
|
|
413
|
+
request = self._shown_confirmation
|
|
414
|
+
self._shown_confirmation = None
|
|
415
|
+
self._confirm_dialog.close()
|
|
416
|
+
if request is not None:
|
|
417
|
+
request.resolve(approved)
|
|
418
|
+
background_tasks.create(self._refresh())
|
|
419
|
+
|
|
420
|
+
def _on_auto_mode_change(self):
|
|
421
|
+
background_tasks.create(self._refresh())
|
|
422
|
+
|
|
423
|
+
async def _on_valve_change(self, e, valve):
|
|
424
|
+
state = ValveState.OPEN if e.value else ValveState.CLOSED
|
|
425
|
+
if self._auto_switch.value:
|
|
426
|
+
await run.io_bound(self._pumping_system.auto, valves={valve: state})
|
|
427
|
+
else:
|
|
428
|
+
await run.io_bound(self._pumping_system._actuate_valve, valve, state)
|
|
429
|
+
await self._refresh()
|
|
430
|
+
|
|
431
|
+
async def _on_pump_change(self, e, pump):
|
|
432
|
+
state = PumpState.ON if e.value else PumpState.OFF
|
|
433
|
+
if self._auto_switch.value:
|
|
434
|
+
await run.io_bound(self._pumping_system.auto, pumps={pump: state})
|
|
435
|
+
else:
|
|
436
|
+
await run.io_bound(self._pumping_system._set_pump, pump, state)
|
|
437
|
+
await self._refresh()
|
|
438
|
+
|
|
439
|
+
async def _on_named_state_click(self, name):
|
|
440
|
+
await run.io_bound(self._pumping_system.auto, name=name)
|
|
441
|
+
await self._refresh()
|
|
442
|
+
|
|
443
|
+
async def _on_stage_click(self, position):
|
|
444
|
+
if self._auto_switch.value:
|
|
445
|
+
await run.io_bound(self._pumping_system.auto, stage=position)
|
|
446
|
+
else:
|
|
447
|
+
await run.io_bound(self._pumping_system._move_stage, position)
|
|
448
|
+
await self._refresh()
|
|
449
|
+
|
|
450
|
+
async def _on_abort_plan(self):
|
|
451
|
+
# `cancel()` joins the executor thread, which can take up to a second,
|
|
452
|
+
# so keep it off the UI event loop.
|
|
453
|
+
await run.io_bound(self._pumping_system.cancel)
|
|
454
|
+
await self._refresh()
|
|
455
|
+
|
|
456
|
+
def _get_snapshot(self):
|
|
457
|
+
safety = self._pumping_system._safety_machine
|
|
458
|
+
valves = {}
|
|
459
|
+
for valve in self._config.valves:
|
|
460
|
+
current = self._pumping_system.get_valve_state(valve)
|
|
461
|
+
valves[valve] = {"state": current, "safe": self._can_flip_valve(safety, valve, current)}
|
|
462
|
+
pumps = {}
|
|
463
|
+
for pump in self._config.pumps:
|
|
464
|
+
current = self._pumping_system.get_pump_state(pump)
|
|
465
|
+
pumps[pump] = {
|
|
466
|
+
"state": current,
|
|
467
|
+
"safe": self._can_flip_pump(safety, pump, current),
|
|
468
|
+
"current": self._pumping_system.get_pump_current(pump),
|
|
469
|
+
"voltage": self._pumping_system.get_pump_voltage(pump),
|
|
470
|
+
"flow": self._pumping_system.get_pump_flow(pump),
|
|
471
|
+
"speed": self._pumping_system.get_pump_speed(pump),
|
|
472
|
+
}
|
|
473
|
+
stage = self._pumping_system.get_stage_position()
|
|
474
|
+
stages = {
|
|
475
|
+
position: self._can_move_stage(safety, position)
|
|
476
|
+
for position in self._config.stage
|
|
477
|
+
}
|
|
478
|
+
gauges = {
|
|
479
|
+
gauge: self._pumping_system.get_gauge_pressure(gauge)
|
|
480
|
+
for gauge in self._config.gauges
|
|
481
|
+
}
|
|
482
|
+
plan = self._pumping_system.plan
|
|
483
|
+
# Recording and building the series here keeps both off the event loop,
|
|
484
|
+
# since `_get_snapshot` already runs via `run.io_bound`.
|
|
485
|
+
self._history.record(gauges)
|
|
486
|
+
return {
|
|
487
|
+
"valves": valves,
|
|
488
|
+
"pumps": pumps,
|
|
489
|
+
"stage": stage,
|
|
490
|
+
"stages": stages,
|
|
491
|
+
"gauges": gauges,
|
|
492
|
+
"gauge_series": self._history.series(),
|
|
493
|
+
"plan_active": bool(plan),
|
|
494
|
+
"plan_description": [self._describe_step(step) for step in plan],
|
|
495
|
+
"pending_confirmation": self._pumping_system._plan_executor.pending_confirmation,
|
|
496
|
+
"unknown": self._get_unknown_state(self._config, self._pumping_system),
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
@staticmethod
|
|
500
|
+
def _can_flip_valve(safety, valve, current):
|
|
501
|
+
if current is None:
|
|
502
|
+
return False
|
|
503
|
+
target = ValveState.CLOSED if current == ValveState.OPEN else ValveState.OPEN
|
|
504
|
+
try:
|
|
505
|
+
return safety.can_actuate_valve(valve, target)
|
|
506
|
+
except ValueError:
|
|
507
|
+
return False
|
|
508
|
+
|
|
509
|
+
@staticmethod
|
|
510
|
+
def _can_flip_pump(safety, pump, current):
|
|
511
|
+
if current is None:
|
|
512
|
+
return False
|
|
513
|
+
target = PumpState.OFF if current == PumpState.ON else PumpState.ON
|
|
514
|
+
try:
|
|
515
|
+
return safety.can_set_pump(pump, target)
|
|
516
|
+
except ValueError:
|
|
517
|
+
return False
|
|
518
|
+
|
|
519
|
+
@staticmethod
|
|
520
|
+
def _can_move_stage(safety, position):
|
|
521
|
+
try:
|
|
522
|
+
return safety.can_move_stage(position)
|
|
523
|
+
except ValueError:
|
|
524
|
+
return False
|
|
525
|
+
|
|
526
|
+
async def _refresh(self):
|
|
527
|
+
snapshot = await run.io_bound(self._get_snapshot)
|
|
528
|
+
self._apply_snapshot(snapshot)
|
|
529
|
+
|
|
530
|
+
@staticmethod
|
|
531
|
+
def _set_value_silently(switch, value):
|
|
532
|
+
# Setting `.value` normally fires the registered change handlers (even for
|
|
533
|
+
# programmatic updates), which would re-trigger actuation from feedback
|
|
534
|
+
# polling. Since those handlers may be async and get scheduled as a
|
|
535
|
+
# background task, a simple before/after boolean flag can't reliably guard
|
|
536
|
+
# against them (the task may run after the flag is reset). Instead, detach
|
|
537
|
+
# the handlers for the duration of the assignment so no event is ever
|
|
538
|
+
# scheduled.
|
|
539
|
+
handlers = switch._change_handlers
|
|
540
|
+
switch._change_handlers = []
|
|
541
|
+
try:
|
|
542
|
+
switch.value = value
|
|
543
|
+
finally:
|
|
544
|
+
switch._change_handlers = handlers
|
|
545
|
+
|
|
546
|
+
@staticmethod
|
|
547
|
+
def _set_button_active(button, active):
|
|
548
|
+
# Active buttons are solid blue; inactive ones are inverted, i.e. a white
|
|
549
|
+
# background with a blue outline and blue text.
|
|
550
|
+
if active:
|
|
551
|
+
button.props("color=primary", remove="outline")
|
|
552
|
+
else:
|
|
553
|
+
button.props("outline color=primary")
|
|
554
|
+
|
|
555
|
+
def _apply_snapshot(self, snapshot):
|
|
556
|
+
auto_mode = self._auto_switch.value
|
|
557
|
+
plan_active = snapshot["plan_active"]
|
|
558
|
+
for valve, switch in self._valve_switches.items():
|
|
559
|
+
data = snapshot["valves"][valve]
|
|
560
|
+
self._set_value_silently(switch, data["state"] == ValveState.OPEN)
|
|
561
|
+
switch.enabled = not plan_active and (auto_mode or data["safe"])
|
|
562
|
+
for pump, switch in self._pump_switches.items():
|
|
563
|
+
data = snapshot["pumps"][pump]
|
|
564
|
+
self._set_value_silently(switch, data["state"] == PumpState.ON)
|
|
565
|
+
switch.enabled = not plan_active and (auto_mode or data["safe"])
|
|
566
|
+
labels = self._pump_labels[pump]
|
|
567
|
+
labels["current"].text = (
|
|
568
|
+
f"Current: {data['current']:.2f}" if data["current"] is not None else ""
|
|
569
|
+
)
|
|
570
|
+
labels["voltage"].text = (
|
|
571
|
+
f"Voltage: {data['voltage']:.2f}" if data["voltage"] is not None else ""
|
|
572
|
+
)
|
|
573
|
+
labels["flow"].text = (
|
|
574
|
+
f"Flow: {data['flow']:.2f}" if data["flow"] is not None else ""
|
|
575
|
+
)
|
|
576
|
+
labels["speed"].text = (
|
|
577
|
+
f"Speed: {data['speed']:.2f}" if data["speed"] is not None else ""
|
|
578
|
+
)
|
|
579
|
+
for position, button in self._stage_buttons.items():
|
|
580
|
+
button.enabled = not plan_active and (auto_mode or snapshot["stages"][position])
|
|
581
|
+
self._set_button_active(button, position == snapshot["stage"])
|
|
582
|
+
for name, button in self._named_state_buttons.items():
|
|
583
|
+
button.enabled = not plan_active and auto_mode
|
|
584
|
+
self._set_button_active(button, self._is_current_named_state(name, snapshot))
|
|
585
|
+
for gauge, label in self._gauge_labels.items():
|
|
586
|
+
label.text = f"{gauge}: {snapshot['gauges'][gauge]:.2e}"
|
|
587
|
+
self._apply_gauge_series(snapshot["gauge_series"])
|
|
588
|
+
self._sync_plan_card(snapshot)
|
|
589
|
+
self._sync_unknown_dialog(snapshot)
|
|
590
|
+
self._sync_confirm_dialog(snapshot)
|
|
591
|
+
|
|
592
|
+
def _sync_unknown_dialog(self, snapshot):
|
|
593
|
+
unknown = snapshot["unknown"]
|
|
594
|
+
if not self._has_unknown_state(unknown):
|
|
595
|
+
if self._unknown_state is not None:
|
|
596
|
+
# Resolved here or in another tab.
|
|
597
|
+
self._unknown_state = None
|
|
598
|
+
self._unknown_dialog.close()
|
|
599
|
+
return
|
|
600
|
+
if unknown == self._unknown_state:
|
|
601
|
+
# Unchanged: leave the open dialog alone so partly-entered input
|
|
602
|
+
# is not discarded on every poll tick.
|
|
603
|
+
return
|
|
604
|
+
self._unknown_state = unknown
|
|
605
|
+
self._build_unknown_content.refresh()
|
|
606
|
+
self._unknown_dialog.open()
|
|
607
|
+
|
|
608
|
+
def _sync_plan_card(self, snapshot):
|
|
609
|
+
# The plan card is only rebuilt when the steps actually change, so the
|
|
610
|
+
# timeline stays current as the executor works through the plan.
|
|
611
|
+
if snapshot["plan_description"] != self._plan_description:
|
|
612
|
+
self._plan_description = snapshot["plan_description"]
|
|
613
|
+
self._build_plan.refresh()
|
|
614
|
+
|
|
615
|
+
def _sync_confirm_dialog(self, snapshot):
|
|
616
|
+
request = snapshot["pending_confirmation"]
|
|
617
|
+
if self._has_unknown_state(snapshot["unknown"]):
|
|
618
|
+
# Never stack two persistent dialogs; the unknown-state prompt takes
|
|
619
|
+
# precedence and a plan cannot meaningfully run from an unknown state.
|
|
620
|
+
if self._shown_confirmation is not None:
|
|
621
|
+
self._shown_confirmation = None
|
|
622
|
+
self._confirm_dialog.close()
|
|
623
|
+
return
|
|
624
|
+
if request is self._shown_confirmation:
|
|
625
|
+
return
|
|
626
|
+
if request is None:
|
|
627
|
+
# Answered in another tab, or the plan was cancelled.
|
|
628
|
+
self._shown_confirmation = None
|
|
629
|
+
self._confirm_dialog.close()
|
|
630
|
+
return
|
|
631
|
+
self._shown_confirmation = request
|
|
632
|
+
self._confirm_content.text = self._describe_step(request.step)
|
|
633
|
+
self._confirm_dialog.open()
|
|
634
|
+
|
|
635
|
+
def _is_current_named_state(self, name, snapshot):
|
|
636
|
+
named_state = self._config.named_states[name]
|
|
637
|
+
for valve, state in named_state.valves.items():
|
|
638
|
+
if snapshot["valves"][valve]["state"] != state:
|
|
639
|
+
return False
|
|
640
|
+
for pump, state in named_state.pumps.items():
|
|
641
|
+
if snapshot["pumps"][pump]["state"] != state:
|
|
642
|
+
return False
|
|
643
|
+
if named_state.stage is not None and snapshot["stage"] != named_state.stage:
|
|
644
|
+
return False
|
|
645
|
+
return True
|
|
646
|
+
|
|
647
|
+
@staticmethod
|
|
648
|
+
def _describe_step(step):
|
|
649
|
+
action_verbs = {
|
|
650
|
+
ValveState.CLOSED: "close",
|
|
651
|
+
}
|
|
652
|
+
if step.wait:
|
|
653
|
+
conditions = [ineq.to_str(gauge) for gauge, ineq in step.wait.items()]
|
|
654
|
+
return f"Wait for {_Page.oxford_comma(conditions)}"
|
|
655
|
+
if step.valve:
|
|
656
|
+
return f"{action_verbs.get(step.state, step.state.value).capitalize()} {step.valve}"
|
|
657
|
+
if step.pump:
|
|
658
|
+
return f"Turn {step.state.value} {step.pump} pump"
|
|
659
|
+
if step.stage:
|
|
660
|
+
return f"Move stage to {step.stage}"
|
|
661
|
+
return ""
|
|
662
|
+
|
|
663
|
+
@ui.refreshable
|
|
664
|
+
def _build_plan(self):
|
|
665
|
+
if self._pumping_system.plan:
|
|
666
|
+
# `self-start` keeps the card from being stretched to full height by
|
|
667
|
+
# the row's `items-stretch`. It must stay shrinkable and capped,
|
|
668
|
+
# though: step descriptions are long sentences, so a `shrink-0` card
|
|
669
|
+
# would size to its max-content width and push the page off-screen.
|
|
670
|
+
with ui.card().tight().classes('self-start shrink min-w-0 max-w-xs'):
|
|
671
|
+
with ui.card_section():
|
|
672
|
+
ui.label("Plan")
|
|
673
|
+
ui.separator()
|
|
674
|
+
with ui.card_section():
|
|
675
|
+
with ui.timeline(side="right"):
|
|
676
|
+
for step in self._pumping_system.plan:
|
|
677
|
+
ui.timeline_entry(self._describe_step(step))
|
|
678
|
+
ui.separator()
|
|
679
|
+
with ui.card_actions():
|
|
680
|
+
ui.button("Abort Plan", on_click=self._on_abort_plan)
|
|
681
|
+
|
|
682
|
+
@staticmethod
|
|
683
|
+
def oxford_comma(items):
|
|
684
|
+
if len(items) == 0:
|
|
685
|
+
return ""
|
|
686
|
+
elif len(items) == 1:
|
|
687
|
+
return items[0]
|
|
688
|
+
elif len(items) == 2:
|
|
689
|
+
return f"{items[0]} and {items[1]}"
|
|
690
|
+
else:
|
|
691
|
+
return f"{', '.join(items[:-1])}, and {items[-1]}"
|