Skip to content

Commit 1dd898f

Browse files
planet.jeroenclaude
andcommitted
fix: altitude-aware drag lifetime in cascade SIR, viewer connection error handling
- Add estimate_drag_lifetime_years() to atmosphere.py using exponential density model and representative debris ballistic coefficient - Fix cascade_evolution_packets: use altitude-appropriate drag lifetime instead of hardcoded 25 yr, sensible debris seed (0.5% of sat count), R_EARTH_EQUATORIAL for altitude calculation - Fix viewer_server do_POST: catch ConnectionAbortedError/BrokenPipeError when client disconnects during analysis generation - Update README: new viewer screenshots, badge version/test count update Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6797f98 commit 1dd898f

File tree

10 files changed

+195
-7
lines changed

10 files changed

+195
-7
lines changed

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
# Humeris
22

3-
[![Version](https://img.shields.io/badge/version-1.28.1-blue.svg)](packages/core/pyproject.toml) [![Python](https://img.shields.io/badge/python-3.11_%7C_3.12_%7C_3.13-blue.svg)](packages/core/pyproject.toml) [![Tests](https://img.shields.io/badge/tests-3601_passing-brightgreen.svg)](tests/) [![License](https://img.shields.io/badge/license-MIT_(core)-green.svg)](LICENSE) [![License](https://img.shields.io/badge/license-Commercial_(pro)-red.svg)](COMMERCIAL-LICENSE.md) [![Architecture](https://img.shields.io/badge/architecture-hexagonal-purple.svg)](docs/architecture.md)
3+
[![Version](https://img.shields.io/badge/version-1.28.2-blue.svg)](packages/core/pyproject.toml) [![Python](https://img.shields.io/badge/python-3.11_%7C_3.12_%7C_3.13-blue.svg)](packages/core/pyproject.toml) [![Tests](https://img.shields.io/badge/tests-3653_passing-brightgreen.svg)](tests/) [![License](https://img.shields.io/badge/license-MIT_(core)-green.svg)](LICENSE) [![License](https://img.shields.io/badge/license-Commercial_(pro)-red.svg)](COMMERCIAL-LICENSE.md) [![Architecture](https://img.shields.io/badge/architecture-hexagonal-purple.svg)](docs/architecture.md)
44

55
A Python library for satellite constellation analysis — from generating
66
Walker shells to propagating orbits, screening conjunctions, and
77
visualizing everything on a 3D globe. Pure Python, no compiled extensions.
88

9-
![Humeris interactive viewer](docs/interface.png)
9+
![Humeris constellation viewer — full orbital shell with ground tracks](docs/Pics/satellite_details_zoomed_out.png)
1010

1111
## What it does
1212

@@ -22,6 +22,8 @@ visualizing everything on a 3D globe. Pure Python, no compiled extensions.
2222
- **Export everywhere** — CSV, GeoJSON, CZML, KML, Celestia, Blender, SpaceEngine, KSP, Universe Sandbox, Stellarium
2323
- **Interactive 3D viewer** — built-in CesiumJS viewer with 21 analysis layer types
2424

25+
![Satellite detail view with orbital parameters and data table](docs/Pics/satelite_details.png)
26+
2527
> **Disclaimer**: This library provides computational models for educational,
2628
> research, and engineering analysis purposes. It is not certified for
2729
> operational mission planning, safety-of-flight decisions, or regulatory
@@ -77,6 +79,10 @@ identity, and coordinate frame round-trips.
7779
The comparison artifacts — including cases where our results are imperfect
7880
or still evolving — are published alongside the code.
7981

82+
![Kessler cascade debris cloud](docs/Pics/kessler_only.png)
83+
84+
![Starlink conjunction screening — Kessler analysis](docs/Pics/starlink_kessler.png)
85+
8086
Details: [Validation](docs/validation.md)
8187

8288
## Documentation

docs/Pics/kessler_only.png

1.42 MB
Loading

docs/Pics/satelite_details.png

270 KB
Loading
1.25 MB
Loading

docs/Pics/starlink_kessler.png

2.7 MB
Loading

docs/interface.png

-822 KB
Binary file not shown.

packages/pro/src/humeris/adapters/czml_visualization.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
from humeris.domain.radiation import compute_l_shell
4848
from humeris.domain.eclipse import compute_beta_angle
4949
from humeris.domain.deorbit import assess_deorbit_compliance, DeorbitRegulation
50-
from humeris.domain.atmosphere import DragConfig
50+
from humeris.domain.atmosphere import DragConfig, estimate_drag_lifetime_years
5151
from humeris.domain.station_keeping import (
5252
compute_station_keeping_budget,
5353
StationKeepingConfig,
@@ -1864,15 +1864,19 @@ def cascade_evolution_packets(
18641864
n_sats = len(states)
18651865

18661866
# Shell parameters for SIR model
1867-
mean_alt_km = sum(s.semi_major_axis_m - OrbitalConstants.R_EARTH for s in states) / (n_sats * 1000.0)
1868-
shell_r = (OrbitalConstants.R_EARTH / 1000.0) + mean_alt_km
1867+
mean_alt_km = sum(s.semi_major_axis_m - OrbitalConstants.R_EARTH_EQUATORIAL for s in states) / (n_sats * 1000.0)
1868+
shell_r = (OrbitalConstants.R_EARTH_EQUATORIAL / 1000.0) + mean_alt_km
18691869
shell_vol = 4.0 * math.pi * shell_r ** 2 * 50.0
1870+
drag_life = estimate_drag_lifetime_years(min(mean_alt_km, 900.0))
1871+
# Initial debris seed: 0.5% of satellite count (background debris)
1872+
debris_seed = max(10.0, n_sats * 0.005)
18701873
duration_yr = max(0.1, total_seconds / (365.25 * 86400.0))
18711874
step_yr = max(1e-6, duration_yr / max(num_steps, 1))
18721875

18731876
sir = compute_cascade_sir(
1874-
shell_volume_km3=shell_vol, spatial_density_per_km3=n_sats / shell_vol,
1877+
shell_volume_km3=shell_vol, spatial_density_per_km3=debris_seed / shell_vol,
18751878
mean_collision_velocity_ms=10000.0, satellite_count=n_sats,
1879+
drag_lifetime_years=drag_life,
18761880
duration_years=duration_yr, step_years=step_yr,
18771881
)
18781882
sir_times, sir_s, sir_i = sir.time_series_years, sir.susceptible, sir.infected

packages/pro/src/humeris/adapters/viewer_server.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1682,9 +1682,14 @@ def _do_GET(self) -> None:
16821682
def do_POST(self) -> None:
16831683
try:
16841684
self._do_POST()
1685+
except (ConnectionAbortedError, BrokenPipeError, ConnectionResetError):
1686+
logger.debug("Client disconnected during POST %s", self.path)
16851687
except Exception:
16861688
logger.exception("Unhandled error in POST %s", self.path)
1687-
self._error_response(500, "Internal server error")
1689+
try:
1690+
self._error_response(500, "Internal server error")
1691+
except (ConnectionAbortedError, BrokenPipeError, ConnectionResetError):
1692+
logger.debug("Client disconnected before error response")
16881693

16891694
def _do_POST(self) -> None:
16901695
base, param = self._route_path()

packages/pro/src/humeris/domain/atmosphere.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,65 @@ def atmospheric_density(
141141
return float(rho_base * np.exp(-(altitude_km - h_base) / scale_height))
142142

143143

144+
def estimate_drag_lifetime_years(
145+
altitude_km: float,
146+
model: AtmosphereModel = AtmosphereModel.VALLADO_4TH,
147+
) -> float:
148+
"""Estimate drag lifetime for debris at a given altitude.
149+
150+
Uses a representative debris ballistic coefficient (C_d=2.2, A=0.1 m²,
151+
m=10 kg → B_c = 0.022 m²/kg) and the exponential atmosphere model to
152+
estimate the time for an orbit to decay from the given altitude.
153+
154+
Approximation: lifetime ≈ H / (ρ * v * B_c * a) where H is the scale
155+
height at the given altitude.
156+
157+
Args:
158+
altitude_km: Altitude above Earth surface in km (100-2000).
159+
model: Atmosphere model for density lookup.
160+
161+
Returns:
162+
Estimated drag lifetime in years. Clamped to [0.01, 1000].
163+
"""
164+
table = _MODEL_TABLES[model]
165+
max_alt = table[-1][0]
166+
167+
# Above atmosphere table: essentially infinite lifetime
168+
if altitude_km > max_alt:
169+
return 1000.0
170+
171+
# Clamp to table minimum
172+
alt = max(altitude_km, table[0][0])
173+
174+
# Representative debris: Cd=2.2, A=0.1 m², m=10 kg
175+
b_c = 2.2 * 0.1 / 10.0 # 0.022 m²/kg
176+
177+
rho = atmospheric_density(alt, model=model)
178+
a_m = OrbitalConstants.R_EARTH_EQUATORIAL + alt * 1000.0
179+
v = float(np.sqrt(OrbitalConstants.MU_EARTH / a_m))
180+
181+
# Scale height at this altitude (interpolate from table)
182+
lo, hi = 0, len(table) - 1
183+
while lo < hi - 1:
184+
mid = (lo + hi) // 2
185+
if table[mid][0] <= alt:
186+
lo = mid
187+
else:
188+
hi = mid
189+
scale_height_km = table[lo][2]
190+
scale_height_m = scale_height_km * 1000.0
191+
192+
# Lifetime ≈ H / (ρ * v * B_c * a)
193+
da_dt = rho * v * b_c * a_m
194+
if da_dt < 1e-30:
195+
return 1000.0
196+
197+
lifetime_s = scale_height_m / da_dt
198+
lifetime_yr = lifetime_s / (365.25 * 86400.0)
199+
200+
return max(0.01, min(lifetime_yr, 1000.0))
201+
202+
144203
def drag_acceleration(density: float, velocity: float, drag_config: DragConfig) -> float:
145204
"""Drag acceleration magnitude.
146205

tests/test_cascade_sir.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import math
77

88
from humeris.domain.cascade_analysis import CascadeSIR, compute_cascade_sir
9+
from humeris.domain.orbital_mechanics import OrbitalConstants
910

1011

1112
class TestCascadeSIRSubcritical:
@@ -267,3 +268,116 @@ def test_time_series_length(self):
267268
assert len(result.susceptible) == expected_steps
268269
assert len(result.infected) == expected_steps
269270
assert len(result.recovered) == expected_steps
271+
272+
273+
# ── estimate_drag_lifetime_years ──────────────────────────────────
274+
275+
class TestEstimateDragLifetime:
276+
"""Tests for altitude-dependent drag lifetime estimation."""
277+
278+
def test_200km_short_lifetime(self):
279+
"""At 200 km, drag lifetime is very short (< 2 years)."""
280+
from humeris.domain.atmosphere import estimate_drag_lifetime_years
281+
282+
lifetime = estimate_drag_lifetime_years(200.0)
283+
assert lifetime < 2.0
284+
285+
def test_550km_moderate_lifetime(self):
286+
"""At 550 km (Starlink altitude), drag lifetime is 5-20 years."""
287+
from humeris.domain.atmosphere import estimate_drag_lifetime_years
288+
289+
lifetime = estimate_drag_lifetime_years(550.0)
290+
assert 5.0 < lifetime < 20.0
291+
292+
def test_800km_long_lifetime(self):
293+
"""At 800 km, drag lifetime is > 30 years."""
294+
from humeris.domain.atmosphere import estimate_drag_lifetime_years
295+
296+
lifetime = estimate_drag_lifetime_years(800.0)
297+
assert lifetime > 30.0
298+
299+
def test_monotonic_increase(self):
300+
"""Drag lifetime increases monotonically with altitude."""
301+
from humeris.domain.atmosphere import estimate_drag_lifetime_years
302+
303+
altitudes = [200, 300, 400, 500, 600, 700, 800, 900]
304+
lifetimes = [estimate_drag_lifetime_years(a) for a in altitudes]
305+
for i in range(len(lifetimes) - 1):
306+
assert lifetimes[i] < lifetimes[i + 1], (
307+
f"Lifetime decreased: {altitudes[i]} km={lifetimes[i]:.1f} yr "
308+
f"> {altitudes[i+1]} km={lifetimes[i+1]:.1f} yr"
309+
)
310+
311+
def test_meo_very_long(self):
312+
"""MEO altitudes (>1000 km) have very long drag lifetimes (>100 yr)."""
313+
from humeris.domain.atmosphere import estimate_drag_lifetime_years
314+
315+
lifetime = estimate_drag_lifetime_years(1000.0)
316+
assert lifetime > 100.0
317+
318+
def test_all_positive(self):
319+
"""Drag lifetime is always positive."""
320+
from humeris.domain.atmosphere import estimate_drag_lifetime_years
321+
322+
for alt in [150, 200, 300, 500, 700, 900]:
323+
assert estimate_drag_lifetime_years(alt) > 0.0
324+
325+
326+
# ── Cascade SIR R₀ with realistic parameters ─────────────────────
327+
328+
class TestCascadeSIRRealisticR0:
329+
"""R₀ should be physically reasonable with altitude-appropriate drag lifetime."""
330+
331+
def test_starlink_r0_below_2(self):
332+
"""Starlink-like constellation at 550 km: R₀ should be < 2.0.
333+
334+
With ~6500 sats at 550 km, drag lifetime ~8-15 yr gives R₀ ~ 0.4-1.2.
335+
The old hardcoded 25 yr gave R₀ ~ 1.7 — unrealistically high.
336+
"""
337+
from humeris.domain.atmosphere import estimate_drag_lifetime_years
338+
339+
alt_km = 550.0
340+
n_sats = 6500
341+
shell_r = (OrbitalConstants.R_EARTH_EQUATORIAL / 1000.0) + alt_km
342+
shell_vol = 4.0 * math.pi * shell_r ** 2 * 50.0
343+
drag_life = estimate_drag_lifetime_years(alt_km)
344+
345+
result = compute_cascade_sir(
346+
shell_volume_km3=shell_vol,
347+
spatial_density_per_km3=0.005 * n_sats / shell_vol,
348+
mean_collision_velocity_ms=10_000.0,
349+
satellite_count=n_sats,
350+
drag_lifetime_years=drag_life,
351+
duration_years=1.0,
352+
)
353+
assert result.r_0 < 2.0, (
354+
f"Starlink R₀={result.r_0:.2f} with drag_life={drag_life:.1f} yr "
355+
f"— should be < 2.0"
356+
)
357+
358+
def test_glonass_subcritical(self):
359+
"""GLONASS at ~19,100 km with 24 sats: R₀ should be << 1 (subcritical).
360+
361+
At MEO, drag is negligible, but with only 24 satellites the collision
362+
rate is far too low for cascade.
363+
"""
364+
from humeris.domain.atmosphere import estimate_drag_lifetime_years
365+
366+
alt_km = 19100.0
367+
n_sats = 24
368+
shell_r = (OrbitalConstants.R_EARTH_EQUATORIAL / 1000.0) + alt_km
369+
shell_vol = 4.0 * math.pi * shell_r ** 2 * 200.0 # wider shell for MEO
370+
# At MEO, clamp lifetime to table max
371+
drag_life = min(estimate_drag_lifetime_years(min(alt_km, 900.0)), 500.0)
372+
373+
result = compute_cascade_sir(
374+
shell_volume_km3=shell_vol,
375+
spatial_density_per_km3=0.005 * n_sats / shell_vol,
376+
mean_collision_velocity_ms=7_000.0,
377+
satellite_count=n_sats,
378+
drag_lifetime_years=drag_life,
379+
duration_years=1.0,
380+
)
381+
assert result.r_0 < 0.1, (
382+
f"GLONASS R₀={result.r_0:.4f} — should be << 1"
383+
)

0 commit comments

Comments
 (0)