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,569 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Solution model for microlens-submit.
|
|
3
|
+
|
|
4
|
+
This module contains the Solution class, which represents an individual
|
|
5
|
+
microlensing model fit with parameters and metadata.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List, Literal, Optional
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, Field, model_validator
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Solution(BaseModel):
|
|
19
|
+
"""Container for an individual microlensing model fit.
|
|
20
|
+
|
|
21
|
+
This data model stores everything required to describe a single
|
|
22
|
+
microlensing solution, including the numeric parameters of the fit and
|
|
23
|
+
metadata about how it was produced. Instances are normally created via
|
|
24
|
+
:meth:`Event.add_solution` and persisted to disk when
|
|
25
|
+
:meth:`Submission.save` is called.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
solution_id: Unique identifier for the solution (auto-generated UUID).
|
|
29
|
+
model_type: Specific lens/source configuration such as "1S1L" or "1S2L".
|
|
30
|
+
bands: List of photometric bands used in the fit (e.g., ["0", "1", "2"]).
|
|
31
|
+
higher_order_effects: List of physical effects modeled (e.g., ["parallax"]).
|
|
32
|
+
t_ref: Reference time for time-dependent effects (Julian Date).
|
|
33
|
+
parameters: Dictionary of model parameters used for the fit.
|
|
34
|
+
is_active: Flag indicating whether the solution should be included in
|
|
35
|
+
the final submission export.
|
|
36
|
+
alias: Optional human-readable alias for the solution (e.g., "best_fit", "parallax_model").
|
|
37
|
+
When provided, this alias is used as the primary identifier in dossier displays,
|
|
38
|
+
with the UUID shown as a secondary identifier. The combination of event_id and
|
|
39
|
+
alias must be unique within the project. If not unique, an error will be raised
|
|
40
|
+
during validation or save operations.
|
|
41
|
+
compute_info: Metadata about the computing environment, populated by
|
|
42
|
+
:meth:`set_compute_info`.
|
|
43
|
+
posterior_path: Optional path to a file containing posterior samples.
|
|
44
|
+
lightcurve_plot_path: Optional path to the lightcurve plot file.
|
|
45
|
+
lens_plane_plot_path: Optional path to the lens plane plot file.
|
|
46
|
+
notes_path: Path to the markdown notes file for this solution.
|
|
47
|
+
used_astrometry: Whether astrometric information was used when fitting.
|
|
48
|
+
used_postage_stamps: Whether postage stamp data was used.
|
|
49
|
+
limb_darkening_model: Name of the limb darkening model employed.
|
|
50
|
+
limb_darkening_coeffs: Mapping of limb darkening coefficients.
|
|
51
|
+
parameter_uncertainties: Uncertainties for parameters in parameters.
|
|
52
|
+
physical_parameters: Physical parameters derived from the model.
|
|
53
|
+
log_likelihood: Log-likelihood value of the fit.
|
|
54
|
+
relative_probability: Optional probability of this solution being the best model.
|
|
55
|
+
n_data_points: Number of data points used in the fit.
|
|
56
|
+
creation_timestamp: UTC timestamp when the solution was created.
|
|
57
|
+
saved: Flag indicating whether the solution has been persisted to disk.
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
Example:
|
|
61
|
+
>>> from microlens_submit import load
|
|
62
|
+
>>>
|
|
63
|
+
>>> # Load a submission and get an event
|
|
64
|
+
>>> submission = load("./my_project")
|
|
65
|
+
>>> event = submission.get_event("EVENT001")
|
|
66
|
+
>>>
|
|
67
|
+
>>> # Create a simple 1S1L solution
|
|
68
|
+
>>> solution = event.add_solution("1S1L", {
|
|
69
|
+
... "t0": 2459123.5, # Time of closest approach
|
|
70
|
+
... "u0": 0.1, # Impact parameter
|
|
71
|
+
... "tE": 20.0 # Einstein crossing time
|
|
72
|
+
... })
|
|
73
|
+
>>>
|
|
74
|
+
>>> # Add metadata
|
|
75
|
+
>>> solution.log_likelihood = -1234.56
|
|
76
|
+
>>> solution.n_data_points = 1250
|
|
77
|
+
>>> solution.relative_probability = 0.8
|
|
78
|
+
>>> solution.higher_order_effects = ["parallax"]
|
|
79
|
+
>>> solution.t_ref = 2459123.0
|
|
80
|
+
>>> solution.alias = "best_parallax_fit" # Set a human-readable alias
|
|
81
|
+
>>>
|
|
82
|
+
>>> # Record compute information
|
|
83
|
+
>>> solution.set_compute_info(cpu_hours=2.5, wall_time_hours=0.5)
|
|
84
|
+
>>>
|
|
85
|
+
>>> # Add notes
|
|
86
|
+
>>> solution.set_notes('''
|
|
87
|
+
... # My Solution Notes
|
|
88
|
+
...
|
|
89
|
+
... This is a simple point lens fit.
|
|
90
|
+
... ''')
|
|
91
|
+
>>>
|
|
92
|
+
>>> # Validate the solution
|
|
93
|
+
>>> messages = solution.run_validation()
|
|
94
|
+
>>> if messages:
|
|
95
|
+
... print("Validation issues:", messages)
|
|
96
|
+
|
|
97
|
+
Note:
|
|
98
|
+
The notes_path field supports Markdown formatting, allowing you to create rich,
|
|
99
|
+
structured documentation with headers, lists, code blocks, tables, and links.
|
|
100
|
+
This is particularly useful for creating detailed submission dossiers for evaluators.
|
|
101
|
+
|
|
102
|
+
The run_validation() method performs comprehensive validation of parameters,
|
|
103
|
+
higher-order effects, and physical consistency. Always validate solutions
|
|
104
|
+
before submission.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
solution_id: str
|
|
108
|
+
model_type: Literal["1S1L", "1S2L", "2S1L", "2S2L", "1S3L", "2S3L", "other"]
|
|
109
|
+
bands: List[str] = Field(default_factory=list)
|
|
110
|
+
higher_order_effects: List[
|
|
111
|
+
Literal[
|
|
112
|
+
"lens-orbital-motion",
|
|
113
|
+
"parallax",
|
|
114
|
+
"finite-source",
|
|
115
|
+
"limb-darkening",
|
|
116
|
+
"xallarap",
|
|
117
|
+
"stellar-rotation",
|
|
118
|
+
"fitted-limb-darkening",
|
|
119
|
+
"gaussian-process",
|
|
120
|
+
"other",
|
|
121
|
+
]
|
|
122
|
+
] = Field(default_factory=list)
|
|
123
|
+
t_ref: Optional[float] = None
|
|
124
|
+
parameters: dict
|
|
125
|
+
is_active: bool = True
|
|
126
|
+
alias: Optional[str] = None
|
|
127
|
+
compute_info: dict = Field(default_factory=dict)
|
|
128
|
+
posterior_path: Optional[str] = None
|
|
129
|
+
lightcurve_plot_path: Optional[str] = None
|
|
130
|
+
lens_plane_plot_path: Optional[str] = None
|
|
131
|
+
notes_path: Optional[str] = None
|
|
132
|
+
used_astrometry: bool = False
|
|
133
|
+
used_postage_stamps: bool = False
|
|
134
|
+
limb_darkening_model: Optional[str] = None
|
|
135
|
+
limb_darkening_coeffs: Optional[dict] = None
|
|
136
|
+
parameter_uncertainties: Optional[dict] = None
|
|
137
|
+
physical_parameters: Optional[dict] = None
|
|
138
|
+
log_likelihood: Optional[float] = None
|
|
139
|
+
relative_probability: Optional[float] = None
|
|
140
|
+
n_data_points: Optional[int] = None
|
|
141
|
+
creation_timestamp: str = Field(default_factory=lambda: datetime.utcnow().isoformat())
|
|
142
|
+
saved: bool = Field(default=False, exclude=True)
|
|
143
|
+
|
|
144
|
+
@model_validator(mode="before")
|
|
145
|
+
@classmethod
|
|
146
|
+
def validate_solution_at_creation(cls, values):
|
|
147
|
+
"""Perform only basic type/structure checks at creation. Warn if issues, but allow creation."""
|
|
148
|
+
try:
|
|
149
|
+
import warnings
|
|
150
|
+
|
|
151
|
+
from ..validate_parameters import validate_solution_rigorously
|
|
152
|
+
|
|
153
|
+
model_type = values.get("model_type")
|
|
154
|
+
parameters = values.get("parameters", {})
|
|
155
|
+
higher_order_effects = values.get("higher_order_effects", [])
|
|
156
|
+
bands = values.get("bands", [])
|
|
157
|
+
t_ref = values.get("t_ref")
|
|
158
|
+
|
|
159
|
+
# Only check for totally broken objects (e.g., wrong types)
|
|
160
|
+
basic_errors = []
|
|
161
|
+
if not isinstance(parameters, dict):
|
|
162
|
+
basic_errors.append("parameters must be a dict")
|
|
163
|
+
if bands is not None and not isinstance(bands, list):
|
|
164
|
+
basic_errors.append("bands must be a list")
|
|
165
|
+
if higher_order_effects is not None and not isinstance(higher_order_effects, list):
|
|
166
|
+
basic_errors.append("higher_order_effects must be a list")
|
|
167
|
+
if t_ref is not None and not isinstance(t_ref, (int, float)):
|
|
168
|
+
basic_errors.append("t_ref must be numeric if provided")
|
|
169
|
+
if basic_errors:
|
|
170
|
+
raise ValueError("; ".join(basic_errors))
|
|
171
|
+
|
|
172
|
+
# Run full validation, but only warn if there are issues
|
|
173
|
+
validation_warnings = validate_solution_rigorously(
|
|
174
|
+
model_type=model_type,
|
|
175
|
+
parameters=parameters,
|
|
176
|
+
higher_order_effects=higher_order_effects,
|
|
177
|
+
bands=bands,
|
|
178
|
+
t_ref=t_ref,
|
|
179
|
+
)
|
|
180
|
+
if validation_warnings:
|
|
181
|
+
warnings.warn(f"Solution created with potential issues: {'; '.join(validation_warnings)}", UserWarning)
|
|
182
|
+
except ImportError:
|
|
183
|
+
# If validate_parameters module is not available, skip validation
|
|
184
|
+
pass
|
|
185
|
+
return values
|
|
186
|
+
|
|
187
|
+
def set_compute_info(
|
|
188
|
+
self,
|
|
189
|
+
cpu_hours: Optional[float] = None,
|
|
190
|
+
wall_time_hours: Optional[float] = None,
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Record compute metadata and capture environment details.
|
|
193
|
+
|
|
194
|
+
When called, this method populates :attr:`compute_info` with timing
|
|
195
|
+
information as well as a list of installed Python packages and the
|
|
196
|
+
current Git state. It is safe to call multiple times—previous values
|
|
197
|
+
will be overwritten.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
cpu_hours: Total CPU time consumed by the model fit in hours.
|
|
201
|
+
wall_time_hours: Real-world time consumed by the fit in hours.
|
|
202
|
+
|
|
203
|
+
Example:
|
|
204
|
+
>>> solution = event.add_solution("1S1L", {"t0": 2459123.5, "u0": 0.1})
|
|
205
|
+
>>>
|
|
206
|
+
>>> # Record compute information
|
|
207
|
+
>>> solution.set_compute_info(cpu_hours=2.5, wall_time_hours=0.5)
|
|
208
|
+
>>>
|
|
209
|
+
>>> # The compute_info now contains:
|
|
210
|
+
>>> # - cpu_hours: 2.5
|
|
211
|
+
>>> # - wall_time_hours: 0.5
|
|
212
|
+
>>> # - dependencies: [list of installed packages]
|
|
213
|
+
>>> # - git_info: {commit, branch, is_dirty}
|
|
214
|
+
|
|
215
|
+
Note:
|
|
216
|
+
This method automatically captures the current Python environment
|
|
217
|
+
(via pip freeze) and Git state (commit, branch, dirty status).
|
|
218
|
+
If Git is not available or not a repository, git_info will be None.
|
|
219
|
+
If pip is not available, dependencies will be an empty list.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
# Set timing information
|
|
223
|
+
if cpu_hours is not None:
|
|
224
|
+
self.compute_info["cpu_hours"] = cpu_hours
|
|
225
|
+
if wall_time_hours is not None:
|
|
226
|
+
self.compute_info["wall_time_hours"] = wall_time_hours
|
|
227
|
+
|
|
228
|
+
# Capture Python environment dependencies
|
|
229
|
+
try:
|
|
230
|
+
result = subprocess.run(
|
|
231
|
+
[sys.executable, "-m", "pip", "freeze"],
|
|
232
|
+
capture_output=True,
|
|
233
|
+
text=True,
|
|
234
|
+
check=True,
|
|
235
|
+
)
|
|
236
|
+
self.compute_info["dependencies"] = result.stdout.strip().split("\n") if result.stdout else []
|
|
237
|
+
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
|
238
|
+
logging.warning("Could not capture pip environment: %s", e)
|
|
239
|
+
self.compute_info["dependencies"] = []
|
|
240
|
+
|
|
241
|
+
# Capture Git repository information
|
|
242
|
+
try:
|
|
243
|
+
commit = subprocess.run(
|
|
244
|
+
["git", "rev-parse", "HEAD"],
|
|
245
|
+
capture_output=True,
|
|
246
|
+
text=True,
|
|
247
|
+
check=True,
|
|
248
|
+
).stdout.strip()
|
|
249
|
+
branch = subprocess.run(
|
|
250
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
251
|
+
capture_output=True,
|
|
252
|
+
text=True,
|
|
253
|
+
check=True,
|
|
254
|
+
).stdout.strip()
|
|
255
|
+
status = subprocess.run(
|
|
256
|
+
["git", "status", "--porcelain"],
|
|
257
|
+
capture_output=True,
|
|
258
|
+
text=True,
|
|
259
|
+
check=True,
|
|
260
|
+
).stdout.strip()
|
|
261
|
+
self.compute_info["git_info"] = {
|
|
262
|
+
"commit": commit,
|
|
263
|
+
"branch": branch,
|
|
264
|
+
"is_dirty": bool(status),
|
|
265
|
+
}
|
|
266
|
+
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
|
267
|
+
logging.warning("Could not capture git info: %s", e)
|
|
268
|
+
self.compute_info["git_info"] = None
|
|
269
|
+
|
|
270
|
+
def deactivate(self) -> None:
|
|
271
|
+
"""Mark this solution as inactive.
|
|
272
|
+
|
|
273
|
+
Inactive solutions are excluded from submission exports and dossier
|
|
274
|
+
generation. This is useful for keeping alternative fits without
|
|
275
|
+
including them in the final submission.
|
|
276
|
+
|
|
277
|
+
Example:
|
|
278
|
+
>>> solution = event.get_solution("solution_uuid")
|
|
279
|
+
>>> solution.deactivate()
|
|
280
|
+
>>>
|
|
281
|
+
>>> # The solution is now inactive and won't be included in exports
|
|
282
|
+
>>> submission.save() # Persist the change
|
|
283
|
+
|
|
284
|
+
Note:
|
|
285
|
+
This method only changes the is_active flag. The solution data
|
|
286
|
+
remains intact and can be reactivated later using activate().
|
|
287
|
+
"""
|
|
288
|
+
self.is_active = False
|
|
289
|
+
|
|
290
|
+
def activate(self) -> None:
|
|
291
|
+
"""Mark this solution as active.
|
|
292
|
+
|
|
293
|
+
Active solutions are included in submission exports and dossier
|
|
294
|
+
generation. This is the default state for new solutions.
|
|
295
|
+
|
|
296
|
+
Example:
|
|
297
|
+
>>> solution = event.get_solution("solution_uuid")
|
|
298
|
+
>>> solution.activate()
|
|
299
|
+
>>>
|
|
300
|
+
>>> # The solution is now active and will be included in exports
|
|
301
|
+
>>> submission.save() # Persist the change
|
|
302
|
+
|
|
303
|
+
Note:
|
|
304
|
+
This method only changes the is_active flag. The solution data
|
|
305
|
+
remains intact.
|
|
306
|
+
"""
|
|
307
|
+
self.is_active = True
|
|
308
|
+
|
|
309
|
+
def run_validation(self) -> List[str]:
|
|
310
|
+
"""Validate this solution's parameters and configuration.
|
|
311
|
+
|
|
312
|
+
This method performs comprehensive validation using centralized validation logic
|
|
313
|
+
to ensure the solution is complete, consistent, and ready for submission.
|
|
314
|
+
|
|
315
|
+
The validation includes:
|
|
316
|
+
|
|
317
|
+
* Parameter completeness for the given model type
|
|
318
|
+
* Higher-order effect requirements (e.g., parallax needs piEN, piEE)
|
|
319
|
+
* Band-specific flux parameters when bands are specified
|
|
320
|
+
* Reference time requirements for time-dependent effects
|
|
321
|
+
* Parameter data types and physically meaningful ranges
|
|
322
|
+
* Physical consistency checks
|
|
323
|
+
* Model-specific parameter requirements
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
None
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
list[str]: Human-readable validation messages. Empty list indicates all
|
|
330
|
+
validations passed. Messages may include warnings (non-critical)
|
|
331
|
+
and errors (critical issues that should be addressed).
|
|
332
|
+
|
|
333
|
+
Example:
|
|
334
|
+
>>> solution = event.add_solution("1S2L", {"t0": 2459123.5, "u0": 0.1})
|
|
335
|
+
>>> messages = solution.run_validation()
|
|
336
|
+
>>> if messages:
|
|
337
|
+
... print("Validation issues found:")
|
|
338
|
+
... for msg in messages:
|
|
339
|
+
... print(f" - {msg}")
|
|
340
|
+
... else:
|
|
341
|
+
... print("Solution is valid!")
|
|
342
|
+
|
|
343
|
+
Note:
|
|
344
|
+
Always validate solutions before submission. The validation logic
|
|
345
|
+
is centralized and covers all model types and higher-order effects.
|
|
346
|
+
Some warnings may be non-critical but should be reviewed.
|
|
347
|
+
"""
|
|
348
|
+
from ..validate_parameters import (
|
|
349
|
+
check_solution_completeness,
|
|
350
|
+
validate_parameter_types,
|
|
351
|
+
validate_parameter_uncertainties,
|
|
352
|
+
validate_solution_consistency,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
messages = []
|
|
356
|
+
|
|
357
|
+
# Check solution completeness
|
|
358
|
+
completeness_messages = check_solution_completeness(
|
|
359
|
+
model_type=self.model_type,
|
|
360
|
+
parameters=self.parameters,
|
|
361
|
+
higher_order_effects=self.higher_order_effects,
|
|
362
|
+
bands=self.bands,
|
|
363
|
+
t_ref=self.t_ref,
|
|
364
|
+
)
|
|
365
|
+
messages.extend(completeness_messages)
|
|
366
|
+
|
|
367
|
+
# Check parameter types
|
|
368
|
+
type_messages = validate_parameter_types(parameters=self.parameters, model_type=self.model_type)
|
|
369
|
+
messages.extend(type_messages)
|
|
370
|
+
|
|
371
|
+
# Check parameter uncertainties
|
|
372
|
+
uncertainty_messages = validate_parameter_uncertainties(
|
|
373
|
+
parameters=self.parameters, uncertainties=self.parameter_uncertainties
|
|
374
|
+
)
|
|
375
|
+
messages.extend(uncertainty_messages)
|
|
376
|
+
|
|
377
|
+
# Check solution consistency
|
|
378
|
+
consistency_messages = validate_solution_consistency(
|
|
379
|
+
model_type=self.model_type,
|
|
380
|
+
parameters=self.parameters,
|
|
381
|
+
relative_probability=self.relative_probability,
|
|
382
|
+
)
|
|
383
|
+
messages.extend(consistency_messages)
|
|
384
|
+
|
|
385
|
+
return messages
|
|
386
|
+
|
|
387
|
+
def _save(self, event_path: Path) -> None:
|
|
388
|
+
"""Write this solution to disk.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
event_path: Directory of the parent event within the project.
|
|
392
|
+
|
|
393
|
+
Example:
|
|
394
|
+
>>> # This is called automatically by Event._save()
|
|
395
|
+
>>> event._save() # This calls solution._save() for each solution
|
|
396
|
+
|
|
397
|
+
Note:
|
|
398
|
+
This is an internal method. Solutions are automatically saved
|
|
399
|
+
when the parent event is saved via submission.save().
|
|
400
|
+
"""
|
|
401
|
+
solutions_dir = event_path / "solutions"
|
|
402
|
+
solutions_dir.mkdir(parents=True, exist_ok=True)
|
|
403
|
+
out_path = solutions_dir / f"{self.solution_id}.json"
|
|
404
|
+
with out_path.open("w", encoding="utf-8") as fh:
|
|
405
|
+
fh.write(self.model_dump_json(indent=2))
|
|
406
|
+
|
|
407
|
+
def get_notes(self, project_root: Optional[Path] = None) -> str:
|
|
408
|
+
"""Read notes from the notes file, if present.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
project_root: Optional project root path for resolving relative
|
|
412
|
+
notes_path. If None, uses the current working directory.
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
str: The contents of the notes file as a string, or empty string
|
|
416
|
+
if no notes file exists or notes_path is not set.
|
|
417
|
+
|
|
418
|
+
Example:
|
|
419
|
+
>>> solution = event.get_solution("solution_uuid")
|
|
420
|
+
>>> notes = solution.get_notes(project_root=Path("./my_project"))
|
|
421
|
+
>>> print(notes)
|
|
422
|
+
# My Solution Notes
|
|
423
|
+
|
|
424
|
+
This is a detailed description of my fit...
|
|
425
|
+
|
|
426
|
+
Note:
|
|
427
|
+
This method handles both absolute and relative notes_path values.
|
|
428
|
+
If notes_path is relative, it's resolved against project_root.
|
|
429
|
+
"""
|
|
430
|
+
if not self.notes_path:
|
|
431
|
+
return ""
|
|
432
|
+
path = Path(self.notes_path)
|
|
433
|
+
if not path.is_absolute() and project_root is not None:
|
|
434
|
+
path = project_root / path
|
|
435
|
+
if path.exists():
|
|
436
|
+
return path.read_text(encoding="utf-8")
|
|
437
|
+
return ""
|
|
438
|
+
|
|
439
|
+
def set_notes(
|
|
440
|
+
self,
|
|
441
|
+
content: str,
|
|
442
|
+
project_root: Optional[Path] = None,
|
|
443
|
+
convert_escapes: bool = False,
|
|
444
|
+
) -> None:
|
|
445
|
+
"""Write notes to the notes file, creating it if needed.
|
|
446
|
+
|
|
447
|
+
If notes_path is not set, creates a temporary file in tmp/<solution_id>.md
|
|
448
|
+
and sets notes_path. On Submission.save(), temporary notes files are
|
|
449
|
+
moved to the canonical location.
|
|
450
|
+
|
|
451
|
+
⚠️ WARNING: This method writes files immediately. If you're testing and
|
|
452
|
+
don't want to create files, consider using a temporary project directory
|
|
453
|
+
or checking the content before calling this method.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
content: The markdown content to write to the notes file.
|
|
457
|
+
project_root: Optional project root path for resolving relative
|
|
458
|
+
notes_path. If None, uses the current working directory.
|
|
459
|
+
convert_escapes: If True, convert literal \\n and \\r to actual newlines
|
|
460
|
+
and carriage returns. Useful for CSV import where notes contain
|
|
461
|
+
literal escape sequences. Defaults to False for backward compatibility.
|
|
462
|
+
|
|
463
|
+
Example:
|
|
464
|
+
>>> solution = event.get_solution("solution_uuid")
|
|
465
|
+
>>>
|
|
466
|
+
>>> # Set notes with markdown content
|
|
467
|
+
>>> solution.set_notes('''
|
|
468
|
+
... # My Solution Notes
|
|
469
|
+
...
|
|
470
|
+
... This is a detailed description of my microlensing fit.
|
|
471
|
+
...
|
|
472
|
+
... ## Parameters
|
|
473
|
+
... - t0: Time of closest approach
|
|
474
|
+
... - u0: Impact parameter
|
|
475
|
+
... - tE: Einstein crossing time
|
|
476
|
+
...
|
|
477
|
+
... ## Notes
|
|
478
|
+
... The fit shows clear evidence of a binary lens...
|
|
479
|
+
... ''', project_root=Path("./my_project"))
|
|
480
|
+
>>>
|
|
481
|
+
>>> # The notes are now saved and can be read back
|
|
482
|
+
>>> notes = solution.get_notes(project_root=Path("./my_project"))
|
|
483
|
+
|
|
484
|
+
Note:
|
|
485
|
+
This method supports markdown formatting. The notes will be
|
|
486
|
+
rendered as HTML in the dossier with syntax highlighting
|
|
487
|
+
for code blocks.
|
|
488
|
+
|
|
489
|
+
For testing purposes, you can:
|
|
490
|
+
1. Use a temporary project directory: load("./tmp_test_project")
|
|
491
|
+
2. Check the content before calling: print("Notes content:", content)
|
|
492
|
+
3. Use a dry-run approach by setting notes_path manually
|
|
493
|
+
"""
|
|
494
|
+
if convert_escapes:
|
|
495
|
+
content = content.replace("\\n", "\n").replace("\\r", "\r")
|
|
496
|
+
|
|
497
|
+
if not self.notes_path:
|
|
498
|
+
# Use tmp/ for unsaved notes
|
|
499
|
+
tmp_dir = Path(project_root or ".") / "tmp"
|
|
500
|
+
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
501
|
+
tmp_path = tmp_dir / f"{self.solution_id}.md"
|
|
502
|
+
self.notes_path = str(tmp_path.relative_to(project_root or "."))
|
|
503
|
+
path = Path(self.notes_path)
|
|
504
|
+
if not path.is_absolute() and project_root is not None:
|
|
505
|
+
path = project_root / path
|
|
506
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
507
|
+
path.write_text(content, encoding="utf-8")
|
|
508
|
+
|
|
509
|
+
@property
|
|
510
|
+
def notes(self) -> str:
|
|
511
|
+
"""Return the Markdown notes string from the notes file (read-only).
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
str: The contents of the notes file as a string, or empty string
|
|
515
|
+
if no notes file exists.
|
|
516
|
+
|
|
517
|
+
Example:
|
|
518
|
+
>>> solution = event.get_solution("solution_uuid")
|
|
519
|
+
>>> print(solution.notes)
|
|
520
|
+
# My Solution Notes
|
|
521
|
+
|
|
522
|
+
This is a detailed description of my fit...
|
|
523
|
+
|
|
524
|
+
Note:
|
|
525
|
+
This is a read-only property. Use set_notes() to modify the notes.
|
|
526
|
+
The property uses the current working directory to resolve relative
|
|
527
|
+
notes_path. For more control, use get_notes() with project_root.
|
|
528
|
+
"""
|
|
529
|
+
return self.get_notes()
|
|
530
|
+
|
|
531
|
+
def view_notes(self, render_html: bool = True, project_root: Optional[Path] = None) -> str:
|
|
532
|
+
"""Return the notes as Markdown or rendered HTML.
|
|
533
|
+
|
|
534
|
+
Args:
|
|
535
|
+
render_html: If True, return HTML using markdown.markdown with
|
|
536
|
+
extensions for tables and fenced code blocks. If False,
|
|
537
|
+
return the raw Markdown string.
|
|
538
|
+
project_root: Optionally specify the project root for relative
|
|
539
|
+
notes_path resolution.
|
|
540
|
+
|
|
541
|
+
Returns:
|
|
542
|
+
str: Markdown or HTML string depending on render_html parameter.
|
|
543
|
+
|
|
544
|
+
Example:
|
|
545
|
+
>>> solution = event.get_solution("solution_uuid")
|
|
546
|
+
>>>
|
|
547
|
+
>>> # Get raw markdown
|
|
548
|
+
>>> md = solution.view_notes(render_html=False)
|
|
549
|
+
>>> print(md)
|
|
550
|
+
# My Solution Notes
|
|
551
|
+
|
|
552
|
+
>>> # Get rendered HTML (useful for Jupyter/IPython)
|
|
553
|
+
>>> html = solution.view_notes(render_html=True)
|
|
554
|
+
>>> print(html)
|
|
555
|
+
<h1>My Solution Notes</h1>
|
|
556
|
+
<p>...</p>
|
|
557
|
+
|
|
558
|
+
Note:
|
|
559
|
+
When render_html=True, the markdown is rendered with extensions
|
|
560
|
+
for tables, fenced code blocks, and other advanced features.
|
|
561
|
+
This is particularly useful for displaying notes in Jupyter
|
|
562
|
+
notebooks or other HTML contexts.
|
|
563
|
+
"""
|
|
564
|
+
md = self.get_notes(project_root=project_root)
|
|
565
|
+
if render_html:
|
|
566
|
+
import markdown
|
|
567
|
+
|
|
568
|
+
return markdown.markdown(md or "", extensions=["extra", "tables", "fenced_code", "nl2br"])
|
|
569
|
+
return md
|