microlens-submit 0.12.1__py3-none-any.whl

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.
@@ -0,0 +1,1803 @@
1
+ """Command line interface for microlens-submit.
2
+
3
+ This module provides a comprehensive CLI for managing microlensing challenge
4
+ submissions. It includes commands for project initialization, solution management,
5
+ validation, dossier generation, and export functionality.
6
+
7
+ The CLI is built using Typer and provides rich, colored output with helpful
8
+ error messages and validation feedback. All commands support both interactive
9
+ and scripted usage patterns.
10
+
11
+ **Key Commands:**
12
+ - init: Create new submission projects
13
+ - add-solution: Add microlensing solutions with parameters
14
+ - validate-submission: Check submission completeness
15
+ - generate-dossier: Create HTML documentation
16
+ - export: Create submission archives
17
+
18
+ **Example Workflow:**
19
+ # Initialize a new project
20
+ microlens-submit init --team-name "Team Alpha" --tier "advanced" ./my_project
21
+
22
+ # Add a solution
23
+ microlens-submit add-solution EVENT001 1S1L ./my_project \
24
+ --param t0=2459123.5 --param u0=0.1 --param tE=20.0 \
25
+ --log-likelihood -1234.56 --cpu-hours 2.5
26
+
27
+ # Validate and generate dossier
28
+ microlens-submit validate-submission ./my_project
29
+ microlens-submit generate-dossier ./my_project
30
+
31
+ # Export for submission
32
+ microlens-submit export submission.zip ./my_project
33
+
34
+ **Note:**
35
+ All commands that modify data automatically save changes to disk.
36
+ Use --dry-run flags to preview changes without saving.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ import math
43
+ from pathlib import Path
44
+ from typing import List, Optional, Literal
45
+ import os
46
+ import subprocess
47
+
48
+ import typer
49
+ from rich.console import Console
50
+ from rich.panel import Panel
51
+ from rich.table import Table
52
+
53
+ from .api import load
54
+ from .dossier import generate_dashboard_html
55
+ from . import __version__
56
+
57
+ console = Console()
58
+ app = typer.Typer()
59
+
60
+
61
+ @app.command("version")
62
+ def version() -> None:
63
+ """Show the version of microlens-submit.
64
+
65
+ Displays the current version of the microlens-submit package.
66
+
67
+ Example:
68
+ >>> microlens-submit version
69
+ microlens-submit version 0.12.0-dev
70
+
71
+ Note:
72
+ This command is useful for verifying the installed version
73
+ and for debugging purposes.
74
+ """
75
+ console.print(f"microlens-submit version {__version__}")
76
+
77
+
78
+ def _parse_pairs(pairs: Optional[List[str]]) -> Optional[dict]:
79
+ """Convert CLI key=value options into a dictionary.
80
+
81
+ Parses command-line arguments in the format "key=value" and converts
82
+ them to a Python dictionary. Handles JSON parsing for numeric and
83
+ boolean values, falling back to string values.
84
+
85
+ Args:
86
+ pairs: List of strings in "key=value" format, or None.
87
+
88
+ Returns:
89
+ dict: Parsed key-value pairs, or None if pairs is None/empty.
90
+
91
+ Raises:
92
+ typer.BadParameter: If any pair is not in "key=value" format.
93
+
94
+ Example:
95
+ >>> _parse_pairs(["t0=2459123.5", "u0=0.1", "active=true"])
96
+ {'t0': 2459123.5, 'u0': 0.1, 'active': True}
97
+
98
+ >>> _parse_pairs(["name=test", "value=123"])
99
+ {'name': 'test', 'value': 123}
100
+
101
+ Note:
102
+ This function attempts to parse values as JSON first (for numbers,
103
+ booleans, etc.), then falls back to string values if JSON parsing fails.
104
+ """
105
+ if not pairs:
106
+ return None
107
+ out: dict = {}
108
+ for item in pairs:
109
+ if "=" not in item:
110
+ raise typer.BadParameter(f"Invalid format: {item}")
111
+ key, value = item.split("=", 1)
112
+ try:
113
+ out[key] = json.loads(value)
114
+ except json.JSONDecodeError:
115
+ out[key] = value
116
+ return out
117
+
118
+
119
+ def _params_file_callback(ctx: typer.Context, value: Optional[Path]) -> Optional[Path]:
120
+ """Validate mutually exclusive parameter options.
121
+
122
+ Ensures that --params-file and --param options are not used together,
123
+ as they are mutually exclusive ways of specifying parameters.
124
+
125
+ Args:
126
+ ctx: Typer context containing other parameter values.
127
+ value: The value of the --params-file option.
128
+
129
+ Returns:
130
+ Path: The validated file path, or None.
131
+
132
+ Raises:
133
+ typer.BadParameter: If both --params-file and --param are specified,
134
+ or if neither is specified when required.
135
+
136
+ Example:
137
+ # This would raise an error:
138
+ # microlens-submit add-solution EVENT001 1S1L --param t0=123 --params-file params.json
139
+
140
+ # This is valid:
141
+ # microlens-submit add-solution EVENT001 1S1L --params-file params.json
142
+
143
+ Note:
144
+ This is a Typer callback function used for parameter validation.
145
+ It's automatically called when processing command-line arguments.
146
+ """
147
+ param_vals = ctx.params.get("param")
148
+ if value is not None and param_vals:
149
+ raise typer.BadParameter("Cannot use --param with --params-file")
150
+ if value is None and not param_vals and not ctx.resilient_parsing:
151
+ raise typer.BadParameter("Provide either --param or --params-file")
152
+ return value
153
+
154
+
155
+ @app.callback()
156
+ def main(
157
+ ctx: typer.Context,
158
+ no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
159
+ ) -> None:
160
+ """Handle global CLI options.
161
+
162
+ Sets up global configuration for the CLI, including color output
163
+ preferences that apply to all commands.
164
+
165
+ Args:
166
+ ctx: Typer context for command execution.
167
+ no_color: If True, disable colored output for all commands.
168
+
169
+ Example:
170
+ # Disable colors for all commands
171
+ microlens-submit --no-color init --team-name "Team" --tier "basic" ./project
172
+
173
+ Note:
174
+ This is a Typer callback that runs before any command execution.
175
+ It's used to configure global settings like color output.
176
+ """
177
+ if no_color:
178
+ global console
179
+ console = Console(color_system=None)
180
+
181
+
182
+ @app.command()
183
+ def init(
184
+ team_name: str = typer.Option(..., help="Team name"),
185
+ tier: str = typer.Option(..., help="Challenge tier"),
186
+ project_path: Path = typer.Argument(Path("."), help="Project directory"),
187
+ ) -> None:
188
+ """Create a new submission project in the specified directory.
189
+
190
+ Initializes a new microlensing submission project with the given team name
191
+ and tier. The project directory structure is created automatically, and
192
+ the submission.json file is initialized with basic metadata.
193
+
194
+ This command also attempts to auto-detect the GitHub repository URL
195
+ from the current git configuration and provides helpful feedback.
196
+
197
+ Args:
198
+ team_name: Name of the participating team (e.g., "Team Alpha").
199
+ tier: Challenge tier level (e.g., "basic", "advanced").
200
+ project_path: Directory where the project will be created.
201
+ Defaults to current directory if not specified.
202
+
203
+ Raises:
204
+ OSError: If unable to create the project directory or write files.
205
+
206
+ Example:
207
+ # Create project in current directory
208
+ microlens-submit init --team-name "Team Alpha" --tier "advanced"
209
+
210
+ # Create project in specific directory
211
+ microlens-submit init --team-name "Team Beta" --tier "basic" ./my_submission
212
+
213
+ # Project structure created:
214
+ # ./my_submission/
215
+ # ├── submission.json
216
+ # └── events/
217
+
218
+ Note:
219
+ If the project directory already exists, it will be used as-is.
220
+ If a git repository is detected, the GitHub URL will be automatically
221
+ set. Otherwise, a warning is shown and you can set it later with
222
+ set-repo-url command.
223
+ """
224
+ sub = load(str(project_path))
225
+ sub.team_name = team_name
226
+ sub.tier = tier
227
+ # Try to auto-detect repo_url
228
+ try:
229
+ repo_url = subprocess.check_output([
230
+ 'git', 'config', '--get', 'remote.origin.url'
231
+ ], stderr=subprocess.DEVNULL).decode().strip()
232
+ except Exception:
233
+ repo_url = None
234
+ if repo_url:
235
+ sub.repo_url = repo_url
236
+ console.print(f"[green]Auto-detected GitHub repo URL:[/green] {repo_url}")
237
+ else:
238
+ console.print("[yellow]Could not auto-detect a GitHub repository URL. Please add it using 'microlens-submit set-repo-url <url> <project_dir>'.[/yellow]")
239
+ sub.save()
240
+ console.print(Panel(f"Initialized project at {project_path}", style="bold green"))
241
+
242
+
243
+ @app.command("nexus-init")
244
+ def nexus_init(
245
+ team_name: str = typer.Option(..., help="Team name"),
246
+ tier: str = typer.Option(..., help="Challenge tier"),
247
+ project_path: Path = typer.Argument(Path("."), help="Project directory"),
248
+ ) -> None:
249
+ """Create a project and record Roman Nexus environment details.
250
+
251
+ This command combines the functionality of init() with automatic
252
+ detection of Roman Science Platform environment information. It
253
+ populates hardware_info with CPU details, memory information, and
254
+ the Nexus image identifier.
255
+
256
+ Args:
257
+ team_name: Name of the participating team (e.g., "Team Alpha").
258
+ tier: Challenge tier level (e.g., "basic", "advanced").
259
+ project_path: Directory where the project will be created.
260
+ Defaults to current directory if not specified.
261
+
262
+ Example:
263
+ # Initialize project with Nexus platform info
264
+ microlens-submit nexus-init --team-name "Team Alpha" --tier "advanced" ./project
265
+
266
+ # This will automatically detect:
267
+ # - CPU model from /proc/cpuinfo
268
+ # - Memory from /proc/meminfo
269
+ # - Nexus image from JUPYTER_IMAGE_SPEC
270
+
271
+ Note:
272
+ This command is specifically designed for the Roman Science Platform
273
+ environment. It will silently skip any environment information that
274
+ cannot be detected (e.g., if running outside of Nexus).
275
+ """
276
+ init(team_name=team_name, tier=tier, project_path=project_path)
277
+ sub = load(str(project_path))
278
+ sub.autofill_nexus_info()
279
+ sub.save()
280
+ console.print("Nexus platform info captured.", style="bold green")
281
+
282
+
283
+ @app.command("add-solution")
284
+ def add_solution(
285
+ event_id: str,
286
+ model_type: str = typer.Argument(
287
+ ...,
288
+ metavar="{1S1L|1S2L|2S1L|2S2L|1S3L|2S3L|other}",
289
+ help="Type of model used for the solution (e.g., 1S1L, 1S2L)",
290
+ ),
291
+ param: Optional[List[str]] = typer.Option(
292
+ None, help="Model parameters as key=value"
293
+ ),
294
+ params_file: Optional[Path] = typer.Option(
295
+ None,
296
+ "--params-file",
297
+ help="Path to JSON or YAML file with model parameters and uncertainties",
298
+ callback=_params_file_callback,
299
+ ),
300
+ bands: Optional[List[str]] = typer.Option(
301
+ None,
302
+ "--bands",
303
+ help="Comma-separated list of photometric bands used (e.g., 0,1,2)",
304
+ ),
305
+ higher_order_effect: Optional[List[str]] = typer.Option(
306
+ None,
307
+ "--higher-order-effect",
308
+ help="List of higher-order effects (e.g., parallax, finite-source)",
309
+ ),
310
+ t_ref: Optional[float] = typer.Option(
311
+ None,
312
+ "--t-ref",
313
+ help="Reference time for the model",
314
+ ),
315
+ used_astrometry: bool = typer.Option(False, help="Set if astrometry was used"),
316
+ used_postage_stamps: bool = typer.Option(
317
+ False, help="Set if postage stamps were used"
318
+ ),
319
+ limb_darkening_model: Optional[str] = typer.Option(
320
+ None, help="Limb darkening model name"
321
+ ),
322
+ limb_darkening_coeff: Optional[List[str]] = typer.Option(
323
+ None,
324
+ "--limb-darkening-coeff",
325
+ help="Limb darkening coefficients as key=value",
326
+ ),
327
+ parameter_uncertainty: Optional[List[str]] = typer.Option(
328
+ None,
329
+ "--param-uncertainty",
330
+ help="Parameter uncertainties as key=value",
331
+ ),
332
+ physical_param: Optional[List[str]] = typer.Option(
333
+ None,
334
+ "--physical-param",
335
+ help="Physical parameters as key=value",
336
+ ),
337
+ relative_probability: Optional[float] = typer.Option(
338
+ None,
339
+ "--relative-probability",
340
+ help="Relative probability of this solution",
341
+ ),
342
+ log_likelihood: Optional[float] = typer.Option(None, help="Log likelihood"),
343
+ n_data_points: Optional[int] = typer.Option(
344
+ None,
345
+ "--n-data-points",
346
+ help="Number of data points used in this solution",
347
+ ),
348
+ cpu_hours: Optional[float] = typer.Option(
349
+ None,
350
+ "--cpu-hours",
351
+ help="CPU hours used for this solution",
352
+ ),
353
+ wall_time_hours: Optional[float] = typer.Option(
354
+ None,
355
+ "--wall-time-hours",
356
+ help="Wall time hours used for this solution",
357
+ ),
358
+ lightcurve_plot_path: Optional[Path] = typer.Option(
359
+ None, "--lightcurve-plot-path", help="Path to lightcurve plot file"
360
+ ),
361
+ lens_plane_plot_path: Optional[Path] = typer.Option(
362
+ None, "--lens-plane-plot-path", help="Path to lens plane plot file"
363
+ ),
364
+ notes: Optional[str] = typer.Option(None, help="Notes for the solution (supports Markdown formatting)"),
365
+ notes_file: Optional[Path] = typer.Option(None, "--notes-file", help="Path to a Markdown file for solution notes (mutually exclusive with --notes)"),
366
+ dry_run: bool = typer.Option(
367
+ False,
368
+ "--dry-run",
369
+ help="Parse inputs and display the resulting Solution without saving",
370
+ ),
371
+ project_path: Path = typer.Argument(Path("."), help="Project directory"),
372
+ ) -> None:
373
+ """Add a new solution entry for a microlensing event.
374
+
375
+ Creates a new solution with the specified model type and parameters,
376
+ automatically generating a unique solution ID. The solution is added
377
+ to the specified event and saved to disk.
378
+
379
+ **Model Types Supported:**
380
+ - 1S1L: Single source, single lens (point source, point lens)
381
+ - 1S2L: Single source, binary lens
382
+ - 2S1L: Binary source, single lens
383
+ - 2S2L: Binary source, binary lens
384
+ - 1S3L: Single source, triple lens
385
+ - 2S3L: Binary source, triple lens
386
+ - other: Custom model type
387
+
388
+ **Parameter Specification:**
389
+ Parameters can be specified either via individual --param options
390
+ or using a structured file with --params-file. The file can be JSON
391
+ or YAML format and can include both parameters and uncertainties.
392
+
393
+ **Higher-Order Effects:**
394
+ - parallax: Annual parallax effect
395
+ - finite-source: Finite source size effects
396
+ - lens-orbital-motion: Lens orbital motion
397
+ - limb-darkening: Limb darkening effects
398
+ - xallarap: Xallarap (source orbital motion)
399
+ - stellar-rotation: Stellar rotation effects
400
+ - fitted-limb-darkening: Fitted limb darkening coefficients
401
+ - gaussian-process: Gaussian process noise modeling
402
+ - other: Custom higher-order effects
403
+
404
+ Args:
405
+ event_id: Identifier for the microlensing event (e.g., "EVENT001").
406
+ model_type: Type of microlensing model used for the fit.
407
+ param: Model parameters as key=value pairs (e.g., "t0=2459123.5").
408
+ params_file: Path to JSON/YAML file containing parameters and uncertainties.
409
+ bands: List of photometric bands used in the fit (e.g., ["0", "1", "2"]).
410
+ higher_order_effect: List of higher-order physical effects included.
411
+ t_ref: Reference time for time-dependent effects (Julian Date).
412
+ used_astrometry: Whether astrometric data was used in the fit.
413
+ used_postage_stamps: Whether postage stamp data was used.
414
+ limb_darkening_model: Name of the limb darkening model employed.
415
+ limb_darkening_coeff: Limb darkening coefficients as key=value pairs.
416
+ parameter_uncertainty: Parameter uncertainties as key=value pairs.
417
+ physical_param: Derived physical parameters as key=value pairs.
418
+ relative_probability: Probability of this solution being the best model.
419
+ log_likelihood: Log-likelihood value of the fit.
420
+ n_data_points: Number of data points used in the fit.
421
+ cpu_hours: Total CPU time consumed by the fit.
422
+ wall_time_hours: Real-world time consumed by the fit.
423
+ lightcurve_plot_path: Path to the lightcurve plot file.
424
+ lens_plane_plot_path: Path to the lens plane plot file.
425
+ notes: Markdown-formatted notes for the solution.
426
+ notes_file: Path to a Markdown file containing solution notes.
427
+ dry_run: If True, display the solution without saving.
428
+ project_path: Directory of the submission project.
429
+
430
+ Raises:
431
+ typer.BadParameter: If parameter format is invalid or model type is unsupported.
432
+ OSError: If unable to write files or create directories.
433
+
434
+ Example:
435
+ # Simple 1S1L solution with inline parameters
436
+ microlens-submit add-solution EVENT001 1S1L ./project \
437
+ --param t0=2459123.5 --param u0=0.1 --param tE=20.0 \
438
+ --log-likelihood -1234.56 --n-data-points 1250 \
439
+ --cpu-hours 2.5 --wall-time-hours 0.5 \
440
+ --relative-probability 0.8 \
441
+ --notes "# Simple Point Lens Fit\n\nThis is a basic 1S1L solution."
442
+
443
+ # Binary lens solution with higher-order effects
444
+ microlens-submit add-solution EVENT002 1S2L ./project \
445
+ --param t0=2459156.2 --param u0=0.08 --param tE=35.7 \
446
+ --param q=0.0005 --param s=0.95 --param alpha=78.3 \
447
+ --higher-order-effect parallax --higher-order-effect finite-source \
448
+ --t-ref 2459156.0 --log-likelihood -2156.78 \
449
+ --cpu-hours 28.5 --wall-time-hours 7.2
450
+
451
+ # Using a parameter file
452
+ microlens-submit add-solution EVENT003 1S1L ./project \
453
+ --params-file parameters.json \
454
+ --log-likelihood -987.65 --cpu-hours 8.1
455
+
456
+ # Dry run to preview without saving
457
+ microlens-submit add-solution EVENT001 1S1L ./project \
458
+ --param t0=2459123.5 --param u0=0.1 --param tE=20.0 \
459
+ --dry-run
460
+
461
+ Note:
462
+ The solution is automatically assigned a unique UUID and marked as active.
463
+ If notes are provided, they are saved as a Markdown file in the project
464
+ structure. Use --dry-run to preview the solution before saving.
465
+ The command automatically validates the solution and displays any warnings.
466
+ """
467
+ sub = load(str(project_path))
468
+ evt = sub.get_event(event_id)
469
+ params: dict = {}
470
+ uncertainties: dict = {}
471
+ if params_file is not None:
472
+ params, uncertainties = _parse_structured_params_file(params_file)
473
+ else:
474
+ for p in param or []:
475
+ if "=" not in p:
476
+ raise typer.BadParameter(f"Invalid parameter format: {p}")
477
+ key, value = p.split("=", 1)
478
+ try:
479
+ params[key] = json.loads(value)
480
+ except json.JSONDecodeError:
481
+ params[key] = value
482
+ allowed_model_types = [
483
+ "1S1L",
484
+ "1S2L",
485
+ "2S1L",
486
+ "2S2L",
487
+ "1S3L",
488
+ "2S3L",
489
+ "other",
490
+ ]
491
+ if model_type not in allowed_model_types:
492
+ raise typer.BadParameter(f"model_type must be one of {allowed_model_types}")
493
+ if bands and len(bands) == 1 and "," in bands[0]:
494
+ bands = bands[0].split(",")
495
+ if (
496
+ higher_order_effect
497
+ and len(higher_order_effect) == 1
498
+ and "," in higher_order_effect[0]
499
+ ):
500
+ higher_order_effect = higher_order_effect[0].split(",")
501
+ sol = evt.add_solution(model_type=model_type, parameters=params)
502
+ sol.bands = bands or []
503
+ sol.higher_order_effects = higher_order_effect or []
504
+ sol.t_ref = t_ref
505
+ sol.used_astrometry = used_astrometry
506
+ sol.used_postage_stamps = used_postage_stamps
507
+ sol.limb_darkening_model = limb_darkening_model
508
+ sol.limb_darkening_coeffs = _parse_pairs(limb_darkening_coeff)
509
+ sol.parameter_uncertainties = _parse_pairs(parameter_uncertainty) or uncertainties
510
+ sol.physical_parameters = _parse_pairs(physical_param)
511
+ sol.log_likelihood = log_likelihood
512
+ sol.relative_probability = relative_probability
513
+ sol.n_data_points = n_data_points
514
+ if cpu_hours is not None or wall_time_hours is not None:
515
+ sol.set_compute_info(cpu_hours=cpu_hours, wall_time_hours=wall_time_hours)
516
+ sol.lightcurve_plot_path = (
517
+ str(lightcurve_plot_path) if lightcurve_plot_path else None
518
+ )
519
+ sol.lens_plane_plot_path = (
520
+ str(lens_plane_plot_path) if lens_plane_plot_path else None
521
+ )
522
+ # Handle notes file logic
523
+ canonical_notes_path = Path(project_path) / "events" / event_id / "solutions" / f"{sol.solution_id}.md"
524
+ if notes_file is not None:
525
+ sol.notes_path = str(notes_file)
526
+ else:
527
+ sol.notes_path = str(canonical_notes_path.relative_to(project_path))
528
+ if dry_run:
529
+ parsed = {
530
+ "event_id": event_id,
531
+ "model_type": model_type,
532
+ "parameters": params,
533
+ "bands": bands,
534
+ "higher_order_effects": higher_order_effect,
535
+ "t_ref": t_ref,
536
+ "used_astrometry": used_astrometry,
537
+ "used_postage_stamps": used_postage_stamps,
538
+ "limb_darkening_model": limb_darkening_model,
539
+ "limb_darkening_coeffs": _parse_pairs(limb_darkening_coeff),
540
+ "parameter_uncertainties": _parse_pairs(parameter_uncertainty),
541
+ "physical_parameters": _parse_pairs(physical_param),
542
+ "log_likelihood": log_likelihood,
543
+ "relative_probability": relative_probability,
544
+ "n_data_points": n_data_points,
545
+ "cpu_hours": cpu_hours,
546
+ "wall_time_hours": wall_time_hours,
547
+ "lightcurve_plot_path": (
548
+ str(lightcurve_plot_path) if lightcurve_plot_path else None
549
+ ),
550
+ "lens_plane_plot_path": (
551
+ str(lens_plane_plot_path) if lens_plane_plot_path else None
552
+ ),
553
+ "notes_path": sol.notes_path,
554
+ }
555
+ console.print(Panel("Parsed Input", style="cyan"))
556
+ console.print(json.dumps(parsed, indent=2))
557
+ console.print(Panel("Schema Output", style="cyan"))
558
+ console.print(sol.model_dump_json(indent=2))
559
+ validation_messages = sol.validate()
560
+ if validation_messages:
561
+ console.print(Panel("Validation Warnings", style="yellow"))
562
+ for msg in validation_messages:
563
+ console.print(f" • {msg}")
564
+ else:
565
+ console.print(Panel("Solution validated successfully!", style="green"))
566
+ return
567
+ # Only write files if not dry_run
568
+ if notes_file is not None:
569
+ # If a notes file is provided, do not overwrite it, just ensure path is set
570
+ pass
571
+ else:
572
+ if notes is not None:
573
+ canonical_notes_path.parent.mkdir(parents=True, exist_ok=True)
574
+ canonical_notes_path.write_text(notes, encoding="utf-8")
575
+ elif not canonical_notes_path.exists():
576
+ canonical_notes_path.parent.mkdir(parents=True, exist_ok=True)
577
+ canonical_notes_path.write_text("", encoding="utf-8")
578
+ sub.save()
579
+ validation_messages = sol.validate()
580
+ if validation_messages:
581
+ console.print(Panel("Validation Warnings", style="yellow"))
582
+ for msg in validation_messages:
583
+ console.print(f" • {msg}")
584
+ else:
585
+ console.print(Panel("Solution validated successfully!", style="green"))
586
+ console.print(f"Created solution: [bold cyan]{sol.solution_id}[/bold cyan]")
587
+
588
+
589
+ @app.command()
590
+ def deactivate(
591
+ solution_id: str,
592
+ project_path: Path = typer.Argument(Path("."), help="Project directory"),
593
+ ) -> None:
594
+ """Mark a solution as inactive so it is excluded from exports.
595
+
596
+ Deactivates a solution by setting its is_active flag to False. Inactive
597
+ solutions are excluded from submission exports and dossier generation,
598
+ but their data remains intact and can be reactivated later.
599
+
600
+ Args:
601
+ solution_id: The unique identifier of the solution to deactivate.
602
+ project_path: Directory of the submission project.
603
+
604
+ Raises:
605
+ typer.Exit: If the solution is not found in any event.
606
+
607
+ Example:
608
+ # Deactivate a specific solution
609
+ microlens-submit deactivate abc12345-def6-7890-ghij-klmnopqrstuv ./project
610
+
611
+ # The solution is now inactive and won't be included in exports
612
+ microlens-submit export submission.zip ./project # Excludes inactive solutions
613
+
614
+ Note:
615
+ This command only changes the active status. The solution data remains
616
+ intact and can be reactivated using the activate command. Use this to
617
+ keep alternative fits without including them in the final submission.
618
+ """
619
+ sub = load(str(project_path))
620
+ for event in sub.events.values():
621
+ if solution_id in event.solutions:
622
+ event.solutions[solution_id].deactivate()
623
+ sub.save()
624
+ console.print(f"Deactivated {solution_id}")
625
+ return
626
+ console.print(f"Solution {solution_id} not found", style="bold red")
627
+ raise typer.Exit(code=1)
628
+
629
+
630
+ @app.command()
631
+ def activate(
632
+ solution_id: str,
633
+ project_path: Path = typer.Argument(Path("."), help="Project directory"),
634
+ ) -> None:
635
+ """Mark a solution as active so it is included in exports.
636
+
637
+ Activates a solution by setting its is_active flag to True. Active
638
+ solutions are included in submission exports and dossier generation.
639
+ This is the default state for newly created solutions.
640
+
641
+ Args:
642
+ solution_id: The unique identifier of the solution to activate.
643
+ project_path: Directory of the submission project.
644
+
645
+ Raises:
646
+ typer.Exit: If the solution is not found in any event.
647
+
648
+ Example:
649
+ # Activate a previously deactivated solution
650
+ microlens-submit activate abc12345-def6-7890-ghij-klmnopqrstuv ./project
651
+
652
+ # The solution is now active and will be included in exports
653
+ microlens-submit export submission.zip ./project # Includes active solutions
654
+
655
+ Note:
656
+ This command only changes the active status. Use this to reactivate
657
+ solutions that were previously deactivated using the deactivate command.
658
+ """
659
+ sub = load(str(project_path))
660
+ for event in sub.events.values():
661
+ if solution_id in event.solutions:
662
+ event.solutions[solution_id].activate()
663
+ sub.save()
664
+ console.print(f"Activated {solution_id}")
665
+ return
666
+ console.print(f"Solution {solution_id} not found", style="bold red")
667
+ raise typer.Exit(code=1)
668
+
669
+
670
+ @app.command()
671
+ def export(
672
+ output_path: Path,
673
+ force: bool = typer.Option(False, "--force", help="Skip validation prompts"),
674
+ project_path: Path = typer.Argument(Path("."), help="Project directory"),
675
+ ) -> None:
676
+ """Generate a zip archive containing all active solutions.
677
+
678
+ Creates a compressed zip file containing the complete submission data,
679
+ including all active solutions, parameters, notes, and referenced files.
680
+ The archive is suitable for submission to the challenge organizers.
681
+
682
+ Before creating the export, the command validates the submission and
683
+ displays any warnings. If validation issues are found, the user is
684
+ prompted to continue or cancel the export (unless --force is used).
685
+
686
+ Args:
687
+ output_path: Path where the zip archive will be created.
688
+ force: If True, skip validation prompts and continue with export.
689
+ project_path: Directory of the submission project.
690
+
691
+ Raises:
692
+ typer.Exit: If validation fails and user cancels export.
693
+ ValueError: If referenced files (plots, posterior data) don't exist.
694
+ OSError: If unable to create the zip file.
695
+
696
+ Example:
697
+ # Export with validation prompts
698
+ microlens-submit export submission.zip ./project
699
+
700
+ # Force export without prompts
701
+ microlens-submit export submission.zip --force ./project
702
+
703
+ # Export to specific directory
704
+ microlens-submit export /path/to/submissions/my_submission.zip ./project
705
+
706
+ Note:
707
+ Only active solutions are included in the export. Inactive solutions
708
+ are excluded even if they exist in the project. The export includes:
709
+ - submission.json with metadata
710
+ - All active solutions with parameters
711
+ - Notes files for each solution
712
+ - Referenced files (plots, posterior data)
713
+
714
+ Relative probabilities are automatically calculated for solutions
715
+ that don't have them set, using BIC if sufficient data is available.
716
+ """
717
+ sub = load(str(project_path))
718
+ warnings = sub.validate()
719
+ if warnings:
720
+ console.print(Panel("Validation Warnings", style="yellow"))
721
+ for w in warnings:
722
+ console.print(f"- {w}")
723
+ if not force:
724
+ if not typer.confirm("Continue with export?"):
725
+ console.print("Export cancelled", style="bold red")
726
+ raise typer.Exit()
727
+ sub.export(str(output_path))
728
+ console.print(Panel(f"Exported submission to {output_path}", style="bold green"))
729
+
730
+
731
+ @app.command("generate-dossier")
732
+ def generate_dossier(
733
+ project_path: Path = typer.Argument(Path("."), help="Project directory"),
734
+ event_id: Optional[str] = typer.Option(None, "--event-id", help="Generate dossier for a specific event only (omit for full dossier)"),
735
+ solution_id: Optional[str] = typer.Option(None, "--solution-id", help="Generate dossier for a specific solution only (omit for full dossier)"),
736
+ ) -> None:
737
+ """Generate an HTML dossier for the submission.
738
+
739
+ Creates a comprehensive HTML dashboard that provides an overview of the submission,
740
+ including event summaries, solution statistics, and metadata. The dossier is saved
741
+ to the `dossier/` subdirectory of the project directory with the main dashboard as index.html.
742
+
743
+ The dossier includes:
744
+ - Main dashboard (index.html) with submission overview and statistics
745
+ - Individual event pages for each event with solution tables
746
+ - Individual solution pages with parameters, notes, and metadata
747
+ - Full comprehensive dossier (full_dossier_report.html) for printing
748
+
749
+ All pages use Tailwind CSS for styling and include syntax highlighting for
750
+ code blocks in participant notes.
751
+
752
+ Args:
753
+ project_path: Directory of the submission project.
754
+ event_id: If specified, only generate dossier for this event.
755
+ solution_id: If specified, only generate dossier for this solution.
756
+
757
+ Raises:
758
+ OSError: If unable to create output directory or write files.
759
+ ValueError: If submission data is invalid or missing required fields.
760
+
761
+ Example:
762
+ # Generate complete dossier for all events and solutions
763
+ microlens-submit generate-dossier ./project
764
+
765
+ # Generate dossier for specific event only
766
+ microlens-submit generate-dossier --event-id EVENT001 ./project
767
+
768
+ # Generate dossier for specific solution only
769
+ microlens-submit generate-dossier --solution-id abc12345-def6-7890-ghij-klmnopqrstuv ./project
770
+
771
+ # Files created:
772
+ # ./project/dossier/
773
+ # ├── index.html (main dashboard)
774
+ # ├── full_dossier_report.html (printable version)
775
+ # ├── EVENT001.html (event page)
776
+ # ├── solution_id.html (solution pages)
777
+ # └── assets/ (logos and icons)
778
+
779
+ Note:
780
+ The dossier is generated in the dossier/ subdirectory of the project.
781
+ The main dashboard provides navigation to individual event and solution pages.
782
+ The full dossier report combines all content into a single printable document.
783
+ GitHub repository links are included if available in the submission metadata.
784
+ """
785
+ sub = load(str(project_path))
786
+ output_dir = Path(project_path) / "dossier"
787
+ # Always generate dashboard (even if partial)
788
+ from .dossier import _generate_full_dossier_report_html
789
+ generate_dashboard_html(sub, output_dir)
790
+
791
+ # Determine if it's a full generation (no specific event/solution requested)
792
+ is_full_generation = not event_id and not solution_id
793
+
794
+ # Generate event/solution pages as needed (for now, always generate all, but this is where you'd restrict)
795
+ # TODO: In future, restrict event/solution generation if flags are set
796
+
797
+ if is_full_generation:
798
+ console.print(Panel("Generating comprehensive printable dossier...", style="cyan"))
799
+ _generate_full_dossier_report_html(sub, output_dir)
800
+ # Replace placeholder in index.html with the real link
801
+ dashboard_path = output_dir / "index.html"
802
+ if dashboard_path.exists():
803
+ with dashboard_path.open("r", encoding="utf-8") as f:
804
+ dashboard_html = f.read()
805
+ dashboard_html = dashboard_html.replace(
806
+ "<!--FULL_DOSSIER_LINK_PLACEHOLDER-->",
807
+ '<div class="text-center"><a href="./full_dossier_report.html" class="inline-block bg-rtd-accent text-white py-3 px-6 rounded-lg shadow-md hover:bg-rtd-secondary transition-colors duration-200 text-lg font-semibold mt-8">View Full Comprehensive Dossier (Printable)</a></div>'
808
+ )
809
+ with dashboard_path.open("w", encoding="utf-8") as f:
810
+ f.write(dashboard_html)
811
+ console.print(Panel("Comprehensive dossier generated!", style="bold green"))
812
+
813
+ console.print(Panel(f"Dossier generated successfully at {output_dir / 'index.html'}", style="bold green"))
814
+
815
+
816
+ @app.command("list-solutions")
817
+ def list_solutions(
818
+ event_id: str,
819
+ project_path: Path = typer.Argument(Path("."), help="Project directory"),
820
+ ) -> None:
821
+ """Display a table of solutions for a specific event.
822
+
823
+ Shows a formatted table containing all solutions for the specified event,
824
+ including their model types, active status, and notes snippets. The table
825
+ is displayed using rich formatting with color-coded status indicators.
826
+
827
+ Args:
828
+ event_id: Identifier of the event to list solutions for.
829
+ project_path: Directory of the submission project.
830
+
831
+ Raises:
832
+ typer.Exit: If the event is not found in the project.
833
+
834
+ Example:
835
+ # List all solutions for EVENT001
836
+ microlens-submit list-solutions EVENT001 ./project
837
+
838
+ # Output shows:
839
+ # ┌─────────────────────────────────────────────────────────┬──────────┬────────┬─────────────────┐
840
+ # │ Solution ID │ Model │ Status │ Notes │
841
+ # │ │ Type │ │ │
842
+ # ├─────────────────────────────────────────────────────────┼──────────┼────────┼─────────────────┤
843
+ # │ abc12345-def6-7890-ghij-klmnopqrstuv │ 1S1L │ Active │ Simple point... │
844
+ # │ def67890-abc1-2345-klmn-opqrstuvwxyz │ 1S2L │ Active │ Binary lens... │
845
+ # └─────────────────────────────────────────────────────────┴──────────┴────────┴─────────────────┘
846
+
847
+ Note:
848
+ The table shows both active and inactive solutions. Active solutions
849
+ are marked in green, inactive solutions in red. Notes are truncated
850
+ to fit the table display. Use the solution ID to reference specific
851
+ solutions in other commands.
852
+ """
853
+ sub = load(str(project_path))
854
+ if event_id not in sub.events:
855
+ console.print(f"Event {event_id} not found", style="bold red")
856
+ raise typer.Exit(code=1)
857
+ evt = sub.events[event_id]
858
+ table = Table(title=f"Solutions for {event_id}")
859
+ table.add_column("Solution ID")
860
+ table.add_column("Model Type")
861
+ table.add_column("Status")
862
+ table.add_column("Notes")
863
+ for sol in evt.solutions.values():
864
+ status = "[green]Active[/green]" if sol.is_active else "[red]Inactive[/red]"
865
+ table.add_row(sol.solution_id, sol.model_type, status, sol.notes)
866
+ console.print(table)
867
+
868
+
869
+ @app.command("compare-solutions")
870
+ def compare_solutions(
871
+ event_id: str,
872
+ project_path: Path = typer.Argument(Path("."), help="Project directory"),
873
+ ) -> None:
874
+ """Rank active solutions for an event using the Bayesian Information Criterion.
875
+
876
+ Compares all active solutions for the specified event using BIC to rank
877
+ them by relative probability. The BIC is calculated as:
878
+
879
+ BIC = k * ln(n) - 2 * ln(L)
880
+
881
+ where k is the number of parameters, n is the number of data points,
882
+ and L is the likelihood. Lower BIC values indicate better models.
883
+
884
+ The command displays a table with solution rankings and automatically
885
+ calculates relative probabilities for solutions that don't have them set.
886
+
887
+ Args:
888
+ event_id: Identifier of the event to compare solutions for.
889
+ project_path: Directory of the submission project.
890
+
891
+ Raises:
892
+ typer.Exit: If the event is not found in the project.
893
+
894
+ Example:
895
+ # Compare solutions for EVENT001
896
+ microlens-submit compare-solutions EVENT001 ./project
897
+
898
+ # Output shows:
899
+ # ┌─────────────────────────────────────────────────────────┬──────────┬─────────────────────┬─────────┬─────────────────┬─────────┬─────────────────┐
900
+ # │ Solution ID │ Model │ Higher-Order │ # Params│ Log-Likelihood │ BIC │ Relative Prob │
901
+ # │ │ Type │ Effects │ (k) │ │ │ │
902
+ # ├─────────────────────────────────────────────────────────┼──────────┼─────────────────────┼─────────┼─────────────────┼─────────┼─────────────────┤
903
+ # │ abc12345-def6-7890-ghij-klmnopqrstuv │ 1S1L │ - │ 3 │ -1234.56 │ 2475.12 │ 0.600 │
904
+ # │ def67890-abc1-2345-klmn-opqrstuvwxyz │ 1S2L │ parallax,finite-... │ 6 │ -1189.34 │ 2394.68 │ 0.400 │
905
+ # └─────────────────────────────────────────────────────────┴──────────┴─────────────────────┴─────────┴─────────────────┴─────────┴─────────────────┘
906
+
907
+ Note:
908
+ Only active solutions with valid log_likelihood and n_data_points
909
+ are included in the comparison. Solutions missing these values are
910
+ skipped with a warning. Relative probabilities are automatically
911
+ calculated using BIC if not already set.
912
+ """
913
+ sub = load(str(project_path))
914
+ if event_id not in sub.events:
915
+ console.print(f"Event {event_id} not found", style="bold red")
916
+ raise typer.Exit(code=1)
917
+
918
+ evt = sub.events[event_id]
919
+ solutions = []
920
+ for s in evt.get_active_solutions():
921
+ if s.log_likelihood is None or s.n_data_points is None:
922
+ continue
923
+ if s.n_data_points <= 0:
924
+ console.print(
925
+ f"Skipping {s.solution_id}: n_data_points <= 0",
926
+ style="bold red",
927
+ )
928
+ continue
929
+ solutions.append(s)
930
+
931
+ table = Table(title=f"Solution Comparison for {event_id}")
932
+ table.add_column("Solution ID")
933
+ table.add_column("Model Type")
934
+ table.add_column("Higher-Order Effects")
935
+ table.add_column("# Params (k)")
936
+ table.add_column("Log-Likelihood")
937
+ table.add_column("BIC")
938
+ table.add_column("Relative Prob")
939
+
940
+ rel_prob_map: dict[str, float] = {}
941
+ note = None
942
+ if solutions:
943
+ provided_sum = sum(
944
+ s.relative_probability or 0.0
945
+ for s in solutions
946
+ if s.relative_probability is not None
947
+ )
948
+ need_calc = [s for s in solutions if s.relative_probability is None]
949
+ if need_calc:
950
+ can_calc = all(
951
+ s.log_likelihood is not None
952
+ and s.n_data_points
953
+ and s.n_data_points > 0
954
+ and len(s.parameters) > 0
955
+ for s in need_calc
956
+ )
957
+ remaining = max(1.0 - provided_sum, 0.0)
958
+ if can_calc:
959
+ bic_vals = {
960
+ s.solution_id: len(s.parameters) * math.log(s.n_data_points)
961
+ - 2 * s.log_likelihood
962
+ for s in need_calc
963
+ }
964
+ bic_min = min(bic_vals.values())
965
+ weights = {
966
+ sid: math.exp(-0.5 * (bic - bic_min))
967
+ for sid, bic in bic_vals.items()
968
+ }
969
+ wsum = sum(weights.values())
970
+ for sid, w in weights.items():
971
+ rel_prob_map[sid] = (
972
+ remaining * w / wsum if wsum > 0 else remaining / len(weights)
973
+ )
974
+ note = "Relative probabilities calculated using BIC"
975
+ else:
976
+ eq = remaining / len(need_calc) if need_calc else 0.0
977
+ for s in need_calc:
978
+ rel_prob_map[s.solution_id] = eq
979
+ note = "Relative probabilities set equal due to missing data"
980
+
981
+ rows = []
982
+ for sol in solutions:
983
+ k = len(sol.parameters)
984
+ bic = k * math.log(sol.n_data_points) - 2 * sol.log_likelihood
985
+ rp = (
986
+ sol.relative_probability
987
+ if sol.relative_probability is not None
988
+ else rel_prob_map.get(sol.solution_id)
989
+ )
990
+ rows.append(
991
+ (
992
+ bic,
993
+ [
994
+ sol.solution_id,
995
+ sol.model_type,
996
+ (
997
+ ",".join(sol.higher_order_effects)
998
+ if sol.higher_order_effects
999
+ else "-"
1000
+ ),
1001
+ str(k),
1002
+ f"{sol.log_likelihood:.2f}",
1003
+ f"{bic:.2f}",
1004
+ f"{rp:.3f}" if rp is not None else "N/A",
1005
+ ],
1006
+ )
1007
+ )
1008
+
1009
+ for _, cols in sorted(rows, key=lambda x: x[0]):
1010
+ table.add_row(*cols)
1011
+
1012
+ console.print(table)
1013
+ if note:
1014
+ console.print(note, style="yellow")
1015
+
1016
+
1017
+ @app.command("validate-solution")
1018
+ def validate_solution(
1019
+ solution_id: str,
1020
+ project_path: Path = typer.Argument(Path("."), help="Project directory"),
1021
+ ) -> None:
1022
+ """Validate a specific solution's parameters and configuration.
1023
+
1024
+ This command uses the centralized validation logic to check:
1025
+ - Parameter completeness for the model type
1026
+ - Higher-order effect requirements
1027
+ - Parameter types and value ranges
1028
+ - Physical consistency of parameters
1029
+
1030
+ The validation provides detailed feedback about any issues found,
1031
+ helping ensure solutions are complete and ready for submission.
1032
+
1033
+ Args:
1034
+ solution_id: The unique identifier of the solution to validate.
1035
+ project_path: Directory of the submission project.
1036
+
1037
+ Raises:
1038
+ typer.Exit: If the solution is not found in any event.
1039
+
1040
+ Example:
1041
+ # Validate a specific solution
1042
+ microlens-submit validate-solution abc12345-def6-7890-ghij-klmnopqrstuv ./project
1043
+
1044
+ # Output shows:
1045
+ # ✅ All validations passed for abc12345-def6-7890-ghij-klmnopqrstuv (event EVENT001)
1046
+
1047
+ # Or if issues are found:
1048
+ # ┌─────────────────────────────────────────────────────────────────────────────┐
1049
+ # │ Validation Results for abc12345-def6-7890-ghij-klmnopqrstuv (event EVENT001) │
1050
+ # └─────────────────────────────────────────────────────────────────────────────┘
1051
+ # • Missing required parameter 'tE' for model type '1S1L'
1052
+ # • Parameter 'u0' has invalid value: -0.5 (must be positive)
1053
+
1054
+ Note:
1055
+ The validation checks are comprehensive and cover all model types
1056
+ and higher-order effects. Always validate solutions before submission
1057
+ to catch any issues early.
1058
+ """
1059
+ sub = load(str(project_path))
1060
+
1061
+ # Find the solution
1062
+ target_solution = None
1063
+ target_event_id = None
1064
+ for event_id, event in sub.events.items():
1065
+ if solution_id in event.solutions:
1066
+ target_solution = event.solutions[solution_id]
1067
+ target_event_id = event_id
1068
+ break
1069
+
1070
+ if target_solution is None:
1071
+ console.print(f"Solution {solution_id} not found", style="bold red")
1072
+ raise typer.Exit(code=1)
1073
+
1074
+ # Run validation
1075
+ messages = target_solution.validate()
1076
+
1077
+ if not messages:
1078
+ console.print(
1079
+ Panel(
1080
+ f"✅ All validations passed for {solution_id} (event {target_event_id})",
1081
+ style="bold green",
1082
+ )
1083
+ )
1084
+ else:
1085
+ console.print(
1086
+ Panel(
1087
+ f"Validation Results for {solution_id} (event {target_event_id})",
1088
+ style="yellow",
1089
+ )
1090
+ )
1091
+ for msg in messages:
1092
+ console.print(f" • {msg}")
1093
+
1094
+
1095
+ @app.command("validate-submission")
1096
+ def validate_submission(
1097
+ project_path: Path = typer.Argument(Path("."), help="Project directory"),
1098
+ ) -> None:
1099
+ """Validate the entire submission for missing or incomplete information.
1100
+
1101
+ This command performs comprehensive validation of all active solutions
1102
+ and returns a list of warnings describing potential issues. It checks
1103
+ for common problems like missing metadata, incomplete solutions, and
1104
+ validation issues in individual solutions.
1105
+
1106
+ The validation is particularly strict about the GitHub repository URL,
1107
+ which is required for submission. If repo_url is missing or invalid,
1108
+ the command will exit with an error.
1109
+
1110
+ Args:
1111
+ project_path: Directory of the submission project.
1112
+
1113
+ Raises:
1114
+ typer.Exit: If repo_url is missing or invalid (exit code 1).
1115
+
1116
+ Example:
1117
+ # Validate the entire submission
1118
+ microlens-submit validate-submission ./project
1119
+
1120
+ # Output if all validations pass:
1121
+ # ┌─────────────────────────────────────┐
1122
+ # │ ✅ All validations passed! │
1123
+ # └─────────────────────────────────────┘
1124
+
1125
+ # Output if issues are found:
1126
+ # ┌─────────────────────────────────────┐
1127
+ # │ Validation Warnings │
1128
+ # └─────────────────────────────────────┘
1129
+ # • Hardware info is missing
1130
+ # • Event EVENT001: Solution abc12345 is missing log_likelihood
1131
+ # • Solution def67890 in event EVENT002: Missing required parameter 'tE'
1132
+
1133
+ Note:
1134
+ This command checks for:
1135
+ - Missing or invalid repo_url (GitHub repository URL)
1136
+ - Missing hardware information
1137
+ - Events with no active solutions
1138
+ - Solutions with missing required metadata
1139
+ - Individual solution validation issues
1140
+ - Relative probability consistency
1141
+
1142
+ Always run this command before exporting your submission to ensure
1143
+ all required information is present and valid.
1144
+ """
1145
+ sub = load(str(project_path))
1146
+ warnings = sub.validate()
1147
+
1148
+ # Check for missing repo_url
1149
+ repo_url_warning = next((w for w in warnings if 'repo_url' in w.lower() or 'github' in w.lower()), None)
1150
+ if repo_url_warning:
1151
+ console.print(Panel(f"[red]Error: {repo_url_warning}\nPlease add your GitHub repository URL using 'microlens-submit set-repo-url <url> <project_dir>'.[/red]", style="bold red"))
1152
+ raise typer.Exit(code=1)
1153
+
1154
+ if not warnings:
1155
+ console.print(Panel("\u2705 All validations passed!", style="bold green"))
1156
+ else:
1157
+ console.print(Panel("Validation Warnings", style="yellow"))
1158
+ for warning in warnings:
1159
+ console.print(f" \u2022 {warning}")
1160
+ console.print(f"\nFound {len(warnings)} validation issue(s)", style="yellow")
1161
+
1162
+
1163
+ @app.command("validate-event")
1164
+ def validate_event(
1165
+ event_id: str,
1166
+ project_path: Path = typer.Argument(Path("."), help="Project directory"),
1167
+ ) -> None:
1168
+ """Validate all solutions for a specific event.
1169
+
1170
+ Runs validation on all solutions (both active and inactive) for the
1171
+ specified event. This provides a focused validation check for a single
1172
+ event, useful when working on specific events or debugging issues.
1173
+
1174
+ Args:
1175
+ event_id: Identifier of the event to validate.
1176
+ project_path: Directory of the submission project.
1177
+
1178
+ Raises:
1179
+ typer.Exit: If the event is not found in the project.
1180
+
1181
+ Example:
1182
+ # Validate all solutions for EVENT001
1183
+ microlens-submit validate-event EVENT001 ./project
1184
+
1185
+ # Output shows:
1186
+ # ┌─────────────────────────────────────┐
1187
+ # │ Validating Event: EVENT001 │
1188
+ # └─────────────────────────────────────┘
1189
+ #
1190
+ # Solution abc12345-def6-7890-ghij-klmnopqrstuv:
1191
+ # • Missing required parameter 'tE' for model type '1S1L'
1192
+ #
1193
+ # ✅ Solution def67890-abc1-2345-klmn-opqrstuvwxyz: All validations passed
1194
+
1195
+ # ┌─────────────────────────────────────┐
1196
+ # │ ✅ All solutions passed validation! │
1197
+ # └─────────────────────────────────────┘
1198
+
1199
+ Note:
1200
+ This command validates all solutions in the event, regardless of
1201
+ their active status. It's useful for checking solutions that might
1202
+ be inactive but could be reactivated later.
1203
+ """
1204
+ sub = load(str(project_path))
1205
+
1206
+ if event_id not in sub.events:
1207
+ console.print(f"Event {event_id} not found", style="bold red")
1208
+ raise typer.Exit(code=1)
1209
+
1210
+ event = sub.events[event_id]
1211
+ all_messages = []
1212
+
1213
+ console.print(Panel(f"Validating Event: {event_id}", style="cyan"))
1214
+
1215
+ for solution in event.solutions.values():
1216
+ messages = solution.validate()
1217
+ if messages:
1218
+ console.print(f"\n[bold]Solution {solution.solution_id}:[/bold]")
1219
+ for msg in messages:
1220
+ console.print(f" • {msg}")
1221
+ all_messages.append(f"{solution.solution_id}: {msg}")
1222
+ else:
1223
+ console.print(f"✅ Solution {solution.solution_id}: All validations passed")
1224
+
1225
+ if not all_messages:
1226
+ console.print(Panel("✅ All solutions passed validation!", style="bold green"))
1227
+ else:
1228
+ console.print(
1229
+ f"\nFound {len(all_messages)} validation issue(s) across all solutions",
1230
+ style="yellow",
1231
+ )
1232
+
1233
+
1234
+ def _parse_structured_params_file(params_file: Path) -> tuple[dict, dict]:
1235
+ """Parse a structured parameter file that can contain both parameters and uncertainties.
1236
+
1237
+ Supports both JSON and YAML formats. The file can have either:
1238
+ 1. Simple format: {"param1": value1, "param2": value2, ...}
1239
+ 2. Structured format: {"parameters": {...}, "uncertainties": {...}}
1240
+
1241
+ Args:
1242
+ params_file: Path to the parameter file (JSON or YAML format).
1243
+
1244
+ Returns:
1245
+ tuple: (parameters_dict, uncertainties_dict) - Two dictionaries containing
1246
+ the parsed parameters and their uncertainties.
1247
+
1248
+ Raises:
1249
+ OSError: If the file cannot be read.
1250
+ json.JSONDecodeError: If JSON parsing fails.
1251
+ yaml.YAMLError: If YAML parsing fails.
1252
+
1253
+ Example:
1254
+ # Simple format (all keys are parameters)
1255
+ # params.json:
1256
+ # {
1257
+ # "t0": 2459123.5,
1258
+ # "u0": 0.1,
1259
+ # "tE": 20.0
1260
+ # }
1261
+
1262
+ # Structured format (separate parameters and uncertainties)
1263
+ # params.yaml:
1264
+ # parameters:
1265
+ # t0: 2459123.5
1266
+ # u0: 0.1
1267
+ # tE: 20.0
1268
+ # uncertainties:
1269
+ # t0: 0.1
1270
+ # u0: 0.01
1271
+ # tE: 0.5
1272
+
1273
+ params, uncertainties = _parse_structured_params_file(Path("params.json"))
1274
+
1275
+ Note:
1276
+ This function automatically detects the file format based on the file
1277
+ extension (.json, .yaml, .yml). For structured format, both parameters
1278
+ and uncertainties sections are optional - missing sections return empty
1279
+ dictionaries.
1280
+ """
1281
+ import yaml
1282
+
1283
+ with params_file.open("r", encoding="utf-8") as fh:
1284
+ if params_file.suffix.lower() in ['.yaml', '.yml']:
1285
+ data = yaml.safe_load(fh)
1286
+ else:
1287
+ data = json.load(fh)
1288
+
1289
+ # Handle structured format
1290
+ if isinstance(data, dict) and ('parameters' in data or 'uncertainties' in data):
1291
+ parameters = data.get('parameters', {})
1292
+ uncertainties = data.get('uncertainties', {})
1293
+ else:
1294
+ # Simple format - all keys are parameters
1295
+ parameters = data
1296
+ uncertainties = {}
1297
+
1298
+ return parameters, uncertainties
1299
+
1300
+
1301
+ @app.command("edit-solution")
1302
+ def edit_solution(
1303
+ solution_id: str,
1304
+ relative_probability: Optional[float] = typer.Option(
1305
+ None,
1306
+ "--relative-probability",
1307
+ help="Relative probability of this solution",
1308
+ ),
1309
+ log_likelihood: Optional[float] = typer.Option(None, help="Log likelihood"),
1310
+ n_data_points: Optional[int] = typer.Option(
1311
+ None,
1312
+ "--n-data-points",
1313
+ help="Number of data points used in this solution",
1314
+ ),
1315
+ notes: Optional[str] = typer.Option(None, help="Notes for the solution (supports Markdown formatting)"),
1316
+ notes_file: Optional[Path] = typer.Option(None, "--notes-file", help="Path to a Markdown file for solution notes (mutually exclusive with --notes)"),
1317
+ append_notes: Optional[str] = typer.Option(
1318
+ None,
1319
+ "--append-notes",
1320
+ help="Append text to existing notes (use --notes to replace instead)",
1321
+ ),
1322
+ clear_notes: bool = typer.Option(False, help="Clear all notes"),
1323
+ clear_relative_probability: bool = typer.Option(False, help="Clear relative probability"),
1324
+ clear_log_likelihood: bool = typer.Option(False, help="Clear log likelihood"),
1325
+ clear_n_data_points: bool = typer.Option(False, help="Clear n_data_points"),
1326
+ clear_parameter_uncertainties: bool = typer.Option(False, help="Clear parameter uncertainties"),
1327
+ clear_physical_parameters: bool = typer.Option(False, help="Clear physical parameters"),
1328
+ cpu_hours: Optional[float] = typer.Option(None, help="CPU hours used"),
1329
+ wall_time_hours: Optional[float] = typer.Option(None, help="Wall time hours used"),
1330
+ param: Optional[List[str]] = typer.Option(
1331
+ None, help="Model parameters as key=value (updates existing parameters)"
1332
+ ),
1333
+ param_uncertainty: Optional[List[str]] = typer.Option(
1334
+ None,
1335
+ "--param-uncertainty",
1336
+ help="Parameter uncertainties as key=value (updates existing uncertainties)"
1337
+ ),
1338
+ higher_order_effect: Optional[List[str]] = typer.Option(
1339
+ None,
1340
+ "--higher-order-effect",
1341
+ help="Higher-order effects (replaces existing effects)",
1342
+ ),
1343
+ clear_higher_order_effects: bool = typer.Option(False, help="Clear all higher-order effects"),
1344
+ dry_run: bool = typer.Option(
1345
+ False,
1346
+ "--dry-run",
1347
+ help="Show what would be changed without saving",
1348
+ ),
1349
+ project_path: Path = typer.Argument(Path("."), help="Project directory"),
1350
+ ) -> None:
1351
+ """Edit an existing solution's attributes, including file-based notes.
1352
+
1353
+ This command allows you to modify various attributes of an existing solution
1354
+ without having to recreate it. It supports updating parameters, metadata,
1355
+ notes, and compute information. The command provides detailed feedback about
1356
+ what changes were made.
1357
+
1358
+ **Notes Management:**
1359
+ - Use --notes to replace existing notes with new content
1360
+ - Use --append-notes to add text to existing notes
1361
+ - Use --notes-file to reference an external Markdown file
1362
+ - Use --clear-notes to remove all notes
1363
+
1364
+ **Parameter Updates:**
1365
+ - Use --param to update individual model parameters
1366
+ - Use --param-uncertainty to update parameter uncertainties
1367
+ - Use --higher-order-effect to replace all higher-order effects
1368
+
1369
+ **Metadata Updates:**
1370
+ - Use --relative-probability, --log-likelihood, --n-data-points
1371
+ - Use --cpu-hours and --wall-time-hours to update compute info
1372
+ - Use --clear-* flags to remove specific fields
1373
+
1374
+ Args:
1375
+ solution_id: The unique identifier of the solution to edit.
1376
+ relative_probability: New relative probability value.
1377
+ log_likelihood: New log-likelihood value.
1378
+ n_data_points: New number of data points value.
1379
+ notes: New notes content (replaces existing notes).
1380
+ notes_file: Path to external notes file.
1381
+ append_notes: Text to append to existing notes.
1382
+ clear_notes: If True, clear all notes.
1383
+ clear_relative_probability: If True, clear relative probability.
1384
+ clear_log_likelihood: If True, clear log likelihood.
1385
+ clear_n_data_points: If True, clear n_data_points.
1386
+ clear_parameter_uncertainties: If True, clear parameter uncertainties.
1387
+ clear_physical_parameters: If True, clear physical parameters.
1388
+ cpu_hours: New CPU hours value.
1389
+ wall_time_hours: New wall time hours value.
1390
+ param: Model parameters as key=value pairs (updates existing).
1391
+ param_uncertainty: Parameter uncertainties as key=value pairs.
1392
+ higher_order_effect: Higher-order effects (replaces existing).
1393
+ clear_higher_order_effects: If True, clear all higher-order effects.
1394
+ dry_run: If True, show changes without saving.
1395
+ project_path: Directory of the submission project.
1396
+
1397
+ Raises:
1398
+ typer.Exit: If the solution is not found.
1399
+ typer.BadParameter: If parameter format is invalid.
1400
+
1401
+ Example:
1402
+ # Update solution metadata
1403
+ microlens-submit edit-solution abc12345-def6-7890-ghij-klmnopqrstuv ./project \
1404
+ --log-likelihood -1200.0 \
1405
+ --relative-probability 0.8 \
1406
+ --cpu-hours 3.5
1407
+
1408
+ # Update model parameters
1409
+ microlens-submit edit-solution abc12345-def6-7890-ghij-klmnopqrstuv ./project \
1410
+ --param t0=2459123.6 --param u0=0.12
1411
+
1412
+ # Replace notes
1413
+ microlens-submit edit-solution abc12345-def6-7890-ghij-klmnopqrstuv ./project \
1414
+ --notes "# Updated Solution Notes\n\nThis is the updated description."
1415
+
1416
+ # Append to existing notes
1417
+ microlens-submit edit-solution abc12345-def6-7890-ghij-klmnopqrstuv ./project \
1418
+ --append-notes "\n\n## Additional Analysis\n\nFurther investigation shows..."
1419
+
1420
+ # Clear specific fields
1421
+ microlens-submit edit-solution abc12345-def6-7890-ghij-klmnopqrstuv ./project \
1422
+ --clear-relative-probability --clear-notes
1423
+
1424
+ # Dry run to preview changes
1425
+ microlens-submit edit-solution abc12345-def6-7890-ghij-klmnopqrstuv ./project \
1426
+ --log-likelihood -1200.0 --dry-run
1427
+
1428
+ Note:
1429
+ This command only modifies the specified fields. Unspecified fields
1430
+ remain unchanged. Use --dry-run to preview changes before applying them.
1431
+ The command automatically saves changes to disk after successful updates.
1432
+ """
1433
+ sub = load(str(project_path))
1434
+ target_solution = None
1435
+ target_event_id = None
1436
+ for event_id, event in sub.events.items():
1437
+ if solution_id in event.solutions:
1438
+ target_solution = event.solutions[solution_id]
1439
+ target_event_id = event_id
1440
+ break
1441
+ if target_solution is None:
1442
+ console.print(f"Solution {solution_id} not found", style="bold red")
1443
+ raise typer.Exit(code=1)
1444
+ changes = []
1445
+ if clear_relative_probability:
1446
+ if target_solution.relative_probability is not None:
1447
+ changes.append(f"Clear relative_probability: {target_solution.relative_probability}")
1448
+ target_solution.relative_probability = None
1449
+ elif relative_probability is not None:
1450
+ if target_solution.relative_probability != relative_probability:
1451
+ changes.append(f"Update relative_probability: {target_solution.relative_probability} → {relative_probability}")
1452
+ target_solution.relative_probability = relative_probability
1453
+ if clear_log_likelihood:
1454
+ if target_solution.log_likelihood is not None:
1455
+ changes.append(f"Clear log_likelihood: {target_solution.log_likelihood}")
1456
+ target_solution.log_likelihood = None
1457
+ elif log_likelihood is not None:
1458
+ if target_solution.log_likelihood != log_likelihood:
1459
+ changes.append(f"Update log_likelihood: {target_solution.log_likelihood} → {log_likelihood}")
1460
+ target_solution.log_likelihood = log_likelihood
1461
+ if clear_n_data_points:
1462
+ if target_solution.n_data_points is not None:
1463
+ changes.append(f"Clear n_data_points: {target_solution.n_data_points}")
1464
+ target_solution.n_data_points = None
1465
+ elif n_data_points is not None:
1466
+ if target_solution.n_data_points != n_data_points:
1467
+ changes.append(f"Update n_data_points: {target_solution.n_data_points} → {n_data_points}")
1468
+ target_solution.n_data_points = n_data_points
1469
+ # Notes file logic
1470
+ canonical_notes_path = Path(project_path) / "events" / target_event_id / "solutions" / f"{target_solution.solution_id}.md"
1471
+ if notes_file is not None:
1472
+ target_solution.notes_path = str(notes_file)
1473
+ changes.append(f"Set notes_path to {notes_file}")
1474
+ elif notes is not None:
1475
+ target_solution.notes_path = str(canonical_notes_path.relative_to(project_path))
1476
+ canonical_notes_path.parent.mkdir(parents=True, exist_ok=True)
1477
+ canonical_notes_path.write_text(notes, encoding="utf-8")
1478
+ changes.append(f"Updated notes in {canonical_notes_path}")
1479
+ elif append_notes is not None:
1480
+ if target_solution.notes_path:
1481
+ notes_file_path = Path(project_path) / target_solution.notes_path
1482
+ old_content = notes_file_path.read_text(encoding="utf-8") if notes_file_path.exists() else ""
1483
+ notes_file_path.parent.mkdir(parents=True, exist_ok=True)
1484
+ notes_file_path.write_text(old_content + "\n" + append_notes, encoding="utf-8")
1485
+ changes.append(f"Appended notes in {notes_file_path}")
1486
+ elif clear_notes:
1487
+ if target_solution.notes_path:
1488
+ notes_file_path = Path(project_path) / target_solution.notes_path
1489
+ notes_file_path.parent.mkdir(parents=True, exist_ok=True)
1490
+ notes_file_path.write_text("", encoding="utf-8")
1491
+ changes.append(f"Cleared notes in {notes_file_path}")
1492
+ if clear_parameter_uncertainties:
1493
+ if target_solution.parameter_uncertainties:
1494
+ changes.append("Clear parameter_uncertainties")
1495
+ target_solution.parameter_uncertainties = None
1496
+ if clear_physical_parameters:
1497
+ if target_solution.physical_parameters:
1498
+ changes.append("Clear physical_parameters")
1499
+ target_solution.physical_parameters = None
1500
+ if cpu_hours is not None or wall_time_hours is not None:
1501
+ old_cpu = target_solution.compute_info.get("cpu_hours")
1502
+ old_wall = target_solution.compute_info.get("wall_time_hours")
1503
+ if cpu_hours is not None and old_cpu != cpu_hours:
1504
+ changes.append(f"Update cpu_hours: {old_cpu} → {cpu_hours}")
1505
+ if wall_time_hours is not None and old_wall != wall_time_hours:
1506
+ changes.append(f"Update wall_time_hours: {old_wall} → {wall_time_hours}")
1507
+ target_solution.set_compute_info(
1508
+ cpu_hours=cpu_hours if cpu_hours is not None else old_cpu,
1509
+ wall_time_hours=wall_time_hours if wall_time_hours is not None else old_wall
1510
+ )
1511
+ if param:
1512
+ for p in param:
1513
+ if "=" not in p:
1514
+ raise typer.BadParameter(f"Invalid parameter format: {p}")
1515
+ key, value = p.split("=", 1)
1516
+ try:
1517
+ new_value = json.loads(value)
1518
+ except json.JSONDecodeError:
1519
+ new_value = value
1520
+ old_value = target_solution.parameters.get(key)
1521
+ if old_value != new_value:
1522
+ changes.append(f"Update parameter {key}: {old_value} → {new_value}")
1523
+ target_solution.parameters[key] = new_value
1524
+ if param_uncertainty:
1525
+ if target_solution.parameter_uncertainties is None:
1526
+ target_solution.parameter_uncertainties = {}
1527
+ for p in param_uncertainty:
1528
+ if "=" not in p:
1529
+ raise typer.BadParameter(f"Invalid uncertainty format: {p}")
1530
+ key, value = p.split("=", 1)
1531
+ try:
1532
+ new_value = json.loads(value)
1533
+ except json.JSONDecodeError:
1534
+ new_value = value
1535
+ old_value = target_solution.parameter_uncertainties.get(key)
1536
+ if old_value != new_value:
1537
+ changes.append(f"Update uncertainty {key}: {old_value} → {new_value}")
1538
+ target_solution.parameter_uncertainties[key] = new_value
1539
+ if clear_higher_order_effects:
1540
+ if target_solution.higher_order_effects:
1541
+ changes.append(f"Clear higher_order_effects: {target_solution.higher_order_effects}")
1542
+ target_solution.higher_order_effects = []
1543
+ elif higher_order_effect:
1544
+ if target_solution.higher_order_effects != higher_order_effect:
1545
+ changes.append(f"Update higher_order_effects: {target_solution.higher_order_effects} → {higher_order_effect}")
1546
+ target_solution.higher_order_effects = higher_order_effect
1547
+ if dry_run:
1548
+ if changes:
1549
+ console.print(Panel(f"Changes for {solution_id} (event {target_event_id})", style="cyan"))
1550
+ for change in changes:
1551
+ console.print(f" • {change}")
1552
+ else:
1553
+ console.print(Panel("No changes would be made", style="yellow"))
1554
+ return
1555
+ if changes:
1556
+ sub.save()
1557
+ console.print(Panel(f"Updated {solution_id} (event {target_event_id})", style="green"))
1558
+ for change in changes:
1559
+ console.print(f" • {change}")
1560
+ else:
1561
+ console.print(Panel("No changes made", style="yellow"))
1562
+
1563
+
1564
+ @app.command("notes")
1565
+ def edit_notes(solution_id: str, project_path: Path = typer.Argument(Path("."), help="Project directory")) -> None:
1566
+ """Open the notes file for a solution in the default text editor.
1567
+
1568
+ Launches the system's default text editor (or a fallback) to edit the
1569
+ notes file for the specified solution. This provides a convenient way
1570
+ to edit solution notes without having to manually locate the file.
1571
+
1572
+ The command uses the $EDITOR environment variable if set, otherwise
1573
+ falls back to nano, then vi. The notes file is created if it doesn't
1574
+ exist.
1575
+
1576
+ Args:
1577
+ solution_id: The unique identifier of the solution to edit notes for.
1578
+ project_path: Directory of the submission project.
1579
+
1580
+ Raises:
1581
+ typer.Exit: If the solution is not found or no editor is available.
1582
+
1583
+ Example:
1584
+ # Edit notes for a specific solution
1585
+ microlens-submit notes abc12345-def6-7890-ghij-klmnopqrstuv ./project
1586
+
1587
+ # This will open the notes file in your default editor:
1588
+ # ./project/events/EVENT001/solutions/abc12345-def6-7890-ghij-klmnopqrstuv.md
1589
+
1590
+ Note:
1591
+ The notes file is opened in the system's default text editor.
1592
+ If $EDITOR is not set, the command tries nano, then vi as fallbacks.
1593
+ The notes file supports Markdown formatting and will be rendered
1594
+ as HTML in the dossier with syntax highlighting for code blocks.
1595
+ """
1596
+ sub = load(str(project_path))
1597
+ for event in sub.events.values():
1598
+ if solution_id in event.solutions:
1599
+ sol = event.solutions[solution_id]
1600
+ if not sol.notes_path:
1601
+ console.print(f"No notes file associated with solution {solution_id}", style="bold red")
1602
+ raise typer.Exit(code=1)
1603
+ notes_file = Path(project_path) / sol.notes_path
1604
+ notes_file.parent.mkdir(parents=True, exist_ok=True)
1605
+ if not notes_file.exists():
1606
+ notes_file.write_text("", encoding="utf-8")
1607
+ editor = os.environ.get("EDITOR", None)
1608
+ if editor:
1609
+ os.system(f'{editor} "{notes_file}"')
1610
+ else:
1611
+ # Try nano, then vi
1612
+ for fallback in ["nano", "vi"]:
1613
+ if os.system(f"command -v {fallback} > /dev/null 2>&1") == 0:
1614
+ os.system(f'{fallback} "{notes_file}"')
1615
+ break
1616
+ else:
1617
+ console.print(f"Could not find an editor to open {notes_file}", style="bold red")
1618
+ raise typer.Exit(code=1)
1619
+ return
1620
+ console.print(f"Solution {solution_id} not found", style="bold red")
1621
+ raise typer.Exit(code=1)
1622
+
1623
+
1624
+ @app.command()
1625
+ def set_repo_url(
1626
+ repo_url: str = typer.Argument(..., help="GitHub repository URL (e.g. https://github.com/owner/repo)"),
1627
+ project_path: Path = typer.Argument(Path("."), help="Project directory"),
1628
+ ) -> None:
1629
+ """Set or update the GitHub repository URL in the submission metadata.
1630
+
1631
+ Updates the repo_url field in submission.json with the provided GitHub
1632
+ repository URL. This URL is required for submission validation and is
1633
+ displayed in the generated dossier.
1634
+
1635
+ The command accepts various GitHub URL formats:
1636
+ - HTTPS: https://github.com/owner/repo
1637
+ - SSH: git@github.com:owner/repo.git
1638
+ - With or without .git extension
1639
+
1640
+ Args:
1641
+ repo_url: GitHub repository URL in any standard format.
1642
+ project_path: Directory of the submission project.
1643
+
1644
+ Raises:
1645
+ OSError: If unable to write to submission.json.
1646
+
1647
+ Example:
1648
+ # Set repository URL using HTTPS format
1649
+ microlens-submit set-repo-url https://github.com/team-alpha/microlens-submit ./project
1650
+
1651
+ # Set repository URL using SSH format
1652
+ microlens-submit set-repo-url git@github.com:team-alpha/microlens-submit.git ./project
1653
+
1654
+ # Update existing repository URL
1655
+ microlens-submit set-repo-url https://github.com/team-alpha/new-repo ./project
1656
+
1657
+ Note:
1658
+ The repository URL is used for:
1659
+ - Submission validation (required field)
1660
+ - Display in the generated dossier
1661
+ - Linking to specific commits in solution pages
1662
+
1663
+ The URL should point to the repository containing your analysis code
1664
+ and submission preparation scripts.
1665
+ """
1666
+ sub = load(str(project_path))
1667
+ sub.repo_url = repo_url
1668
+ sub.save()
1669
+ console.print(Panel(f"Set repo_url to {repo_url} in {project_path}/submission.json", style="bold green"))
1670
+
1671
+
1672
+ @app.command("set-hardware-info")
1673
+ def set_hardware_info(
1674
+ cpu: Optional[str] = typer.Option(None, "--cpu", help="CPU model/description"),
1675
+ cpu_details: Optional[str] = typer.Option(None, "--cpu-details", help="Detailed CPU information"),
1676
+ memory_gb: Optional[float] = typer.Option(None, "--memory-gb", help="Memory in GB"),
1677
+ ram_gb: Optional[float] = typer.Option(None, "--ram-gb", help="RAM in GB (alternative to --memory-gb)"),
1678
+ platform: Optional[str] = typer.Option(None, "--platform", help="Platform description (e.g., 'Local Analysis', 'Roman Nexus')"),
1679
+ nexus_image: Optional[str] = typer.Option(None, "--nexus-image", help="Roman Nexus image identifier"),
1680
+ clear: bool = typer.Option(False, "--clear", help="Clear all existing hardware info"),
1681
+ dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be changed without saving"),
1682
+ project_path: Path = typer.Argument(Path("."), help="Project directory"),
1683
+ ) -> None:
1684
+ """Set or update hardware information in the submission metadata.
1685
+
1686
+ Updates the hardware_info field in submission.json with computational
1687
+ resource details. This information is displayed in the generated dossier
1688
+ and helps with reproducibility and resource tracking.
1689
+
1690
+ The command accepts various hardware information fields and can be used
1691
+ to update existing information or set it for the first time.
1692
+
1693
+ Args:
1694
+ cpu: Basic CPU model/description (e.g., "Intel Xeon E5-2680 v4").
1695
+ cpu_details: Detailed CPU information (takes precedence over --cpu).
1696
+ memory_gb: Memory in gigabytes (e.g., 64.0).
1697
+ ram_gb: RAM in gigabytes (alternative to --memory-gb).
1698
+ platform: Platform description (e.g., "Local Analysis", "Roman Nexus").
1699
+ nexus_image: Roman Nexus image identifier.
1700
+ clear: If True, clear all existing hardware info before setting new values.
1701
+ dry_run: If True, show what would be changed without saving.
1702
+ project_path: Directory of the submission project.
1703
+
1704
+ Raises:
1705
+ OSError: If unable to write to submission.json.
1706
+
1707
+ Example:
1708
+ # Set basic hardware info
1709
+ microlens-submit set-hardware-info --cpu "Intel Xeon E5-2680 v4" --memory-gb 64 ./project
1710
+
1711
+ # Set detailed platform info
1712
+ microlens-submit set-hardware-info --platform "Local Analysis" --cpu-details "Intel Xeon E5-2680 v4 @ 2.4GHz" ./project
1713
+
1714
+ # Set Roman Nexus info
1715
+ microlens-submit set-hardware-info --platform "Roman Nexus" --nexus-image "roman-science-platform:latest" ./project
1716
+
1717
+ # Update existing info
1718
+ microlens-submit set-hardware-info --memory-gb 128 ./project
1719
+
1720
+ # Clear and set new info
1721
+ microlens-submit set-hardware-info --clear --cpu "AMD EPYC" --memory-gb 256 ./project
1722
+
1723
+ # Dry run to preview changes
1724
+ microlens-submit set-hardware-info --cpu "Intel i7" --memory-gb 32 --dry-run ./project
1725
+
1726
+ Note:
1727
+ Hardware information is used for:
1728
+ - Display in the generated dossier
1729
+ - Reproducibility documentation
1730
+ - Resource usage tracking
1731
+
1732
+ The --cpu-details option takes precedence over --cpu if both are provided.
1733
+ The --memory-gb and --ram-gb options are equivalent; use whichever is clearer.
1734
+ Use --clear to replace all existing hardware info with new values.
1735
+ """
1736
+ sub = load(str(project_path))
1737
+
1738
+ # Initialize hardware_info if it doesn't exist
1739
+ if sub.hardware_info is None:
1740
+ sub.hardware_info = {}
1741
+
1742
+ changes = []
1743
+ old_hardware_info = sub.hardware_info.copy()
1744
+
1745
+ # Clear existing info if requested
1746
+ if clear:
1747
+ if sub.hardware_info:
1748
+ changes.append("Clear all existing hardware info")
1749
+ sub.hardware_info = {}
1750
+
1751
+ # Set new values
1752
+ if cpu_details is not None:
1753
+ if sub.hardware_info.get('cpu_details') != cpu_details:
1754
+ changes.append(f"Set cpu_details: {cpu_details}")
1755
+ sub.hardware_info['cpu_details'] = cpu_details
1756
+ elif cpu is not None:
1757
+ if sub.hardware_info.get('cpu') != cpu:
1758
+ changes.append(f"Set cpu: {cpu}")
1759
+ sub.hardware_info['cpu'] = cpu
1760
+
1761
+ if memory_gb is not None:
1762
+ if sub.hardware_info.get('memory_gb') != memory_gb:
1763
+ changes.append(f"Set memory_gb: {memory_gb}")
1764
+ sub.hardware_info['memory_gb'] = memory_gb
1765
+ elif ram_gb is not None:
1766
+ if sub.hardware_info.get('ram_gb') != ram_gb:
1767
+ changes.append(f"Set ram_gb: {ram_gb}")
1768
+ sub.hardware_info['ram_gb'] = ram_gb
1769
+
1770
+ if platform is not None:
1771
+ if sub.hardware_info.get('platform') != platform:
1772
+ changes.append(f"Set platform: {platform}")
1773
+ sub.hardware_info['platform'] = platform
1774
+
1775
+ if nexus_image is not None:
1776
+ if sub.hardware_info.get('nexus_image') != nexus_image:
1777
+ changes.append(f"Set nexus_image: {nexus_image}")
1778
+ sub.hardware_info['nexus_image'] = nexus_image
1779
+
1780
+ # Show dry run results
1781
+ if dry_run:
1782
+ if changes:
1783
+ console.print(Panel("Hardware info changes (dry run):", style="cyan"))
1784
+ for change in changes:
1785
+ console.print(f" • {change}")
1786
+ console.print(f"\nNew hardware_info: {sub.hardware_info}")
1787
+ else:
1788
+ console.print(Panel("No changes would be made", style="yellow"))
1789
+ return
1790
+
1791
+ # Apply changes
1792
+ if changes:
1793
+ sub.save()
1794
+ console.print(Panel(f"Updated hardware info in {project_path}/submission.json", style="bold green"))
1795
+ for change in changes:
1796
+ console.print(f" • {change}")
1797
+ console.print(f"\nCurrent hardware_info: {sub.hardware_info}")
1798
+ else:
1799
+ console.print(Panel("No changes made", style="yellow"))
1800
+
1801
+
1802
+ if __name__ == "__main__": # pragma: no cover
1803
+ app()