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.
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 +499 -0
  13. microlens_submit/dossier/event_page.py +369 -0
  14. microlens_submit/dossier/full_report.py +330 -0
  15. microlens_submit/dossier/solution_page.py +533 -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.1.dist-info → microlens_submit-0.16.0.dist-info}/METADATA +54 -37
  26. microlens_submit-0.16.0.dist-info/RECORD +32 -0
  27. {microlens_submit-0.12.1.dist-info → microlens_submit-0.16.0.dist-info}/WHEEL +1 -1
  28. microlens_submit/api.py +0 -1274
  29. microlens_submit/cli.py +0 -1803
  30. microlens_submit/dossier.py +0 -1443
  31. microlens_submit-0.12.1.dist-info/RECORD +0 -13
  32. {microlens_submit-0.12.1.dist-info/licenses → microlens_submit-0.16.0.dist-info}/LICENSE +0 -0
  33. {microlens_submit-0.12.1.dist-info → microlens_submit-0.16.0.dist-info}/entry_points.txt +0 -0
  34. {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)