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/executor.py
ADDED
|
@@ -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
|