openc3-cosmos-constellation 0.7.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 92ba930c90d600f072a1839dfda26a2229a78221f7a5c54d601dde7d3085f798
4
+ data.tar.gz: 841b21340ba2c55f542c164f8efb377f38c7cd4f6226e904ce0dc4c64da300a9
5
+ SHA512:
6
+ metadata.gz: 30372f02fa3a4da05455a033c72a5fe40b24c9a0897d4dbc4cb1cb8ab773366f844cfc64fc4d787d5f877acb657911cac5e8925358229e56e4c448f19c4e859a
7
+ data.tar.gz: dc2327b8a1aa00fc1f569de11f992d3b0536f98e6f9fc49406c4c6751aadebedeb3b5710e56b9e63fdc71100b16dfbfadfa874cfa5b15f1c24e293ba8276bddf
data/Rakefile ADDED
@@ -0,0 +1,24 @@
1
+ # Builds openc3-cosmos-constellation-<VERSION>.gem: rake build VERSION=0.7.0
2
+ # (or ./openc3.sh cli rake build VERSION=0.7.0 inside a COSMOS checkout).
3
+ PLUGIN_NAME = Dir['*.gemspec'][0].split('.')[0..-2].join('.')
4
+
5
+ # The tool draws the shared Constellation Forecast view, copied in from this repo when it is here.
6
+ VIEW = 'tools/constellationforecast/forecast-view.js'
7
+ SHARED_VIEW = '../shared/forecast-view.js'
8
+ # The bridge imports the Python package's results module (constellation_space.results), copied in next to it
9
+ # the same way as a one-module constellation_space package.
10
+ RESULTS = 'microservices/BRIDGE/constellation_space/results.py'
11
+ SHARED_RESULTS = '../../clients/python/src/constellation_space/results.py'
12
+
13
+ task :build do
14
+ abort('VERSION is required: rake build VERSION=0.7.0') unless ENV['VERSION']
15
+ FileUtils.cp(SHARED_VIEW, VIEW) if File.exist?(SHARED_VIEW)
16
+ abort("#{VIEW} is missing: copy bridges/shared/forecast-view.js there") unless File.exist?(VIEW)
17
+ if File.exist?(SHARED_RESULTS)
18
+ FileUtils.mkdir_p(File.dirname(RESULTS))
19
+ FileUtils.cp(SHARED_RESULTS, RESULTS)
20
+ FileUtils.touch(File.join(File.dirname(RESULTS), '__init__.py'))
21
+ end
22
+ abort("#{RESULTS} is missing: copy clients/python/src/constellation_space/results.py there") unless File.exist?(RESULTS)
23
+ system("gem build #{PLUGIN_NAME}.gemspec") || abort('gem build failed')
24
+ end
@@ -0,0 +1,546 @@
1
+ """Constellation workflow forecasts into OpenC3 COSMOS, run by COSMOS as a plugin microservice.
2
+
3
+ Pulls the Constellation feed outbound (GET /predictions/feed) as its own connection (CONSUMER),
4
+ reads only the part of each run a feed output node addressed to this connection (the
5
+ resourceLogs whose constellation.connection is CONSUMER; one for another system is refused and
6
+ reported, as the Yamcs and Lattice bridges do), and injects the forecast into the CONSTELLATION
7
+ target's packets (targets/CONSTELLATION/cmd_tlm/tlm.txt):
8
+
9
+ NEXT_PASS the next predicted pass and its expected GB (p10/p50/p90)
10
+ NEXT_24H passes and expected GB over the next 24 hours
11
+ SWAP the soonest rain-fade swap to decide, with minutes left (limits go yellow, then red)
12
+ RUN which workflow run the values came from
13
+
14
+ So COSMOS's own Telemetry Viewer, Telemetry Grapher, Limits Monitor and Data Extractor show the
15
+ forecast with no other install. It also writes each run, and an index of workflows, as JSON into
16
+ COSMOS's config bucket (<SCOPE>/constellation/), the documents the Yamcs bridge writes to its
17
+ bucket, which the plugin's Constellation Forecast tool reads through COSMOS's storage API. It checks in and reports what it wrote, like the Yamcs bridge.
18
+ The last cursor applied and the forecast it held are kept in the same bucket
19
+ (<SCOPE>/constellation/bridge-<consumer>.json), so a restart resumes there with the same packets;
20
+ a first start applies only the newest run for this connection.
21
+ The Constellation key comes from COSMOS's secrets (Admin > Secrets), read at each poll so a key
22
+ added or changed there is used without a reinstall, and never leaves this process.
23
+ """
24
+
25
+ import json
26
+ import os
27
+ import sys
28
+ import time
29
+ import urllib.error
30
+ import urllib.request
31
+ from datetime import UTC, datetime, timedelta
32
+ from typing import Any
33
+
34
+ from constellation_space import results
35
+
36
+ VERSION = "0.7.0"
37
+ TARGET = "CONSTELLATION"
38
+ POLL_S = int(os.environ.get("POLL_SECONDS", "15"))
39
+ # COSMOS shows a packet stale 30 s after its last value: re-inject every poll so a quiet feed
40
+ # still reads as current, and the next pass and minutes left keep moving.
41
+ REFRESH_S = 10
42
+ # How often, with no key or a refused one, it looks for a new key in Admin > Secrets.
43
+ KEY_WAIT_S = 60
44
+ SCOPE = os.environ.get("OPENC3_SCOPE", "DEFAULT")
45
+ # Where the Constellation Forecast tool (tools/constellationforecast) reads the documents.
46
+ DOCS = f"{SCOPE}/constellation"
47
+ # COSMOS's script API signs in with OPENC3_API_PASSWORD, which COSMOS gives a plugin microservice
48
+ # only as the service password; without it every call fails with "Token is required". Set here,
49
+ # on start: the first COSMOS import (writing the Forecast tool's documents) fixes the client's
50
+ # credentials for the life of the process.
51
+ if "OPENC3_SERVICE_PASSWORD" in os.environ:
52
+ os.environ.setdefault("OPENC3_API_PASSWORD", os.environ["OPENC3_SERVICE_PASSWORD"])
53
+
54
+
55
+ def inject(packet: str, values: dict[str, Any]) -> None:
56
+ """COSMOS's inject_tlm, from whichever module this COSMOS exposes it in."""
57
+ try:
58
+ from openc3.script import inject_tlm # type: ignore[import-not-found]
59
+ except ImportError: # pragma: no cover - older COSMOS
60
+ from openc3.api import inject_tlm # type: ignore[import-not-found]
61
+ inject_tlm(TARGET, packet, values)
62
+
63
+
64
+ def api_key() -> str:
65
+ """CONSTELLATION_API_KEY as Admin > Secrets holds it now, else as the microservice started
66
+ with it (SECRET ENV in plugin.txt; the only source outside COSMOS). Empty when neither has it."""
67
+ try:
68
+ from openc3.utilities.secrets import Secrets # type: ignore[import-not-found]
69
+
70
+ key = Secrets.get_client().get("CONSTELLATION_API_KEY", scope=SCOPE)
71
+ except Exception:
72
+ key = None
73
+ return (key or os.environ.get("CONSTELLATION_API_KEY") or "").strip()
74
+
75
+
76
+ def call(method: str, path: str, body: Any = None) -> Any:
77
+ api = os.environ["CONSTELLATION_API_URL"].rstrip("/")
78
+ return results.http(method, f"{api}{path}", body, {"X-API-Key": api_key()})
79
+
80
+
81
+ def log(message: str) -> None:
82
+ print(f"constellation-bridge: {message}", flush=True)
83
+
84
+
85
+ def output_of(resource: dict[str, Any]) -> dict[str, Any]:
86
+ """The feed output node's settings as the run published them (constellation.output)."""
87
+ return json.loads(resource.get("constellation.output") or "{}")
88
+
89
+
90
+
91
+ def documents(
92
+ resource: dict[str, Any],
93
+ items: list[dict[str, Any]],
94
+ blocks: list[str],
95
+ index: dict[str, Any],
96
+ now: datetime,
97
+ ) -> dict[str, dict[str, Any]]:
98
+ """The run as the Constellation Forecast view reads it: workflow-<slug>.json, and index.json
99
+ with this workflow's entry replaced. Kept identical to publish_widget() in the Yamcs bridge
100
+ (a test compares them)."""
101
+ slug = resource.get("constellation.workflow") or "workflow"
102
+ entry = {
103
+ "workflow": slug,
104
+ "workflow_version": resource.get("constellation.workflow_version"),
105
+ "kind": resource.get("constellation.template") or results.kind(items),
106
+ "run_id": resource.get("constellation.run_id"),
107
+ "delivered_at": now.isoformat(),
108
+ "blocks": blocks,
109
+ "count": len(items),
110
+ }
111
+ workflows = {w["workflow"]: w for w in index.get("workflows", [])}
112
+ workflows[slug] = entry
113
+ return {
114
+ f"workflow-{slug}.json": {**entry, "items": items},
115
+ "index.json": {"workflows": sorted(workflows.values(), key=lambda w: w["workflow"])},
116
+ }
117
+
118
+
119
+ def bucket() -> tuple[Any, str]:
120
+ """COSMOS's bucket client and its config bucket, which the storage API serves to the tool."""
121
+ from openc3.utilities.bucket import Bucket # type: ignore[import-not-found]
122
+
123
+ return Bucket.get_client(), os.environ["OPENC3_CONFIG_BUCKET"]
124
+
125
+
126
+ def publish_documents(
127
+ resource: dict[str, Any], items: list[dict[str, Any]], blocks: list[str]
128
+ ) -> None:
129
+ client, name = bucket()
130
+ found = client.get_object(bucket=name, key=f"{DOCS}/index.json")
131
+ index = json.loads(found["Body"].read()) if found else {}
132
+ for key, doc in documents(resource, items, blocks, index, datetime.now(UTC)).items():
133
+ body = json.dumps(doc).encode()
134
+ client.put_object(
135
+ bucket=name, key=f"{DOCS}/{key}", body=body, content_type="application/json"
136
+ )
137
+
138
+
139
+ def _minute(iso: str | None) -> str:
140
+ return f"{iso[:16].replace('T', ' ')}Z" if iso else ""
141
+
142
+
143
+
144
+
145
+
146
+ def priced_passes(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
147
+ """The items that are a plain forecast pass with expected GB: no action of any kind (advice,
148
+ approved, held or rejected), no series."""
149
+ return [
150
+ i
151
+ for i in items
152
+ if (i.get("entity") or {}).get("type") == "pass"
153
+ and results.expected_gb(i) is not None
154
+ and results.action_state(i) is None
155
+ and not i.get("series")
156
+ ]
157
+
158
+
159
+ def swap_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
160
+ """The items advising a rain-fade swap, or approved in Constellation, with the deadline to
161
+ decide by: never one held or rejected, even when its action is sent with it. One with no
162
+ expected GB is logged and skipped: it can never be shown, and raising on it would retry the
163
+ run forever."""
164
+ swaps = [
165
+ i
166
+ for i in items
167
+ if results.action_state(i) in ("advice", "approved")
168
+ and (i.get("action") or {}).get("verb") == "swap"
169
+ and i["action"].get("deadline")
170
+ ]
171
+ shown = [i for i in swaps if results.expected_gb(i) is not None]
172
+ if len(shown) < len(swaps):
173
+ log(f"skipped {len(swaps) - len(shown)} swap(s) with no expected GB in the item")
174
+ return shown
175
+
176
+
177
+ # Every SWAP item is written each time, so a cleared swap never leaves the last one's values
178
+ # behind. With nothing to decide, the minutes left sit far above the limits: green, not red.
179
+ NO_SWAP: dict[str, Any] = {
180
+ "OPEN": 0,
181
+ "SATELLITE": "NONE",
182
+ "ANTENNA": "NONE",
183
+ "AOS": "",
184
+ "KEEPS_GB_P10": 0.0,
185
+ "EXPECTED_GB_P50": 0.0,
186
+ "BACKUP_ANTENNA": "NONE",
187
+ "BACKUP_AOS": "",
188
+ "DEADLINE": "",
189
+ "MINUTES_TO_DEADLINE": 99999.0,
190
+ "STATUS": "NONE",
191
+ "APPROVED": 0,
192
+ "PENDING": 0,
193
+ }
194
+
195
+
196
+ def capacity_packet(items: list[dict[str, Any]], now: datetime) -> dict[str, Any] | None:
197
+ """CAPACITY: one day's capacity across the antennas (today's, else the next day forecast),
198
+ and the antenna with the least of it. None without antenna-day items."""
199
+ days = [
200
+ i
201
+ for i in items
202
+ if (i.get("entity") or {}).get("type") == "antenna" and "p50" in (i.get("value") or {})
203
+ ]
204
+ if not days:
205
+ return None
206
+ today = now.date().isoformat()
207
+ dates = sorted({d["target_at"][:10] for d in days})
208
+ day = today if today in dates else next((d for d in dates if d > today), dates[-1])
209
+ rows = [d for d in days if d["target_at"][:10] == day]
210
+ tightest = min(rows, key=lambda d: d["value"]["p50"])
211
+ # A run that forecasts p50 only has no wet tail: 0, as the packet field cannot be empty.
212
+ wet_tail = all(isinstance(r["value"].get("p10"), int | float) for r in rows)
213
+ return {
214
+ "DAY": day,
215
+ "ANTENNAS": len(rows),
216
+ "PASSES": sum(int(r["value"].get("passes") or 0) for r in rows),
217
+ "SELLABLE_MIN": round(sum(float(r["value"].get("sellable_min") or 0) for r in rows), 1),
218
+ "GB_P10": round(sum(r["value"]["p10"] for r in rows), 3) if wet_tail else 0.0,
219
+ "GB_P50": round(sum(r["value"]["p50"] for r in rows), 3),
220
+ "TIGHTEST_ANTENNA": tightest["entity"]["id"],
221
+ "TIGHTEST_GB_P50": round(tightest["value"]["p50"], 3),
222
+ }
223
+
224
+
225
+ def modcod_plans(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
226
+ """The passes with a MODCOD schedule: each change of MODCOD over the pass, and when."""
227
+ return [
228
+ i for i in items if any("modcod" in (s.get("value") or {}) for s in i.get("series") or [])
229
+ ]
230
+
231
+
232
+ def modcod_packet(items: list[dict[str, Any]], now: datetime) -> dict[str, Any] | None:
233
+ """MODCOD: the next pass's MODCOD plan, its changes and its lowest SINR. None without a plan
234
+ still to fly."""
235
+ plans = sorted(
236
+ (p for p in modcod_plans(items) if datetime.fromisoformat(p["until"]) > now),
237
+ key=lambda p: p["target_at"],
238
+ )
239
+ if not plans:
240
+ return None
241
+ nxt = plans[0]
242
+ steps = nxt["series"]
243
+ sinr = [
244
+ s["value"]["sinr_db"] for s in steps if isinstance(s["value"].get("sinr_db"), int | float)
245
+ ]
246
+ return {
247
+ "SATELLITE": nxt["entity"]["satellite_id"],
248
+ "ANTENNA": nxt["entity"]["antenna_id"],
249
+ "AOS": _minute(nxt["target_at"]),
250
+ "CHANGES": len(steps) - 1,
251
+ "FIRST": str(steps[0]["value"]["modcod"]),
252
+ "LAST": str(steps[-1]["value"]["modcod"]),
253
+ "MIN_SINR_DB": round(min(sinr), 2) if sinr else 0.0,
254
+ "PLANNED_PASSES": len(plans),
255
+ }
256
+
257
+
258
+ def packets(
259
+ passes: list[dict[str, Any]], swaps: list[dict[str, Any]], now: datetime, pending: int = 0
260
+ ) -> dict[str, dict[str, Any]]:
261
+ """The CONSTELLATION packets for what is forecast now, from spec items: values only, ready to
262
+ inject. `pending` is how many swaps the newest swap run holds for approval in Constellation."""
263
+ upcoming = sorted(
264
+ (p for p in passes if datetime.fromisoformat(p["until"]) > now),
265
+ key=lambda p: p["target_at"],
266
+ )
267
+ day = [
268
+ p for p in upcoming if datetime.fromisoformat(p["target_at"]) < now + timedelta(hours=24)
269
+ ]
270
+ open_swaps = sorted(
271
+ (s for s in swaps if datetime.fromisoformat(s["action"]["deadline"]) > now),
272
+ key=lambda s: s["action"]["deadline"],
273
+ )
274
+ out: dict[str, dict[str, Any]] = {
275
+ "NEXT_24H": {
276
+ "PASSES": len(day),
277
+ "EXPECTED_GB_P10": round(sum(results.expected_gb(p)["p10"] for p in day), 3),
278
+ "EXPECTED_GB_P50": round(sum(results.expected_gb(p)["p50"] for p in day), 3),
279
+ },
280
+ "SWAP": {**NO_SWAP, "PENDING": pending},
281
+ }
282
+ if upcoming:
283
+ nxt = upcoming[0]
284
+ gb = results.expected_gb(nxt)
285
+ elevation = results.max_elevation(nxt)
286
+ out["NEXT_PASS"] = {
287
+ "SATELLITE": nxt["entity"]["satellite_id"],
288
+ "ANTENNA": nxt["entity"]["antenna_id"],
289
+ "AOS": _minute(nxt["target_at"]),
290
+ "LOS": _minute(nxt["until"]),
291
+ "EXPECTED_GB_P10": gb["p10"],
292
+ "EXPECTED_GB_P50": gb["p50"],
293
+ "EXPECTED_GB_P90": gb["p90"],
294
+ "MAX_ELEVATION": float(elevation or 0.0),
295
+ }
296
+ if open_swaps:
297
+ swap = open_swaps[0]
298
+ action, gb = swap["action"], results.expected_gb(swap)
299
+ deadline = datetime.fromisoformat(action["deadline"])
300
+ out["SWAP"] = {
301
+ "OPEN": len(open_swaps),
302
+ "SATELLITE": swap["entity"]["satellite_id"],
303
+ "ANTENNA": swap["entity"]["antenna_id"],
304
+ "AOS": _minute(swap["target_at"]),
305
+ "KEEPS_GB_P10": gb["p10"],
306
+ "EXPECTED_GB_P50": gb["p50"],
307
+ "BACKUP_ANTENNA": action.get("to_antenna_id") or "NONE",
308
+ "BACKUP_AOS": _minute(action.get("to_at")),
309
+ "DEADLINE": _minute(action["deadline"]),
310
+ "MINUTES_TO_DEADLINE": round((deadline - now).total_seconds() / 60, 1),
311
+ "STATUS": "APPROVED" if results.action_state(swap) == "approved" else "ADVISED",
312
+ "APPROVED": sum(results.action_state(s) == "approved" for s in open_swaps),
313
+ "PENDING": pending,
314
+ }
315
+ return out
316
+
317
+
318
+ class Forecast:
319
+ """The newest answer of each kind of workflow: a run replaces what the last run of its kind
320
+ said, empty or not (a swap run advising nothing takes the swap down), and never another
321
+ kind's. Which kind is the run's own constellation.template, else read off its items."""
322
+
323
+ def __init__(self) -> None:
324
+ self.passes: list[dict[str, Any]] = []
325
+ self.swaps: list[dict[str, Any]] = []
326
+ # Swaps the newest swap run holds for approval in Constellation: counted, not shown.
327
+ self.pending = 0
328
+ self.capacity: list[dict[str, Any]] = []
329
+ self.plans: list[dict[str, Any]] = []
330
+ self.run: dict[str, Any] = {}
331
+ # Approvals already reported carried out, by decision: each run shows them again.
332
+ self.acted: dict[str, str] = {}
333
+
334
+ def take(self, resource: dict[str, Any], items: list[dict[str, Any]]) -> int:
335
+ answers = resource.get("constellation.template") or results.kind(items)
336
+ if answers == "rain-fade-swap":
337
+ self.swaps = swap_items(items)
338
+ self.pending = sum(results.action_state(i) == "pending" for i in items)
339
+ elif answers == "antenna-capacity":
340
+ self.capacity = items
341
+ elif answers == "modcod-plan":
342
+ self.plans = modcod_plans(items)
343
+ elif answers == "pass-yield":
344
+ self.passes = priced_passes(items)
345
+ self.run = {
346
+ "WORKFLOW": resource.get("constellation.workflow") or "",
347
+ "TEMPLATE": answers,
348
+ "WORKFLOW_VERSION": int(resource.get("constellation.workflow_version") or 0),
349
+ "RUN_ID": resource.get("constellation.run_id") or "",
350
+ "RESULTS": len(items),
351
+ }
352
+ return len(items)
353
+
354
+ def saved(self) -> dict[str, Any]:
355
+ return dict(vars(self))
356
+
357
+ def restore(self, saved: dict[str, Any]) -> None:
358
+ vars(self).update(saved)
359
+
360
+ def publish(self, now: datetime) -> int:
361
+ out = packets(self.passes, self.swaps, now, self.pending)
362
+ for packet, values in (
363
+ ("CAPACITY", capacity_packet(self.capacity, now)),
364
+ ("MODCOD", modcod_packet(self.plans, now)),
365
+ ):
366
+ if values is not None:
367
+ out[packet] = values
368
+ if self.run:
369
+ out["RUN"] = self.run
370
+ for packet, values in out.items():
371
+ inject(packet, values)
372
+ return len(out)
373
+
374
+
375
+ def check_in(consumer: str) -> bool:
376
+ """Whether Constellation took the check-in; one that failed is tried again next poll. A
377
+ refused key or plan (401, 403) is raised, as the bridge stops polling on it."""
378
+ try:
379
+ call(
380
+ "POST",
381
+ "/predictions/feed/checkin",
382
+ {
383
+ "consumer": consumer,
384
+ "system": "cosmos",
385
+ "target": f"OpenC3 COSMOS target {TARGET}",
386
+ "bridge_version": VERSION,
387
+ # The feed output it reads: only the widget layout, from mapping version 2.
388
+ "mapping_versions": [2],
389
+ "can": {"events": False, "timeline": False, "parameters": True, "buckets": True},
390
+ # The Constellation Forecast tool ships in this plugin with the bridge.
391
+ "widget": {"installed": True, "version": VERSION},
392
+ },
393
+ )
394
+ except Exception as exc:
395
+ if isinstance(exc, urllib.error.HTTPError) and exc.code in (401, 403):
396
+ raise
397
+ print(f"constellation-bridge: could not check in ({exc})", file=sys.stderr, flush=True)
398
+ return False
399
+ return True
400
+
401
+
402
+ def report(consumer: str, cursor: str, applied: dict[str, int]) -> None:
403
+ try:
404
+ results.report(call, consumer, cursor, applied, f"OpenC3 COSMOS target {TARGET}")
405
+ except Exception as exc:
406
+ print(f"constellation-bridge: could not report ({exc})", file=sys.stderr, flush=True)
407
+
408
+
409
+ def state_key(consumer: str) -> str:
410
+ return f"{DOCS}/bridge-{consumer}.json"
411
+
412
+
413
+ def resume(consumer: str, forecast: Forecast) -> str:
414
+ """The cursor to read on from: the kept one, with the forecast kept beside it, else a first
415
+ start's."""
416
+ client, name = bucket()
417
+ found = client.get_object(bucket=name, key=state_key(consumer))
418
+ if not found:
419
+ cursor = results.start_cursor(call, consumer)
420
+ print(f"constellation-bridge: first start, reading after {cursor!r}", flush=True)
421
+ return cursor
422
+ saved = json.loads(found["Body"].read())
423
+ forecast.restore(saved["forecast"])
424
+ print(f"constellation-bridge: resuming after {saved['cursor']!r}", flush=True)
425
+ return saved["cursor"]
426
+
427
+
428
+ def keep(consumer: str, cursor: str, forecast: Forecast) -> None:
429
+ client, name = bucket()
430
+ body = json.dumps({"cursor": cursor, "forecast": forecast.saved()}).encode()
431
+ client.put_object(
432
+ bucket=name, key=state_key(consumer), body=body, content_type="application/json"
433
+ )
434
+
435
+
436
+ def main() -> None:
437
+ consumer = os.environ.get("CONSUMER", "cosmos-bridge")
438
+ forecast, refreshed, checked_in, refused, held = Forecast(), 0.0, "", "", ""
439
+ checked_at = 0.0
440
+ cursor: str | None = None
441
+ if why := results.key_refusal(os.environ["CONSTELLATION_API_URL"]):
442
+ print(f"constellation-bridge: {why}", file=sys.stderr, flush=True)
443
+ sys.exit(2)
444
+ print(f"constellation-bridge: feed -> COSMOS {TARGET} as {consumer}", flush=True)
445
+ while True:
446
+ key = api_key()
447
+ if not key:
448
+ print(
449
+ "constellation-bridge: No CONSTELLATION_API_KEY in Admin > Secrets yet",
450
+ file=sys.stderr,
451
+ flush=True,
452
+ )
453
+ time.sleep(KEY_WAIT_S)
454
+ continue
455
+ if key == refused:
456
+ time.sleep(KEY_WAIT_S)
457
+ continue
458
+ try:
459
+ stale = time.time() - checked_at > results.CHECKIN_S
460
+ if (key != checked_in or stale) and check_in(consumer):
461
+ checked_in, checked_at = key, time.time()
462
+ if cursor is None:
463
+ cursor = resume(consumer, forecast)
464
+ page = results.results_page(call, consumer, cursor)
465
+ held = ""
466
+ if lost := results.expired(page, cursor):
467
+ print(f"constellation-bridge: {lost}", file=sys.stderr, flush=True)
468
+ for entry in page["entries"]:
469
+ entry = results.whole(entry, lambda c, n: results.rest_of_run(call, consumer, c, n))
470
+ parts = results.unpack(entry["otlp"], consumer, log)
471
+ if not parts:
472
+ cursor = entry["cursor"]
473
+ keep(consumer, cursor, forecast)
474
+ continue
475
+ applied = {"parameters": 0, "forecasts": 0}
476
+ for resource, items in parts:
477
+ system = resource.get("constellation.system")
478
+ if system != "cosmos":
479
+ log(
480
+ f"refused an output of run {resource.get('constellation.run_id')}: it"
481
+ f" is a {system} output, not a COSMOS one"
482
+ )
483
+ applied["refused"] = applied.get("refused", 0) + 1
484
+ continue
485
+ # One malformed item is skipped, logged and counted, never worth the runs
486
+ # after it: raising on it would retry this run forever.
487
+ good = results.well_formed(items, resource.get("constellation.run_id"), log)
488
+ if bad := len(items) - len(good):
489
+ applied["malformed"] = applied.get("malformed", 0) + bad
490
+ items = good
491
+ applied["forecasts"] += forecast.take(resource, items)
492
+ # Approved swaps it shows as instructions: the platform shows them carried out.
493
+ if acted := results.first_acted(swap_items(items), forecast.acted):
494
+ applied["acted"] = applied.get("acted", 0) + acted
495
+ widget = output_of(resource).get("widget") or {}
496
+ if widget.get("on"):
497
+ publish_documents(resource, items, widget["blocks"])
498
+ applied["widget_updates"] = applied.get("widget_updates", 0) + 1
499
+ applied["parameters"] = forecast.publish(datetime.now(UTC))
500
+ refreshed = time.time()
501
+ # Only a run applied moves the cursor: one that failed part-way is retried.
502
+ cursor = entry["cursor"]
503
+ report(consumer, cursor, applied)
504
+ keep(consumer, cursor, forecast)
505
+ print(
506
+ f"constellation-bridge: injected {applied['parameters']} packets, {cursor}",
507
+ flush=True,
508
+ )
509
+ # The next pass and the minutes left to a swap move with the clock, not only with runs.
510
+ if time.time() - refreshed > REFRESH_S and forecast.run:
511
+ forecast.publish(datetime.now(UTC))
512
+ refreshed = time.time()
513
+ if page["entries"]:
514
+ continue # catching up on the runs since the cursor: the next one now
515
+ except urllib.error.HTTPError as exc:
516
+ body = exc.read()
517
+ if why := results.refusal(exc.code, body):
518
+ print(
519
+ f"constellation-bridge: {why}. Stopped polling until the key in Admin >"
520
+ " Secrets changes or the plugin restarts",
521
+ file=sys.stderr,
522
+ flush=True,
523
+ )
524
+ refused = key
525
+ time.sleep(KEY_WAIT_S)
526
+ continue
527
+ # Locked out, rate limited or out of plan: said once and waited out, never hammered.
528
+ if hold := results.held_off(exc.code, body):
529
+ why, wait = hold
530
+ if why != held:
531
+ print(
532
+ f"constellation-bridge: {why}. Asking again in {wait:.0f} s",
533
+ file=sys.stderr,
534
+ flush=True,
535
+ )
536
+ held = why
537
+ time.sleep(wait)
538
+ continue
539
+ print(f"constellation-bridge: {exc}", file=sys.stderr, flush=True)
540
+ except Exception as exc: # a bad poll is logged and retried, never fatal
541
+ print(f"constellation-bridge: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
542
+ time.sleep(POLL_S)
543
+
544
+
545
+ if __name__ == "__main__":
546
+ main()