dhcp-simulator 2.9.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.
Files changed (98) hide show
  1. dhcp/__init__.py +2 -0
  2. dhcp/ai_operations/contracts.py +19 -0
  3. dhcp/apps/operations_console.py +33 -0
  4. dhcp/apps/simulation_console.py +35 -0
  5. dhcp/audit/journal.py +313 -0
  6. dhcp/communications/lease.py +10 -0
  7. dhcp/communications/simulation_link.py +9 -0
  8. dhcp/core/clock.py +7 -0
  9. dhcp/core/monitoring_audio.py +741 -0
  10. dhcp/core/numeric.py +20 -0
  11. dhcp/domain/enums.py +35 -0
  12. dhcp/domain/models.py +110 -0
  13. dhcp/domain/state_machines.py +23 -0
  14. dhcp/emergencies/engine.py +8 -0
  15. dhcp/eta/engine.py +9 -0
  16. dhcp/identity/authorization.py +11 -0
  17. dhcp/mapping/__init__.py +3 -0
  18. dhcp/mapping/engine.py +134 -0
  19. dhcp/mapping/models.py +41 -0
  20. dhcp/mapping/providers/__init__.py +2 -0
  21. dhcp/mapping/providers/base.py +7 -0
  22. dhcp/mapping/providers/maptiler.py +13 -0
  23. dhcp/persistence/__init__.py +3 -0
  24. dhcp/persistence/sqlite_repository.py +791 -0
  25. dhcp/runtime.py +853 -0
  26. dhcp/safety/kernel.py +321 -0
  27. dhcp/services/coordinator.py +232 -0
  28. dhcp/simulation/aircraft.py +493 -0
  29. dhcp/simulation/airspace_operations.py +241 -0
  30. dhcp/simulation/awareness.py +42 -0
  31. dhcp/simulation/dispatch.py +124 -0
  32. dhcp/simulation/flight_phases.py +1617 -0
  33. dhcp/simulation/flight_sequencing.py +160 -0
  34. dhcp/simulation/heavy_transport.py +328 -0
  35. dhcp/simulation/hub.py +103 -0
  36. dhcp/simulation/intelligence.py +262 -0
  37. dhcp/simulation/live_operations.py +187 -0
  38. dhcp/simulation/maintenance_execution.py +135 -0
  39. dhcp/simulation/model_evidence.py +572 -0
  40. dhcp/simulation/operations.py +133 -0
  41. dhcp/simulation/performance.py +434 -0
  42. dhcp/simulation/postflight_operations.py +174 -0
  43. dhcp/simulation/recovery_operations.py +255 -0
  44. dhcp/simulation/reference_scenarios.py +755 -0
  45. dhcp/simulation/reliability.py +102 -0
  46. dhcp/simulation/replay_evidence.py +306 -0
  47. dhcp/simulation/resilience.py +171 -0
  48. dhcp/simulation/scenarios.py +40 -0
  49. dhcp/simulation/scheduling.py +134 -0
  50. dhcp/simulation/sensitivity.py +619 -0
  51. dhcp/simulation/session.py +3047 -0
  52. dhcp/simulation/traffic.py +150 -0
  53. dhcp/simulation/turnaround.py +114 -0
  54. dhcp/ui/__init__.py +1 -0
  55. dhcp/ui/assets/icons/activity.svg +1 -0
  56. dhcp/ui/assets/icons/alert.svg +1 -0
  57. dhcp/ui/assets/icons/audio.svg +4 -0
  58. dhcp/ui/assets/icons/audio_muted.svg +4 -0
  59. dhcp/ui/assets/icons/audit.svg +1 -0
  60. dhcp/ui/assets/icons/battery.svg +1 -0
  61. dhcp/ui/assets/icons/communications.svg +1 -0
  62. dhcp/ui/assets/icons/drone.svg +1 -0
  63. dhcp/ui/assets/icons/filter.svg +1 -0
  64. dhcp/ui/assets/icons/link.svg +1 -0
  65. dhcp/ui/assets/icons/map.svg +1 -0
  66. dhcp/ui/assets/icons/mission.svg +1 -0
  67. dhcp/ui/assets/icons/more.svg +1 -0
  68. dhcp/ui/assets/icons/overview.svg +1 -0
  69. dhcp/ui/assets/icons/pad.svg +1 -0
  70. dhcp/ui/assets/icons/pause.svg +1 -0
  71. dhcp/ui/assets/icons/play.svg +1 -0
  72. dhcp/ui/assets/icons/reset.svg +1 -0
  73. dhcp/ui/assets/icons/route.svg +1 -0
  74. dhcp/ui/assets/icons/settings.svg +1 -0
  75. dhcp/ui/assets/icons/simulation.svg +1 -0
  76. dhcp/ui/assets/icons/telemetry.svg +1 -0
  77. dhcp/ui/assets/icons/transport.svg +5 -0
  78. dhcp/ui/assets/map/maplibre_shell.html +3 -0
  79. dhcp/ui/assets/transport_classes/README.md +35 -0
  80. dhcp/ui/assets/transport_classes/extra_heavy.png +0 -0
  81. dhcp/ui/assets/transport_classes/heavy.png +0 -0
  82. dhcp/ui/assets/transport_classes/large.png +0 -0
  83. dhcp/ui/data_catalog.py +69 -0
  84. dhcp/ui/design_system.py +74 -0
  85. dhcp/ui/focus.py +24 -0
  86. dhcp/ui/graphics.py +123 -0
  87. dhcp/ui/icons.py +24 -0
  88. dhcp/ui/main_window.py +247 -0
  89. dhcp/ui/menus.py +34 -0
  90. dhcp/ui/monitoring_audio_adapter.py +285 -0
  91. dhcp/ui/pages.py +1693 -0
  92. dhcp/ui/theme.py +80 -0
  93. dhcp/ui/widgets.py +490 -0
  94. dhcp/ui/workspace.py +25 -0
  95. dhcp_simulator-2.9.0.dist-info/METADATA +298 -0
  96. dhcp_simulator-2.9.0.dist-info/RECORD +98 -0
  97. dhcp_simulator-2.9.0.dist-info/WHEEL +4 -0
  98. dhcp_simulator-2.9.0.dist-info/licenses/LICENSE +21 -0
dhcp/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Drone Hub Control Port educational simulator."""
2
+ __version__ = "2.9.0-sim"
@@ -0,0 +1,19 @@
1
+ from dataclasses import dataclass, field
2
+ from datetime import datetime
3
+ from uuid import UUID, uuid4
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class AIRecommendation:
8
+ mission_id: UUID
9
+ action: str
10
+ confidence: float
11
+ evidence_refs: tuple[str, ...]
12
+ limitations: tuple[str, ...]
13
+ expires_at: datetime
14
+ requires_human_approval: bool = True
15
+ recommendation_id: UUID = field(default_factory=uuid4)
16
+
17
+ def __post_init__(self) -> None:
18
+ if not 0 <= self.confidence <= 1:
19
+ raise ValueError("confidence must be between 0 and 1")
@@ -0,0 +1,33 @@
1
+ def main() -> None:
2
+ import sys
3
+ try:
4
+ from PyQt6.QtWidgets import QApplication
5
+ except ImportError as exc:
6
+ raise SystemExit("Install the optional UI dependency: pip install -e '.[ui]'") from exc
7
+ from dhcp.ui.main_window import OperationsMainWindow
8
+ from dhcp.ui.theme import APP_STYLESHEET
9
+ app = QApplication([])
10
+ app.setApplicationName("DHCP Simulator")
11
+ app.setStyleSheet(APP_STYLESHEET)
12
+ window = OperationsMainWindow()
13
+
14
+ def contain_uncaught_exception(exc_type, exc, traceback) -> None:
15
+ # Qt callback failures must never terminate the operations console.
16
+ window.session.containment.capture(
17
+ "application", "uncaught_exception", exc, window.session.elapsed_s, recovered=True
18
+ )
19
+ try:
20
+ window.session.request_command(
21
+ "pause_after_uncaught_ui_exception",
22
+ window.session.pause,
23
+ )
24
+ except Exception:
25
+ pass
26
+
27
+ sys.excepthook = contain_uncaught_exception
28
+ window.show()
29
+ app.exec()
30
+
31
+
32
+ if __name__ == "__main__":
33
+ main()
@@ -0,0 +1,35 @@
1
+ from dhcp.core.clock import ManualClock
2
+ from dhcp.audit.journal import AppendOnlyJournal
3
+ from dhcp.communications.lease import LeaseRegistry
4
+ from dhcp.safety.kernel import SafetyKernel
5
+ from dhcp.services.coordinator import CommandCoordinator
6
+ from dhcp.domain.models import *
7
+ from dhcp.domain.enums import *
8
+ from dhcp.simulation.aircraft import SimulatedAircraft
9
+ from dhcp.emergencies.engine import EmergencyEngine
10
+ from dhcp.eta.engine import EtaEngine
11
+
12
+ def command(a,m,op,kind,seq,clock):
13
+ return AuthorizedCommand(a.aircraft_id,m.mission_id,kind,op,1,seq,clock.now(),clock.now()+30)
14
+
15
+ def main():
16
+ clock=ManualClock(100); journal=AppendOnlyJournal(); leases=LeaseRegistry(); safety=SafetyKernel()
17
+ coordinator=CommandCoordinator(clock,leases,safety,journal)
18
+ aircraft=Aircraft("SIM-101"); mission=Mission(aircraft.aircraft_id,1.2,3.0)
19
+ operator=OperatorContext("operator-1",frozenset({"dispatcher","pad_operator","flight_operator","emergency_operator"}))
20
+ leases.grant(ControlLease(aircraft.aircraft_id,operator.operator_id,1,clock.now(),clock.now()+3600))
21
+ seq=0
22
+ for kind in (CommandType.RESERVE,CommandType.BEGIN_PREFLIGHT,CommandType.AUTHORIZE_LAUNCH,CommandType.LAUNCH):
23
+ seq+=1; decision=coordinator.execute(aircraft,mission,command(aircraft,mission,operator,kind,seq,clock)); print(kind,decision.reason_code)
24
+ sim=SimulatedAircraft(aircraft,mission); emergency=EmergencyEngine(); eta=EtaEngine()
25
+ while mission.progress<0.92:
26
+ clock.advance(5); telemetry=sim.step(clock.now(),5); issue=emergency.evaluate(telemetry)
27
+ estimate=eta.estimate(mission.route_distance_km,mission.progress,max(telemetry.speed_mps,0.1))
28
+ print(f"progress={mission.progress:5.1%} battery={aircraft.battery_pct:5.1f}% ETA={estimate.seconds:5.0f}s")
29
+ if issue: print("EMERGENCY",issue.classification,issue.response); break
30
+ if not issue:
31
+ seq+=1; print(CommandType.AUTHORIZE_LANDING,coordinator.execute(aircraft,mission,command(aircraft,mission,operator,CommandType.AUTHORIZE_LANDING,seq,clock),telemetry).reason_code)
32
+ seq+=1; print(CommandType.LAND,coordinator.execute(aircraft,mission,command(aircraft,mission,operator,CommandType.LAND,seq,clock),telemetry).reason_code)
33
+ print("final",aircraft.state,mission.state,"audit_records",len(journal.records),"journal_valid",journal.verify())
34
+
35
+ if __name__=='__main__': main()
dhcp/audit/journal.py ADDED
@@ -0,0 +1,313 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from hashlib import sha256
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ from tempfile import mkstemp
10
+ from threading import Lock
11
+ from types import MappingProxyType
12
+ from typing import Any, Mapping
13
+ from uuid import UUID
14
+
15
+ from dhcp.domain.models import Event
16
+
17
+
18
+ _ZERO_HASH = "0" * 64
19
+
20
+
21
+ class JournalCorruptionError(RuntimeError):
22
+ """Raised when a complete journal record fails structural or hash validation."""
23
+
24
+ def __init__(self, path: Path, line_number: int, reason: str) -> None:
25
+ super().__init__(f"journal corruption at {path}, line {line_number}: {reason}")
26
+ self.path = path
27
+ self.line_number = line_number
28
+ self.reason = reason
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class JournalRecoveryReport:
33
+ path: str | None
34
+ loaded_records: int
35
+ original_bytes: int
36
+ valid_bytes: int
37
+ truncated_bytes: int = 0
38
+ recovered_trailing_partial: bool = False
39
+ normalized_encoding: bool = False
40
+
41
+
42
+ def _freeze_json(value: Any) -> Any:
43
+ if isinstance(value, dict):
44
+ return MappingProxyType({str(key): _freeze_json(item) for key, item in value.items()})
45
+ if isinstance(value, list):
46
+ return tuple(_freeze_json(item) for item in value)
47
+ if isinstance(value, tuple):
48
+ return tuple(_freeze_json(item) for item in value)
49
+ return value
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class StoredEvent:
54
+ """Immutable event representation retained by the audit chain."""
55
+
56
+ event_type: str
57
+ payload: Mapping[str, Any]
58
+ event_id: UUID | str
59
+ occurred_at: datetime
60
+ _canonical_text: str = field(repr=False)
61
+
62
+ def canonical(self) -> str:
63
+ return self._canonical_text
64
+
65
+
66
+ def _stored_event(canonical_text: str) -> StoredEvent:
67
+ try:
68
+ raw = json.loads(canonical_text)
69
+ except (TypeError, ValueError) as exc:
70
+ raise ValueError("event payload is not valid canonical JSON") from exc
71
+ if not isinstance(raw, dict):
72
+ raise ValueError("canonical event must be a JSON object")
73
+ required = {"event_id", "event_type", "occurred_at", "payload"}
74
+ if set(raw) != required:
75
+ raise ValueError("canonical event has unexpected fields")
76
+ if not isinstance(raw["event_type"], str) or not isinstance(raw["payload"], dict):
77
+ raise ValueError("canonical event types are invalid")
78
+ try:
79
+ occurred_at = datetime.fromisoformat(str(raw["occurred_at"]))
80
+ except ValueError as exc:
81
+ raise ValueError("canonical event timestamp is invalid") from exc
82
+ try:
83
+ event_id: UUID | str = UUID(str(raw["event_id"]))
84
+ except ValueError:
85
+ event_id = str(raw["event_id"])
86
+ return StoredEvent(
87
+ event_type=raw["event_type"],
88
+ payload=_freeze_json(raw["payload"]),
89
+ event_id=event_id,
90
+ occurred_at=occurred_at,
91
+ _canonical_text=canonical_text,
92
+ )
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class JournalRecord:
97
+ index: int
98
+ previous_hash: str
99
+ record_hash: str
100
+ event: StoredEvent
101
+
102
+
103
+ def _record_bytes(record: JournalRecord) -> bytes:
104
+ return (
105
+ f"{record.index}|{record.previous_hash}|{record.record_hash}|"
106
+ f"{record.event.canonical()}\n"
107
+ ).encode("utf-8")
108
+
109
+
110
+ class AppendOnlyJournal:
111
+ """Thread-safe hash-chain journal with crash-safe restart and rollback support."""
112
+
113
+ def __init__(self, path: Path | str | None = None):
114
+ self._records: list[JournalRecord] = []
115
+ self._encoded_records: list[bytes] = []
116
+ self._lock = Lock()
117
+ self.path = Path(path) if path is not None else None
118
+ if self.path is not None:
119
+ self.path.parent.mkdir(parents=True, exist_ok=True)
120
+ self._recovery_report = self._load()
121
+ else:
122
+ self._recovery_report = JournalRecoveryReport(None, 0, 0, 0)
123
+
124
+ @property
125
+ def recovery_report(self) -> JournalRecoveryReport:
126
+ return self._recovery_report
127
+
128
+ def _parse_record(
129
+ self,
130
+ content: bytes,
131
+ *,
132
+ line_number: int,
133
+ expected_index: int,
134
+ previous_hash: str,
135
+ ) -> JournalRecord:
136
+ try:
137
+ line = content.decode("utf-8")
138
+ except UnicodeDecodeError as exc:
139
+ raise ValueError("record is not valid UTF-8") from exc
140
+ parts = line.split("|", 3)
141
+ if len(parts) != 4:
142
+ raise ValueError("record does not contain four fields")
143
+ raw_index, stored_previous, stored_hash, canonical_text = parts
144
+ try:
145
+ index = int(raw_index)
146
+ except ValueError as exc:
147
+ raise ValueError("record index is not an integer") from exc
148
+ if index != expected_index:
149
+ raise ValueError(f"expected index {expected_index}, found {index}")
150
+ if stored_previous != previous_hash:
151
+ raise ValueError("previous hash does not match the chain")
152
+ if len(stored_hash) != 64:
153
+ raise ValueError("record hash has invalid length")
154
+ try:
155
+ int(stored_hash, 16)
156
+ except ValueError as exc:
157
+ raise ValueError("record hash is not hexadecimal") from exc
158
+ expected_hash = sha256((stored_previous + canonical_text).encode("utf-8")).hexdigest()
159
+ if stored_hash != expected_hash:
160
+ raise ValueError("record hash verification failed")
161
+ event = _stored_event(canonical_text)
162
+ return JournalRecord(index, stored_previous, stored_hash, event)
163
+
164
+ def _load(self) -> JournalRecoveryReport:
165
+ assert self.path is not None
166
+ if not self.path.exists():
167
+ self.path.touch()
168
+ raw = self.path.read_bytes()
169
+ original_size = len(raw)
170
+ physical_lines = raw.splitlines(keepends=True)
171
+ valid_source_bytes = 0
172
+ recovered_partial = False
173
+ previous = _ZERO_HASH
174
+ for position, physical_line in enumerate(physical_lines):
175
+ complete = physical_line.endswith(b"\n")
176
+ content = physical_line.rstrip(b"\r\n")
177
+ line_number = position + 1
178
+ try:
179
+ record = self._parse_record(
180
+ content,
181
+ line_number=line_number,
182
+ expected_index=len(self._records),
183
+ previous_hash=previous,
184
+ )
185
+ except ValueError as exc:
186
+ is_last_incomplete = position == len(physical_lines) - 1 and not complete
187
+ if not is_last_incomplete:
188
+ raise JournalCorruptionError(self.path, line_number, str(exc)) from exc
189
+ recovered_partial = True
190
+ break
191
+ self._records.append(record)
192
+ encoded = _record_bytes(record)
193
+ self._encoded_records.append(encoded)
194
+ previous = record.record_hash
195
+ valid_source_bytes += len(physical_line)
196
+
197
+ normalized = b"".join(self._encoded_records)
198
+ normalized_encoding = raw[:valid_source_bytes] != normalized
199
+ truncated_bytes = original_size - valid_source_bytes if recovered_partial else 0
200
+ if recovered_partial or normalized_encoding or raw != normalized:
201
+ self._replace_file_locked(normalized)
202
+ return JournalRecoveryReport(
203
+ path=str(self.path),
204
+ loaded_records=len(self._records),
205
+ original_bytes=original_size,
206
+ valid_bytes=len(normalized),
207
+ truncated_bytes=max(0, truncated_bytes),
208
+ recovered_trailing_partial=recovered_partial,
209
+ normalized_encoding=normalized_encoding,
210
+ )
211
+
212
+ def _fsync_directory(self) -> None:
213
+ assert self.path is not None
214
+ try:
215
+ descriptor = os.open(self.path.parent, os.O_RDONLY)
216
+ except OSError:
217
+ return
218
+ try:
219
+ os.fsync(descriptor)
220
+ except OSError:
221
+ pass
222
+ finally:
223
+ os.close(descriptor)
224
+
225
+ def _replace_file_locked(self, content: bytes) -> None:
226
+ assert self.path is not None
227
+ descriptor, temporary_name = mkstemp(
228
+ prefix=f".{self.path.name}.",
229
+ suffix=".tmp",
230
+ dir=self.path.parent,
231
+ )
232
+ temporary = Path(temporary_name)
233
+ try:
234
+ with os.fdopen(descriptor, "wb") as handle:
235
+ handle.write(content)
236
+ handle.flush()
237
+ os.fsync(handle.fileno())
238
+ os.replace(temporary, self.path)
239
+ self._fsync_directory()
240
+ finally:
241
+ if temporary.exists():
242
+ temporary.unlink()
243
+
244
+ def append(self, event: Event) -> JournalRecord:
245
+ canonical_text = event.canonical()
246
+ stored = _stored_event(canonical_text)
247
+ with self._lock:
248
+ previous = self._records[-1].record_hash if self._records else _ZERO_HASH
249
+ digest = sha256((previous + canonical_text).encode("utf-8")).hexdigest()
250
+ record = JournalRecord(len(self._records), previous, digest, stored)
251
+ encoded = _record_bytes(record)
252
+ if self.path is not None:
253
+ original_size = self.path.stat().st_size if self.path.exists() else 0
254
+ try:
255
+ with self.path.open("ab") as handle:
256
+ written = handle.write(encoded)
257
+ if written != len(encoded):
258
+ raise OSError("short audit-journal write")
259
+ handle.flush()
260
+ os.fsync(handle.fileno())
261
+ except BaseException:
262
+ try:
263
+ with self.path.open("r+b") as handle:
264
+ handle.truncate(original_size)
265
+ handle.flush()
266
+ os.fsync(handle.fileno())
267
+ except OSError:
268
+ pass
269
+ raise
270
+ self._records.append(record)
271
+ self._encoded_records.append(encoded)
272
+ return record
273
+
274
+ def checkpoint(self) -> int:
275
+ """Return a rollback marker representing the current record count."""
276
+ with self._lock:
277
+ return len(self._records)
278
+
279
+ def rollback(self, checkpoint: int) -> None:
280
+ """Atomically restore the chain to a prior ``checkpoint()`` marker."""
281
+ if not isinstance(checkpoint, int):
282
+ raise TypeError("checkpoint must be an integer record count")
283
+ with self._lock:
284
+ if checkpoint < 0 or checkpoint > len(self._records):
285
+ raise ValueError("checkpoint is outside the current journal")
286
+ if checkpoint == len(self._records):
287
+ return
288
+ retained_bytes = b"".join(self._encoded_records[:checkpoint])
289
+ if self.path is not None:
290
+ self._replace_file_locked(retained_bytes)
291
+ del self._records[checkpoint:]
292
+ del self._encoded_records[checkpoint:]
293
+
294
+ def verify(self) -> bool:
295
+ with self._lock:
296
+ previous = _ZERO_HASH
297
+ for expected_index, record in enumerate(self._records):
298
+ expected = sha256(
299
+ (previous + record.event.canonical()).encode("utf-8")
300
+ ).hexdigest()
301
+ if (
302
+ record.index != expected_index
303
+ or record.previous_hash != previous
304
+ or expected != record.record_hash
305
+ ):
306
+ return False
307
+ previous = record.record_hash
308
+ return True
309
+
310
+ @property
311
+ def records(self) -> tuple[JournalRecord, ...]:
312
+ with self._lock:
313
+ return tuple(self._records)
@@ -0,0 +1,10 @@
1
+ from dhcp.domain.models import ControlLease
2
+ class LeaseRegistry:
3
+ def __init__(self): self._leases={}
4
+ def grant(self,lease:ControlLease):
5
+ current=self._leases.get(lease.aircraft_id)
6
+ if current and lease.epoch<=current.epoch: raise ValueError("control epoch must increase")
7
+ self._leases[lease.aircraft_id]=lease
8
+ def validate(self,aircraft_id,owner,epoch,now):
9
+ lease=self._leases.get(aircraft_id)
10
+ return bool(lease and lease.owner==owner and lease.epoch==epoch and lease.active(now))
@@ -0,0 +1,9 @@
1
+ from dhcp.domain.enums import OperationalMode
2
+ class SimulationAircraftLink:
3
+ def __init__(self,mode=OperationalMode.SIMULATION):
4
+ if mode is not OperationalMode.SIMULATION: raise RuntimeError("simulation link cannot operate outside SIMULATION mode")
5
+ self.mode=mode; self.connected=False
6
+ async def connect(self): self.connected=True
7
+ async def submit_command(self,command):
8
+ if not self.connected: raise RuntimeError("link not connected")
9
+ return {"command_id":str(command.command_id),"acknowledged":True,"sequence":command.sequence_number}
dhcp/core/clock.py ADDED
@@ -0,0 +1,7 @@
1
+ from time import monotonic
2
+ class MonotonicClock:
3
+ def now(self)->float: return monotonic()
4
+ class ManualClock:
5
+ def __init__(self,start:float=0.0): self.value=start
6
+ def now(self)->float: return self.value
7
+ def advance(self,seconds:float)->None: self.value+=seconds