dosync 0.4.1__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,228 @@
1
+ """
2
+ DoSync — Operation guards (real-time in-flight monitoring for composite operations).
3
+ ====================================================================================
4
+
5
+ The PolicyEngine is PRE-FLIGHT AUTHORIZATION: it judges a plan once, before dispatch,
6
+ and answers ALLOW/BLOCK/CONFIRM/MODIFY. These guards are IN-FLIGHT MONITORING: the
7
+ supervisor calls them on every tick while a composite operation is live, and they
8
+ answer "keep going" (None) or "abort, here's why" (a reason string). Two different
9
+ safety systems for two different moments — the panel was explicit that they must not
10
+ be merged (incompatible contracts), but they SHARE the geofence calculation (in
11
+ dosync/geo.py) so the formula never lives in two copies.
12
+
13
+ This is the brain watching the body's vital signs during the trip — not the
14
+ environment (no obstacle/“tree” sensing; that needs sensors the body does not have
15
+ yet), but everything the vehicle itself reports: position (still inside the fence?),
16
+ link (still alive?), mode (did a human take over?), battery, time-in-state.
17
+
18
+ DESIGN (validated by the expert panel):
19
+ * Each guard is an independent, composable object with a name and a check(comp)
20
+ method returning a reason-or-None. A GuardSet runs them in order; the first to
21
+ fire wins (like the PolicyEngine's first-block-wins, shared PATTERN, separate
22
+ component).
23
+ * The guards consume a CONTEXT object the supervisor refreshes from reconciled
24
+ state + telemetry each tick — they never read the raw telemetry queue (that is
25
+ the consumer's job; guards are decision, not perception).
26
+ * First version: DETECT and report a clear abort reason. Differentiated REACTIONS
27
+ (link-loss → return home; human took control → let go; battery → land here) are
28
+ a documented second layer; the supervisor's safe default today is to abort, and
29
+ the caller routes the vehicle home. Keeping the first version to "detect + abort
30
+ with a clear reason" is honest and already large.
31
+ * The human-takeover guard is special and the panel flagged it: when a human is
32
+ flying, the right behavior is NOT to fight for control. The guard fires (so the
33
+ supervisor stops dispatching), and the safe meaning is "let go," distinct from a
34
+ fault. This is recorded in the reason so the second-layer reaction can tell them
35
+ apart later.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from dataclasses import dataclass, field
41
+ from typing import Optional
42
+
43
+ from .geo import is_within_perimeter
44
+
45
+
46
+ @dataclass
47
+ class GuardContext:
48
+ """A snapshot the supervisor refreshes each tick from reconciled state +
49
+ telemetry. The guards read THIS, never the raw queue. Every field is optional so
50
+ a deployment that cannot supply a signal simply leaves it None and the
51
+ corresponding guard abstains (it never invents a reading)."""
52
+ device_id: str
53
+ # Current reported position (from telemetry). None = unknown this tick.
54
+ lat: Optional[float] = None
55
+ lon: Optional[float] = None
56
+ alt: Optional[float] = None
57
+ # Seconds since the last telemetry message arrived. None = unknown.
58
+ seconds_since_telemetry: Optional[float] = None
59
+ # Whether a human currently holds manual control (operation interrupted).
60
+ manual_control_active: bool = False
61
+ # Battery remaining, 0..100. None = unknown.
62
+ battery_percent: Optional[float] = None
63
+ # Seconds the current mission step has been running. None = unknown.
64
+ seconds_in_step: Optional[float] = None
65
+
66
+
67
+ class BaseGuard:
68
+ """A composable in-flight guard. Subclasses implement check(ctx) -> reason|None."""
69
+
70
+ @property
71
+ def name(self) -> str:
72
+ raise NotImplementedError
73
+
74
+ def check(self, ctx: GuardContext) -> Optional[str]:
75
+ raise NotImplementedError
76
+
77
+
78
+ class GeofenceGuard(BaseGuard):
79
+ """Fires if the vehicle's REPORTED position leaves the permitted perimeter.
80
+
81
+ This is the in-flight twin of GeofencePolicy. The policy checks a go_to's TARGET
82
+ before dispatch; this checks where the vehicle ACTUALLY is, every tick — catching
83
+ a vehicle blown off course by wind, GPS drift, or any drift the pre-flight check
84
+ could not foresee. Both call the same is_within_perimeter (dosync/geo.py): one
85
+ rule, two moments (admission + monitoring). Cinturón y airbag.
86
+ """
87
+
88
+ def __init__(self, center_lat: float, center_lon: float,
89
+ max_radius_m: float, max_altitude_m: Optional[float] = None):
90
+ self._center_lat = center_lat
91
+ self._center_lon = center_lon
92
+ self._max_radius_m = max_radius_m
93
+ self._max_altitude_m = max_altitude_m
94
+
95
+ @property
96
+ def name(self) -> str:
97
+ return "geofence_in_flight"
98
+
99
+ def check(self, ctx: GuardContext) -> Optional[str]:
100
+ if ctx.lat is None or ctx.lon is None:
101
+ return None # no position this tick — abstain, never invent
102
+ ok, reason = is_within_perimeter(
103
+ ctx.lat, ctx.lon, self._center_lat, self._center_lon,
104
+ self._max_radius_m, alt=ctx.alt, max_altitude_m=self._max_altitude_m)
105
+ if not ok:
106
+ return f"vehicle left the perimeter in flight: {reason}"
107
+ return None
108
+
109
+
110
+ class LinkLossGuard(BaseGuard):
111
+ """Fires if telemetry has gone silent for longer than the allowed gap — the link
112
+ is presumed lost. Silence is not success: a vehicle we cannot hear is a vehicle
113
+ we are not supervising, and the safe response is to stop and let it come home."""
114
+
115
+ def __init__(self, max_silence_s: float = 5.0):
116
+ self._max_silence_s = max_silence_s
117
+
118
+ @property
119
+ def name(self) -> str:
120
+ return "link_loss"
121
+
122
+ def check(self, ctx: GuardContext) -> Optional[str]:
123
+ if ctx.seconds_since_telemetry is None:
124
+ return None
125
+ if ctx.seconds_since_telemetry > self._max_silence_s:
126
+ return (f"telemetry link silent for {ctx.seconds_since_telemetry:.0f}s "
127
+ f"(> {self._max_silence_s:.0f}s) — link presumed lost")
128
+ return None
129
+
130
+
131
+ class ManualControlGuard(BaseGuard):
132
+ """Fires when a human has taken manual control. The panel flagged this as
133
+ special: the right behavior is NOT to fight the pilot. The guard fires so the
134
+ supervisor stops dispatching; the reason marks it as a handover (not a fault) so a
135
+ later reaction layer can 'let go' rather than 'return home'."""
136
+
137
+ @property
138
+ def name(self) -> str:
139
+ return "manual_control"
140
+
141
+ def check(self, ctx: GuardContext) -> Optional[str]:
142
+ if ctx.manual_control_active:
143
+ return ("human took manual control — DoSync yields (handover, not a "
144
+ "fault); stop dispatching and let the pilot fly")
145
+ return None
146
+
147
+
148
+ class BatteryGuard(BaseGuard):
149
+ """Fires when battery falls below the safe threshold. The vehicle needs enough
150
+ charge to get home; below the floor, the mission must stop."""
151
+
152
+ def __init__(self, min_percent: float = 25.0):
153
+ self._min_percent = min_percent
154
+
155
+ @property
156
+ def name(self) -> str:
157
+ return "battery"
158
+
159
+ def check(self, ctx: GuardContext) -> Optional[str]:
160
+ if ctx.battery_percent is None:
161
+ return None
162
+ if ctx.battery_percent < self._min_percent:
163
+ return (f"battery {ctx.battery_percent:.0f}% below the {self._min_percent:.0f}% "
164
+ f"floor — not enough charge to continue safely")
165
+ return None
166
+
167
+
168
+ class StepTimeoutGuard(BaseGuard):
169
+ """Fires when a single step has run longer than allowed — a stalled step that the
170
+ supervisor's own backstop would also catch, surfaced here as a named guard so the
171
+ reason is explicit. Belt for the supervisor's suspenders."""
172
+
173
+ def __init__(self, max_step_s: float = 120.0):
174
+ self._max_step_s = max_step_s
175
+
176
+ @property
177
+ def name(self) -> str:
178
+ return "step_timeout"
179
+
180
+ def check(self, ctx: GuardContext) -> Optional[str]:
181
+ if ctx.seconds_in_step is None:
182
+ return None
183
+ if ctx.seconds_in_step > self._max_step_s:
184
+ return (f"step running for {ctx.seconds_in_step:.0f}s "
185
+ f"(> {self._max_step_s:.0f}s) — stalled")
186
+ return None
187
+
188
+
189
+ @dataclass
190
+ class GuardSet:
191
+ """An ordered, composable set of guards. Runs them in order on each tick; the
192
+ first to fire wins (shared pattern with the PolicyEngine's first-block-wins,
193
+ separate component). A deployment registers only the guards it wants — a vehicle
194
+ with no battery telemetry simply does not add a BatteryGuard.
195
+
196
+ The GuardSet exposes a `make_guard_fn(context_provider)` that adapts it to the
197
+ supervisor's GuardFn hook: the supervisor passes a CompositeOperation, the
198
+ provider turns it (plus live telemetry) into a GuardContext, and the set runs.
199
+ """
200
+ guards: list = field(default_factory=list)
201
+
202
+ def add(self, guard: BaseGuard) -> "GuardSet":
203
+ self.guards.append(guard)
204
+ return self
205
+
206
+ def check(self, ctx: GuardContext) -> Optional[str]:
207
+ """Run all guards in order; return the first firing guard's reason (prefixed
208
+ with its name), or None if all pass."""
209
+ for guard in self.guards:
210
+ reason = guard.check(ctx)
211
+ if reason is not None:
212
+ return f"[{guard.name}] {reason}"
213
+ return None
214
+
215
+ def make_guard_fn(self, context_provider):
216
+ """Adapt this set to the supervisor's GuardFn(comp) -> reason|None.
217
+
218
+ context_provider: a callable taking the live CompositeOperation and returning
219
+ a GuardContext (built from reconciled state + the latest telemetry). Keeping
220
+ this injection here means the GuardSet stays pure and testable: tests supply a
221
+ trivial provider; production supplies one that reads live telemetry.
222
+ """
223
+ def guard_fn(comp) -> Optional[str]:
224
+ ctx = context_provider(comp)
225
+ if ctx is None:
226
+ return None
227
+ return self.check(ctx)
228
+ return guard_fn
@@ -0,0 +1,216 @@
1
+ """
2
+ DoSync — Operation supervisor (the nervous-system trunk of a composite operation).
3
+ ==================================================================================
4
+
5
+ CompositeOperation is the structure (the ordered steps). RouteComposer fills it with
6
+ geometry. This module is what makes them LIVE: the supervisor drives a composite
7
+ forward, step by step, in a CLOSED LOOP — it dispatches the current step, then WAITS
8
+ for that step's atomic operation to reach a positive terminal state (confirmed
9
+ arrival, never a timer), and only then advances. Between steps it samples continuously
10
+ so guards (next piece) can react in real time.
11
+
12
+ This is the brain coordinating the body: not "fire the plan and walk away" (open
13
+ loop), but "watch what the body actually did, decide the next move, repeat" (closed
14
+ loop). A waypoint's confirmed arrival is not the end of an instruction — it is
15
+ perception that feeds the next decision.
16
+
17
+ DESIGN (validated by a multi-pass expert panel incl. a drone manufacturer, a pilot,
18
+ and two systems professors):
19
+
20
+ * POLLING at a fixed, CONFIGURABLE cadence — not event subscription. The panel's
21
+ reasons: (1) the system ALREADY beats — the telemetry consumer
22
+ (drain_telemetry_once -> apply_telemetry) already runs on a rhythm; the
23
+ supervisor hangs off that nature instead of bolting on a callback mechanism.
24
+ (2) Real-time guards (geofence-in-flight, battery, link loss) are INHERENTLY
25
+ sampling — there is no "geofence-breach event"; you must look. One heartbeat
26
+ serves both arrival-detection and guards. (3) A predictable cadence is a SAFETY
27
+ property: you know exactly how often the brain looks at the body.
28
+
29
+ * The supervisor POLLS THE RECONCILED STATE, never the raw telemetry queue. The
30
+ consumer is perception (telemetry -> state); the supervisor is decision (state ->
31
+ next move). The brain reads the processed body-map, not the raw nerves. This
32
+ separation is non-negotiable — it is what keeps the nervous system clean.
33
+
34
+ * SILENCE IS NOT SUCCESS — inherited end to end. A step only advances when its
35
+ atomic operation reaches a POSITIVE terminal state (completed). A timeout or a
36
+ silent gap never advances anything; it is a stall the guards can act on.
37
+
38
+ * Dependency-injected dispatch and state-read so the whole loop is unit-testable
39
+ with NO drone, NO socket, NO hub: the test supplies a fake "dispatch" and a fake
40
+ "read state", and drives the composite through its lifecycle deterministically.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import logging
46
+ from dataclasses import dataclass
47
+ from typing import Awaitable, Callable, Optional
48
+
49
+ from .composite_operations import CompositeOperation, CompositeState, CompositeStep
50
+
51
+ log = logging.getLogger("dosync.supervisor")
52
+
53
+
54
+ # Default sampling cadence. The panel required this be configurable, not hardcoded:
55
+ # a slow inspection can sample wider; a critical maneuver, finer.
56
+ _DEFAULT_POLL_INTERVAL_S = 0.2 # 5 Hz — the brain looks at the body 5x/second
57
+ # Safety ceiling: how long a single step may sit without reaching a terminal atom
58
+ # state before the supervisor treats it as stalled and aborts the composite. This is
59
+ # a backstop, not the primary mechanism (guards are) — silence must never hang forever.
60
+ _DEFAULT_STEP_TIMEOUT_S = 120.0
61
+
62
+
63
+ # A step dispatcher: given a CompositeStep, start its atomic operation and return the
64
+ # operation_id the supervisor will then watch. Injected so tests need no hub/drone.
65
+ DispatchFn = Callable[[CompositeStep], Awaitable[str]]
66
+
67
+ # A state reader: given an operation_id, return the reconciled atomic state as a
68
+ # string ("pending" | "in_progress" | "completed" | "failed" | "interrupted" | ...),
69
+ # or None if unknown. Reads the RECONCILED state — never the raw queue.
70
+ ReadStateFn = Callable[[str], Optional[str]]
71
+
72
+ # A guard check: inspect the live composite and return an abort reason string if a
73
+ # guard fires, or None to continue. The guards themselves are the next piece; the
74
+ # supervisor just calls this hook each tick. Injected; defaults to "no guards".
75
+ GuardFn = Callable[[CompositeOperation], Optional[str]]
76
+
77
+
78
+ # Atomic states that count as a positive, successful completion of a step.
79
+ _POSITIVE_TERMINAL = {"completed"}
80
+ # Atomic states that mean the step ended without success → the composite cannot
81
+ # simply advance; it must react (abort / return home).
82
+ _NEGATIVE_TERMINAL = {"failed", "rejected", "cancelled", "interrupted"}
83
+
84
+
85
+ @dataclass
86
+ class SupervisorConfig:
87
+ poll_interval_s: float = _DEFAULT_POLL_INTERVAL_S
88
+ step_timeout_s: float = _DEFAULT_STEP_TIMEOUT_S
89
+
90
+
91
+ class OperationSupervisor:
92
+ """Drives one CompositeOperation through its steps in a closed loop.
93
+
94
+ The supervisor owns the composite's mission-level progression. For each step it:
95
+ 1. transitions the composite to the right mission state (in_transit / returning),
96
+ 2. dispatches the step's atomic action (injected DispatchFn) and records the
97
+ resulting operation_id on the step,
98
+ 3. polls the reconciled atomic state at the configured cadence until it reaches
99
+ a terminal state — advancing on a positive one, aborting on a negative one,
100
+ 4. on every tick, runs the guard hook; if a guard fires, it aborts (the safe
101
+ default is to stop and let the caller route to return-home).
102
+
103
+ It never drains the telemetry queue and never reaches into the atom's state
104
+ machine — it reads reconciled state and advances its OWN (composite) machine.
105
+ """
106
+
107
+ def __init__(self, dispatch: DispatchFn, read_state: ReadStateFn,
108
+ guard: Optional[GuardFn] = None,
109
+ config: Optional[SupervisorConfig] = None,
110
+ sleep: Optional[Callable[[float], Awaitable[None]]] = None,
111
+ now: Optional[Callable[[], float]] = None):
112
+ self._dispatch = dispatch
113
+ self._read_state = read_state
114
+ self._guard = guard or (lambda comp: None)
115
+ self._config = config or SupervisorConfig()
116
+ # Injected clock/sleep so tests run instantly and deterministically.
117
+ if sleep is None:
118
+ import asyncio
119
+ sleep = asyncio.sleep
120
+ self._sleep = sleep
121
+ if now is None:
122
+ import time
123
+ now = time.monotonic
124
+ self._now = now
125
+
126
+ async def run(self, comp: CompositeOperation) -> CompositeState:
127
+ """Drive the composite to a terminal mission state and return it.
128
+
129
+ Returns COMPLETED if every step reached a positive terminal state, or
130
+ ABORTED/FAILED if a guard fired, a step failed, or a step stalled.
131
+ """
132
+ if comp.state != CompositeState.PLANNING:
133
+ raise ValueError(
134
+ f"supervisor expects a freshly composed operation in PLANNING, "
135
+ f"got {comp.state.value}")
136
+
137
+ comp.transition_to(CompositeState.IN_TRANSIT, reason="supervisor started")
138
+
139
+ while True:
140
+ step = comp.current_step
141
+ if step is None:
142
+ # All steps consumed without a dedicated return step having moved us to
143
+ # RETURNING — treat the sequence as done.
144
+ break
145
+
146
+ # The return step flips the mission into RETURNING (its own leg).
147
+ if step.kind == "return" and comp.state == CompositeState.IN_TRANSIT:
148
+ comp.transition_to(CompositeState.RETURNING, reason="returning to base")
149
+
150
+ # Guard check BEFORE dispatching the next step — never start a step if a
151
+ # guard already says stop.
152
+ reason = self._guard(comp)
153
+ if reason is not None:
154
+ return self._abort(comp, f"guard: {reason}")
155
+
156
+ # Dispatch the step's atomic action; record the operation_id to watch.
157
+ try:
158
+ operation_id = await self._dispatch(step)
159
+ except Exception as e:
160
+ return self._abort(comp, f"dispatch raised: {e}")
161
+ step.operation_id = operation_id
162
+
163
+ outcome = await self._await_step(comp, operation_id)
164
+ if outcome == "positive":
165
+ comp.advance()
166
+ continue
167
+ if outcome == "guard":
168
+ # _await_step already knows the reason via the guard; abort generically.
169
+ return self._abort(comp, "guard fired during step")
170
+ if outcome == "stalled":
171
+ return self._abort(comp, f"step '{step.action}' stalled (no positive signal)")
172
+ # negative terminal
173
+ return self._abort(comp, f"step '{step.action}' ended in a non-success state")
174
+
175
+ # Every step done. If we were returning, we are home; otherwise close directly.
176
+ if comp.state == CompositeState.RETURNING:
177
+ comp.transition_to(CompositeState.COMPLETED, reason="returned to base")
178
+ else:
179
+ # No explicit return step; still a successful completion of all steps.
180
+ comp.transition_to(CompositeState.RETURNING, reason="no explicit return step")
181
+ comp.transition_to(CompositeState.COMPLETED, reason="all steps completed")
182
+ return comp.state
183
+
184
+ async def _await_step(self, comp: CompositeOperation, operation_id: str) -> str:
185
+ """Poll the reconciled atomic state until terminal. Returns one of:
186
+ 'positive' | 'negative' | 'guard' | 'stalled'. Runs the guard hook each tick —
187
+ the closed loop: every sample, decide."""
188
+ started = self._now()
189
+ while True:
190
+ # Guard check every tick — real-time reaction, not only at waypoints.
191
+ reason = self._guard(comp)
192
+ if reason is not None:
193
+ log.info("supervisor: guard fired mid-step on %s: %s",
194
+ comp.composite_id, reason)
195
+ return "guard"
196
+
197
+ state = self._read_state(operation_id)
198
+ if state in _POSITIVE_TERMINAL:
199
+ return "positive"
200
+ if state in _NEGATIVE_TERMINAL:
201
+ return "negative"
202
+
203
+ # Backstop: a step that never reaches terminal must not hang forever.
204
+ if self._now() - started > self._config.step_timeout_s:
205
+ return "stalled"
206
+
207
+ await self._sleep(self._config.poll_interval_s)
208
+
209
+ def _abort(self, comp: CompositeOperation, reason: str) -> CompositeState:
210
+ """Move the composite to ABORTED (safe default). The caller is responsible for
211
+ routing the vehicle home — the supervisor records the decision; the return
212
+ itself is a separate dispatched action the caller may issue."""
213
+ if not comp.is_terminal:
214
+ comp.transition_to(CompositeState.ABORTED, reason=reason)
215
+ log.warning("supervisor: composite %s aborted — %s", comp.composite_id, reason)
216
+ return comp.state