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
@@ -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