Skip to content

Commit ed132fd

Browse files
pljeroenclaude
andcommitted
feat: add custom constellation designer to GUI export tool
Users can now define Walker shells directly in the GUI — altitude, inclination, planes, sats/plane, phase factor, RAAN offset — with validation and multi-shell support (up to 10). No JSON or CLI needed. 38 tests (7 new). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ed7d509 commit ed132fd

File tree

2 files changed

+315
-7
lines changed

2 files changed

+315
-7
lines changed

packages/core/src/humeris/gui.py

Lines changed: 190 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -222,17 +222,70 @@ def _ubox_factory(**_kwargs: Any) -> Any:
222222
# Satellite loading
223223
# ---------------------------------------------------------------------------
224224

225-
def load_default_satellites() -> list[Any]:
226-
"""Load the default constellation (Walker shells + SSO band)."""
227-
from humeris.cli import get_default_shells
225+
MAX_SATS_PER_SHELL = 5000
226+
MAX_TOTAL_SATS = 50000
227+
228+
229+
def validate_shell_dict(d: dict[str, Any]) -> str | None:
230+
"""Validate a shell config dict. Returns error string or None if valid."""
231+
alt = d.get("altitude_km", 0)
232+
if not (0 < alt < 50000):
233+
return f"Altitude must be between 0 and 50,000 km (got {alt})"
234+
235+
inc = d.get("inclination_deg", 0)
236+
if not (0 <= inc <= 180):
237+
return f"Inclination must be between 0 and 180 degrees (got {inc})"
238+
239+
planes = d.get("num_planes", 0)
240+
if not (1 <= planes <= 100):
241+
return f"Planes must be between 1 and 100 (got {planes})"
242+
243+
spp = d.get("sats_per_plane", 0)
244+
if not (1 <= spp <= 100):
245+
return f"Sats per plane must be between 1 and 100 (got {spp})"
246+
247+
total = planes * spp
248+
if total > MAX_SATS_PER_SHELL:
249+
return f"Too many satellites in one shell: {total:,} (max {MAX_SATS_PER_SHELL:,})"
250+
251+
return None
252+
253+
254+
def build_shell_configs(shell_dicts: list[dict[str, Any]]) -> list[Any]:
255+
"""Build ShellConfig objects from a list of parameter dicts."""
256+
from humeris.domain.constellation import ShellConfig
257+
258+
configs = []
259+
for d in shell_dicts:
260+
configs.append(ShellConfig(
261+
altitude_km=float(d["altitude_km"]),
262+
inclination_deg=float(d["inclination_deg"]),
263+
num_planes=int(d["num_planes"]),
264+
sats_per_plane=int(d["sats_per_plane"]),
265+
phase_factor=int(d["phase_factor"]),
266+
raan_offset_deg=float(d["raan_offset_deg"]),
267+
shell_name=str(d["shell_name"]),
268+
))
269+
return configs
270+
271+
272+
def generate_from_configs(configs: list[Any]) -> list[Any]:
273+
"""Generate satellites from a list of ShellConfig objects."""
228274
from humeris.domain.constellation import generate_walker_shell
229275

230276
satellites: list[Any] = []
231-
for shell in get_default_shells():
232-
satellites.extend(generate_walker_shell(shell))
277+
for config in configs:
278+
satellites.extend(generate_walker_shell(config))
233279
return satellites
234280

235281

282+
def load_default_satellites() -> list[Any]:
283+
"""Load the default constellation (Walker shells + SSO band)."""
284+
from humeris.cli import get_default_shells
285+
286+
return generate_from_configs(get_default_shells())
287+
288+
236289
# ---------------------------------------------------------------------------
237290
# Export orchestration
238291
# ---------------------------------------------------------------------------
@@ -280,6 +333,7 @@ def __init__(self) -> None:
280333
self._celestrak_group_var = tk.StringVar(value="STARLINK")
281334
self._status_var = tk.StringVar(value="Loading default constellation...")
282335
self._export_status_var = tk.StringVar(value="")
336+
self._shell_rows: list[dict[str, tk.StringVar]] = []
283337

284338
# Default output directory
285339
docs = Path.home() / "Documents"
@@ -359,6 +413,28 @@ def _on_linux_scroll_down(event: Any) -> None:
359413
self._celestrak_combo.pack(side="left", padx=5)
360414
self._celestrak_combo.bind("<<ComboboxSelected>>", lambda _: self._on_source_change())
361415

416+
ttk.Radiobutton(
417+
src_frame,
418+
text="Custom constellation",
419+
variable=self._source_var,
420+
value="custom",
421+
command=self._on_source_change,
422+
).pack(anchor="w", padx=5, pady=2)
423+
424+
# Custom shell editor (hidden until "custom" selected)
425+
self._custom_frame = ttk.Frame(src_frame)
426+
self._shell_list_frame = ttk.Frame(self._custom_frame)
427+
self._shell_list_frame.pack(fill="x", padx=5, pady=2)
428+
429+
btn_row = ttk.Frame(self._custom_frame)
430+
btn_row.pack(fill="x", padx=5, pady=5)
431+
ttk.Button(btn_row, text="+ Add shell", command=self._add_shell_row).pack(side="left", padx=2)
432+
ttk.Button(btn_row, text="- Remove last", command=self._remove_shell_row).pack(side="left", padx=2)
433+
ttk.Button(btn_row, text="Generate", command=self._generate_custom).pack(side="left", padx=10)
434+
435+
# Start with one default shell row
436+
self._add_shell_row()
437+
362438
ttk.Label(src_frame, textvariable=self._status_var).pack(anchor="w", padx=5, pady=2)
363439

364440
# --- Format section ---
@@ -461,11 +537,118 @@ def _browse_filename(self, key: str, ext: str) -> None:
461537
self._filename_vars[key].set(os.path.basename(f))
462538
self._output_dir_var.set(os.path.dirname(f))
463539

540+
def _add_shell_row(self) -> None:
541+
"""Add a new shell configuration row."""
542+
if len(self._shell_rows) >= 10:
543+
return
544+
545+
idx = len(self._shell_rows) + 1
546+
row_vars: dict[str, tk.StringVar] = {
547+
"altitude_km": tk.StringVar(value="550"),
548+
"inclination_deg": tk.StringVar(value="53"),
549+
"num_planes": tk.StringVar(value="6"),
550+
"sats_per_plane": tk.StringVar(value="22"),
551+
"phase_factor": tk.StringVar(value="1"),
552+
"raan_offset_deg": tk.StringVar(value="0.0"),
553+
"shell_name": tk.StringVar(value=f"Shell {idx}"),
554+
}
555+
556+
frame = ttk.LabelFrame(self._shell_list_frame, text=f"Shell {idx}")
557+
frame.pack(fill="x", padx=2, pady=2)
558+
row_vars["_frame"] = frame # type: ignore[assignment]
559+
560+
# Row 1: altitude, inclination, name
561+
r1 = ttk.Frame(frame)
562+
r1.pack(fill="x", padx=5, pady=1)
563+
ttk.Label(r1, text="Altitude (km):").pack(side="left")
564+
ttk.Entry(r1, textvariable=row_vars["altitude_km"], width=8).pack(side="left", padx=2)
565+
ttk.Label(r1, text="Inclination (°):").pack(side="left", padx=(10, 0))
566+
ttk.Entry(r1, textvariable=row_vars["inclination_deg"], width=6).pack(side="left", padx=2)
567+
ttk.Label(r1, text="Name:").pack(side="left", padx=(10, 0))
568+
ttk.Entry(r1, textvariable=row_vars["shell_name"], width=15).pack(side="left", padx=2)
569+
570+
# Row 2: planes, sats/plane, phase factor, RAAN offset
571+
r2 = ttk.Frame(frame)
572+
r2.pack(fill="x", padx=5, pady=1)
573+
ttk.Label(r2, text="Planes:").pack(side="left")
574+
ttk.Entry(r2, textvariable=row_vars["num_planes"], width=5).pack(side="left", padx=2)
575+
ttk.Label(r2, text="Sats/plane:").pack(side="left", padx=(10, 0))
576+
ttk.Entry(r2, textvariable=row_vars["sats_per_plane"], width=5).pack(side="left", padx=2)
577+
ttk.Label(r2, text="Phase factor:").pack(side="left", padx=(10, 0))
578+
ttk.Entry(r2, textvariable=row_vars["phase_factor"], width=4).pack(side="left", padx=2)
579+
ttk.Label(r2, text="RAAN offset (°):").pack(side="left", padx=(10, 0))
580+
ttk.Entry(r2, textvariable=row_vars["raan_offset_deg"], width=6).pack(side="left", padx=2)
581+
582+
self._shell_rows.append(row_vars)
583+
584+
def _remove_shell_row(self) -> None:
585+
"""Remove the last shell configuration row."""
586+
if len(self._shell_rows) <= 1:
587+
return
588+
row = self._shell_rows.pop()
589+
row["_frame"].destroy() # type: ignore[union-attr]
590+
591+
def _get_shell_dicts(self) -> list[dict[str, Any]]:
592+
"""Read current shell rows into dicts."""
593+
result = []
594+
for row in self._shell_rows:
595+
try:
596+
result.append({
597+
"altitude_km": float(row["altitude_km"].get()),
598+
"inclination_deg": float(row["inclination_deg"].get()),
599+
"num_planes": int(row["num_planes"].get()),
600+
"sats_per_plane": int(row["sats_per_plane"].get()),
601+
"phase_factor": int(row["phase_factor"].get()),
602+
"raan_offset_deg": float(row["raan_offset_deg"].get()),
603+
"shell_name": row["shell_name"].get(),
604+
})
605+
except ValueError as e:
606+
self._status_var.set(f"Invalid number: {e}")
607+
return []
608+
return result
609+
610+
def _generate_custom(self) -> None:
611+
"""Generate satellites from custom shell specifications."""
612+
shell_dicts = self._get_shell_dicts()
613+
if not shell_dicts:
614+
return
615+
616+
# Validate each shell
617+
for i, d in enumerate(shell_dicts):
618+
error = validate_shell_dict(d)
619+
if error:
620+
self._status_var.set(f"Shell {i + 1}: {error}")
621+
return
622+
623+
# Check total count
624+
total = sum(d["num_planes"] * d["sats_per_plane"] for d in shell_dicts)
625+
if total > MAX_TOTAL_SATS:
626+
self._status_var.set(f"Too many satellites total: {total:,} (max {MAX_TOTAL_SATS:,})")
627+
return
628+
629+
self._status_var.set("Generating custom constellation...")
630+
631+
def _gen() -> None:
632+
try:
633+
configs = build_shell_configs(shell_dicts)
634+
sats = generate_from_configs(configs)
635+
self.root.after(0, lambda: self._on_satellites_loaded(sats))
636+
except Exception as e:
637+
self.root.after(0, lambda: self._status_var.set(f"Error: {e}"))
638+
639+
threading.Thread(target=_gen, daemon=True).start()
640+
464641
def _on_source_change(self) -> None:
465642
"""Handle source radio button change."""
466-
if self._source_var.get() == "default":
467-
self._load_defaults_async()
643+
source = self._source_var.get()
644+
if source == "custom":
645+
self._custom_frame.pack(fill="x", padx=5, pady=2)
468646
else:
647+
self._custom_frame.pack_forget()
648+
649+
if source == "default":
650+
self._load_defaults_async()
651+
elif source == "celestrak":
469652
self._fetch_celestrak_async()
470653

471654
def _load_defaults_async(self) -> None:

tests/test_gui.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,131 @@ def test_celestrak_groups_contains_starlink(self):
308308
assert "STARLINK" in CELESTRAK_GROUPS
309309

310310

311+
class TestCustomShells:
312+
"""Custom constellation shell builder logic."""
313+
314+
def test_build_shell_configs_from_values(self):
315+
from humeris.gui import build_shell_configs
316+
317+
shell_dicts = [
318+
{
319+
"altitude_km": 550.0,
320+
"inclination_deg": 53.0,
321+
"num_planes": 6,
322+
"sats_per_plane": 22,
323+
"phase_factor": 1,
324+
"raan_offset_deg": 0.0,
325+
"shell_name": "Shell 1",
326+
},
327+
]
328+
configs = build_shell_configs(shell_dicts)
329+
assert len(configs) == 1
330+
assert configs[0].altitude_km == 550.0
331+
assert configs[0].num_planes == 6
332+
assert configs[0].sats_per_plane == 22
333+
334+
def test_build_shell_configs_multiple_shells(self):
335+
from humeris.gui import build_shell_configs
336+
337+
shell_dicts = [
338+
{
339+
"altitude_km": 550.0,
340+
"inclination_deg": 53.0,
341+
"num_planes": 6,
342+
"sats_per_plane": 22,
343+
"phase_factor": 1,
344+
"raan_offset_deg": 0.0,
345+
"shell_name": "Shell 1",
346+
},
347+
{
348+
"altitude_km": 1100.0,
349+
"inclination_deg": 70.0,
350+
"num_planes": 4,
351+
"sats_per_plane": 10,
352+
"phase_factor": 0,
353+
"raan_offset_deg": 0.0,
354+
"shell_name": "Shell 2",
355+
},
356+
]
357+
configs = build_shell_configs(shell_dicts)
358+
assert len(configs) == 2
359+
assert configs[1].altitude_km == 1100.0
360+
361+
def test_generate_from_shell_configs(self):
362+
from humeris.gui import build_shell_configs, generate_from_configs
363+
364+
shell_dicts = [
365+
{
366+
"altitude_km": 550.0,
367+
"inclination_deg": 53.0,
368+
"num_planes": 2,
369+
"sats_per_plane": 3,
370+
"phase_factor": 0,
371+
"raan_offset_deg": 0.0,
372+
"shell_name": "Test",
373+
},
374+
]
375+
configs = build_shell_configs(shell_dicts)
376+
sats = generate_from_configs(configs)
377+
assert len(sats) == 6 # 2 planes × 3 sats
378+
379+
def test_validate_shell_rejects_negative_altitude(self):
380+
from humeris.gui import validate_shell_dict
381+
382+
result = validate_shell_dict({
383+
"altitude_km": -100.0,
384+
"inclination_deg": 53.0,
385+
"num_planes": 6,
386+
"sats_per_plane": 22,
387+
"phase_factor": 1,
388+
"raan_offset_deg": 0.0,
389+
"shell_name": "Bad",
390+
})
391+
assert result is not None # returns error string
392+
393+
def test_validate_shell_rejects_bad_inclination(self):
394+
from humeris.gui import validate_shell_dict
395+
396+
result = validate_shell_dict({
397+
"altitude_km": 550.0,
398+
"inclination_deg": 200.0,
399+
"num_planes": 6,
400+
"sats_per_plane": 22,
401+
"phase_factor": 1,
402+
"raan_offset_deg": 0.0,
403+
"shell_name": "Bad",
404+
})
405+
assert result is not None
406+
407+
def test_validate_shell_rejects_too_many_sats(self):
408+
from humeris.gui import validate_shell_dict
409+
410+
result = validate_shell_dict({
411+
"altitude_km": 550.0,
412+
"inclination_deg": 53.0,
413+
"num_planes": 100,
414+
"sats_per_plane": 100,
415+
"phase_factor": 1,
416+
"raan_offset_deg": 0.0,
417+
"shell_name": "Huge",
418+
})
419+
assert result is not None # 10,000 per shell > cap
420+
421+
def test_validate_shell_accepts_valid(self):
422+
from humeris.gui import validate_shell_dict
423+
424+
result = validate_shell_dict({
425+
"altitude_km": 550.0,
426+
"inclination_deg": 53.0,
427+
"num_planes": 6,
428+
"sats_per_plane": 22,
429+
"phase_factor": 1,
430+
"raan_offset_deg": 0.0,
431+
"shell_name": "Good",
432+
})
433+
assert result is None # None = no error
434+
435+
311436
class TestGuiSmoke:
312437
"""GUI smoke test — build and destroy window."""
313438

0 commit comments

Comments
 (0)