microlens-submit 0.12.1__py3-none-any.whl → 0.16.0__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.
- microlens_submit/__init__.py +7 -157
- microlens_submit/cli/__init__.py +5 -0
- microlens_submit/cli/__main__.py +6 -0
- microlens_submit/cli/commands/__init__.py +1 -0
- microlens_submit/cli/commands/dossier.py +139 -0
- microlens_submit/cli/commands/export.py +177 -0
- microlens_submit/cli/commands/init.py +172 -0
- microlens_submit/cli/commands/solutions.py +722 -0
- microlens_submit/cli/commands/validation.py +241 -0
- microlens_submit/cli/main.py +120 -0
- microlens_submit/dossier/__init__.py +51 -0
- microlens_submit/dossier/dashboard.py +499 -0
- microlens_submit/dossier/event_page.py +369 -0
- microlens_submit/dossier/full_report.py +330 -0
- microlens_submit/dossier/solution_page.py +533 -0
- microlens_submit/dossier/utils.py +111 -0
- microlens_submit/error_messages.py +283 -0
- microlens_submit/models/__init__.py +28 -0
- microlens_submit/models/event.py +406 -0
- microlens_submit/models/solution.py +569 -0
- microlens_submit/models/submission.py +569 -0
- microlens_submit/tier_validation.py +208 -0
- microlens_submit/utils.py +373 -0
- microlens_submit/validate_parameters.py +478 -180
- {microlens_submit-0.12.1.dist-info → microlens_submit-0.16.0.dist-info}/METADATA +54 -37
- microlens_submit-0.16.0.dist-info/RECORD +32 -0
- {microlens_submit-0.12.1.dist-info → microlens_submit-0.16.0.dist-info}/WHEEL +1 -1
- microlens_submit/api.py +0 -1274
- microlens_submit/cli.py +0 -1803
- microlens_submit/dossier.py +0 -1443
- microlens_submit-0.12.1.dist-info/RECORD +0 -13
- {microlens_submit-0.12.1.dist-info/licenses → microlens_submit-0.16.0.dist-info}/LICENSE +0 -0
- {microlens_submit-0.12.1.dist-info → microlens_submit-0.16.0.dist-info}/entry_points.txt +0 -0
- {microlens_submit-0.12.1.dist-info → microlens_submit-0.16.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Event model for microlens-submit.
|
|
3
|
+
|
|
4
|
+
This module contains the Event class, which represents a collection of solutions
|
|
5
|
+
for a single microlensing event.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
from .solution import Solution
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .submission import Submission
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Event(BaseModel):
|
|
21
|
+
"""A collection of solutions for a single microlensing event.
|
|
22
|
+
|
|
23
|
+
Events act as containers that group one or more :class:`Solution` objects
|
|
24
|
+
under a common ``event_id``. They are created on demand via
|
|
25
|
+
:meth:`Submission.get_event` and are written to disk when the parent
|
|
26
|
+
submission is saved.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
event_id: Identifier used to reference the event within the project.
|
|
30
|
+
solutions: Mapping of solution IDs to :class:`Solution` instances.
|
|
31
|
+
submission: The parent :class:`Submission` or ``None`` if detached.
|
|
32
|
+
|
|
33
|
+
Example:
|
|
34
|
+
>>> from microlens_submit import load
|
|
35
|
+
>>>
|
|
36
|
+
>>> # Load a submission and get/create an event
|
|
37
|
+
>>> submission = load("./my_project")
|
|
38
|
+
>>> event = submission.get_event("EVENT001")
|
|
39
|
+
>>>
|
|
40
|
+
>>> # Add multiple solutions to the event
|
|
41
|
+
>>> solution1 = event.add_solution("1S1L", {
|
|
42
|
+
... "t0": 2459123.5, "u0": 0.1, "tE": 20.0
|
|
43
|
+
... })
|
|
44
|
+
>>> solution2 = event.add_solution("1S2L", {
|
|
45
|
+
... "t0": 2459123.5, "u0": 0.1, "tE": 20.0,
|
|
46
|
+
... "s": 1.2, "q": 0.5, "alpha": 45.0
|
|
47
|
+
... })
|
|
48
|
+
>>>
|
|
49
|
+
>>> # Get active solutions
|
|
50
|
+
>>> active_solutions = event.get_active_solutions()
|
|
51
|
+
>>> print(f"Event {event.event_id} has {len(active_solutions)} active solutions")
|
|
52
|
+
>>>
|
|
53
|
+
>>> # Deactivate a solution
|
|
54
|
+
>>> solution1.deactivate()
|
|
55
|
+
>>>
|
|
56
|
+
>>> # Save the submission (includes all events and solutions)
|
|
57
|
+
>>> submission.save()
|
|
58
|
+
|
|
59
|
+
Note:
|
|
60
|
+
Events are automatically created when you call submission.get_event()
|
|
61
|
+
with a new event_id. All solutions for an event are stored together
|
|
62
|
+
in the project directory structure.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
event_id: str
|
|
66
|
+
solutions: Dict[str, Solution] = Field(default_factory=dict)
|
|
67
|
+
submission: Optional["Submission"] = Field(default=None, exclude=True)
|
|
68
|
+
|
|
69
|
+
def add_solution(self, model_type: str, parameters: dict, alias: Optional[str] = None) -> Solution:
|
|
70
|
+
"""Create and attach a new solution to this event.
|
|
71
|
+
|
|
72
|
+
Parameters are stored as provided and the new solution is returned for
|
|
73
|
+
further modification. A unique solution_id is automatically generated.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
model_type: Short label describing the model type (e.g., "1S1L", "1S2L").
|
|
77
|
+
parameters: Dictionary of model parameters for the fit.
|
|
78
|
+
alias: Optional human-readable alias for the solution (e.g., "best_fit", "parallax_model").
|
|
79
|
+
When provided, this alias is used as the primary identifier in dossier displays,
|
|
80
|
+
with the UUID shown as a secondary identifier. The combination of event_id and
|
|
81
|
+
alias must be unique within the project.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Solution: The newly created solution instance.
|
|
85
|
+
|
|
86
|
+
Example:
|
|
87
|
+
>>> event = submission.get_event("EVENT001")
|
|
88
|
+
>>>
|
|
89
|
+
>>> # Create a simple point lens solution
|
|
90
|
+
>>> solution = event.add_solution("1S1L", {
|
|
91
|
+
... "t0": 2459123.5, # Time of closest approach
|
|
92
|
+
... "u0": 0.1, # Impact parameter
|
|
93
|
+
... "tE": 20.0 # Einstein crossing time
|
|
94
|
+
... })
|
|
95
|
+
>>>
|
|
96
|
+
>>> # Create a solution with an alias
|
|
97
|
+
>>> solution_with_alias = event.add_solution("1S2L", {
|
|
98
|
+
... "t0": 2459123.5, "u0": 0.1, "tE": 20.0,
|
|
99
|
+
... "s": 1.2, "q": 0.5, "alpha": 45.0
|
|
100
|
+
... }, alias="best_binary_fit")
|
|
101
|
+
>>>
|
|
102
|
+
>>> # The solution is automatically added to the event
|
|
103
|
+
>>> print(f"Event now has {len(event.solutions)} solutions")
|
|
104
|
+
>>> print(f"Solution ID: {solution.solution_id}")
|
|
105
|
+
|
|
106
|
+
Note:
|
|
107
|
+
The solution is automatically marked as active and assigned a
|
|
108
|
+
unique UUID. You can modify the solution attributes after creation
|
|
109
|
+
and then save the submission to persist changes. If an alias is
|
|
110
|
+
provided, it will be validated for uniqueness when the submission
|
|
111
|
+
is saved. Remember to call submission.save() to persist the solution
|
|
112
|
+
to disk.
|
|
113
|
+
"""
|
|
114
|
+
solution_id = str(uuid.uuid4())
|
|
115
|
+
sol = Solution(
|
|
116
|
+
solution_id=solution_id,
|
|
117
|
+
model_type=model_type,
|
|
118
|
+
parameters=parameters,
|
|
119
|
+
alias=alias,
|
|
120
|
+
)
|
|
121
|
+
self.solutions[solution_id] = sol
|
|
122
|
+
|
|
123
|
+
# Provide feedback about the created solution
|
|
124
|
+
alias_info = f" with alias '{alias}'" if alias else ""
|
|
125
|
+
print(f"✅ Created solution {solution_id}{alias_info}")
|
|
126
|
+
print(f" Model: {model_type}, Parameters: {len(parameters)}")
|
|
127
|
+
if alias:
|
|
128
|
+
print(f" ⚠️ Note: Alias '{alias}' will be validated for uniqueness when saved")
|
|
129
|
+
print(" 💾 Remember to call submission.save() to persist to disk")
|
|
130
|
+
|
|
131
|
+
return sol
|
|
132
|
+
|
|
133
|
+
def get_solution(self, solution_id: str) -> Solution:
|
|
134
|
+
"""Return a previously added solution.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
solution_id: Identifier of the solution to retrieve.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Solution: The corresponding solution.
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
KeyError: If the solution_id is not found in this event.
|
|
144
|
+
|
|
145
|
+
Example:
|
|
146
|
+
>>> event = submission.get_event("EVENT001")
|
|
147
|
+
>>>
|
|
148
|
+
>>> # Get a specific solution
|
|
149
|
+
>>> solution = event.get_solution("solution_uuid_here")
|
|
150
|
+
>>> print(f"Model type: {solution.model_type}")
|
|
151
|
+
>>> print(f"Parameters: {solution.parameters}")
|
|
152
|
+
|
|
153
|
+
Note:
|
|
154
|
+
Use this method to retrieve existing solutions. If you need to
|
|
155
|
+
create a new solution, use add_solution() instead.
|
|
156
|
+
"""
|
|
157
|
+
return self.solutions[solution_id]
|
|
158
|
+
|
|
159
|
+
def get_active_solutions(self) -> List[Solution]:
|
|
160
|
+
"""Return a list of active solutions for this event.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
List[Solution]: List of solutions where is_active is True.
|
|
164
|
+
|
|
165
|
+
Example:
|
|
166
|
+
>>> event = submission.get_event("EVENT001")
|
|
167
|
+
>>> active_solutions = event.get_active_solutions()
|
|
168
|
+
>>> print(f"Found {len(active_solutions)} active solutions")
|
|
169
|
+
"""
|
|
170
|
+
return [sol for sol in self.solutions.values() if sol.is_active]
|
|
171
|
+
|
|
172
|
+
def clear_solutions(self) -> None:
|
|
173
|
+
"""Deactivate every solution associated with this event.
|
|
174
|
+
|
|
175
|
+
This method marks all solutions in the event as inactive, effectively
|
|
176
|
+
removing them from submission exports and dossier generation.
|
|
177
|
+
|
|
178
|
+
Example:
|
|
179
|
+
>>> event = submission.get_event("EVENT001")
|
|
180
|
+
>>>
|
|
181
|
+
>>> # Deactivate all solutions in this event
|
|
182
|
+
>>> event.clear_solutions()
|
|
183
|
+
>>>
|
|
184
|
+
>>> # Now no solutions are active
|
|
185
|
+
>>> active_solutions = event.get_active_solutions()
|
|
186
|
+
>>> print(f"Active solutions: {len(active_solutions)}") # 0
|
|
187
|
+
|
|
188
|
+
Note:
|
|
189
|
+
This only deactivates solutions; they are not deleted. You can
|
|
190
|
+
reactivate individual solutions using solution.activate().
|
|
191
|
+
"""
|
|
192
|
+
for sol in self.solutions.values():
|
|
193
|
+
sol.is_active = False
|
|
194
|
+
|
|
195
|
+
def run_validation(self) -> List[str]:
|
|
196
|
+
"""Validate all active solutions in this event.
|
|
197
|
+
|
|
198
|
+
This method performs validation on all active solutions in the event,
|
|
199
|
+
including parameter validation, physical consistency checks, and
|
|
200
|
+
event-specific validation like relative probability sums.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
List[str]: Human-readable validation messages. Empty list indicates
|
|
204
|
+
all validations passed. Messages may include warnings
|
|
205
|
+
(non-critical) and errors (critical issues).
|
|
206
|
+
|
|
207
|
+
Example:
|
|
208
|
+
>>> event = submission.get_event("EVENT001")
|
|
209
|
+
>>>
|
|
210
|
+
>>> # Validate the event
|
|
211
|
+
>>> warnings = event.run_validation()
|
|
212
|
+
>>> if warnings:
|
|
213
|
+
... print("Event validation issues:")
|
|
214
|
+
... for msg in warnings:
|
|
215
|
+
... print(f" - {msg}")
|
|
216
|
+
... else:
|
|
217
|
+
... print("✅ Event is valid!")
|
|
218
|
+
|
|
219
|
+
Note:
|
|
220
|
+
This method validates all active solutions regardless of whether
|
|
221
|
+
they have been saved to disk. It does not check alias uniqueness
|
|
222
|
+
across the entire submission (use submission.run_validation() for that).
|
|
223
|
+
Always validate before saving or exporting.
|
|
224
|
+
"""
|
|
225
|
+
warnings = []
|
|
226
|
+
|
|
227
|
+
# Get all active solutions (saved or unsaved)
|
|
228
|
+
active = [sol for sol in self.solutions.values() if sol.is_active]
|
|
229
|
+
|
|
230
|
+
if not active:
|
|
231
|
+
warnings.append(f"Event {self.event_id} has no active solutions")
|
|
232
|
+
return warnings
|
|
233
|
+
|
|
234
|
+
# Check relative probabilities for active solutions
|
|
235
|
+
if len(active) > 1:
|
|
236
|
+
# Multiple active solutions - check if probabilities sum to 1.0
|
|
237
|
+
total_prob = sum(sol.relative_probability or 0.0 for sol in active)
|
|
238
|
+
|
|
239
|
+
if total_prob > 0.0 and abs(total_prob - 1.0) > 1e-6: # Allow small floating point errors
|
|
240
|
+
warnings.append(
|
|
241
|
+
f"Relative probabilities for active solutions sum to {total_prob:.3f}, "
|
|
242
|
+
f"should sum to 1.0. Solutions: {[sol.solution_id[:8] + '...' for sol in active]}"
|
|
243
|
+
)
|
|
244
|
+
elif len(active) == 1:
|
|
245
|
+
# Single active solution - probability should be 1.0 or None
|
|
246
|
+
sol = active[0]
|
|
247
|
+
if sol.relative_probability is not None and abs(sol.relative_probability - 1.0) > 1e-6:
|
|
248
|
+
warnings.append(
|
|
249
|
+
f"Single active solution has relative_probability {sol.relative_probability:.3f}, "
|
|
250
|
+
f"should be 1.0 or None"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Validate each active solution
|
|
254
|
+
for sol in active:
|
|
255
|
+
# Use the centralized validation
|
|
256
|
+
solution_messages = sol.run_validation()
|
|
257
|
+
for msg in solution_messages:
|
|
258
|
+
# Only include critical errors (not warnings) that should prevent saving
|
|
259
|
+
if not msg.startswith("Warning:"):
|
|
260
|
+
warnings.append(f"Solution {sol.solution_id}: {msg}")
|
|
261
|
+
|
|
262
|
+
return warnings
|
|
263
|
+
|
|
264
|
+
def remove_solution(self, solution_id: str, force: bool = False) -> bool:
|
|
265
|
+
"""Completely remove a solution from this event.
|
|
266
|
+
|
|
267
|
+
⚠️ WARNING: This permanently removes the solution from memory and any
|
|
268
|
+
associated files. This action cannot be undone. Use deactivate() instead
|
|
269
|
+
if you want to keep the solution but exclude it from exports.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
solution_id: Identifier of the solution to remove.
|
|
273
|
+
force: If True, skip confirmation prompts and remove immediately.
|
|
274
|
+
If False, will warn about data loss.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
bool: True if solution was removed, False if not found or cancelled.
|
|
278
|
+
|
|
279
|
+
Raises:
|
|
280
|
+
ValueError: If solution is saved and force=False (to prevent accidental
|
|
281
|
+
removal of persisted data).
|
|
282
|
+
|
|
283
|
+
Example:
|
|
284
|
+
>>> event = submission.get_event("EVENT001")
|
|
285
|
+
>>>
|
|
286
|
+
>>> # Remove an unsaved solution (safe)
|
|
287
|
+
>>> solution = event.add_solution("1S1L", {"t0": 2459123.5, "u0": 0.1})
|
|
288
|
+
>>> removed = event.remove_solution(solution.solution_id)
|
|
289
|
+
>>> print(f"Removed: {removed}")
|
|
290
|
+
>>>
|
|
291
|
+
>>> # Remove a saved solution (requires force=True)
|
|
292
|
+
>>> saved_solution = event.get_solution("existing_uuid")
|
|
293
|
+
... removed = event.remove_solution(saved_solution.solution_id, force=True)
|
|
294
|
+
... print(f"Force removed saved solution: {removed}")
|
|
295
|
+
|
|
296
|
+
Note:
|
|
297
|
+
This method:
|
|
298
|
+
1. Removes the solution from the event's solutions dict
|
|
299
|
+
2. Cleans up any temporary notes files in tmp/
|
|
300
|
+
3. For saved solutions, requires force=True to prevent accidents
|
|
301
|
+
4. Cannot be undone - use deactivate() if you want to keep the data
|
|
302
|
+
"""
|
|
303
|
+
if solution_id not in self.solutions:
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
solution = self.solutions[solution_id]
|
|
307
|
+
|
|
308
|
+
# Safety check for saved solutions
|
|
309
|
+
if solution.saved and not force:
|
|
310
|
+
raise ValueError(
|
|
311
|
+
f"Cannot remove saved solution {solution_id[:8]}... without force=True. "
|
|
312
|
+
f"Use solution.deactivate() to exclude from exports instead, or "
|
|
313
|
+
f"call remove_solution(solution_id, force=True) to force removal."
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# Clean up temporary files
|
|
317
|
+
if solution.notes_path and not solution.saved:
|
|
318
|
+
notes_path = Path(solution.notes_path)
|
|
319
|
+
if notes_path.parts and notes_path.parts[0] == "tmp":
|
|
320
|
+
# Remove temporary notes file
|
|
321
|
+
full_path = Path(self.submission.project_path) / notes_path if self.submission else notes_path
|
|
322
|
+
try:
|
|
323
|
+
if full_path.exists():
|
|
324
|
+
full_path.unlink()
|
|
325
|
+
print(f"🗑️ Removed temporary notes file: {notes_path}")
|
|
326
|
+
except OSError:
|
|
327
|
+
print(f"⚠️ Warning: Could not remove temporary file {notes_path}")
|
|
328
|
+
|
|
329
|
+
# Remove from solutions dict
|
|
330
|
+
del self.solutions[solution_id]
|
|
331
|
+
|
|
332
|
+
print(f"🗑️ Removed solution {solution_id[:8]}... from event {self.event_id}")
|
|
333
|
+
return True
|
|
334
|
+
|
|
335
|
+
def remove_all_solutions(self, force: bool = False) -> int:
|
|
336
|
+
"""Remove all solutions from this event.
|
|
337
|
+
|
|
338
|
+
⚠️ WARNING: This permanently removes ALL solutions from this event.
|
|
339
|
+
This action cannot be undone. Use clear_solutions() instead if you want
|
|
340
|
+
to keep the solutions but exclude them from exports.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
force: If True, skip confirmation prompts and remove immediately.
|
|
344
|
+
If False, will warn about data loss.
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
int: Number of solutions removed.
|
|
348
|
+
|
|
349
|
+
Example:
|
|
350
|
+
>>> event = submission.get_event("EVENT001")
|
|
351
|
+
>>>
|
|
352
|
+
>>> # Remove all solutions (use with caution!)
|
|
353
|
+
>>> removed_count = event.remove_all_solutions(force=True)
|
|
354
|
+
>>> print(f"Removed {removed_count} solutions from event {event.event_id}")
|
|
355
|
+
|
|
356
|
+
Note:
|
|
357
|
+
This is equivalent to calling remove_solution() for each solution
|
|
358
|
+
in the event. Use clear_solutions() if you want to keep the data.
|
|
359
|
+
"""
|
|
360
|
+
solution_ids = list(self.solutions.keys())
|
|
361
|
+
removed_count = 0
|
|
362
|
+
|
|
363
|
+
for solution_id in solution_ids:
|
|
364
|
+
try:
|
|
365
|
+
if self.remove_solution(solution_id, force=force):
|
|
366
|
+
removed_count += 1
|
|
367
|
+
except ValueError:
|
|
368
|
+
if not force:
|
|
369
|
+
print(f"⚠️ Skipped saved solution {solution_id[:8]}... (use force=True to remove)")
|
|
370
|
+
else:
|
|
371
|
+
# Force=True should override the saved check
|
|
372
|
+
if self.remove_solution(solution_id, force=True):
|
|
373
|
+
removed_count += 1
|
|
374
|
+
|
|
375
|
+
return removed_count
|
|
376
|
+
|
|
377
|
+
@classmethod
|
|
378
|
+
def _from_dir(cls, event_dir: Path, submission: "Submission") -> "Event":
|
|
379
|
+
"""Load an event from disk."""
|
|
380
|
+
event_json = event_dir / "event.json"
|
|
381
|
+
if event_json.exists():
|
|
382
|
+
with event_json.open("r", encoding="utf-8") as fh:
|
|
383
|
+
event = cls.model_validate_json(fh.read())
|
|
384
|
+
else:
|
|
385
|
+
event = cls(event_id=event_dir.name)
|
|
386
|
+
event.submission = submission
|
|
387
|
+
solutions_dir = event_dir / "solutions"
|
|
388
|
+
if solutions_dir.exists():
|
|
389
|
+
for sol_file in solutions_dir.glob("*.json"):
|
|
390
|
+
with sol_file.open("r", encoding="utf-8") as fh:
|
|
391
|
+
sol = Solution.model_validate_json(fh.read())
|
|
392
|
+
# Mark loaded solutions as saved since they came from disk
|
|
393
|
+
sol.saved = True
|
|
394
|
+
event.solutions[sol.solution_id] = sol
|
|
395
|
+
return event
|
|
396
|
+
|
|
397
|
+
def _save(self) -> None:
|
|
398
|
+
"""Write this event and its solutions to disk."""
|
|
399
|
+
if self.submission is None:
|
|
400
|
+
raise ValueError("Event is not attached to a submission")
|
|
401
|
+
base = Path(self.submission.project_path) / "events" / self.event_id
|
|
402
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
403
|
+
with (base / "event.json").open("w", encoding="utf-8") as fh:
|
|
404
|
+
fh.write(self.model_dump_json(exclude={"solutions", "submission"}, indent=2))
|
|
405
|
+
for sol in self.solutions.values():
|
|
406
|
+
sol._save(base)
|