Skip to content

Commit f3761d7

Browse files
planet.jeroenclaude
andcommitted
feat: APP-09 CLI sweep + APP-10 CCSDS OEM/OPM import
APP-09: `humeris sweep --param name:min:max:step --metric type -o file` with CSV/JSON output, multi-param sweeps, and progress on stderr. APP-10: CCSDS OPM/OEM KVN parser (ccsds_parser.py) converts Cartesian state vectors to OrbitalState. CLI flags --import-opm/--import-oem. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 903d84e commit f3761d7

File tree

3 files changed

+738
-2
lines changed

3 files changed

+738
-2
lines changed

packages/core/src/humeris/cli.py

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
humeris -i sim.json -o out.json --export-ubox sats.ubox
2828
"""
2929
import argparse
30+
import math
3031
import sys
3132

3233
from humeris.domain.constellation import (
@@ -46,6 +47,7 @@
4647
from humeris.adapters.spaceengine_exporter import SpaceEngineExporter
4748
from humeris.adapters.ksp_exporter import KspExporter
4849
from humeris.adapters.ubox_exporter import UboxExporter
50+
from humeris.domain.orbital_mechanics import OrbitalConstants
4951
from humeris.domain.propagation import derive_orbital_state
5052

5153

@@ -318,10 +320,184 @@ def _run_serve(
318320
server.shutdown()
319321

320322

323+
def _run_sweep(args) -> None:
324+
"""Execute CLI parameter sweep (APP-09)."""
325+
import csv
326+
import json
327+
from datetime import datetime, timezone
328+
from itertools import product as iterproduct
329+
330+
try:
331+
from humeris.adapters.viewer_server import LayerManager
332+
except ImportError:
333+
print(
334+
"Sweep requires humeris-pro.\n"
335+
"Install with: pip install humeris-pro",
336+
file=sys.stderr,
337+
)
338+
sys.exit(1)
339+
340+
# Parse --param flags: "name:min:max:step"
341+
sweep_specs: list[tuple[str, float, float, float]] = []
342+
for p in args.param:
343+
parts = p.split(":")
344+
if len(parts) != 4:
345+
print(
346+
f"Error: --param must be name:min:max:step, got: {p}",
347+
file=sys.stderr,
348+
)
349+
sys.exit(1)
350+
name = parts[0]
351+
try:
352+
lo, hi, step = float(parts[1]), float(parts[2]), float(parts[3])
353+
except ValueError:
354+
print(f"Error: non-numeric values in --param: {p}", file=sys.stderr)
355+
sys.exit(1)
356+
if step <= 0:
357+
print(f"Error: step must be > 0 in --param: {p}", file=sys.stderr)
358+
sys.exit(1)
359+
sweep_specs.append((name, lo, hi, step))
360+
361+
# Generate value ranges for each parameter
362+
param_ranges: list[list[float]] = []
363+
param_names: list[str] = []
364+
for name, lo, hi, step in sweep_specs:
365+
vals: list[float] = []
366+
v = lo
367+
while v <= hi + 1e-9:
368+
vals.append(round(v, 6))
369+
v += step
370+
param_ranges.append(vals)
371+
param_names.append(name)
372+
373+
# Base constellation params (defaults)
374+
base_params: dict[str, float] = {
375+
"altitude_km": 550,
376+
"inclination_deg": 53,
377+
"num_planes": 6,
378+
"sats_per_plane": 10,
379+
"phase_factor": 0,
380+
"raan_offset_deg": 0,
381+
}
382+
383+
epoch = datetime.now(tz=timezone.utc)
384+
mgr = LayerManager(epoch=epoch)
385+
386+
# Cartesian product of all parameter ranges
387+
combos = list(iterproduct(*param_ranges))
388+
total = len(combos)
389+
390+
results: list[dict] = []
391+
for idx, combo in enumerate(combos):
392+
params = dict(base_params)
393+
for name, val in zip(param_names, combo):
394+
params[name] = val
395+
396+
print(
397+
f" [{idx + 1}/{total}] {', '.join(f'{n}={v}' for n, v in zip(param_names, combo))}",
398+
file=sys.stderr,
399+
)
400+
401+
sweep_result = mgr.run_sweep(
402+
base_params=params,
403+
sweep_param=param_names[0], # sweep on first param at its value
404+
sweep_min=combo[0],
405+
sweep_max=combo[0],
406+
sweep_step=1.0, # single value
407+
metric_type=args.metric,
408+
)
409+
if sweep_result:
410+
results.append(sweep_result[0])
411+
412+
# Write output
413+
fmt = getattr(args, "format", "csv")
414+
output_path = args.output
415+
416+
if fmt == "json":
417+
import json as _json
418+
with open(output_path, "w", encoding="utf-8") as f:
419+
_json.dump(results, f, indent=2, default=str)
420+
else:
421+
# CSV
422+
if not results:
423+
print("No results to write.", file=sys.stderr)
424+
sys.exit(1)
425+
# Collect all metric keys
426+
metric_keys: list[str] = []
427+
for r in results:
428+
for k in r.get("metrics", {}):
429+
if k not in metric_keys:
430+
metric_keys.append(k)
431+
fieldnames = list(param_names) + metric_keys
432+
with open(output_path, "w", newline="", encoding="utf-8") as f:
433+
writer = csv.DictWriter(f, fieldnames=fieldnames)
434+
writer.writeheader()
435+
for r in results:
436+
row = {}
437+
for pn in param_names:
438+
row[pn] = r.get("params", {}).get(pn, "")
439+
for mk in metric_keys:
440+
row[mk] = r.get("metrics", {}).get(mk, "")
441+
writer.writerow(row)
442+
443+
print(f"Wrote {len(results)} results to {output_path}", file=sys.stderr)
444+
445+
446+
def _run_import_opm(args) -> None:
447+
"""Import CCSDS OPM file and display satellite info."""
448+
from humeris.domain.ccsds_parser import parse_opm
449+
result = parse_opm(args.import_opm)
450+
print(f"Object: {result.object_name} ({result.object_id})")
451+
print(f"Frame: {result.ref_frame}, Center: {result.center_name}")
452+
for i, state in enumerate(result.states):
453+
alt_km = (state.semi_major_axis_m - OrbitalConstants.R_EARTH) / 1000.0
454+
inc_deg = math.degrees(state.inclination_rad)
455+
print(f" State {i}: alt={alt_km:.1f} km, inc={inc_deg:.1f} deg, epoch={state.reference_epoch}")
456+
457+
458+
def _run_import_oem(args) -> None:
459+
"""Import CCSDS OEM file and display satellite info."""
460+
from humeris.domain.ccsds_parser import parse_oem
461+
result = parse_oem(args.import_oem)
462+
print(f"Object: {result.object_name} ({result.object_id})")
463+
print(f"Frame: {result.ref_frame}, Center: {result.center_name}")
464+
print(f"Ephemeris points: {len(result.states)}")
465+
if result.states:
466+
first = result.states[0]
467+
last = result.states[-1]
468+
print(f" First epoch: {first.reference_epoch}")
469+
print(f" Last epoch: {last.reference_epoch}")
470+
471+
321472
def main():
322473
parser = argparse.ArgumentParser(
323474
description="Generate satellite constellations for simulation (synthetic or live)"
324475
)
476+
subparsers = parser.add_subparsers(dest="command")
477+
478+
# Sweep subcommand
479+
sweep_parser = subparsers.add_parser(
480+
"sweep",
481+
help="Run parameter sweep for trade studies (batch mode)",
482+
)
483+
sweep_parser.add_argument(
484+
'--param', action='append', required=True,
485+
help="Parameter sweep spec: name:min:max:step (repeatable)"
486+
)
487+
sweep_parser.add_argument(
488+
'--metric', required=True,
489+
help="Metric type to compute (coverage, eclipse, beta_angle, deorbit, station_keeping)"
490+
)
491+
sweep_parser.add_argument(
492+
'--output', '-o', required=True,
493+
help="Output file path (.csv or .json)"
494+
)
495+
sweep_parser.add_argument(
496+
'--format', choices=['csv', 'json'], default='csv',
497+
help="Output format (default: csv)"
498+
)
499+
500+
# Main parser flags
325501
parser.add_argument(
326502
'--input', '-i',
327503
help="Path to input simulation JSON (with Earth + Satellite template)"
@@ -359,6 +535,17 @@ def main():
359535
help="Name of satellite template entity (default: Satellite)"
360536
)
361537

538+
# CCSDS import flags
539+
import_group = parser.add_argument_group('CCSDS import')
540+
import_group.add_argument(
541+
'--import-opm',
542+
help="Import CCSDS OPM file (.opm) and display orbital state"
543+
)
544+
import_group.add_argument(
545+
'--import-oem',
546+
help="Import CCSDS OEM file (.oem) and display ephemeris summary"
547+
)
548+
362549
live_group = parser.add_argument_group('live data (CelesTrak)')
363550
live_group.add_argument(
364551
'--live-group',
@@ -435,6 +622,19 @@ def main():
435622

436623
args = parser.parse_args()
437624

625+
# Subcommand dispatch
626+
if args.command == "sweep":
627+
_run_sweep(args)
628+
return
629+
630+
# CCSDS import
631+
if getattr(args, "import_opm", None):
632+
_run_import_opm(args)
633+
return
634+
if getattr(args, "import_oem", None):
635+
_run_import_oem(args)
636+
return
637+
438638
if args.serve:
439639
_run_serve(
440640
port=args.port,

0 commit comments

Comments
 (0)