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 +7 -0
- data/Rakefile +24 -0
- data/microservices/BRIDGE/bridge.py +546 -0
- data/microservices/BRIDGE/constellation_space/__init__.py +0 -0
- data/microservices/BRIDGE/constellation_space/results.py +512 -0
- data/plugin.txt +25 -0
- data/targets/CONSTELLATION/cmd_tlm/tlm.txt +78 -0
- data/targets/CONSTELLATION/target.txt +1 -0
- data/tools/constellationforecast/forecast-view.js +673 -0
- data/tools/constellationforecast/main.js +102 -0
- metadata +57 -0
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()
|
|
File without changes
|