kessler-toolkit 0.1.0__tar.gz

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,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: kessler-toolkit
3
+ Version: 0.1.0
4
+ Summary: Open conjunction assessment: CCSDS CDM parsing, collision probability (Foster/Chan/Max-Pc), SGP4 screening, and risk triage — pure Python.
5
+ Author-email: Ashutosh <siddhvasudev1402@gmail.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/siddhashutosh/kessler
8
+ Project-URL: Documentation, https://github.com/siddhashutosh/kessler/tree/main/documents
9
+ Project-URL: Issues, https://github.com/siddhashutosh/kessler/issues
10
+ Keywords: astrodynamics,conjunction-assessment,collision-probability,space-debris,CDM,CCSDS,SGP4,space-traffic-management,SSA
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
16
+ Classifier: Topic :: Scientific/Engineering :: Physics
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: numpy>=1.24
20
+ Requires-Dist: sgp4>=2.23
21
+ Requires-Dist: python-dateutil>=2.9
22
+
23
+ # kessler-toolkit
24
+
25
+ Open conjunction assessment for Python: parse CCSDS Conjunction Data Messages,
26
+ compute collision probability three honest ways, screen a satellite against a
27
+ catalogue, and triage the results — no MATLAB license required.
28
+
29
+ Extracted from [KESSLER](https://github.com/siddhashutosh/kessler), where the same
30
+ code powers a live dashboard over the public Space-Track feed.
31
+
32
+ ```bash
33
+ pip install kessler-toolkit
34
+ ```
35
+
36
+ ## What's inside
37
+
38
+ | Module | Purpose |
39
+ |---|---|
40
+ | `cdm_parser` | CCSDS 508.0-B-1 KVN CDMs (incl. RTN covariance) and Space-Track `cdm_public` JSON → typed `Cdm` |
41
+ | `collision_probability` | Foster 2-D numerical Pc, Chan analytic series (cross-check), covariance-free Max-Pc bound |
42
+ | `propagation` | SGP4 wrapper (TEME), close-approach refinement to sub-second TCA, ground tracks |
43
+ | `screening` | Catalogue screening: altitude sieve → coarse scan → refined TCA + RTN miss components |
44
+ | `risk_engine` | Operator-community risk classes (1e-4 / 1e-5 / 1e-7), urgency scoring, recommended actions |
45
+
46
+ ## Quick start
47
+
48
+ ```python
49
+ from kessler_toolkit import parse_cdm_kvn, compute_pc
50
+
51
+ cdm = parse_cdm_kvn(open("conjunction.cdm").read())
52
+
53
+ result = compute_pc(
54
+ miss_m=cdm.miss_distance_m,
55
+ hbr_m=20.0,
56
+ r_rel_m=cdm.rel_position_rtn_m,
57
+ v_rel_ms=cdm.rel_velocity_rtn_ms,
58
+ cov1_m2=cdm.sat1.cov_rtn_m2,
59
+ cov2_m2=cdm.sat2.cov_rtn_m2,
60
+ pc_reported=cdm.pc_reported,
61
+ )
62
+ print(result.value, result.method, result.pc_type)
63
+ # e.g. 2.19e-04 foster-2d computed — with Chan cross-check in result.cross_check
64
+ ```
65
+
66
+ Every result carries `pc_type` — `"computed"` (full covariance), `"reported"`
67
+ (source value), or `"max"` (conservative upper bound) — so downstream code can
68
+ never mistake a triage bound for a measurement.
69
+
70
+ Validated against the closed-form isotropic solution (<0.1% error) and
71
+ Foster/Chan cross-agreement; screening results are triage-grade (SGP4/GP
72
+ accuracy limits apply) — not a substitute for operator ephemerides.
73
+
74
+ Apache-2.0. Orbital data used with KESSLER courtesy of Space-Track.org and CelesTrak.
@@ -0,0 +1,52 @@
1
+ # kessler-toolkit
2
+
3
+ Open conjunction assessment for Python: parse CCSDS Conjunction Data Messages,
4
+ compute collision probability three honest ways, screen a satellite against a
5
+ catalogue, and triage the results — no MATLAB license required.
6
+
7
+ Extracted from [KESSLER](https://github.com/siddhashutosh/kessler), where the same
8
+ code powers a live dashboard over the public Space-Track feed.
9
+
10
+ ```bash
11
+ pip install kessler-toolkit
12
+ ```
13
+
14
+ ## What's inside
15
+
16
+ | Module | Purpose |
17
+ |---|---|
18
+ | `cdm_parser` | CCSDS 508.0-B-1 KVN CDMs (incl. RTN covariance) and Space-Track `cdm_public` JSON → typed `Cdm` |
19
+ | `collision_probability` | Foster 2-D numerical Pc, Chan analytic series (cross-check), covariance-free Max-Pc bound |
20
+ | `propagation` | SGP4 wrapper (TEME), close-approach refinement to sub-second TCA, ground tracks |
21
+ | `screening` | Catalogue screening: altitude sieve → coarse scan → refined TCA + RTN miss components |
22
+ | `risk_engine` | Operator-community risk classes (1e-4 / 1e-5 / 1e-7), urgency scoring, recommended actions |
23
+
24
+ ## Quick start
25
+
26
+ ```python
27
+ from kessler_toolkit import parse_cdm_kvn, compute_pc
28
+
29
+ cdm = parse_cdm_kvn(open("conjunction.cdm").read())
30
+
31
+ result = compute_pc(
32
+ miss_m=cdm.miss_distance_m,
33
+ hbr_m=20.0,
34
+ r_rel_m=cdm.rel_position_rtn_m,
35
+ v_rel_ms=cdm.rel_velocity_rtn_ms,
36
+ cov1_m2=cdm.sat1.cov_rtn_m2,
37
+ cov2_m2=cdm.sat2.cov_rtn_m2,
38
+ pc_reported=cdm.pc_reported,
39
+ )
40
+ print(result.value, result.method, result.pc_type)
41
+ # e.g. 2.19e-04 foster-2d computed — with Chan cross-check in result.cross_check
42
+ ```
43
+
44
+ Every result carries `pc_type` — `"computed"` (full covariance), `"reported"`
45
+ (source value), or `"max"` (conservative upper bound) — so downstream code can
46
+ never mistake a triage bound for a measurement.
47
+
48
+ Validated against the closed-form isotropic solution (<0.1% error) and
49
+ Foster/Chan cross-agreement; screening results are triage-grade (SGP4/GP
50
+ accuracy limits apply) — not a substitute for operator ephemerides.
51
+
52
+ Apache-2.0. Orbital data used with KESSLER courtesy of Space-Track.org and CelesTrak.
@@ -0,0 +1,41 @@
1
+ """kessler-toolkit — open conjunction assessment primitives.
2
+
3
+ Generated modules are synced from the KESSLER backend logic layer
4
+ (backend/app/logic), which is the source of truth.
5
+ """
6
+ from kessler_toolkit.cdm_parser import Cdm, CdmObject, parse_cdm_kvn, parse_cdm_public_row
7
+ from kessler_toolkit.collision_probability import (
8
+ PcResult,
9
+ compute_pc,
10
+ encounter_plane_projection,
11
+ pc_chan,
12
+ pc_foster,
13
+ pc_max,
14
+ )
15
+ from kessler_toolkit.exceptions import (
16
+ CdmParseError,
17
+ KesslerError,
18
+ PropagationError,
19
+ )
20
+ from kessler_toolkit.propagation import (
21
+ ground_track,
22
+ load_satrec,
23
+ refine_tca,
24
+ rtn_components,
25
+ state_teme,
26
+ )
27
+ from kessler_toolkit.risk_engine import classify, recommend, urgency
28
+ from kessler_toolkit.screening import CloseApproachHit, altitude_band, screen
29
+
30
+ __version__ = "0.1.0"
31
+
32
+ __all__ = [
33
+ "Cdm", "CdmObject", "parse_cdm_kvn", "parse_cdm_public_row",
34
+ "PcResult", "compute_pc", "encounter_plane_projection",
35
+ "pc_chan", "pc_foster", "pc_max",
36
+ "KesslerError", "CdmParseError", "PropagationError",
37
+ "load_satrec", "state_teme", "refine_tca", "rtn_components", "ground_track",
38
+ "classify", "urgency", "recommend",
39
+ "screen", "altitude_band", "CloseApproachHit",
40
+ "__version__",
41
+ ]
@@ -0,0 +1,234 @@
1
+ # GENERATED from backend/app/logic — edit there, then run packages/sync.py
2
+ """CCSDS Conjunction Data Message parsing (KVN + Space-Track cdm_public JSON).
3
+
4
+ Pure logic: no I/O, no framework imports. Raises CdmParseError with the full
5
+ list of violations (FR-CDM-3).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import math
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timezone
13
+
14
+ import numpy as np
15
+ from dateutil import parser as dtparser
16
+
17
+ from kessler_toolkit.exceptions import CdmParseError
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # CCSDS 508.0-B-1 RTN covariance keys (position block, m**2)
22
+ _COV_KEYS = ["CR_R", "CT_R", "CT_T", "CN_R", "CN_T", "CN_N"]
23
+
24
+
25
+ @dataclass
26
+ class CdmObject:
27
+ designator: str
28
+ name: str = "UNKNOWN"
29
+ object_type: str = "UNKNOWN"
30
+ r_rtn_m: np.ndarray | None = None # position in RTN relative frame [m]
31
+ v_rtn_ms: np.ndarray | None = None
32
+ cov_rtn_m2: np.ndarray | None = None # 3x3 position covariance [m^2]
33
+
34
+
35
+ @dataclass
36
+ class Cdm:
37
+ cdm_id: str
38
+ tca: datetime
39
+ miss_distance_m: float
40
+ sat1: CdmObject
41
+ sat2: CdmObject
42
+ created: datetime | None = None
43
+ relative_speed_ms: float | None = None
44
+ rel_position_rtn_m: np.ndarray | None = None
45
+ rel_velocity_rtn_ms: np.ndarray | None = None
46
+ pc_reported: float | None = None
47
+ emergency_reportable: bool = False
48
+ source: str = "unknown"
49
+ raw: dict = field(default_factory=dict)
50
+
51
+ @property
52
+ def covariance_available(self) -> bool:
53
+ return self.sat1.cov_rtn_m2 is not None and self.sat2.cov_rtn_m2 is not None
54
+
55
+
56
+ def _parse_dt(value: str, errors: list[str], label: str) -> datetime | None:
57
+ try:
58
+ dt = dtparser.parse(value)
59
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
60
+ except (ValueError, TypeError, OverflowError):
61
+ errors.append(f"{label}: unparseable datetime {value!r}")
62
+ return None
63
+
64
+
65
+ def _finite(value, errors: list[str], label: str) -> float | None:
66
+ if value is None or value == "":
67
+ return None
68
+ try:
69
+ f = float(value)
70
+ except (TypeError, ValueError):
71
+ errors.append(f"{label}: not numeric ({value!r})")
72
+ return None
73
+ if not math.isfinite(f):
74
+ errors.append(f"{label}: non-finite value")
75
+ return None
76
+ return f
77
+
78
+
79
+ def _validate_covariance(cov: np.ndarray, label: str) -> np.ndarray | None:
80
+ """Symmetrise and check positive semi-definiteness; None if unusable."""
81
+ cov = 0.5 * (cov + cov.T)
82
+ try:
83
+ eigvals = np.linalg.eigvalsh(cov)
84
+ except np.linalg.LinAlgError:
85
+ logger.warning("%s: covariance eigendecomposition failed; dropping", label)
86
+ return None
87
+ if np.any(eigvals < -1e-6 * max(1.0, float(np.max(np.abs(eigvals))))):
88
+ logger.warning("%s: covariance not PSD (eig min %.3e); dropping", label, eigvals.min())
89
+ return None
90
+ return cov
91
+
92
+
93
+ # ---------------------------------------------------------------- KVN format
94
+ def parse_cdm_kvn(text: str) -> Cdm:
95
+ """Parse a CCSDS 508.0-B-1 KVN Conjunction Data Message."""
96
+ errors: list[str] = []
97
+ header: dict[str, str] = {}
98
+ objects: dict[str, dict[str, str]] = {"OBJECT1": {}, "OBJECT2": {}}
99
+ current = None
100
+
101
+ for lineno, raw_line in enumerate(text.splitlines(), 1):
102
+ line = raw_line.split("COMMENT")[0].strip() if raw_line.strip().startswith("COMMENT") else raw_line.strip()
103
+ if not line or "=" not in line:
104
+ continue
105
+ key, _, value = line.partition("=")
106
+ key, value = key.strip().upper(), value.strip()
107
+ # strip trailing units like "[km]"
108
+ if value.endswith("]") and "[" in value:
109
+ value = value[: value.rindex("[")].strip()
110
+ if key == "OBJECT":
111
+ current = value.upper()
112
+ if current not in objects:
113
+ errors.append(f"line {lineno}: unknown OBJECT block {value!r}")
114
+ current = None
115
+ continue
116
+ if current:
117
+ objects[current][key] = value
118
+ else:
119
+ header[key] = value
120
+
121
+ tca = _parse_dt(header.get("TCA", ""), errors, "TCA")
122
+ miss = _finite(header.get("MISS_DISTANCE"), errors, "MISS_DISTANCE")
123
+ if miss is None:
124
+ errors.append("MISS_DISTANCE: missing")
125
+
126
+ def build_object(block_key: str) -> CdmObject:
127
+ block = objects[block_key]
128
+ designator = block.get("OBJECT_DESIGNATOR", "")
129
+ if not designator:
130
+ errors.append(f"{block_key}: OBJECT_DESIGNATOR missing")
131
+ obj = CdmObject(
132
+ designator=designator or "?",
133
+ name=block.get("OBJECT_NAME", "UNKNOWN"),
134
+ object_type=block.get("OBJECT_TYPE", "UNKNOWN"),
135
+ )
136
+ if all(k in block for k in _COV_KEYS):
137
+ vals = [_finite(block[k], errors, f"{block_key}.{k}") for k in _COV_KEYS]
138
+ if all(v is not None for v in vals):
139
+ cr_r, ct_r, ct_t, cn_r, cn_t, cn_n = vals # type: ignore[misc]
140
+ cov = np.array(
141
+ [[cr_r, ct_r, cn_r], [ct_r, ct_t, cn_t], [cn_r, cn_t, cn_n]],
142
+ dtype=float,
143
+ )
144
+ obj.cov_rtn_m2 = _validate_covariance(cov, block_key)
145
+ return obj
146
+
147
+ sat1, sat2 = build_object("OBJECT1"), build_object("OBJECT2")
148
+
149
+ rel_r = rel_v = None
150
+ rel_keys_r = ["RELATIVE_POSITION_R", "RELATIVE_POSITION_T", "RELATIVE_POSITION_N"]
151
+ rel_keys_v = ["RELATIVE_VELOCITY_R", "RELATIVE_VELOCITY_T", "RELATIVE_VELOCITY_N"]
152
+ if all(k in header for k in rel_keys_r):
153
+ comps = [_finite(header[k], errors, k) for k in rel_keys_r]
154
+ if all(c is not None for c in comps):
155
+ rel_r = np.array(comps, dtype=float)
156
+ if all(k in header for k in rel_keys_v):
157
+ comps = [_finite(header[k], errors, k) for k in rel_keys_v]
158
+ if all(c is not None for c in comps):
159
+ rel_v = np.array(comps, dtype=float)
160
+
161
+ if errors:
162
+ raise CdmParseError("CDM KVN validation failed", detail=errors)
163
+ assert tca is not None and miss is not None # guaranteed by errors check
164
+
165
+ rel_speed = _finite(header.get("RELATIVE_SPEED"), [], "RELATIVE_SPEED")
166
+ if rel_speed is None and rel_v is not None:
167
+ rel_speed = float(np.linalg.norm(rel_v))
168
+
169
+ return Cdm(
170
+ cdm_id=header.get("MESSAGE_ID", f"KVN-{tca.isoformat()}"),
171
+ tca=tca,
172
+ miss_distance_m=miss,
173
+ sat1=sat1,
174
+ sat2=sat2,
175
+ created=_parse_dt(header.get("CREATION_DATE", ""), [], "CREATION_DATE"),
176
+ relative_speed_ms=rel_speed,
177
+ rel_position_rtn_m=rel_r,
178
+ rel_velocity_rtn_ms=rel_v,
179
+ source="kvn",
180
+ raw={"header": header},
181
+ )
182
+
183
+
184
+ # ------------------------------------------------- Space-Track cdm_public row
185
+ def parse_cdm_public_row(row: dict) -> Cdm:
186
+ """Parse one Space-Track `cdm_public` JSON row.
187
+
188
+ Public rows carry no covariance/state vectors — only summary fields
189
+ (CDM_ID, CREATED, TCA, MIN_RNG [km], PC, SAT_1_ID, SAT_1_NAME,
190
+ SAT1_OBJECT_TYPE, SAT_2_*, EMERGENCY_REPORTABLE). Pc falls back to the
191
+ max-Pc path downstream (FR-PC-3).
192
+ """
193
+ errors: list[str] = []
194
+ tca = _parse_dt(str(row.get("TCA", "")), errors, "TCA")
195
+
196
+ min_rng_km = _finite(row.get("MIN_RNG"), errors, "MIN_RNG")
197
+ if min_rng_km is None:
198
+ errors.append("MIN_RNG: missing")
199
+
200
+ sat1_id = str(row.get("SAT_1_ID") or "").strip()
201
+ sat2_id = str(row.get("SAT_2_ID") or "").strip()
202
+ if not sat1_id:
203
+ errors.append("SAT_1_ID: missing")
204
+ if not sat2_id:
205
+ errors.append("SAT_2_ID: missing")
206
+
207
+ if errors:
208
+ raise CdmParseError("cdm_public row validation failed", detail=errors)
209
+ assert tca is not None and min_rng_km is not None
210
+
211
+ pc = _finite(row.get("PC"), [], "PC")
212
+ rel_speed_kms = _finite(row.get("REL_SPEED") or row.get("RELATIVE_SPEED"), [], "REL_SPEED")
213
+
214
+ return Cdm(
215
+ cdm_id=str(row.get("CDM_ID") or f"ST-{sat1_id}-{sat2_id}-{tca.isoformat()}"),
216
+ tca=tca,
217
+ miss_distance_m=min_rng_km * 1000.0,
218
+ sat1=CdmObject(
219
+ designator=sat1_id,
220
+ name=str(row.get("SAT_1_NAME") or "UNKNOWN"),
221
+ object_type=str(row.get("SAT1_OBJECT_TYPE") or "UNKNOWN"),
222
+ ),
223
+ sat2=CdmObject(
224
+ designator=sat2_id,
225
+ name=str(row.get("SAT_2_NAME") or "UNKNOWN"),
226
+ object_type=str(row.get("SAT2_OBJECT_TYPE") or "UNKNOWN"),
227
+ ),
228
+ created=_parse_dt(str(row.get("CREATED", "")), [], "CREATED"),
229
+ relative_speed_ms=rel_speed_kms * 1000.0 if rel_speed_kms is not None else None,
230
+ pc_reported=pc,
231
+ emergency_reportable=str(row.get("EMERGENCY_REPORTABLE", "N")).upper() in ("Y", "YES", "TRUE", "1"),
232
+ source="spacetrack:cdm_public",
233
+ raw=dict(row),
234
+ )
@@ -0,0 +1,201 @@
1
+ # GENERATED from backend/app/logic — edit there, then run packages/sync.py
2
+ """Collision probability (Pc) computation — the mathematical core (FR-PC).
3
+
4
+ Methods:
5
+ * pc_foster : 2D numerical integration of the bivariate Gaussian over the
6
+ hard-body disc in the encounter plane (Foster & Estes 1992).
7
+ * pc_chan : Chan (2008) equivalent-area analytic series — independent
8
+ cross-check for pc_foster.
9
+ * pc_max : covariance-free conservative upper bound (triage grade).
10
+
11
+ All functions are pure (no I/O) and unit-tested against closed-form cases.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import math
17
+ from dataclasses import dataclass
18
+
19
+ import numpy as np
20
+
21
+ from kessler_toolkit.exceptions import PropagationError
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ _DIVERGENCE_TOLERANCE = 0.05 # FR-PC-2: methods must agree within 5%
26
+
27
+
28
+ @dataclass
29
+ class PcResult:
30
+ value: float
31
+ method: str
32
+ pc_type: str # "computed" | "max" | "reported"
33
+ cross_check: float | None = None
34
+ divergence_flag: bool = False
35
+
36
+
37
+ def encounter_plane_projection(
38
+ r_rel_m: np.ndarray,
39
+ v_rel_ms: np.ndarray,
40
+ cov1_m2: np.ndarray,
41
+ cov2_m2: np.ndarray,
42
+ ) -> tuple[np.ndarray, np.ndarray]:
43
+ """Project the miss vector and combined covariance onto the encounter plane.
44
+
45
+ The encounter plane is perpendicular to the relative velocity at TCA;
46
+ for hypervelocity conjunctions the along-track dimension integrates out,
47
+ reducing Pc to a 2D problem (standard short-encounter assumption).
48
+
49
+ Returns (mu_2d [m], C_2d [m^2]).
50
+ """
51
+ v_norm = float(np.linalg.norm(v_rel_ms))
52
+ if v_norm < 1e-9:
53
+ raise PropagationError(
54
+ "Relative velocity ~0; short-encounter assumption invalid",
55
+ detail={"v_rel_ms": v_rel_ms.tolist()},
56
+ )
57
+
58
+ ez = v_rel_ms / v_norm
59
+ # ex: component of miss vector orthogonal to ez
60
+ r_orth = r_rel_m - np.dot(r_rel_m, ez) * ez
61
+ r_orth_norm = float(np.linalg.norm(r_orth))
62
+ if r_orth_norm < 1e-12:
63
+ # miss vector parallel to velocity — any orthogonal basis works
64
+ candidate = np.array([1.0, 0.0, 0.0])
65
+ if abs(np.dot(candidate, ez)) > 0.9:
66
+ candidate = np.array([0.0, 1.0, 0.0])
67
+ ex = candidate - np.dot(candidate, ez) * ez
68
+ ex /= np.linalg.norm(ex)
69
+ else:
70
+ ex = r_orth / r_orth_norm
71
+ ey = np.cross(ez, ex)
72
+
73
+ basis = np.vstack([ex, ey]) # 2x3
74
+ combined = cov1_m2 + cov2_m2 # independent OD errors
75
+ mu = basis @ r_rel_m
76
+ c2d = basis @ combined @ basis.T
77
+ # numerical symmetrisation
78
+ c2d = 0.5 * (c2d + c2d.T)
79
+ return mu, c2d
80
+
81
+
82
+ def pc_foster(
83
+ mu_m: np.ndarray,
84
+ cov_m2: np.ndarray,
85
+ hbr_m: float,
86
+ n_r: int = 60,
87
+ n_theta: int = 90,
88
+ ) -> float:
89
+ """Foster 2D Pc: midpoint-rule polar integration of N(x; mu, C) over the HBR disc."""
90
+ det = float(np.linalg.det(cov_m2))
91
+ if det <= 0 or hbr_m <= 0:
92
+ raise PropagationError(
93
+ "Degenerate 2D covariance or non-positive HBR in Foster integration",
94
+ detail={"det": det, "hbr_m": hbr_m},
95
+ )
96
+ inv = np.linalg.inv(cov_m2)
97
+ norm_const = 1.0 / (2.0 * math.pi * math.sqrt(det))
98
+
99
+ radii = (np.arange(n_r) + 0.5) * (hbr_m / n_r)
100
+ thetas = (np.arange(n_theta) + 0.5) * (2.0 * math.pi / n_theta)
101
+ r_grid, t_grid = np.meshgrid(radii, thetas, indexing="ij")
102
+ x = r_grid * np.cos(t_grid) - mu_m[0]
103
+ y = r_grid * np.sin(t_grid) - mu_m[1]
104
+ quad = inv[0, 0] * x * x + 2.0 * inv[0, 1] * x * y + inv[1, 1] * y * y
105
+ density = norm_const * np.exp(-0.5 * quad)
106
+ d_area = (hbr_m / n_r) * (2.0 * math.pi / n_theta) * r_grid
107
+ return float(np.clip(np.sum(density * d_area), 0.0, 1.0))
108
+
109
+
110
+ def pc_chan(mu_m: np.ndarray, cov_m2: np.ndarray, hbr_m: float, terms: int = 24) -> float:
111
+ """Chan (2008) analytic series via equivalent-area isotropic transformation.
112
+
113
+ Diagonalise C -> (sx^2, sy^2); u = hbr^2 / (sx*sy); v = scaled miss distance.
114
+ Pc = e^(-v/2) * sum_{m=0}^{M} [ (v/2)^m / m! * (1 - e^(-u/2) sum_{k=0}^{m} (u/2)^k / k!) ]
115
+ """
116
+ eigvals, eigvecs = np.linalg.eigh(cov_m2)
117
+ if np.any(eigvals <= 0) or hbr_m <= 0:
118
+ raise PropagationError(
119
+ "Degenerate 2D covariance or non-positive HBR in Chan series",
120
+ detail={"eigvals": eigvals.tolist(), "hbr_m": hbr_m},
121
+ )
122
+ sx2, sy2 = float(eigvals[0]), float(eigvals[1])
123
+ mu_rot = eigvecs.T @ mu_m
124
+ u = hbr_m * hbr_m / math.sqrt(sx2 * sy2)
125
+ v = mu_rot[0] ** 2 / sx2 + mu_rot[1] ** 2 / sy2
126
+
127
+ # series with running factorial terms (numerically safe for small u, v)
128
+ inner_sum = 0.0
129
+ inner_term = 1.0
130
+ exp_u = math.exp(-0.5 * u)
131
+ outer_total = 0.0
132
+ outer_term = 1.0 # (v/2)^m / m!
133
+ for m in range(terms):
134
+ if m > 0:
135
+ outer_term *= (0.5 * v) / m
136
+ inner_term *= (0.5 * u) / m
137
+ inner_sum += inner_term # sum_{k<=m} (u/2)^k/k!
138
+ outer_total += outer_term * (1.0 - exp_u * inner_sum)
139
+ return float(np.clip(math.exp(-0.5 * v) * outer_total, 0.0, 1.0))
140
+
141
+
142
+ def pc_max(miss_m: float, hbr_m: float) -> float:
143
+ """Conservative covariance-free upper bound.
144
+
145
+ For an isotropic 2D Gaussian, Pc(sigma) is maximised at sigma* = miss/sqrt(2),
146
+ giving Pc_max = (hbr^2 / miss^2) * e^-1 (small-HBR limit), clipped to 1.
147
+ Labelled triage-grade; documented in LLD §3.3.
148
+ """
149
+ if hbr_m <= 0:
150
+ return 0.0
151
+ if miss_m <= hbr_m:
152
+ return 1.0
153
+ return float(min(1.0, (hbr_m * hbr_m) / (miss_m * miss_m) * math.exp(-1.0)))
154
+
155
+
156
+ def compute_pc(
157
+ *,
158
+ miss_m: float,
159
+ hbr_m: float,
160
+ r_rel_m: np.ndarray | None = None,
161
+ v_rel_ms: np.ndarray | None = None,
162
+ cov1_m2: np.ndarray | None = None,
163
+ cov2_m2: np.ndarray | None = None,
164
+ pc_reported: float | None = None,
165
+ ) -> PcResult:
166
+ """Dispatch Pc computation per FR-PC.
167
+
168
+ Full state + covariance -> Foster with Chan cross-check.
169
+ Otherwise -> reported Pc if the source supplied one, else max-Pc bound.
170
+ """
171
+ has_full_state = (
172
+ r_rel_m is not None
173
+ and v_rel_ms is not None
174
+ and cov1_m2 is not None
175
+ and cov2_m2 is not None
176
+ )
177
+ if has_full_state:
178
+ try:
179
+ mu, c2d = encounter_plane_projection(r_rel_m, v_rel_ms, cov1_m2, cov2_m2)
180
+ foster = pc_foster(mu, c2d, hbr_m)
181
+ chan = pc_chan(mu, c2d, hbr_m)
182
+ reference = max(foster, chan, 1e-300)
183
+ divergence = abs(foster - chan) / reference > _DIVERGENCE_TOLERANCE
184
+ if divergence:
185
+ logger.warning(
186
+ "Pc method divergence: foster=%.3e chan=%.3e", foster, chan
187
+ )
188
+ return PcResult(
189
+ value=foster,
190
+ method="foster-2d",
191
+ pc_type="computed",
192
+ cross_check=chan,
193
+ divergence_flag=divergence,
194
+ )
195
+ except PropagationError as exc:
196
+ logger.warning("Full-state Pc failed (%s); falling back to max-Pc", exc.message)
197
+
198
+ if pc_reported is not None and math.isfinite(pc_reported):
199
+ return PcResult(value=float(pc_reported), method="source-reported", pc_type="reported")
200
+
201
+ return PcResult(value=pc_max(miss_m, hbr_m), method="alfano-max", pc_type="max")
@@ -0,0 +1,64 @@
1
+ # GENERATED from backend/app/logic — edit there, then run packages/sync.py
2
+ """Typed exception hierarchy — the only errors allowed to cross layer boundaries.
3
+
4
+ Boundary rule (HLD §4): logic raises, service contextualises/falls back,
5
+ API converts to the JSON error envelope. Nothing else escapes.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+
12
+ class KesslerError(Exception):
13
+ code = "KESSLER_ERROR"
14
+ http_status = 500
15
+
16
+ def __init__(self, message: str, detail: Any = None):
17
+ super().__init__(message)
18
+ self.message = message
19
+ self.detail = detail
20
+
21
+ def envelope(self, request_id: str = "-") -> dict:
22
+ return {
23
+ "error": {
24
+ "code": self.code,
25
+ "message": self.message,
26
+ "detail": self.detail,
27
+ "request_id": request_id,
28
+ }
29
+ }
30
+
31
+
32
+ class ConfigError(KesslerError):
33
+ code = "CONFIG_ERROR"
34
+ http_status = 500
35
+
36
+
37
+ class DataSourceError(KesslerError):
38
+ code = "DATA_SOURCE_ERROR"
39
+ http_status = 502
40
+
41
+
42
+ class RateLimitError(KesslerError):
43
+ code = "RATE_LIMITED"
44
+ http_status = 503
45
+
46
+
47
+ class CdmParseError(KesslerError):
48
+ code = "CDM_PARSE_ERROR"
49
+ http_status = 422
50
+
51
+
52
+ class PropagationError(KesslerError):
53
+ code = "PROPAGATION_ERROR"
54
+ http_status = 500
55
+
56
+
57
+ class NotFoundError(KesslerError):
58
+ code = "NOT_FOUND"
59
+ http_status = 404
60
+
61
+
62
+ class ValidationFailure(KesslerError):
63
+ code = "VALIDATION_ERROR"
64
+ http_status = 422
@@ -0,0 +1,189 @@
1
+ # GENERATED from backend/app/logic — edit there, then run packages/sync.py
2
+ """SGP4 propagation utilities (TEME frame) + TCA refinement (FR-SCR-3).
3
+
4
+ Pure logic: numpy + sgp4 only.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import math
10
+ from datetime import datetime, timedelta, timezone
11
+
12
+ import numpy as np
13
+ from sgp4.api import WGS72, Satrec, jday
14
+
15
+ from kessler_toolkit.exceptions import PropagationError
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _EARTH_RADIUS_KM = 6378.137
20
+ _DEG = math.pi / 180.0
21
+ # SGP4 mean-motion conversion: rev/day -> rad/min
22
+ _XPDOTP = 1440.0 / (2.0 * math.pi)
23
+
24
+
25
+ def load_satrec(omm: dict) -> Satrec:
26
+ """Build an sgp4 Satrec from an OMM dict (CelesTrak gp.php JSON fields)."""
27
+ try:
28
+ epoch = datetime.fromisoformat(str(omm["EPOCH"]).replace("Z", "+00:00"))
29
+ if epoch.tzinfo is None:
30
+ epoch = epoch.replace(tzinfo=timezone.utc)
31
+ # sgp4 epoch: days since 1949 December 31 00:00 UT
32
+ base = datetime(1949, 12, 31, tzinfo=timezone.utc)
33
+ epoch_days = (epoch - base).total_seconds() / 86400.0
34
+
35
+ sat = Satrec()
36
+ sat.sgp4init(
37
+ WGS72,
38
+ "i",
39
+ int(omm["NORAD_CAT_ID"]),
40
+ epoch_days,
41
+ float(omm.get("BSTAR", 0.0)),
42
+ float(omm.get("MEAN_MOTION_DOT", 0.0)) * 2.0 * math.pi / (1440.0**2),
43
+ float(omm.get("MEAN_MOTION_DDOT", 0.0)) * 2.0 * math.pi / (1440.0**3),
44
+ float(omm["ECCENTRICITY"]),
45
+ float(omm["ARG_OF_PERICENTER"]) * _DEG,
46
+ float(omm["INCLINATION"]) * _DEG,
47
+ float(omm["MEAN_ANOMALY"]) * _DEG,
48
+ float(omm["MEAN_MOTION"]) / _XPDOTP, # rad/min
49
+ float(omm["RA_OF_ASC_NODE"]) * _DEG,
50
+ )
51
+ return sat
52
+ except (KeyError, ValueError, TypeError) as exc:
53
+ raise PropagationError(
54
+ f"Invalid OMM element set: {exc}", detail={"norad": omm.get("NORAD_CAT_ID")}
55
+ ) from exc
56
+
57
+
58
+ def state_teme(sat: Satrec, t: datetime) -> tuple[np.ndarray, np.ndarray]:
59
+ """Position [km] and velocity [km/s] in TEME at time t (UTC)."""
60
+ if t.tzinfo is None:
61
+ t = t.replace(tzinfo=timezone.utc)
62
+ jd, fr = jday(t.year, t.month, t.day, t.hour, t.minute, t.second + t.microsecond / 1e6)
63
+ code, r, v = sat.sgp4(jd, fr)
64
+ if code != 0:
65
+ raise PropagationError(
66
+ f"SGP4 error code {code} at {t.isoformat()}",
67
+ detail={"satnum": sat.satnum, "code": code},
68
+ )
69
+ return np.array(r), np.array(v)
70
+
71
+
72
+ def sample_states(
73
+ sat: Satrec, t0: datetime, duration_s: float, step_s: float
74
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
75
+ """Vectorised state sampling. Returns (offsets_s, r[N,3] km, v[N,3] km/s)."""
76
+ if t0.tzinfo is None:
77
+ t0 = t0.replace(tzinfo=timezone.utc)
78
+ n = max(2, int(duration_s / step_s) + 1)
79
+ offsets = np.arange(n) * step_s
80
+ jd0, fr0 = jday(t0.year, t0.month, t0.day, t0.hour, t0.minute,
81
+ t0.second + t0.microsecond / 1e6)
82
+ jd = np.full(n, jd0)
83
+ fr = fr0 + offsets / 86400.0
84
+ codes, r, v = sat.sgp4_array(jd, fr)
85
+ if np.all(codes != 0):
86
+ raise PropagationError(
87
+ f"SGP4 failed across entire window for sat {sat.satnum}",
88
+ detail={"first_code": int(codes[0])},
89
+ )
90
+ bad = codes != 0
91
+ if np.any(bad):
92
+ # mask decayed/err samples with NaN so range math ignores them
93
+ r = r.copy()
94
+ r[bad] = np.nan
95
+ return offsets, r, v
96
+
97
+
98
+ def _range_at(sat_a: Satrec, sat_b: Satrec, t: datetime) -> float:
99
+ ra, _ = state_teme(sat_a, t)
100
+ rb, _ = state_teme(sat_b, t)
101
+ return float(np.linalg.norm(ra - rb))
102
+
103
+
104
+ def refine_tca(
105
+ sat_a: Satrec,
106
+ sat_b: Satrec,
107
+ t_guess: datetime,
108
+ half_window_s: float = 90.0,
109
+ tol_s: float = 0.25,
110
+ ) -> tuple[datetime, float, float]:
111
+ """Golden-section minimisation of inter-satellite range around t_guess.
112
+
113
+ Returns (tca, miss_distance_m, relative_speed_ms); TCA accuracy <= tol_s.
114
+ """
115
+ phi = (math.sqrt(5.0) - 1.0) / 2.0
116
+ a, b = -half_window_s, half_window_s
117
+
118
+ def f(offset: float) -> float:
119
+ return _range_at(sat_a, sat_b, t_guess + timedelta(seconds=offset))
120
+
121
+ c = b - phi * (b - a)
122
+ d = a + phi * (b - a)
123
+ fc, fd = f(c), f(d)
124
+ while (b - a) > tol_s:
125
+ if fc < fd:
126
+ b, d, fd = d, c, fc
127
+ c = b - phi * (b - a)
128
+ fc = f(c)
129
+ else:
130
+ a, c, fc = c, d, fd
131
+ d = a + phi * (b - a)
132
+ fd = f(d)
133
+ t_offset = 0.5 * (a + b)
134
+ tca = t_guess + timedelta(seconds=t_offset)
135
+
136
+ ra, va = state_teme(sat_a, tca)
137
+ rb, vb = state_teme(sat_b, tca)
138
+ miss_m = float(np.linalg.norm(ra - rb)) * 1000.0
139
+ rel_speed_ms = float(np.linalg.norm(va - vb)) * 1000.0
140
+ return tca, miss_m, rel_speed_ms
141
+
142
+
143
+ def rtn_components(r_primary_km: np.ndarray, v_primary_kms: np.ndarray,
144
+ rel_vector_km: np.ndarray) -> np.ndarray:
145
+ """Express a relative vector in the primary's RTN frame [same units as input]."""
146
+ r_hat = r_primary_km / np.linalg.norm(r_primary_km)
147
+ n_vec = np.cross(r_primary_km, v_primary_kms)
148
+ n_hat = n_vec / np.linalg.norm(n_vec)
149
+ t_hat = np.cross(n_hat, r_hat)
150
+ return np.array([
151
+ float(np.dot(rel_vector_km, r_hat)),
152
+ float(np.dot(rel_vector_km, t_hat)),
153
+ float(np.dot(rel_vector_km, n_hat)),
154
+ ])
155
+
156
+
157
+ def ground_track(sat: Satrec, t0: datetime, minutes: int, step_s: int) -> list[dict]:
158
+ """Sub-satellite points (lat/lon/alt) using spherical Earth + GMST rotation.
159
+
160
+ Triage-grade accuracy (no polar motion/nutation) — sufficient for viz.
161
+ """
162
+ points: list[dict] = []
163
+ if t0.tzinfo is None:
164
+ t0 = t0.replace(tzinfo=timezone.utc)
165
+ for k in range(0, minutes * 60 + 1, step_s):
166
+ t = t0 + timedelta(seconds=k)
167
+ try:
168
+ r, _ = state_teme(sat, t)
169
+ except PropagationError:
170
+ continue # skip decayed samples, keep the rest of the track
171
+ # GMST (IAU 1982 approximation)
172
+ jd, fr = jday(t.year, t.month, t.day, t.hour, t.minute,
173
+ t.second + t.microsecond / 1e6)
174
+ tut1 = (jd + fr - 2451545.0) / 36525.0
175
+ gmst_s = 67310.54841 + (876600.0 * 3600.0 + 8640184.812866) * tut1 \
176
+ + 0.093104 * tut1**2 - 6.2e-6 * tut1**3
177
+ gmst = math.fmod(gmst_s * (math.pi / 43200.0) / 240.0 * 240.0, 2.0 * math.pi)
178
+ norm = float(np.linalg.norm(r))
179
+ lat = math.asin(r[2] / norm)
180
+ lon = math.atan2(r[1], r[0]) - gmst
181
+ lon = math.atan2(math.sin(lon), math.cos(lon)) # wrap to [-pi, pi]
182
+ points.append({
183
+ "t": t,
184
+ "lat": math.degrees(lat),
185
+ "lon": math.degrees(lon),
186
+ "alt_km": norm - _EARTH_RADIUS_KM,
187
+ "r_eci_km": [float(r[0]), float(r[1]), float(r[2])],
188
+ })
189
+ return points
@@ -0,0 +1,77 @@
1
+ # GENERATED from backend/app/logic — edit there, then run packages/sync.py
2
+ """Risk classification, urgency scoring, recommended actions (FR-RSK)."""
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+
7
+ # Operator-community Pc thresholds (NASA CARA heritage), FR-RSK-1
8
+ _CRITICAL = 1e-4
9
+ _WARNING = 1e-5
10
+ _MONITOR = 1e-7
11
+
12
+ _ESCALATION_MISS_M = 1000.0 # <1 km miss without covariance escalates one class
13
+
14
+ _ORDER = ["NEGLIGIBLE", "MONITOR", "WARNING", "CRITICAL"]
15
+
16
+
17
+ def classify(pc: float, pc_type: str, miss_m: float, covariance_available: bool) -> str:
18
+ if pc >= _CRITICAL:
19
+ risk = "CRITICAL"
20
+ elif pc >= _WARNING:
21
+ risk = "WARNING"
22
+ elif pc >= _MONITOR:
23
+ risk = "MONITOR"
24
+ else:
25
+ risk = "NEGLIGIBLE"
26
+
27
+ if not covariance_available and miss_m < _ESCALATION_MISS_M:
28
+ idx = _ORDER.index(risk)
29
+ risk = _ORDER[min(idx + 1, len(_ORDER) - 1)]
30
+ return risk
31
+
32
+
33
+ def urgency(risk: str, tca: datetime, created: datetime | None,
34
+ now: datetime | None = None) -> float:
35
+ """0-100 score: base by class + up to 20 for imminent TCA + up to 10 for stale data."""
36
+ now = now or datetime.now(timezone.utc)
37
+ base = {"CRITICAL": 70.0, "WARNING": 45.0, "MONITOR": 20.0, "NEGLIGIBLE": 5.0}[risk]
38
+
39
+ hours_to_tca = (tca - now).total_seconds() / 3600.0
40
+ if hours_to_tca <= 0:
41
+ time_score = 0.0 # event passed
42
+ elif hours_to_tca >= 24.0:
43
+ time_score = 2.0
44
+ else:
45
+ time_score = 20.0 * (1.0 - hours_to_tca / 24.0)
46
+
47
+ stale_score = 0.0
48
+ if created is not None:
49
+ age_h = (now - created).total_seconds() / 3600.0
50
+ stale_score = min(10.0, max(0.0, (age_h - 8.0) / 4.0))
51
+
52
+ return round(min(100.0, base + time_score + stale_score), 1)
53
+
54
+
55
+ def recommend(risk: str, tca: datetime, covariance_available: bool,
56
+ now: datetime | None = None, pc_type: str = "computed") -> str:
57
+ now = now or datetime.now(timezone.utc)
58
+ hours = max(0.0, (tca - now).total_seconds() / 3600.0)
59
+ horizon = f"T-{hours:.0f} h" if hours >= 1 else "imminent TCA"
60
+
61
+ if risk == "CRITICAL":
62
+ base = ("Treat as actionable: contact secondary operator, request refreshed "
63
+ "operator CDM with covariance, evaluate manoeuvre options")
64
+ elif risk == "WARNING":
65
+ base = "Elevated: request CDM refresh and re-screen every update cycle"
66
+ elif risk == "MONITOR":
67
+ base = "Track: re-screen at next catalogue update"
68
+ else:
69
+ base = "No action required; routine monitoring"
70
+
71
+ if pc_type == "max":
72
+ cov_note = " (Pc is a covariance-free upper bound — obtain operator CDM for full-fidelity Pc)"
73
+ elif not covariance_available:
74
+ cov_note = " (source-reported Pc; obtain operator CDM with covariance for full-fidelity assessment)"
75
+ else:
76
+ cov_note = ""
77
+ return f"{base} ({horizon}){cov_note}."
@@ -0,0 +1,140 @@
1
+ # GENERATED from backend/app/logic — edit there, then run packages/sync.py
2
+ """Catalogue screening: altitude sieve -> coarse scan -> TCA refinement (FR-SCR)."""
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import math
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timedelta, timezone
9
+
10
+ import numpy as np
11
+
12
+ from kessler_toolkit.exceptions import PropagationError
13
+ from kessler_toolkit import propagation
14
+ from kessler_toolkit.collision_probability import pc_max
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _MU_EARTH = 398600.4418 # km^3/s^2
19
+ _EARTH_RADIUS_KM = 6378.137
20
+
21
+
22
+ @dataclass
23
+ class CloseApproachHit:
24
+ norad_id: str
25
+ name: str
26
+ object_type: str
27
+ tca: datetime
28
+ miss_distance_m: float
29
+ relative_speed_ms: float
30
+ miss_rtn_m: list[float]
31
+ pc_max: float
32
+
33
+
34
+ def altitude_band(omm: dict) -> tuple[float, float]:
35
+ """(perigee_km, apogee_km) altitude band from mean elements."""
36
+ n_rad_s = float(omm["MEAN_MOTION"]) * 2.0 * math.pi / 86400.0
37
+ a_km = (_MU_EARTH / (n_rad_s**2)) ** (1.0 / 3.0)
38
+ ecc = float(omm["ECCENTRICITY"])
39
+ perigee = a_km * (1.0 - ecc) - _EARTH_RADIUS_KM
40
+ apogee = a_km * (1.0 + ecc) - _EARTH_RADIUS_KM
41
+ return perigee, apogee
42
+
43
+
44
+ def bands_overlap(band_a: tuple[float, float], band_b: tuple[float, float],
45
+ pad_km: float) -> bool:
46
+ return band_a[0] - pad_km <= band_b[1] and band_b[0] - pad_km <= band_a[1]
47
+
48
+
49
+ def screen(
50
+ asset_omm: dict,
51
+ catalog: list[dict],
52
+ start: datetime,
53
+ window_hours: int,
54
+ threshold_km: float = 10.0,
55
+ hbr_m: float = 20.0,
56
+ coarse_step_s: float = 60.0,
57
+ ) -> tuple[int, list[CloseApproachHit]]:
58
+ """Screen an asset against the catalogue. Returns (objects_screened, hits)."""
59
+ if start.tzinfo is None:
60
+ start = start.replace(tzinfo=timezone.utc)
61
+ duration_s = window_hours * 3600.0
62
+
63
+ asset_sat = propagation.load_satrec(asset_omm)
64
+ asset_band = altitude_band(asset_omm)
65
+ asset_id = str(asset_omm["NORAD_CAT_ID"])
66
+
67
+ _, asset_r, asset_v = propagation.sample_states(asset_sat, start, duration_s, coarse_step_s)
68
+
69
+ screened = 0
70
+ hits: list[CloseApproachHit] = []
71
+ coarse_gate_km = max(threshold_km * 5.0, 50.0)
72
+
73
+ for omm in catalog:
74
+ norad = str(omm.get("NORAD_CAT_ID", ""))
75
+ if norad == asset_id:
76
+ continue
77
+ try:
78
+ band = altitude_band(omm)
79
+ except (KeyError, ValueError, ZeroDivisionError):
80
+ logger.warning("Skipping catalogue object with bad elements: %s", norad)
81
+ continue
82
+ if not bands_overlap(asset_band, band, pad_km=threshold_km + 5.0):
83
+ continue # FR-SCR-2 sieve
84
+
85
+ screened += 1
86
+ try:
87
+ _, obj_r, _ = propagation.sample_states(
88
+ propagation.load_satrec(omm), start, duration_s, coarse_step_s
89
+ )
90
+ except PropagationError as exc:
91
+ logger.warning("Propagation failed for %s: %s", norad, exc.message)
92
+ continue
93
+
94
+ n = min(len(asset_r), len(obj_r))
95
+ ranges = np.linalg.norm(asset_r[:n] - obj_r[:n], axis=1)
96
+ if np.all(np.isnan(ranges)):
97
+ continue
98
+
99
+ # local minima below the coarse gate (NaN-safe)
100
+ with np.errstate(invalid="ignore"):
101
+ candidates = [
102
+ i for i in range(1, n - 1)
103
+ if ranges[i] == ranges[i] and ranges[i] < coarse_gate_km
104
+ and ranges[i] <= ranges[i - 1] and ranges[i] <= ranges[i + 1]
105
+ ]
106
+
107
+ obj_sat = propagation.load_satrec(omm)
108
+ seen_tca: list[datetime] = []
109
+ for idx in candidates:
110
+ t_guess = start + timedelta(seconds=idx * coarse_step_s)
111
+ try:
112
+ tca, miss_m, rel_speed = propagation.refine_tca(
113
+ asset_sat, obj_sat, t_guess, half_window_s=coarse_step_s * 1.5
114
+ )
115
+ except PropagationError as exc:
116
+ logger.warning("TCA refinement failed for %s: %s", norad, exc.message)
117
+ continue
118
+ if miss_m > threshold_km * 1000.0:
119
+ continue
120
+ if any(abs((tca - prev).total_seconds()) < 30.0 for prev in seen_tca):
121
+ continue # duplicate minimum from adjacent samples
122
+ seen_tca.append(tca)
123
+
124
+ r_a, v_a = propagation.state_teme(asset_sat, tca)
125
+ r_b, _ = propagation.state_teme(obj_sat, tca)
126
+ rtn_km = propagation.rtn_components(r_a, v_a, r_b - r_a)
127
+
128
+ hits.append(CloseApproachHit(
129
+ norad_id=norad,
130
+ name=str(omm.get("OBJECT_NAME", "UNKNOWN")),
131
+ object_type=str(omm.get("OBJECT_TYPE", "UNKNOWN")),
132
+ tca=tca,
133
+ miss_distance_m=miss_m,
134
+ relative_speed_ms=rel_speed,
135
+ miss_rtn_m=[c * 1000.0 for c in rtn_km],
136
+ pc_max=pc_max(miss_m, hbr_m),
137
+ ))
138
+
139
+ hits.sort(key=lambda h: h.miss_distance_m)
140
+ return screened, hits
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: kessler-toolkit
3
+ Version: 0.1.0
4
+ Summary: Open conjunction assessment: CCSDS CDM parsing, collision probability (Foster/Chan/Max-Pc), SGP4 screening, and risk triage — pure Python.
5
+ Author-email: Ashutosh <siddhvasudev1402@gmail.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/siddhashutosh/kessler
8
+ Project-URL: Documentation, https://github.com/siddhashutosh/kessler/tree/main/documents
9
+ Project-URL: Issues, https://github.com/siddhashutosh/kessler/issues
10
+ Keywords: astrodynamics,conjunction-assessment,collision-probability,space-debris,CDM,CCSDS,SGP4,space-traffic-management,SSA
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
16
+ Classifier: Topic :: Scientific/Engineering :: Physics
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: numpy>=1.24
20
+ Requires-Dist: sgp4>=2.23
21
+ Requires-Dist: python-dateutil>=2.9
22
+
23
+ # kessler-toolkit
24
+
25
+ Open conjunction assessment for Python: parse CCSDS Conjunction Data Messages,
26
+ compute collision probability three honest ways, screen a satellite against a
27
+ catalogue, and triage the results — no MATLAB license required.
28
+
29
+ Extracted from [KESSLER](https://github.com/siddhashutosh/kessler), where the same
30
+ code powers a live dashboard over the public Space-Track feed.
31
+
32
+ ```bash
33
+ pip install kessler-toolkit
34
+ ```
35
+
36
+ ## What's inside
37
+
38
+ | Module | Purpose |
39
+ |---|---|
40
+ | `cdm_parser` | CCSDS 508.0-B-1 KVN CDMs (incl. RTN covariance) and Space-Track `cdm_public` JSON → typed `Cdm` |
41
+ | `collision_probability` | Foster 2-D numerical Pc, Chan analytic series (cross-check), covariance-free Max-Pc bound |
42
+ | `propagation` | SGP4 wrapper (TEME), close-approach refinement to sub-second TCA, ground tracks |
43
+ | `screening` | Catalogue screening: altitude sieve → coarse scan → refined TCA + RTN miss components |
44
+ | `risk_engine` | Operator-community risk classes (1e-4 / 1e-5 / 1e-7), urgency scoring, recommended actions |
45
+
46
+ ## Quick start
47
+
48
+ ```python
49
+ from kessler_toolkit import parse_cdm_kvn, compute_pc
50
+
51
+ cdm = parse_cdm_kvn(open("conjunction.cdm").read())
52
+
53
+ result = compute_pc(
54
+ miss_m=cdm.miss_distance_m,
55
+ hbr_m=20.0,
56
+ r_rel_m=cdm.rel_position_rtn_m,
57
+ v_rel_ms=cdm.rel_velocity_rtn_ms,
58
+ cov1_m2=cdm.sat1.cov_rtn_m2,
59
+ cov2_m2=cdm.sat2.cov_rtn_m2,
60
+ pc_reported=cdm.pc_reported,
61
+ )
62
+ print(result.value, result.method, result.pc_type)
63
+ # e.g. 2.19e-04 foster-2d computed — with Chan cross-check in result.cross_check
64
+ ```
65
+
66
+ Every result carries `pc_type` — `"computed"` (full covariance), `"reported"`
67
+ (source value), or `"max"` (conservative upper bound) — so downstream code can
68
+ never mistake a triage bound for a measurement.
69
+
70
+ Validated against the closed-form isotropic solution (<0.1% error) and
71
+ Foster/Chan cross-agreement; screening results are triage-grade (SGP4/GP
72
+ accuracy limits apply) — not a substitute for operator ephemerides.
73
+
74
+ Apache-2.0. Orbital data used with KESSLER courtesy of Space-Track.org and CelesTrak.
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ kessler_toolkit/__init__.py
4
+ kessler_toolkit/cdm_parser.py
5
+ kessler_toolkit/collision_probability.py
6
+ kessler_toolkit/exceptions.py
7
+ kessler_toolkit/propagation.py
8
+ kessler_toolkit/risk_engine.py
9
+ kessler_toolkit/screening.py
10
+ kessler_toolkit.egg-info/PKG-INFO
11
+ kessler_toolkit.egg-info/SOURCES.txt
12
+ kessler_toolkit.egg-info/dependency_links.txt
13
+ kessler_toolkit.egg-info/requires.txt
14
+ kessler_toolkit.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ numpy>=1.24
2
+ sgp4>=2.23
3
+ python-dateutil>=2.9
@@ -0,0 +1 @@
1
+ kessler_toolkit
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "kessler-toolkit"
7
+ version = "0.1.0"
8
+ description = "Open conjunction assessment: CCSDS CDM parsing, collision probability (Foster/Chan/Max-Pc), SGP4 screening, and risk triage — pure Python."
9
+ readme = "README.md"
10
+ license = { text = "Apache-2.0" }
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Ashutosh", email = "siddhvasudev1402@gmail.com" }]
13
+ keywords = [
14
+ "astrodynamics", "conjunction-assessment", "collision-probability",
15
+ "space-debris", "CDM", "CCSDS", "SGP4", "space-traffic-management", "SSA",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Intended Audience :: Science/Research",
20
+ "License :: OSI Approved :: Apache Software License",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Scientific/Engineering :: Astronomy",
23
+ "Topic :: Scientific/Engineering :: Physics",
24
+ ]
25
+ dependencies = [
26
+ "numpy>=1.24",
27
+ "sgp4>=2.23",
28
+ "python-dateutil>=2.9",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/siddhashutosh/kessler"
33
+ Documentation = "https://github.com/siddhashutosh/kessler/tree/main/documents"
34
+ Issues = "https://github.com/siddhashutosh/kessler/issues"
35
+
36
+ [tool.setuptools]
37
+ packages = ["kessler_toolkit"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+