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.
- kessler_toolkit-0.1.0/PKG-INFO +74 -0
- kessler_toolkit-0.1.0/README.md +52 -0
- kessler_toolkit-0.1.0/kessler_toolkit/__init__.py +41 -0
- kessler_toolkit-0.1.0/kessler_toolkit/cdm_parser.py +234 -0
- kessler_toolkit-0.1.0/kessler_toolkit/collision_probability.py +201 -0
- kessler_toolkit-0.1.0/kessler_toolkit/exceptions.py +64 -0
- kessler_toolkit-0.1.0/kessler_toolkit/propagation.py +189 -0
- kessler_toolkit-0.1.0/kessler_toolkit/risk_engine.py +77 -0
- kessler_toolkit-0.1.0/kessler_toolkit/screening.py +140 -0
- kessler_toolkit-0.1.0/kessler_toolkit.egg-info/PKG-INFO +74 -0
- kessler_toolkit-0.1.0/kessler_toolkit.egg-info/SOURCES.txt +14 -0
- kessler_toolkit-0.1.0/kessler_toolkit.egg-info/dependency_links.txt +1 -0
- kessler_toolkit-0.1.0/kessler_toolkit.egg-info/requires.txt +3 -0
- kessler_toolkit-0.1.0/kessler_toolkit.egg-info/top_level.txt +1 -0
- kessler_toolkit-0.1.0/pyproject.toml +37 -0
- kessler_toolkit-0.1.0/setup.cfg +4 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|