@@ -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 :
0 commit comments