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.
@@ -0,0 +1,178 @@
1
+ from threading import Event, Thread
2
+ from time import sleep
3
+
4
+
5
+ class ConfirmationRequest:
6
+ """A pending request for the operator to approve a single plan step.
7
+
8
+ Created by the executor thread and resolved from the UI event loop, so the
9
+ handshake goes through a :class:`threading.Event`.
10
+ """
11
+
12
+ def __init__(self, step):
13
+ self.step = step
14
+ self._event = Event()
15
+ self._approved = False
16
+
17
+ @property
18
+ def resolved(self):
19
+ return self._event.is_set()
20
+
21
+ def resolve(self, approved):
22
+ self._approved = bool(approved)
23
+ self._event.set()
24
+
25
+ def wait_for_response(self, should_continue=None):
26
+ """Block until the request is resolved, returning whether it was approved.
27
+
28
+ Polls rather than blocking indefinitely so that a plan cancelled while
29
+ the dialog is open does not leave this thread stuck forever.
30
+ """
31
+ while not self._event.wait(timeout=0.1):
32
+ if should_continue is not None and not should_continue():
33
+ return False
34
+ return self._approved
35
+
36
+
37
+ class PlanExecutor:
38
+ def __init__(self, pumping_system):
39
+ self._pumping_system = pumping_system
40
+ self._current_step = None
41
+ self._plan = None
42
+ self._thread = None
43
+ self._pending_confirmation = None
44
+ self._confirmed_step = None
45
+
46
+ @property
47
+ def plan(self):
48
+ return ([self._current_step] if self._current_step is not None else []) + (
49
+ self._plan if self._plan is not None else []
50
+ )
51
+
52
+ @property
53
+ def pending_confirmation(self):
54
+ return self._pending_confirmation
55
+
56
+ def execute_plan(self, plan):
57
+ self.cancel()
58
+ self._plan = plan
59
+ self._thread = Thread(target=self._plan_executor)
60
+ self._thread.start()
61
+
62
+ def cancel(self):
63
+ self._plan = None
64
+ self._current_step = None
65
+ self._confirmed_step = None
66
+ # Release a thread parked on a confirmation dialog before joining it,
67
+ # otherwise cancelling while the dialog is open deadlocks.
68
+ pending = self._pending_confirmation
69
+ if pending is not None:
70
+ pending.resolve(False)
71
+ if self._thread is not None:
72
+ self._thread.join()
73
+ self._thread = None
74
+ self._pending_confirmation = None
75
+
76
+ def _requires_confirmation(self, step):
77
+ confirm = self._pumping_system._config.confirm
78
+ if step.wait is not None:
79
+ return False
80
+ if step.valve is not None:
81
+ return self._confirm_flag(
82
+ confirm.valve, step.valve, step.state, confirm.default
83
+ )
84
+ if step.pump is not None:
85
+ return self._confirm_flag(
86
+ confirm.pump, step.pump, step.state, confirm.default
87
+ )
88
+ if step.stage is not None:
89
+ # `confirm.stage` lists the positions that need confirmation; any
90
+ # other position falls back to `confirm.default`.
91
+ if confirm.stage is not None and step.stage in confirm.stage:
92
+ return True
93
+ return confirm.default
94
+ return False
95
+
96
+ @staticmethod
97
+ def _confirm_flag(mapping, key, state, default):
98
+ """Resolve a `Confirm` entry for a single target.
99
+
100
+ A boolean entry applies to every action on that target, a state entry
101
+ only applies when the action moves the target to that state, and an
102
+ absent entry falls back to `confirm.default`.
103
+ """
104
+ if mapping is None or key not in mapping:
105
+ return default
106
+ value = mapping[key]
107
+ if isinstance(value, bool):
108
+ return value
109
+ return value == state
110
+
111
+ def _request_confirmation(self, step):
112
+ """Ask the operator to approve `step`, blocking until they answer.
113
+
114
+ A step may be revisited by the loop below while it waits for a safety
115
+ condition, so the answer is remembered against that step rather than
116
+ being asked again on every pass.
117
+ """
118
+ if self._confirmed_step is step:
119
+ return True
120
+ if not self._requires_confirmation(step):
121
+ self._confirmed_step = step
122
+ return True
123
+ request = ConfirmationRequest(step)
124
+ self._pending_confirmation = request
125
+ try:
126
+ approved = request.wait_for_response(lambda: self._plan is not None)
127
+ finally:
128
+ self._pending_confirmation = None
129
+ if approved:
130
+ self._confirmed_step = step
131
+ return approved
132
+
133
+ def _wait_satisfied(self, step):
134
+ return all(
135
+ ineq.evaluate(self._pumping_system.get_gauge_pressure(gauge))
136
+ for gauge, ineq in step.wait.items()
137
+ )
138
+
139
+ def _plan_executor(self):
140
+ while self._plan is not None and len(self._plan) > 0:
141
+ if self._current_step is None:
142
+ self._current_step = self._plan.pop(0)
143
+ step = self._current_step
144
+ if step.wait is not None:
145
+ if self._wait_satisfied(step):
146
+ self._current_step = None
147
+ continue
148
+ elif step.stage is not None and self._pumping_system._safety_machine.can_move_stage(step.stage):
149
+ if self._request_confirmation(step):
150
+ self._pumping_system._move_stage(step.stage)
151
+ self._current_step = None
152
+ continue
153
+ else:
154
+ self._plan = None
155
+ self._current_step = None
156
+ self._confirmed_step = None
157
+ return
158
+ elif step.valve is not None and self._pumping_system._safety_machine.can_actuate_valve(step.valve, step.state):
159
+ if self._request_confirmation(step):
160
+ self._pumping_system._actuate_valve(step.valve, step.state)
161
+ self._current_step = None
162
+ continue
163
+ else:
164
+ self._plan = None
165
+ self._current_step = None
166
+ self._confirmed_step = None
167
+ return
168
+ elif step.pump is not None and self._pumping_system._safety_machine.can_set_pump(step.pump, step.state):
169
+ if self._request_confirmation(step):
170
+ self._pumping_system._set_pump(step.pump, step.state)
171
+ self._current_step = None
172
+ continue
173
+ else:
174
+ self._plan = None
175
+ self._current_step = None
176
+ self._confirmed_step = None
177
+ return
178
+ sleep(1)
auto_pumping/graph.py ADDED
@@ -0,0 +1,358 @@
1
+ import re
2
+
3
+ from networkx import DiGraph, dijkstra_path, dijkstra_path_length, relabel_nodes
4
+ from yaml import Node
5
+ from .config import ValveState, PumpState, Inequality, StrictModel
6
+ from pydantic import model_validator
7
+ from uuid import uuid4, UUID
8
+ from math import inf
9
+ from pyvis.network import Network
10
+ from http.server import BaseHTTPRequestHandler, HTTPServer
11
+ import webbrowser
12
+
13
+
14
+ class NodeModel(StrictModel):
15
+ name: str | None = None
16
+ valves: dict[str, ValveState] = {}
17
+ pumps: dict[str, PumpState] = {}
18
+ stage: str | None = None
19
+ gauges: dict[str, tuple[float, float]] = {}
20
+
21
+
22
+ class EdgeModel(StrictModel):
23
+ time: float
24
+ wait: dict[str, Inequality] = {}
25
+
26
+
27
+ class PlanStep(StrictModel):
28
+ wait: dict[str, Inequality] | None = None
29
+ stage: str | None = None
30
+ valve: str | None = None
31
+ pump: str | None = None
32
+ state: ValveState | PumpState | None = None
33
+
34
+ @model_validator(mode="after")
35
+ def validate_wait(self):
36
+ if self.wait is not None:
37
+ if self.stage is not None:
38
+ raise ValueError("Cannot specify both wait and stage")
39
+ if self.valve is not None:
40
+ raise ValueError("Cannot specify both wait and valve")
41
+ if self.pump is not None:
42
+ raise ValueError("Cannot specify both wait and pump")
43
+ if self.state is not None:
44
+ raise ValueError("Cannot specify both wait and state")
45
+ return self
46
+
47
+ @model_validator(mode="after")
48
+ def validate_state(self):
49
+ if self.state is not None:
50
+ if self.valve is None and self.pump is None:
51
+ raise ValueError("State specified without valve or pump")
52
+ if self.valve is not None and self.pump is not None:
53
+ raise ValueError("Cannot specify both valve and pump with state")
54
+ if self.valve is not None and not isinstance(self.state, ValveState):
55
+ raise ValueError("State must be a ValveState when valve is specified")
56
+ if self.pump is not None and not isinstance(self.state, PumpState):
57
+ raise ValueError("State must be a PumpState when pump is specified")
58
+ return self
59
+
60
+
61
+ class PumpingGraph:
62
+ def __init__(self, config):
63
+ self._gauge_ranges = self._discretize_gauge_ranges(
64
+ config.gauges, config.transitions
65
+ )
66
+ self._graph = DiGraph()
67
+ self._add_initial_states(config.initial_states, config.named_states)
68
+ self._add_reachable_states(config.transitions, config.named_states)
69
+
70
+ def _get_gauge_range(self, gauge, pressure):
71
+ for gauge_range in self._gauge_ranges[gauge]:
72
+ if gauge_range[0] <= pressure < gauge_range[1]:
73
+ return gauge_range
74
+ raise ValueError(f"Pressure {pressure} is out of range for gauge {gauge}")
75
+
76
+ def _add_initial_states(self, initial_states, named_states):
77
+ for state in initial_states:
78
+ node_data = NodeModel(
79
+ name=self._get_state_name(state, named_states),
80
+ valves=state.valves,
81
+ pumps=state.pumps,
82
+ stage=state.stage,
83
+ gauges={
84
+ gauge: self._get_gauge_range(gauge, pressure)
85
+ for gauge, pressure in state.gauges.items()
86
+ },
87
+ )
88
+ self._graph.add_node(
89
+ uuid4(),
90
+ **node_data.model_dump(),
91
+ )
92
+
93
+ def _add_reachable_states(self, transitions, named_states):
94
+ for state_id in list(self._graph.nodes):
95
+ self._add_reachable_states_from(state_id, transitions, named_states)
96
+
97
+ def _get_state_data(self, state_id):
98
+ return NodeModel(**self._graph.nodes[state_id])
99
+
100
+ def _add_state(self, state_data):
101
+ assert self._find_state(state_data) is None
102
+ state_id = uuid4()
103
+ self._graph.add_node(state_id, **state_data.model_dump())
104
+ return state_id
105
+
106
+ def _add_reachable_states_from(self, state_id, transitions, named_states):
107
+ state_data = self._get_state_data(state_id)
108
+ for transition in transitions:
109
+ if not self._is_valid_transition(state_data, transition):
110
+ continue
111
+ reachable_states = self._build_reachable_states(
112
+ state_data, transition, named_states
113
+ )
114
+ for new_state_data, time in reachable_states:
115
+ new_state_id = self._find_state(new_state_data)
116
+ if new_state_id is None:
117
+ new_state_id = self._add_state(new_state_data)
118
+ self._add_reachable_states_from(
119
+ new_state_id, transitions, named_states
120
+ )
121
+ self._add_edge(state_id, new_state_id, transition, time)
122
+
123
+ def _add_edge(self, from_state_id, to_state_id, transition, time):
124
+ if not self._graph.has_edge(from_state_id, to_state_id):
125
+ edge_data = EdgeModel(
126
+ wait=transition.wait,
127
+ time=time,
128
+ )
129
+ self._graph.add_edge(
130
+ from_state_id,
131
+ to_state_id,
132
+ **edge_data.model_dump(),
133
+ )
134
+
135
+ @staticmethod
136
+ def _is_valid_transition(state_data, transition):
137
+ if transition.stage and state_data.stage != transition.from_state:
138
+ return False
139
+ if (
140
+ transition.valve is not None
141
+ and state_data.valves.get(transition.valve) != transition.from_state
142
+ ):
143
+ return False
144
+ if (
145
+ transition.pump is not None
146
+ and state_data.pumps.get(transition.pump) != transition.from_state
147
+ ):
148
+ return False
149
+ if transition.condition is None:
150
+ return True
151
+ for gauge, ineq in transition.condition.gauge.items():
152
+ pressure_range = state_data.gauges[gauge]
153
+ pressure = sum(pressure_range) / 2
154
+ if not ineq.evaluate(pressure):
155
+ return False
156
+ for valve, valve_condition in transition.condition.valve.items():
157
+ valve_state = valve_condition.state
158
+ if state_data.valves.get(valve) != valve_state:
159
+ return False
160
+ for pump, pump_state in transition.condition.pump.items():
161
+ if state_data.pumps.get(pump) != pump_state.state:
162
+ return False
163
+ if (
164
+ transition.condition.stage is not None
165
+ and state_data.stage != transition.condition.stage
166
+ ):
167
+ return False
168
+ return True
169
+
170
+ def _find_state(self, state_data):
171
+ for node_id in self._graph.nodes:
172
+ node_data = self._get_state_data(node_id)
173
+ if (
174
+ node_data.valves == state_data.valves
175
+ and node_data.pumps == state_data.pumps
176
+ and node_data.stage == state_data.stage
177
+ and node_data.gauges == state_data.gauges
178
+ ):
179
+ return node_id
180
+ return None
181
+
182
+ def _find_states(self, name=None, valves=None, pumps=None, stage=None):
183
+ for node_id in self._graph.nodes:
184
+ node_data = self._get_state_data(node_id)
185
+ if name is not None and node_data.name != name:
186
+ continue
187
+ if valves is not None and any(
188
+ state != node_data.valves.get(valve) for valve, state in valves.items()
189
+ ):
190
+ continue
191
+ if pumps is not None and any(
192
+ state != node_data.pumps.get(pump) for pump, state in pumps.items()
193
+ ):
194
+ continue
195
+ if stage is not None and node_data.stage != stage:
196
+ continue
197
+ yield node_id
198
+
199
+ def _build_reachable_states(self, state_data, transition, named_states):
200
+ new_state_data = state_data.model_copy(deep=True)
201
+ if transition.valve is not None:
202
+ new_state_data.valves[transition.valve] = transition.to_state
203
+ if transition.pump is not None:
204
+ new_state_data.pumps[transition.pump] = transition.to_state
205
+ if transition.stage:
206
+ new_state_data.stage = transition.to_state
207
+ new_state_data.name = self._get_state_name(new_state_data, named_states)
208
+ yield new_state_data, 1
209
+ for pressure_change in transition.pressure:
210
+ new_state_data = new_state_data.model_copy(deep=True)
211
+ new_state_data.gauges[pressure_change.gauge] = self._get_gauge_range(
212
+ pressure_change.gauge, pressure_change.pressure
213
+ )
214
+ new_state_data.name = self._get_state_name(new_state_data, named_states)
215
+ yield new_state_data, 60 * pressure_change.time
216
+
217
+ @staticmethod
218
+ def _get_state_name(state, named_states):
219
+ for name, named_state in named_states.items():
220
+ is_named = True
221
+ for valve, valve_state in named_state.valves.items():
222
+ if state.valves.get(valve) != valve_state:
223
+ is_named = False
224
+ break
225
+ for pump, pump_state in named_state.pumps.items():
226
+ if state.pumps.get(pump) != pump_state:
227
+ is_named = False
228
+ break
229
+ if named_state.stage is not None and state.stage != named_state.stage:
230
+ is_named = False
231
+ if is_named:
232
+ return name
233
+ return None
234
+
235
+ @staticmethod
236
+ def _discretize_gauge_ranges(gauges, transitions):
237
+ gauge_range_edges = {gauge: [0, inf] for gauge in gauges}
238
+ for transition in transitions:
239
+ if transition.condition is None:
240
+ continue
241
+ if transition.condition.gauge is None:
242
+ continue
243
+ for gauge, ineq in transition.condition.gauge.items():
244
+ if ineq.LE is not None and ineq.LE not in gauge_range_edges[gauge]:
245
+ gauge_range_edges[gauge].append(ineq.LE)
246
+ if ineq.GE is not None and ineq.GE not in gauge_range_edges[gauge]:
247
+ gauge_range_edges[gauge].append(ineq.GE)
248
+ for edges in gauge_range_edges.values():
249
+ edges.sort()
250
+ return {
251
+ gauge: [(edges[i], edges[i + 1]) for i in range(len(edges) - 1)]
252
+ for gauge, edges in gauge_range_edges.items()
253
+ }
254
+
255
+ @staticmethod
256
+ def _state_diff(start_state_data, end_state_data):
257
+ if start_state_data.stage != end_state_data.stage:
258
+ return PlanStep(stage=end_state_data.stage)
259
+ for valve, state in end_state_data.valves.items():
260
+ if start_state_data.valves.get(valve) != state:
261
+ return PlanStep(valve=valve, state=state)
262
+ for pump, state in end_state_data.pumps.items():
263
+ if start_state_data.pumps.get(pump) != state:
264
+ return PlanStep(pump=pump, state=state)
265
+ raise ValueError("No difference between states")
266
+
267
+ def make_plan(self, start_state, **goal_state_data):
268
+ if not isinstance(start_state, NodeModel):
269
+ start_state = NodeModel(**start_state)
270
+ if not isinstance(start_state, UUID):
271
+ start_state = self._find_state(start_state)
272
+ assert start_state is not None, f"Cannot find state {start_state}"
273
+ goal_lengths = sorted(
274
+ [
275
+ (
276
+ dijkstra_path_length(self._graph, start_state, goal_state, "time"),
277
+ goal_state,
278
+ )
279
+ for goal_state in self._find_states(**goal_state_data)
280
+ ],
281
+ key=lambda x: x[0],
282
+ )
283
+ assert len(goal_lengths), "No goal state found matching criteria"
284
+ path = dijkstra_path(self._graph, start_state, goal_lengths[0][1], "time")
285
+ for i in range(len(path) - 1):
286
+ edge_data = self._graph.get_edge_data(path[i], path[i + 1])
287
+ start_node_data = self._get_state_data(path[i])
288
+ end_node_data = self._get_state_data(path[i + 1])
289
+ if edge_data["wait"]:
290
+ yield PlanStep(wait=edge_data["wait"])
291
+ yield self._state_diff(start_node_data, end_node_data)
292
+
293
+ def draw(self, output_file=None, port=8000):
294
+ net = Network(height="100vh", width="100%", directed=True)
295
+ graph = relabel_nodes(self._graph, str, copy=True)
296
+ for node in graph.nodes:
297
+ node_data = self._get_state_data(UUID(node))
298
+ title = ""
299
+ title += f"Stage: {node_data.stage}\n"
300
+ title += "Valves:\n"
301
+ for valve, state in node_data.valves.items():
302
+ title += f" {valve}: {state.value}\n"
303
+ title += "Pumps:\n"
304
+ for pump, state in node_data.pumps.items():
305
+ title += f" {pump}: {state.value}\n"
306
+ title += "Gauges:\n"
307
+ for gauge, pressure_range in node_data.gauges.items():
308
+ title += f" {gauge}: {pressure_range[0]} - {pressure_range[1]}\n"
309
+ graph.nodes[node]["label"] = node_data.name if node_data.name else " "
310
+ graph.nodes[node]["title"] = title.strip()
311
+ for edge in graph.edges:
312
+ edge_data = EdgeModel(**graph.edges[edge])
313
+ title = f"Time: {edge_data.time} s\n"
314
+ if edge_data.wait:
315
+ title += "Wait:\n"
316
+ for gauge, ineq in edge_data.wait.items():
317
+ title += f" {gauge}: {ineq}\n"
318
+ graph.edges[edge]["title"] = title.strip()
319
+ edge_diff = self._state_diff(
320
+ self._get_state_data(UUID(edge[0])), self._get_state_data(UUID(edge[1]))
321
+ )
322
+ if edge_diff.stage is not None:
323
+ graph.edges[edge]["label"] = f"to {edge_diff.stage}"
324
+ if edge_diff.valve is not None:
325
+ graph.edges[edge][
326
+ "label"
327
+ ] = f"{edge_diff.valve} {edge_diff.state.value}"
328
+ if edge_diff.pump is not None:
329
+ graph.edges[edge]["label"] = f"{edge_diff.pump} {edge_diff.state.value}"
330
+ net.from_nx(graph)
331
+ if output_file:
332
+ net.write_html(output_file)
333
+ return
334
+ page = net.generate_html().encode("utf-8")
335
+
336
+ class InMemoryGraphHandler(BaseHTTPRequestHandler):
337
+ def do_GET(self):
338
+ self.send_response(200)
339
+ self.send_header("Content-type", "text/html")
340
+ self.send_header("Content-length", str(len(page)))
341
+ self.end_headers()
342
+ self.wfile.write(page)
343
+
344
+ def log_message(self, format, *args):
345
+ return # Suppress logging
346
+
347
+ HTTPServer.allow_reuse_address = True
348
+ server = HTTPServer(("127.0.0.1", port), InMemoryGraphHandler)
349
+ print(f"Server at http://127.0.0.1:{port}")
350
+
351
+ # Open the browser automatically
352
+ webbrowser.open(f"http://127.0.0.1:{port}")
353
+
354
+ try:
355
+ server.serve_forever()
356
+ except KeyboardInterrupt:
357
+ print("\nShutting down server.")
358
+ server.server_close()
auto_pumping/safety.py ADDED
@@ -0,0 +1,83 @@
1
+ from time import time
2
+
3
+
4
+ class SafetyMachine:
5
+ def __init__(self, config, pumping_system):
6
+ self._config = config
7
+ self._pumping_system = pumping_system
8
+ self._valve_times = {valve: time() for valve in config.valves}
9
+ self._pump_times = {pump: time() for pump in config.pumps}
10
+
11
+ def record_valve_change(self, valve):
12
+ self._valve_times[valve] = time()
13
+
14
+ def record_pump_change(self, pump):
15
+ self._pump_times[pump] = time()
16
+
17
+ def can_actuate_valve(self, valve, state):
18
+ current_state = self._pumping_system.get_valve_state(valve)
19
+ transition = self._find_transition(
20
+ valve=valve, from_state=current_state, to_state=state
21
+ )
22
+ return self._check_conditions(transition.condition)
23
+
24
+ def can_set_pump(self, pump, state):
25
+ current_state = self._pumping_system.get_pump_state(pump)
26
+ transition = self._find_transition(
27
+ pump=pump, from_state=current_state, to_state=state
28
+ )
29
+ return self._check_conditions(transition.condition)
30
+
31
+ def can_move_stage(self, position):
32
+ current_position = self._pumping_system.get_stage_position()
33
+ transition = self._find_transition(
34
+ stage=True, from_state=current_position, to_state=position
35
+ )
36
+ return self._check_conditions(transition.condition)
37
+
38
+ def _find_transition(self, **kwargs):
39
+ for transition in self._config.transitions:
40
+ if all(getattr(transition, key) == value for key, value in kwargs.items()):
41
+ return transition
42
+ raise ValueError(f"No transition found for {kwargs}")
43
+
44
+ def _check_conditions(self, conditions):
45
+ if conditions is None:
46
+ return True
47
+ for gauge, ineq in conditions.gauge.items():
48
+ if not ineq.evaluate(self._pumping_system.get_gauge_pressure(gauge)):
49
+ return False
50
+ if (
51
+ conditions.stage is not None
52
+ and conditions.stage != self._pumping_system.get_stage_position()
53
+ ):
54
+ return False
55
+ for valve, condition in conditions.valve.items():
56
+ if self._pumping_system.get_valve_state(valve) != condition.state:
57
+ return False
58
+ if (
59
+ condition.time is not None
60
+ and time() - self._valve_times[valve] < condition.time
61
+ ):
62
+ return False
63
+ for pump, condition in conditions.pump.items():
64
+ if condition.state is not None and self._pumping_system.get_pump_state(pump) != condition.state:
65
+ return False
66
+ if (
67
+ condition.time is not None
68
+ and time() - self._pump_times[pump] < condition.time
69
+ ):
70
+ return False
71
+ if condition.current is not None and not condition.current.evaluate(
72
+ self._pumping_system.get_pump_current(pump)
73
+ ):
74
+ return False
75
+ if condition.voltage is not None and not condition.voltage.evaluate(
76
+ self._pumping_system.get_pump_voltage(pump)
77
+ ):
78
+ return False
79
+ if condition.flow is not None and not condition.flow.evaluate(self._pumping_system.get_pump_flow(pump)):
80
+ return False
81
+ if condition.speed is not None and not condition.speed.evaluate(self._pumping_system.get_pump_speed(pump)):
82
+ return False
83
+ return True