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/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]}"