microlens-submit 0.12.2__py3-none-any.whl → 0.16.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.
Files changed (34) hide show
  1. microlens_submit/__init__.py +7 -157
  2. microlens_submit/cli/__init__.py +5 -0
  3. microlens_submit/cli/__main__.py +6 -0
  4. microlens_submit/cli/commands/__init__.py +1 -0
  5. microlens_submit/cli/commands/dossier.py +139 -0
  6. microlens_submit/cli/commands/export.py +177 -0
  7. microlens_submit/cli/commands/init.py +172 -0
  8. microlens_submit/cli/commands/solutions.py +722 -0
  9. microlens_submit/cli/commands/validation.py +241 -0
  10. microlens_submit/cli/main.py +120 -0
  11. microlens_submit/dossier/__init__.py +51 -0
  12. microlens_submit/dossier/dashboard.py +503 -0
  13. microlens_submit/dossier/event_page.py +370 -0
  14. microlens_submit/dossier/full_report.py +330 -0
  15. microlens_submit/dossier/solution_page.py +534 -0
  16. microlens_submit/dossier/utils.py +111 -0
  17. microlens_submit/error_messages.py +283 -0
  18. microlens_submit/models/__init__.py +28 -0
  19. microlens_submit/models/event.py +406 -0
  20. microlens_submit/models/solution.py +569 -0
  21. microlens_submit/models/submission.py +569 -0
  22. microlens_submit/tier_validation.py +208 -0
  23. microlens_submit/utils.py +373 -0
  24. microlens_submit/validate_parameters.py +478 -180
  25. {microlens_submit-0.12.2.dist-info → microlens_submit-0.16.1.dist-info}/METADATA +52 -14
  26. microlens_submit-0.16.1.dist-info/RECORD +32 -0
  27. microlens_submit/api.py +0 -1257
  28. microlens_submit/cli.py +0 -1803
  29. microlens_submit/dossier.py +0 -1443
  30. microlens_submit-0.12.2.dist-info/RECORD +0 -13
  31. {microlens_submit-0.12.2.dist-info → microlens_submit-0.16.1.dist-info}/WHEEL +0 -0
  32. {microlens_submit-0.12.2.dist-info → microlens_submit-0.16.1.dist-info}/entry_points.txt +0 -0
  33. {microlens_submit-0.12.2.dist-info → microlens_submit-0.16.1.dist-info}/licenses/LICENSE +0 -0
  34. {microlens_submit-0.12.2.dist-info → microlens_submit-0.16.1.dist-info}/top_level.txt +0 -0
microlens_submit/api.py DELETED
@@ -1,1257 +0,0 @@
1
- from __future__ import annotations
2
-
3
- """Core API for microlens-submit.
4
-
5
- This module provides the core data models and API for managing microlensing
6
- challenge submissions. The main classes are:
7
-
8
- - :class:`Submission`: Top-level container for a submission project
9
- - :class:`Event`: Container for solutions to a single microlensing event
10
- - :class:`Solution`: Individual model fit with parameters and metadata
11
-
12
- The :class:`Submission` class provides methods for validation, export, and
13
- persistence. The :func:`load` function is the main entry point for loading
14
- or creating submission projects.
15
-
16
- Example:
17
- >>> from microlens_submit import load
18
- >>>
19
- >>> # Load or create a submission project
20
- >>> submission = load("./my_project")
21
- >>>
22
- >>> # Set submission metadata
23
- >>> submission.team_name = "Team Alpha"
24
- >>> submission.tier = "advanced"
25
- >>> submission.repo_url = "https://github.com/team/repo"
26
- >>>
27
- >>> # Add an event and solution
28
- >>> event = submission.get_event("EVENT001")
29
- >>> solution = event.add_solution("1S1L", {"t0": 2459123.5, "u0": 0.1, "tE": 20.0})
30
- >>> solution.log_likelihood = -1234.56
31
- >>> solution.set_compute_info(cpu_hours=2.5, wall_time_hours=0.5)
32
- >>>
33
- >>> # Save the submission
34
- >>> submission.save()
35
- >>>
36
- >>> # Export for submission
37
- >>> submission.export("submission.zip")
38
- """
39
-
40
- import logging
41
- import os
42
- import subprocess
43
- import sys
44
- import uuid
45
- import zipfile
46
- import math
47
- from datetime import datetime
48
- from pathlib import Path
49
- from typing import Dict, Optional, Literal, List
50
-
51
- from pydantic import BaseModel, Field
52
-
53
-
54
- class Solution(BaseModel):
55
- """Container for an individual microlensing model fit.
56
-
57
- This data model stores everything required to describe a single
58
- microlensing solution, including the numeric parameters of the fit and
59
- metadata about how it was produced. Instances are normally created via
60
- :meth:`Event.add_solution` and persisted to disk when
61
- :meth:`Submission.save` is called.
62
-
63
- Attributes:
64
- solution_id: Unique identifier for the solution (auto-generated UUID).
65
- model_type: Specific lens/source configuration such as "1S1L" or "1S2L".
66
- bands: List of photometric bands used in the fit (e.g., ["0", "1", "2"]).
67
- higher_order_effects: List of physical effects modeled (e.g., ["parallax"]).
68
- t_ref: Reference time for time-dependent effects (Julian Date).
69
- parameters: Dictionary of model parameters used for the fit.
70
- is_active: Flag indicating whether the solution should be included in
71
- the final submission export.
72
- compute_info: Metadata about the computing environment, populated by
73
- :meth:`set_compute_info`.
74
- posterior_path: Optional path to a file containing posterior samples.
75
- lightcurve_plot_path: Optional path to the lightcurve plot file.
76
- lens_plane_plot_path: Optional path to the lens plane plot file.
77
- notes_path: Path to the markdown notes file for this solution.
78
- used_astrometry: Whether astrometric information was used when fitting.
79
- used_postage_stamps: Whether postage stamp data was used.
80
- limb_darkening_model: Name of the limb darkening model employed.
81
- limb_darkening_coeffs: Mapping of limb darkening coefficients.
82
- parameter_uncertainties: Uncertainties for parameters in parameters.
83
- physical_parameters: Physical parameters derived from the model.
84
- log_likelihood: Log-likelihood value of the fit.
85
- relative_probability: Optional probability of this solution being the best model.
86
- n_data_points: Number of data points used in the fit.
87
- creation_timestamp: UTC timestamp when the solution was created.
88
-
89
-
90
- Example:
91
- >>> from microlens_submit import load
92
- >>>
93
- >>> # Load a submission and get an event
94
- >>> submission = load("./my_project")
95
- >>> event = submission.get_event("EVENT001")
96
- >>>
97
- >>> # Create a simple 1S1L solution
98
- >>> solution = event.add_solution("1S1L", {
99
- ... "t0": 2459123.5, # Time of closest approach
100
- ... "u0": 0.1, # Impact parameter
101
- ... "tE": 20.0 # Einstein crossing time
102
- ... })
103
- >>>
104
- >>> # Add metadata
105
- >>> solution.log_likelihood = -1234.56
106
- >>> solution.n_data_points = 1250
107
- >>> solution.relative_probability = 0.8
108
- >>> solution.higher_order_effects = ["parallax"]
109
- >>> solution.t_ref = 2459123.0
110
- >>>
111
- >>> # Record compute information
112
- >>> solution.set_compute_info(cpu_hours=2.5, wall_time_hours=0.5)
113
- >>>
114
- >>> # Add notes
115
- >>> solution.set_notes('''
116
- ... # My Solution Notes
117
- ...
118
- ... This is a simple point lens fit.
119
- ... ''')
120
- >>>
121
- >>> # Validate the solution
122
- >>> messages = solution.run_validation()
123
- >>> if messages:
124
- ... print("Validation issues:", messages)
125
-
126
- Note:
127
- The notes_path field supports Markdown formatting, allowing you to create rich,
128
- structured documentation with headers, lists, code blocks, tables, and links.
129
- This is particularly useful for creating detailed submission dossiers for evaluators.
130
-
131
- The run_validation() method performs comprehensive validation of parameters,
132
- higher-order effects, and physical consistency. Always validate solutions
133
- before submission.
134
- """
135
-
136
- solution_id: str
137
- model_type: Literal["1S1L", "1S2L", "2S1L", "2S2L", "1S3L", "2S3L", "other"]
138
- bands: List[str] = Field(default_factory=list)
139
- higher_order_effects: List[
140
- Literal[
141
- "lens-orbital-motion",
142
- "parallax",
143
- "finite-source",
144
- "limb-darkening",
145
- "xallarap",
146
- "stellar-rotation",
147
- "fitted-limb-darkening",
148
- "gaussian-process",
149
- "other",
150
- ]
151
- ] = Field(default_factory=list)
152
- t_ref: Optional[float] = None
153
- parameters: dict
154
- is_active: bool = True
155
- compute_info: dict = Field(default_factory=dict)
156
- posterior_path: Optional[str] = None
157
- lightcurve_plot_path: Optional[str] = None
158
- lens_plane_plot_path: Optional[str] = None
159
- notes_path: Optional[str] = None
160
- used_astrometry: bool = False
161
- used_postage_stamps: bool = False
162
- limb_darkening_model: Optional[str] = None
163
- limb_darkening_coeffs: Optional[dict] = None
164
- parameter_uncertainties: Optional[dict] = None
165
- physical_parameters: Optional[dict] = None
166
- log_likelihood: Optional[float] = None
167
- relative_probability: Optional[float] = None
168
- n_data_points: Optional[int] = None
169
- creation_timestamp: str = Field(
170
- default_factory=lambda: datetime.utcnow().isoformat()
171
- )
172
-
173
- def set_compute_info(
174
- self,
175
- cpu_hours: float | None = None,
176
- wall_time_hours: float | None = None,
177
- ) -> None:
178
- """Record compute metadata and capture environment details.
179
-
180
- When called, this method populates :attr:`compute_info` with timing
181
- information as well as a list of installed Python packages and the
182
- current Git state. It is safe to call multiple times—previous values
183
- will be overwritten.
184
-
185
- Args:
186
- cpu_hours: Total CPU time consumed by the model fit in hours.
187
- wall_time_hours: Real-world time consumed by the fit in hours.
188
-
189
- Example:
190
- >>> solution = event.add_solution("1S1L", {"t0": 2459123.5, "u0": 0.1})
191
- >>>
192
- >>> # Record compute information
193
- >>> solution.set_compute_info(cpu_hours=2.5, wall_time_hours=0.5)
194
- >>>
195
- >>> # The compute_info now contains:
196
- >>> # - cpu_hours: 2.5
197
- >>> # - wall_time_hours: 0.5
198
- >>> # - dependencies: [list of installed packages]
199
- >>> # - git_info: {commit, branch, is_dirty}
200
-
201
- Note:
202
- This method automatically captures the current Python environment
203
- (via pip freeze) and Git state (commit, branch, dirty status).
204
- If Git is not available or not a repository, git_info will be None.
205
- If pip is not available, dependencies will be an empty list.
206
- """
207
-
208
- if cpu_hours is not None:
209
- self.compute_info["cpu_hours"] = cpu_hours
210
- if wall_time_hours is not None:
211
- self.compute_info["wall_time_hours"] = wall_time_hours
212
-
213
- try:
214
- result = subprocess.run(
215
- [sys.executable, "-m", "pip", "freeze"],
216
- capture_output=True,
217
- text=True,
218
- check=True,
219
- )
220
- self.compute_info["dependencies"] = (
221
- result.stdout.strip().split("\n") if result.stdout else []
222
- )
223
- except (subprocess.CalledProcessError, FileNotFoundError) as e:
224
- logging.warning("Could not capture pip environment: %s", e)
225
- self.compute_info["dependencies"] = []
226
-
227
- try:
228
- commit = subprocess.run(
229
- ["git", "rev-parse", "HEAD"],
230
- capture_output=True,
231
- text=True,
232
- check=True,
233
- ).stdout.strip()
234
- branch = subprocess.run(
235
- ["git", "rev-parse", "--abbrev-ref", "HEAD"],
236
- capture_output=True,
237
- text=True,
238
- check=True,
239
- ).stdout.strip()
240
- status = subprocess.run(
241
- ["git", "status", "--porcelain"],
242
- capture_output=True,
243
- text=True,
244
- check=True,
245
- ).stdout.strip()
246
- self.compute_info["git_info"] = {
247
- "commit": commit,
248
- "branch": branch,
249
- "is_dirty": bool(status),
250
- }
251
- except (subprocess.CalledProcessError, FileNotFoundError) as e:
252
- logging.warning("Could not capture git info: %s", e)
253
- self.compute_info["git_info"] = None
254
-
255
- def deactivate(self) -> None:
256
- """Mark this solution as inactive.
257
-
258
- Inactive solutions are excluded from submission exports and dossier
259
- generation. This is useful for keeping alternative fits without
260
- including them in the final submission.
261
-
262
- Example:
263
- >>> solution = event.get_solution("solution_uuid")
264
- >>> solution.deactivate()
265
- >>>
266
- >>> # The solution is now inactive and won't be included in exports
267
- >>> submission.save() # Persist the change
268
-
269
- Note:
270
- This method only changes the is_active flag. The solution data
271
- remains intact and can be reactivated later using activate().
272
- """
273
- self.is_active = False
274
-
275
- def activate(self) -> None:
276
- """Mark this solution as active.
277
-
278
- Active solutions are included in submission exports and dossier
279
- generation. This is the default state for new solutions.
280
-
281
- Example:
282
- >>> solution = event.get_solution("solution_uuid")
283
- >>> solution.activate()
284
- >>>
285
- >>> # The solution is now active and will be included in exports
286
- >>> submission.save() # Persist the change
287
-
288
- Note:
289
- This method only changes the is_active flag. The solution data
290
- remains intact.
291
- """
292
- self.is_active = True
293
-
294
- def run_validation(self) -> list[str]:
295
- """Validate this solution's parameters and configuration.
296
-
297
- This method performs comprehensive validation using centralized validation logic
298
- to ensure the solution is complete, consistent, and ready for submission.
299
-
300
- The validation includes:
301
-
302
- * Parameter completeness for the given model type
303
- * Higher-order effect requirements (e.g., parallax needs piEN, piEE)
304
- * Band-specific flux parameters when bands are specified
305
- * Reference time requirements for time-dependent effects
306
- * Parameter data types and physically meaningful ranges
307
- * Physical consistency checks
308
- * Model-specific parameter requirements
309
-
310
- Args:
311
- None
312
-
313
- Returns:
314
- list[str]: Human-readable validation messages. Empty list indicates all
315
- validations passed. Messages may include warnings (non-critical)
316
- and errors (critical issues that should be addressed).
317
-
318
- Example:
319
- >>> solution = event.add_solution("1S2L", {"t0": 2459123.5, "u0": 0.1})
320
- >>> messages = solution.run_validation()
321
- >>> if messages:
322
- ... print("Validation issues found:")
323
- ... for msg in messages:
324
- ... print(f" - {msg}")
325
- ... else:
326
- ... print("Solution is valid!")
327
-
328
- Note:
329
- Always validate solutions before submission. The validation logic
330
- is centralized and covers all model types and higher-order effects.
331
- Some warnings may be non-critical but should be reviewed.
332
- """
333
- from .validate_parameters import (
334
- check_solution_completeness,
335
- validate_parameter_types,
336
- validate_solution_consistency,
337
- validate_parameter_uncertainties
338
- )
339
-
340
- messages = []
341
-
342
- # Check solution completeness
343
- completeness_messages = check_solution_completeness(
344
- model_type=self.model_type,
345
- parameters=self.parameters,
346
- higher_order_effects=self.higher_order_effects,
347
- bands=self.bands,
348
- t_ref=self.t_ref
349
- )
350
- messages.extend(completeness_messages)
351
-
352
- # Check parameter types
353
- type_messages = validate_parameter_types(
354
- parameters=self.parameters,
355
- model_type=self.model_type
356
- )
357
- messages.extend(type_messages)
358
-
359
- # Check parameter uncertainties
360
- uncertainty_messages = validate_parameter_uncertainties(
361
- parameters=self.parameters,
362
- uncertainties=self.parameter_uncertainties
363
- )
364
- messages.extend(uncertainty_messages)
365
-
366
- # Check solution consistency
367
- consistency_messages = validate_solution_consistency(
368
- model_type=self.model_type,
369
- parameters=self.parameters,
370
- relative_probability=self.relative_probability,
371
- )
372
- messages.extend(consistency_messages)
373
-
374
- return messages
375
-
376
- def _save(self, event_path: Path) -> None:
377
- """Write this solution to disk.
378
-
379
- Args:
380
- event_path: Directory of the parent event within the project.
381
-
382
- Example:
383
- >>> # This is called automatically by Event._save()
384
- >>> event._save() # This calls solution._save() for each solution
385
-
386
- Note:
387
- This is an internal method. Solutions are automatically saved
388
- when the parent event is saved via submission.save().
389
- """
390
- solutions_dir = event_path / "solutions"
391
- solutions_dir.mkdir(parents=True, exist_ok=True)
392
- out_path = solutions_dir / f"{self.solution_id}.json"
393
- with out_path.open("w", encoding="utf-8") as fh:
394
- fh.write(self.model_dump_json(indent=2))
395
-
396
- def get_notes(self, project_root: Optional[Path] = None) -> str:
397
- """Read notes from the notes file, if present.
398
-
399
- Args:
400
- project_root: Optional project root path for resolving relative
401
- notes_path. If None, uses the current working directory.
402
-
403
- Returns:
404
- str: The contents of the notes file as a string, or empty string
405
- if no notes file exists or notes_path is not set.
406
-
407
- Example:
408
- >>> solution = event.get_solution("solution_uuid")
409
- >>> notes = solution.get_notes(project_root=Path("./my_project"))
410
- >>> print(notes)
411
- # My Solution Notes
412
-
413
- This is a detailed description of my fit...
414
-
415
- Note:
416
- This method handles both absolute and relative notes_path values.
417
- If notes_path is relative, it's resolved against project_root.
418
- """
419
- if not self.notes_path:
420
- return ""
421
- path = Path(self.notes_path)
422
- if not path.is_absolute() and project_root is not None:
423
- path = project_root / path
424
- if path.exists():
425
- return path.read_text(encoding="utf-8")
426
- return ""
427
-
428
- def set_notes(self, content: str, project_root: Optional[Path] = None) -> None:
429
- """Write notes to the notes file, creating it if needed.
430
-
431
- If notes_path is not set, creates a temporary file in tmp/<solution_id>.md
432
- and sets notes_path. On Submission.save(), temporary notes files are
433
- moved to the canonical location.
434
-
435
- Args:
436
- content: The markdown content to write to the notes file.
437
- project_root: Optional project root path for resolving relative
438
- notes_path. If None, uses the current working directory.
439
-
440
- Example:
441
- >>> solution = event.get_solution("solution_uuid")
442
- >>>
443
- >>> # Set notes with markdown content
444
- >>> solution.set_notes('''
445
- ... # My Solution Notes
446
- ...
447
- ... This is a detailed description of my microlensing fit.
448
- ...
449
- ... ## Parameters
450
- ... - t0: Time of closest approach
451
- ... - u0: Impact parameter
452
- ... - tE: Einstein crossing time
453
- ...
454
- ... ## Notes
455
- ... The fit shows clear evidence of a binary lens...
456
- ... ''', project_root=Path("./my_project"))
457
- >>>
458
- >>> # The notes are now saved and can be read back
459
- >>> notes = solution.get_notes(project_root=Path("./my_project"))
460
-
461
- Note:
462
- This method supports markdown formatting. The notes will be
463
- rendered as HTML in the dossier with syntax highlighting
464
- for code blocks.
465
- """
466
- if not self.notes_path:
467
- # Use tmp/ for unsaved notes
468
- tmp_dir = Path(project_root or ".") / "tmp"
469
- tmp_dir.mkdir(parents=True, exist_ok=True)
470
- tmp_path = tmp_dir / f"{self.solution_id}.md"
471
- self.notes_path = str(tmp_path.relative_to(project_root or "."))
472
- path = Path(self.notes_path)
473
- if not path.is_absolute() and project_root is not None:
474
- path = project_root / path
475
- path.parent.mkdir(parents=True, exist_ok=True)
476
- path.write_text(content, encoding="utf-8")
477
-
478
- @property
479
- def notes(self) -> str:
480
- """Return the Markdown notes string from the notes file (read-only).
481
-
482
- Returns:
483
- str: The contents of the notes file as a string, or empty string
484
- if no notes file exists.
485
-
486
- Example:
487
- >>> solution = event.get_solution("solution_uuid")
488
- >>> print(solution.notes)
489
- # My Solution Notes
490
-
491
- This is a detailed description of my fit...
492
-
493
- Note:
494
- This is a read-only property. Use set_notes() to modify the notes.
495
- The property uses the current working directory to resolve relative
496
- notes_path. For more control, use get_notes() with project_root.
497
- """
498
- return self.get_notes()
499
-
500
- def view_notes(self, render_html: bool = True, project_root: Optional[Path] = None) -> str:
501
- """Return the notes as Markdown or rendered HTML.
502
-
503
- Args:
504
- render_html: If True, return HTML using markdown.markdown with
505
- extensions for tables and fenced code blocks. If False,
506
- return the raw Markdown string.
507
- project_root: Optionally specify the project root for relative
508
- notes_path resolution.
509
-
510
- Returns:
511
- str: Markdown or HTML string depending on render_html parameter.
512
-
513
- Example:
514
- >>> solution = event.get_solution("solution_uuid")
515
- >>>
516
- >>> # Get raw markdown
517
- >>> md = solution.view_notes(render_html=False)
518
- >>> print(md)
519
- # My Solution Notes
520
-
521
- >>> # Get rendered HTML (useful for Jupyter/IPython)
522
- >>> html = solution.view_notes(render_html=True)
523
- >>> print(html)
524
- <h1>My Solution Notes</h1>
525
- <p>...</p>
526
-
527
- Note:
528
- When render_html=True, the markdown is rendered with extensions
529
- for tables, fenced code blocks, and other advanced features.
530
- This is particularly useful for displaying notes in Jupyter
531
- notebooks or other HTML contexts.
532
- """
533
- md = self.get_notes(project_root=project_root)
534
- if render_html:
535
- import markdown
536
- return markdown.markdown(md or "", extensions=["extra", "tables", "fenced_code"])
537
- return md
538
-
539
-
540
- class Event(BaseModel):
541
- """A collection of solutions for a single microlensing event.
542
-
543
- Events act as containers that group one or more :class:`Solution` objects
544
- under a common ``event_id``. They are created on demand via
545
- :meth:`Submission.get_event` and are written to disk when the parent
546
- submission is saved.
547
-
548
- Attributes:
549
- event_id: Identifier used to reference the event within the project.
550
- solutions: Mapping of solution IDs to :class:`Solution` instances.
551
- submission: The parent :class:`Submission` or ``None`` if detached.
552
-
553
- Example:
554
- >>> from microlens_submit import load
555
- >>>
556
- >>> # Load a submission and get/create an event
557
- >>> submission = load("./my_project")
558
- >>> event = submission.get_event("EVENT001")
559
- >>>
560
- >>> # Add multiple solutions to the event
561
- >>> solution1 = event.add_solution("1S1L", {
562
- ... "t0": 2459123.5, "u0": 0.1, "tE": 20.0
563
- ... })
564
- >>> solution2 = event.add_solution("1S2L", {
565
- ... "t0": 2459123.5, "u0": 0.1, "tE": 20.0,
566
- ... "s": 1.2, "q": 0.5, "alpha": 45.0
567
- ... })
568
- >>>
569
- >>> # Get active solutions
570
- >>> active_solutions = event.get_active_solutions()
571
- >>> print(f"Event {event.event_id} has {len(active_solutions)} active solutions")
572
- >>>
573
- >>> # Deactivate a solution
574
- >>> solution1.deactivate()
575
- >>>
576
- >>> # Save the submission (includes all events and solutions)
577
- >>> submission.save()
578
-
579
- Note:
580
- Events are automatically created when you call submission.get_event()
581
- with a new event_id. All solutions for an event are stored together
582
- in the project directory structure.
583
- """
584
-
585
- event_id: str
586
- solutions: Dict[str, Solution] = Field(default_factory=dict)
587
- submission: Optional["Submission"] = Field(default=None, exclude=True)
588
-
589
- def add_solution(self, model_type: str, parameters: dict) -> Solution:
590
- """Create and attach a new solution to this event.
591
-
592
- Parameters are stored as provided and the new solution is returned for
593
- further modification. A unique solution_id is automatically generated.
594
-
595
- Args:
596
- model_type: Short label describing the model type (e.g., "1S1L", "1S2L").
597
- parameters: Dictionary of model parameters for the fit.
598
-
599
- Returns:
600
- Solution: The newly created solution instance.
601
-
602
- Example:
603
- >>> event = submission.get_event("EVENT001")
604
- >>>
605
- >>> # Create a simple point lens solution
606
- >>> solution = event.add_solution("1S1L", {
607
- ... "t0": 2459123.5, # Time of closest approach
608
- ... "u0": 0.1, # Impact parameter
609
- ... "tE": 20.0 # Einstein crossing time
610
- ... })
611
- >>>
612
- >>> # The solution is automatically added to the event
613
- >>> print(f"Event now has {len(event.solutions)} solutions")
614
- >>> print(f"Solution ID: {solution.solution_id}")
615
-
616
- Note:
617
- The solution is automatically marked as active and assigned a
618
- unique UUID. You can modify the solution attributes after creation
619
- and then save the submission to persist changes.
620
- """
621
- solution_id = str(uuid.uuid4())
622
- sol = Solution(
623
- solution_id=solution_id, model_type=model_type, parameters=parameters
624
- )
625
- self.solutions[solution_id] = sol
626
- return sol
627
-
628
- def get_solution(self, solution_id: str) -> Solution:
629
- """Return a previously added solution.
630
-
631
- Args:
632
- solution_id: Identifier of the solution to retrieve.
633
-
634
- Returns:
635
- Solution: The corresponding solution.
636
-
637
- Raises:
638
- KeyError: If the solution_id is not found in this event.
639
-
640
- Example:
641
- >>> event = submission.get_event("EVENT001")
642
- >>>
643
- >>> # Get a specific solution
644
- >>> solution = event.get_solution("solution_uuid_here")
645
- >>> print(f"Model type: {solution.model_type}")
646
- >>> print(f"Parameters: {solution.parameters}")
647
-
648
- Note:
649
- Use this method to retrieve existing solutions. If you need to
650
- create a new solution, use add_solution() instead.
651
- """
652
- return self.solutions[solution_id]
653
-
654
- def get_active_solutions(self) -> list[Solution]:
655
- """Return all solutions currently marked as active.
656
-
657
- Returns:
658
- list[Solution]: List of all active solutions in this event.
659
-
660
- Example:
661
- >>> event = submission.get_event("EVENT001")
662
- >>>
663
- >>> # Get only active solutions
664
- >>> active_solutions = event.get_active_solutions()
665
- >>> print(f"Event has {len(active_solutions)} active solutions")
666
- >>>
667
- >>> # Only active solutions are included in exports
668
- >>> for solution in active_solutions:
669
- ... print(f"- {solution.solution_id}: {solution.model_type}")
670
-
671
- Note:
672
- Only active solutions are included in submission exports and
673
- dossier generation. Use deactivate() to exclude solutions from
674
- the final submission.
675
- """
676
- return [sol for sol in self.solutions.values() if sol.is_active]
677
-
678
- def clear_solutions(self) -> None:
679
- """Deactivate every solution associated with this event.
680
-
681
- This method marks all solutions in the event as inactive, effectively
682
- removing them from submission exports and dossier generation.
683
-
684
- Example:
685
- >>> event = submission.get_event("EVENT001")
686
- >>>
687
- >>> # Deactivate all solutions in this event
688
- >>> event.clear_solutions()
689
- >>>
690
- >>> # Now no solutions are active
691
- >>> active_solutions = event.get_active_solutions()
692
- >>> print(f"Active solutions: {len(active_solutions)}") # 0
693
-
694
- Note:
695
- This only deactivates solutions; they are not deleted. You can
696
- reactivate individual solutions using solution.activate().
697
- """
698
- for sol in self.solutions.values():
699
- sol.is_active = False
700
-
701
- @classmethod
702
- def _from_dir(cls, event_dir: Path, submission: "Submission") -> "Event":
703
- """Load an event from disk."""
704
- event_json = event_dir / "event.json"
705
- if event_json.exists():
706
- with event_json.open("r", encoding="utf-8") as fh:
707
- event = cls.model_validate_json(fh.read())
708
- else:
709
- event = cls(event_id=event_dir.name)
710
- event.submission = submission
711
- solutions_dir = event_dir / "solutions"
712
- if solutions_dir.exists():
713
- for sol_file in solutions_dir.glob("*.json"):
714
- with sol_file.open("r", encoding="utf-8") as fh:
715
- sol = Solution.model_validate_json(fh.read())
716
- event.solutions[sol.solution_id] = sol
717
- return event
718
-
719
- def _save(self) -> None:
720
- """Write this event and its solutions to disk."""
721
- if self.submission is None:
722
- raise ValueError("Event is not attached to a submission")
723
- base = Path(self.submission.project_path) / "events" / self.event_id
724
- base.mkdir(parents=True, exist_ok=True)
725
- with (base / "event.json").open("w", encoding="utf-8") as fh:
726
- fh.write(
727
- self.model_dump_json(exclude={"solutions", "submission"}, indent=2)
728
- )
729
- for sol in self.solutions.values():
730
- sol._save(base)
731
-
732
-
733
- class Submission(BaseModel):
734
- """Top-level object representing an on-disk submission project.
735
-
736
- A ``Submission`` manages a collection of :class:`Event` objects and handles
737
- serialization to the project directory. Users typically obtain an instance
738
- via :func:`load` and then interact with events and solutions before calling
739
- :meth:`save` or :meth:`export`.
740
-
741
- Attributes:
742
- project_path: Root directory where submission files are stored.
743
- team_name: Name of the participating team.
744
- tier: Challenge tier for the submission (e.g., "basic", "advanced").
745
- hardware_info: Optional dictionary describing the compute platform.
746
- events: Mapping of event IDs to :class:`Event` instances.
747
- repo_url: GitHub repository URL for the team codebase.
748
-
749
- Example:
750
- >>> from microlens_submit import load
751
- >>>
752
- >>> # Load or create a submission project
753
- >>> submission = load("./my_project")
754
- >>>
755
- >>> # Set submission metadata
756
- >>> submission.team_name = "Team Alpha"
757
- >>> submission.tier = "advanced"
758
- >>> submission.repo_url = "https://github.com/team/microlens-submit"
759
- >>>
760
- >>> # Add events and solutions
761
- >>> event1 = submission.get_event("EVENT001")
762
- >>> solution1 = event1.add_solution("1S1L", {"t0": 2459123.5, "u0": 0.1, "tE": 20.0})
763
- >>>
764
- >>> event2 = submission.get_event("EVENT002")
765
- >>> solution2 = event2.add_solution("1S2L", {"t0": 2459156.2, "u0": 0.08, "tE": 35.7, "s": 0.95, "q": 0.0005, "alpha": 78.3})
766
- >>>
767
- >>> # Validate the submission
768
- >>> warnings = submission.run_validation()
769
- >>> if warnings:
770
- ... print("Validation warnings:")
771
- ... for warning in warnings:
772
- ... print(f" - {warning}")
773
- ... else:
774
- ... print("✅ Submission is valid!")
775
- >>>
776
- >>> # Save the submission
777
- >>> submission.save()
778
- >>>
779
- >>> # Export for submission
780
- >>> submission.export("submission.zip")
781
-
782
- Note:
783
- The submission project structure is automatically created when you
784
- first call load() with a new directory. All data is stored in JSON
785
- format with a clear directory structure for events and solutions.
786
- """
787
-
788
- project_path: str = Field(default="", exclude=True)
789
- team_name: str = ""
790
- tier: str = ""
791
- hardware_info: Optional[dict] = None
792
- events: Dict[str, Event] = Field(default_factory=dict)
793
- repo_url: Optional[str] = None
794
-
795
- def run_validation(self) -> list[str]:
796
- """Check the submission for missing or incomplete information.
797
-
798
- The method performs lightweight validation and returns a list of
799
- warnings describing potential issues. It does not raise exceptions and
800
- can be used to provide user feedback prior to exporting.
801
-
802
- Returns:
803
- list[str]: Human-readable warning messages. Empty list indicates
804
- no issues found.
805
-
806
- Example:
807
- >>> submission = load("./my_project")
808
- >>>
809
- >>> # Validate the submission
810
- >>> warnings = submission.run_validation()
811
- >>> if warnings:
812
- ... print("Validation warnings:")
813
- ... for warning in warnings:
814
- ... print(f" - {warning}")
815
- ... else:
816
- ... print("✅ Submission is valid!")
817
-
818
- Note:
819
- This method checks for common issues like missing repo_url,
820
- inactive events, incomplete solution data, and validation
821
- problems in individual solutions. Always validate before
822
- exporting your submission.
823
- """
824
-
825
- warnings: list[str] = []
826
- if not self.hardware_info:
827
- warnings.append("Hardware info is missing")
828
-
829
- # Check for missing or invalid repo_url
830
- if not self.repo_url or not isinstance(self.repo_url, str) or not self.repo_url.strip():
831
- warnings.append("repo_url (GitHub repository URL) is missing from submission.json")
832
- elif not ("github.com" in self.repo_url):
833
- warnings.append(f"repo_url does not appear to be a valid GitHub URL: {self.repo_url}")
834
-
835
- for event in self.events.values():
836
- active = [sol for sol in event.solutions.values() if sol.is_active]
837
- if not active:
838
- warnings.append(f"Event {event.event_id} has no active solutions")
839
- else:
840
- # Check relative probabilities for active solutions
841
- if len(active) > 1:
842
- # Multiple active solutions - check if probabilities sum to 1.0
843
- total_prob = sum(sol.relative_probability or 0.0 for sol in active)
844
-
845
- if total_prob > 0.0 and abs(total_prob - 1.0) > 1e-6: # Allow small floating point errors
846
- warnings.append(
847
- f"Event {event.event_id}: Relative probabilities for active solutions sum to {total_prob:.3f}, "
848
- f"should sum to 1.0. Solutions: {[sol.solution_id[:8] + '...' for sol in active]}"
849
- )
850
- elif len(active) == 1:
851
- # Single active solution - probability should be 1.0 or None
852
- sol = active[0]
853
- if sol.relative_probability is not None and abs(sol.relative_probability - 1.0) > 1e-6:
854
- warnings.append(
855
- f"Event {event.event_id}: Single active solution has relative_probability {sol.relative_probability:.3f}, "
856
- f"should be 1.0 or None"
857
- )
858
-
859
- for sol in active:
860
- # Use the new centralized validation
861
- solution_messages = sol.run_validation()
862
- for msg in solution_messages:
863
- warnings.append(f"Solution {sol.solution_id} in event {event.event_id}: {msg}")
864
-
865
- # Additional checks for missing metadata
866
- if sol.log_likelihood is None:
867
- warnings.append(
868
- f"Solution {sol.solution_id} in event {event.event_id} is missing log_likelihood"
869
- )
870
- if sol.lightcurve_plot_path is None:
871
- warnings.append(
872
- f"Solution {sol.solution_id} in event {event.event_id} is missing lightcurve_plot_path"
873
- )
874
- if sol.lens_plane_plot_path is None:
875
- warnings.append(
876
- f"Solution {sol.solution_id} in event {event.event_id} is missing lens_plane_plot_path"
877
- )
878
- # Check for missing compute info
879
- compute_info = sol.compute_info or {}
880
- if "cpu_hours" not in compute_info:
881
- warnings.append(
882
- f"Solution {sol.solution_id} in event {event.event_id} is missing cpu_hours"
883
- )
884
- if "wall_time_hours" not in compute_info:
885
- warnings.append(
886
- f"Solution {sol.solution_id} in event {event.event_id} is missing wall_time_hours"
887
- )
888
-
889
- return warnings
890
-
891
- def get_event(self, event_id: str) -> Event:
892
- """Return the event with ``event_id``.
893
-
894
- If the event does not yet exist in the submission it will be created
895
- automatically and attached to the submission.
896
-
897
- Args:
898
- event_id: Identifier of the event.
899
-
900
- Returns:
901
- Event: The corresponding event object.
902
-
903
- Example:
904
- >>> submission = load("./my_project")
905
- >>>
906
- >>> # Get an existing event or create a new one
907
- >>> event = submission.get_event("EVENT001")
908
- >>>
909
- >>> # The event is automatically added to the submission
910
- >>> print(f"Submission has {len(submission.events)} events")
911
- >>> print(f"Event {event.event_id} has {len(event.solutions)} solutions")
912
-
913
- Note:
914
- Events are created on-demand when you first access them. This
915
- allows you to work with events without explicitly creating them
916
- first. The event is automatically saved when you call
917
- submission.save().
918
- """
919
- if event_id not in self.events:
920
- self.events[event_id] = Event(event_id=event_id, submission=self)
921
- return self.events[event_id]
922
-
923
- def autofill_nexus_info(self) -> None:
924
- """Populate :attr:`hardware_info` with Roman Nexus platform details.
925
-
926
- This helper reads a few well-known files from the Roman Science
927
- Platform environment to infer CPU model, available memory and the image
928
- identifier. Missing information is silently ignored.
929
-
930
- Example:
931
- >>> submission = load("./my_project")
932
- >>>
933
- >>> # Auto-detect Nexus platform information
934
- >>> submission.autofill_nexus_info()
935
- >>>
936
- >>> # Check what was detected
937
- >>> if submission.hardware_info:
938
- ... print("Hardware info:", submission.hardware_info)
939
- ... else:
940
- ... print("No hardware info detected")
941
-
942
- Note:
943
- This method is designed for the Roman Science Platform environment.
944
- It reads from /proc/cpuinfo, /proc/meminfo, and JUPYTER_IMAGE_SPEC
945
- environment variable. If these are not available (e.g., on a
946
- different platform), the method will silently skip them.
947
- """
948
-
949
- if self.hardware_info is None:
950
- self.hardware_info = {}
951
-
952
- try:
953
- image = os.environ.get("JUPYTER_IMAGE_SPEC")
954
- if image:
955
- self.hardware_info["nexus_image"] = image
956
- except Exception as exc: # pragma: no cover - environment may not exist
957
- logging.debug("Failed to read JUPYTER_IMAGE_SPEC: %s", exc)
958
-
959
- try:
960
- with open("/proc/cpuinfo", "r", encoding="utf-8") as fh:
961
- for line in fh:
962
- if line.lower().startswith("model name"):
963
- self.hardware_info["cpu_details"] = line.split(":", 1)[
964
- 1
965
- ].strip()
966
- break
967
- except OSError as exc: # pragma: no cover
968
- logging.debug("Failed to read /proc/cpuinfo: %s", exc)
969
-
970
- try:
971
- with open("/proc/meminfo", "r", encoding="utf-8") as fh:
972
- for line in fh:
973
- if line.startswith("MemTotal"):
974
- mem_kb = int(line.split(":", 1)[1].strip().split()[0])
975
- self.hardware_info["memory_gb"] = round(mem_kb / 1024**2, 2)
976
- break
977
- except OSError as exc: # pragma: no cover
978
- logging.debug("Failed to read /proc/meminfo: %s", exc)
979
-
980
- def save(self) -> None:
981
- """Persist the current state of the submission to ``project_path``.
982
-
983
- This method writes all submission data to disk, including events,
984
- solutions, and metadata. It also handles moving temporary notes
985
- files to their canonical locations.
986
-
987
- Example:
988
- >>> submission = load("./my_project")
989
- >>>
990
- >>> # Make changes to the submission
991
- >>> submission.team_name = "Team Alpha"
992
- >>> event = submission.get_event("EVENT001")
993
- >>> solution = event.add_solution("1S1L", {"t0": 2459123.5, "u0": 0.1, "tE": 20.0})
994
- >>>
995
- >>> # Save all changes to disk
996
- >>> submission.save()
997
- >>>
998
- >>> # All data is now persisted in the project directory
999
-
1000
- Note:
1001
- This method creates the project directory structure if it doesn't
1002
- exist and moves any temporary notes files from tmp/ to their
1003
- canonical locations in events/{event_id}/solutions/{solution_id}.md.
1004
- Always call save() after making changes to persist them.
1005
- """
1006
- project = Path(self.project_path)
1007
- events_dir = project / "events"
1008
- events_dir.mkdir(parents=True, exist_ok=True)
1009
- # Move any notes files from tmp/ to canonical location
1010
- for event in self.events.values():
1011
- for sol in event.solutions.values():
1012
- if sol.notes_path:
1013
- notes_path = Path(sol.notes_path)
1014
- if notes_path.parts and notes_path.parts[0] == "tmp":
1015
- # Move to canonical location
1016
- canonical = Path("events") / event.event_id / "solutions" / f"{sol.solution_id}.md"
1017
- src = project / notes_path
1018
- dst = project / canonical
1019
- dst.parent.mkdir(parents=True, exist_ok=True)
1020
- if src.exists():
1021
- src.replace(dst)
1022
- sol.notes_path = str(canonical)
1023
- with (project / "submission.json").open("w", encoding="utf-8") as fh:
1024
- fh.write(self.model_dump_json(exclude={"events", "project_path"}, indent=2))
1025
- for event in self.events.values():
1026
- event.submission = self
1027
- event._save()
1028
-
1029
- def export(self, output_path: str) -> None:
1030
- """Create a zip archive of all active solutions.
1031
-
1032
- The archive is created using ``zipfile.ZIP_DEFLATED`` compression to
1033
- minimize file size. Only active solutions are included in the export.
1034
-
1035
- Args:
1036
- output_path: Destination path for the zip archive.
1037
-
1038
- Raises:
1039
- ValueError: If referenced files (plots, posterior data) don't exist.
1040
- OSError: If unable to create the zip file.
1041
-
1042
- Example:
1043
- >>> submission = load("./my_project")
1044
- >>>
1045
- >>> # Validate before export
1046
- >>> warnings = submission.run_validation()
1047
- >>> if warnings:
1048
- ... print("Fix validation issues before export:", warnings)
1049
- ... else:
1050
- ... # Export the submission
1051
- ... submission.export("my_submission.zip")
1052
- ... print("Submission exported to my_submission.zip")
1053
-
1054
- Note:
1055
- The export includes:
1056
- - submission.json with metadata
1057
- - All active solutions with parameters
1058
- - Notes files for each solution
1059
- - Referenced files (plots, posterior data)
1060
-
1061
- Relative probabilities are automatically calculated for solutions
1062
- that don't have them set, using BIC if sufficient data is available.
1063
- Only active solutions are included in the export.
1064
- """
1065
- project = Path(self.project_path)
1066
- with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
1067
- submission_json = project / "submission.json"
1068
- if submission_json.exists():
1069
- zf.write(submission_json, arcname="submission.json")
1070
- events_dir = project / "events"
1071
- for event in self.events.values():
1072
- event_dir = events_dir / event.event_id
1073
- event_json = event_dir / "event.json"
1074
- if event_json.exists():
1075
- zf.write(event_json, arcname=f"events/{event.event_id}/event.json")
1076
- active_sols = [s for s in event.solutions.values() if s.is_active]
1077
-
1078
- # Determine relative probabilities for this event
1079
- rel_prob_map: dict[str, float] = {}
1080
- if active_sols:
1081
- provided_sum = sum(
1082
- s.relative_probability or 0.0
1083
- for s in active_sols
1084
- if s.relative_probability is not None
1085
- )
1086
- need_calc = [
1087
- s for s in active_sols if s.relative_probability is None
1088
- ]
1089
- if need_calc:
1090
- can_calc = True
1091
- for s in need_calc:
1092
- if (
1093
- s.log_likelihood is None
1094
- or s.n_data_points is None
1095
- or s.n_data_points <= 0
1096
- or len(s.parameters) == 0
1097
- ):
1098
- can_calc = False
1099
- break
1100
- remaining = max(1.0 - provided_sum, 0.0)
1101
- if can_calc:
1102
- bic_vals = {
1103
- s.solution_id: len(s.parameters)
1104
- * math.log(s.n_data_points)
1105
- - 2 * s.log_likelihood
1106
- for s in need_calc
1107
- }
1108
- bic_min = min(bic_vals.values())
1109
- weights = {
1110
- sid: math.exp(-0.5 * (bic - bic_min))
1111
- for sid, bic in bic_vals.items()
1112
- }
1113
- wsum = sum(weights.values())
1114
- for sid, w in weights.items():
1115
- rel_prob_map[sid] = (
1116
- remaining * w / wsum
1117
- if wsum > 0
1118
- else remaining / len(weights)
1119
- )
1120
- logging.warning(
1121
- "relative_probability calculated for event %s using BIC",
1122
- event.event_id,
1123
- )
1124
- else:
1125
- eq = remaining / len(need_calc) if need_calc else 0.0
1126
- for s in need_calc:
1127
- rel_prob_map[s.solution_id] = eq
1128
- logging.warning(
1129
- "relative_probability set equally for event %s due to missing data",
1130
- event.event_id,
1131
- )
1132
-
1133
- for sol in active_sols:
1134
- sol_path = event_dir / "solutions" / f"{sol.solution_id}.json"
1135
- if sol_path.exists():
1136
- arc = (
1137
- f"events/{event.event_id}/solutions/{sol.solution_id}.json"
1138
- )
1139
- export_sol = sol.model_copy()
1140
- for attr in [
1141
- "posterior_path",
1142
- "lightcurve_plot_path",
1143
- "lens_plane_plot_path",
1144
- ]:
1145
- path = getattr(sol, attr)
1146
- if path is not None:
1147
- filename = Path(path).name
1148
- new_path = f"events/{event.event_id}/solutions/{sol.solution_id}/{filename}"
1149
- setattr(export_sol, attr, new_path)
1150
- if sol.notes_path:
1151
- notes_file = Path(self.project_path) / sol.notes_path
1152
- if notes_file.exists():
1153
- notes_filename = notes_file.name
1154
- notes_arc = f"events/{event.event_id}/solutions/{sol.solution_id}/{notes_filename}"
1155
- export_sol.notes_path = notes_arc
1156
- zf.write(notes_file, arcname=notes_arc)
1157
- if export_sol.relative_probability is None:
1158
- export_sol.relative_probability = rel_prob_map.get(
1159
- sol.solution_id
1160
- )
1161
- zf.writestr(arc, export_sol.model_dump_json(indent=2))
1162
- # Include any referenced external files
1163
- sol_dir_arc = f"events/{event.event_id}/solutions/{sol.solution_id}"
1164
- for attr in [
1165
- "posterior_path",
1166
- "lightcurve_plot_path",
1167
- "lens_plane_plot_path",
1168
- ]:
1169
- path = getattr(sol, attr)
1170
- if path is not None:
1171
- file_path = Path(self.project_path) / path
1172
- if not file_path.exists():
1173
- raise ValueError(
1174
- f"Error: File specified by {attr} in solution {sol.solution_id} does not exist: {file_path}"
1175
- )
1176
- zf.write(
1177
- file_path,
1178
- arcname=f"{sol_dir_arc}/{Path(path).name}",
1179
- )
1180
-
1181
-
1182
- def load(project_path: str) -> Submission:
1183
- """Load an existing submission or create a new one.
1184
-
1185
- The directory specified by ``project_path`` becomes the working
1186
- directory for all subsequent operations. If the directory does not
1187
- exist, a new project structure is created automatically.
1188
-
1189
- Args:
1190
- project_path: Location of the submission project on disk.
1191
-
1192
- Returns:
1193
- Submission: The loaded or newly created submission instance.
1194
-
1195
- Raises:
1196
- OSError: If unable to create the project directory or read files.
1197
- ValueError: If existing submission.json is invalid.
1198
-
1199
- Example:
1200
- >>> from microlens_submit import load
1201
- >>>
1202
- >>> # Load existing project
1203
- >>> submission = load("./existing_project")
1204
- >>> print(f"Team: {submission.team_name}")
1205
- >>> print(f"Events: {len(submission.events)}")
1206
- >>>
1207
- >>> # Create new project
1208
- >>> submission = load("./new_project")
1209
- >>> submission.team_name = "Team Beta"
1210
- >>> submission.tier = "basic"
1211
- >>> submission.save()
1212
- >>>
1213
- >>> # The project structure is automatically created:
1214
- >>> # ./new_project/
1215
- >>> # ├── submission.json
1216
- >>> # └── events/
1217
- >>> # └── (event directories created as needed)
1218
-
1219
- Note:
1220
- This is the main entry point for working with submission projects.
1221
- The function automatically creates the project directory structure
1222
- if it doesn't exist, making it safe to use with new projects.
1223
- All subsequent operations (adding events, solutions, etc.) work
1224
- with the returned Submission instance.
1225
- """
1226
- project = Path(project_path)
1227
- events_dir = project / "events"
1228
-
1229
- if not project.exists():
1230
- events_dir.mkdir(parents=True, exist_ok=True)
1231
- submission = Submission(project_path=str(project))
1232
- with (project / "submission.json").open("w", encoding="utf-8") as fh:
1233
- fh.write(
1234
- submission.model_dump_json(exclude={"events", "project_path"}, indent=2)
1235
- )
1236
- return submission
1237
-
1238
- sub_json = project / "submission.json"
1239
- if sub_json.exists():
1240
- with sub_json.open("r", encoding="utf-8") as fh:
1241
- submission = Submission.model_validate_json(fh.read())
1242
- submission.project_path = str(project)
1243
- else:
1244
- submission = Submission(project_path=str(project))
1245
-
1246
- if events_dir.exists():
1247
- for event_dir in events_dir.iterdir():
1248
- if event_dir.is_dir():
1249
- event = Event._from_dir(event_dir, submission)
1250
- submission.events[event.event_id] = event
1251
-
1252
- return submission
1253
-
1254
-
1255
- # Resolve forward references
1256
- Event.model_rebuild()
1257
- Submission.model_rebuild()