microlens-submit 0.12.2__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.2.dist-info → microlens_submit-0.16.0.dist-info}/METADATA +42 -27
  26. microlens_submit-0.16.0.dist-info/RECORD +32 -0
  27. {microlens_submit-0.12.2.dist-info → microlens_submit-0.16.0.dist-info}/WHEEL +1 -1
  28. microlens_submit/api.py +0 -1257
  29. microlens_submit/cli.py +0 -1803
  30. microlens_submit/dossier.py +0 -1443
  31. microlens_submit-0.12.2.dist-info/RECORD +0 -13
  32. {microlens_submit-0.12.2.dist-info/licenses → microlens_submit-0.16.0.dist-info}/LICENSE +0 -0
  33. {microlens_submit-0.12.2.dist-info → microlens_submit-0.16.0.dist-info}/entry_points.txt +0 -0
  34. {microlens_submit-0.12.2.dist-info → microlens_submit-0.16.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,208 @@
1
+ """
2
+ Tier validation module for microlens-submit.
3
+
4
+ This module provides centralized validation logic for challenge tiers and their
5
+ associated event lists. It validates event IDs against tier-specific event lists
6
+ and provides tier definitions for the microlensing data challenge.
7
+
8
+ The module defines:
9
+ - Tier definitions with associated event lists
10
+ - Event ID validation functions
11
+ - Tier-specific validation logic
12
+
13
+ **Supported Tiers:**
14
+ - basic: Basic challenge tier with limited event set
15
+ - standard: Standard challenge tier with full event set
16
+ - advanced: Advanced challenge tier with all events
17
+ - test: Testing tier for development
18
+ - 2018-test: 2018 test events tier
19
+ - None: No validation tier (skips event validation)
20
+
21
+ Example:
22
+ >>> from microlens_submit.tier_validation import validate_event_id, TIER_DEFINITIONS
23
+ >>>
24
+ >>> # Check if an event is valid for a tier
25
+ >>> is_valid = validate_event_id("EVENT001", "standard")
26
+ >>> if is_valid:
27
+ ... print("Event is valid for standard tier")
28
+ >>> else:
29
+ ... print("Event is not valid for standard tier")
30
+
31
+ >>> # Get available tiers
32
+ >>> print("Available tiers:", list(TIER_DEFINITIONS.keys()))
33
+
34
+ Note:
35
+ All validation functions return boolean values and provide human-readable
36
+ error messages for invalid events. The "None" tier skips all validation.
37
+ """
38
+
39
+ from typing import Dict, List, Optional, Set
40
+
41
+ # Tier definitions with their associated event lists
42
+ TIER_DEFINITIONS = {
43
+ "standard": {
44
+ "description": "Standard challenge tier with limited event set",
45
+ "event_list": [
46
+ # Add standard tier events here
47
+ "EVENT001",
48
+ "EVENT002",
49
+ "EVENT003",
50
+ ],
51
+ },
52
+ "advanced": {
53
+ "description": "Advanced challenge tier with full event set",
54
+ "event_list": [
55
+ # Add advanced tier events here
56
+ "EVENT001",
57
+ "EVENT002",
58
+ "EVENT003",
59
+ "EVENT004",
60
+ "EVENT005",
61
+ "EVENT006",
62
+ "EVENT007",
63
+ ],
64
+ },
65
+ "test": {
66
+ "description": "Testing tier for development",
67
+ "event_list": [
68
+ # Add test events here
69
+ "evt",
70
+ "test-event",
71
+ ],
72
+ },
73
+ "2018-test": {
74
+ "description": "2018 test events tier",
75
+ "event_list": [
76
+ # Add 2018 test events here
77
+ "2018-EVENT-001",
78
+ "2018-EVENT-002",
79
+ ],
80
+ },
81
+ "None": {
82
+ "description": "No validation tier (skips event validation)",
83
+ "event_list": [], # Empty list means no validation
84
+ },
85
+ }
86
+
87
+ # Cache for event lists to avoid repeated list creation
88
+ _EVENT_LIST_CACHE: Dict[str, Set[str]] = {}
89
+
90
+
91
+ def get_tier_event_list(tier: str) -> Set[str]:
92
+ """Get the set of valid event IDs for a given tier.
93
+
94
+ Args:
95
+ tier: The challenge tier name.
96
+
97
+ Returns:
98
+ Set[str]: Set of valid event IDs for the tier.
99
+
100
+ Raises:
101
+ ValueError: If the tier is not defined.
102
+
103
+ Example:
104
+ >>> events = get_tier_event_list("standard")
105
+ >>> print(f"Standard tier has {len(events)} events")
106
+ >>> print("EVENT001" in events)
107
+ """
108
+ if tier not in TIER_DEFINITIONS:
109
+ raise ValueError(f"Unknown tier: {tier}. Available tiers: {list(TIER_DEFINITIONS.keys())}")
110
+
111
+ # Use cache for performance
112
+ if tier not in _EVENT_LIST_CACHE:
113
+ _EVENT_LIST_CACHE[tier] = set(TIER_DEFINITIONS[tier]["event_list"])
114
+
115
+ return _EVENT_LIST_CACHE[tier]
116
+
117
+
118
+ def validate_event_id(event_id: str, tier: str) -> bool:
119
+ """Validate if an event ID is valid for a given tier.
120
+
121
+ Args:
122
+ event_id: The event ID to validate.
123
+ tier: The challenge tier to validate against.
124
+
125
+ Returns:
126
+ bool: True if the event ID is valid for the tier, False otherwise.
127
+
128
+ Example:
129
+ >>> is_valid = validate_event_id("EVENT001", "standard")
130
+ >>> if is_valid:
131
+ ... print("Event is valid for standard tier")
132
+ >>> else:
133
+ ... print("Event is not valid for standard tier")
134
+ """
135
+ # Skip validation for "None" tier or if tier is not defined
136
+ if tier == "None" or tier not in TIER_DEFINITIONS:
137
+ return True
138
+
139
+ valid_events = get_tier_event_list(tier)
140
+ return event_id in valid_events
141
+
142
+
143
+ def get_event_validation_error(event_id: str, tier: str) -> Optional[str]:
144
+ """Get a human-readable error message for an invalid event ID.
145
+
146
+ Args:
147
+ event_id: The event ID that failed validation.
148
+ tier: The challenge tier that was validated against.
149
+
150
+ Returns:
151
+ Optional[str]: Error message if the event is invalid, None if valid.
152
+
153
+ Example:
154
+ >>> error = get_event_validation_error("INVALID_EVENT", "standard")
155
+ >>> if error:
156
+ ... print(f"Validation error: {error}")
157
+ >>> else:
158
+ ... print("Event is valid")
159
+ """
160
+ if validate_event_id(event_id, tier):
161
+ return None
162
+
163
+ # No error for "None" tier or undefined tiers
164
+ if tier == "None" or tier not in TIER_DEFINITIONS:
165
+ return None
166
+
167
+ valid_events = get_tier_event_list(tier)
168
+ tier_desc = TIER_DEFINITIONS[tier]["description"]
169
+
170
+ return (
171
+ f"Event '{event_id}' is not valid for tier '{tier}' ({tier_desc}). "
172
+ f"Valid events for this tier: {sorted(valid_events)}"
173
+ )
174
+
175
+
176
+ def get_available_tiers() -> List[str]:
177
+ """Get a list of all available tiers.
178
+
179
+ Returns:
180
+ List[str]: List of all available tier names.
181
+
182
+ Example:
183
+ >>> tiers = get_available_tiers()
184
+ >>> print(f"Available tiers: {tiers}")
185
+ """
186
+ return list(TIER_DEFINITIONS.keys())
187
+
188
+
189
+ def get_tier_description(tier: str) -> str:
190
+ """Get the description for a given tier.
191
+
192
+ Args:
193
+ tier: The tier name.
194
+
195
+ Returns:
196
+ str: Description of the tier.
197
+
198
+ Raises:
199
+ ValueError: If the tier is not defined.
200
+
201
+ Example:
202
+ >>> desc = get_tier_description("standard")
203
+ >>> print(f"Standard tier: {desc}")
204
+ """
205
+ if tier not in TIER_DEFINITIONS:
206
+ raise ValueError(f"Unknown tier: {tier}. Available tiers: {list(TIER_DEFINITIONS.keys())}")
207
+
208
+ return TIER_DEFINITIONS[tier]["description"]
@@ -0,0 +1,373 @@
1
+ """Utility functions for microlens-submit.
2
+
3
+ This module contains utility functions for importing data and loading
4
+ submissions.
5
+ """
6
+
7
+ import csv
8
+ import json
9
+ import shutil
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ # Resolve forward references
14
+ from .models.event import Event
15
+ from .models.submission import Submission
16
+
17
+
18
+ def load(project_path: str) -> Submission:
19
+ """Load or create a submission project from a directory.
20
+
21
+ This is the main entry point for working with submission projects. If the
22
+ directory doesn't exist, it will be created with a basic project structure.
23
+ If it exists, the submission data will be loaded from disk.
24
+
25
+ Args:
26
+ project_path: Path to the project directory.
27
+
28
+ Returns:
29
+ A :class:`Submission` instance representing the project.
30
+
31
+ Example:
32
+ >>> from microlens_submit import load
33
+ >>>
34
+ >>> # Load or create a submission project
35
+ >>> submission = load("./my_project")
36
+ >>>
37
+ >>> # Set submission metadata
38
+ >>> submission.team_name = "Team Alpha"
39
+ >>> submission.tier = "advanced"
40
+ >>> submission.repo_url = "https://github.com/team/repo"
41
+ >>>
42
+ >>> # Add an event and solution
43
+ >>> event = submission.get_event("EVENT001")
44
+ >>> params = {"t0": 2459123.5, "u0": 0.1, "tE": 20.0}
45
+ >>> solution = event.add_solution("1S1L", params)
46
+ >>> solution.log_likelihood = -1234.56
47
+ >>> solution.set_compute_info(cpu_hours=2.5, wall_time_hours=0.5)
48
+ >>>
49
+ >>> # Save the submission
50
+ >>> submission.save()
51
+ >>>
52
+ >>> # Export for submission
53
+ >>> submission.export("submission.zip")
54
+
55
+ Note:
56
+ The project directory structure is automatically created when you
57
+ first call load() with a new directory. All data is stored in JSON
58
+ format with a clear directory structure for events and solutions.
59
+ """
60
+ project = Path(project_path)
61
+ events_dir = project / "events"
62
+
63
+ if not project.exists():
64
+ events_dir.mkdir(parents=True, exist_ok=True)
65
+ submission = Submission(project_path=str(project))
66
+ with (project / "submission.json").open("w", encoding="utf-8") as fh:
67
+ fh.write(
68
+ submission.model_dump_json(
69
+ exclude={"events", "project_path"},
70
+ indent=2,
71
+ )
72
+ )
73
+ return submission
74
+
75
+ sub_json = project / "submission.json"
76
+ if sub_json.exists():
77
+ with sub_json.open("r", encoding="utf-8") as fh:
78
+ submission = Submission.model_validate_json(fh.read())
79
+ submission.project_path = str(project)
80
+ else:
81
+ submission = Submission(project_path=str(project))
82
+
83
+ if events_dir.exists():
84
+ for event_dir in events_dir.iterdir():
85
+ if event_dir.is_dir():
86
+ event = Event._from_dir(event_dir, submission)
87
+ submission.events[event.event_id] = event
88
+
89
+ return submission
90
+
91
+
92
+ def import_solutions_from_csv(
93
+ submission,
94
+ csv_file: Path,
95
+ parameter_map_file: Optional[Path] = None,
96
+ delimiter: Optional[str] = None,
97
+ dry_run: bool = False,
98
+ validate: bool = False,
99
+ on_duplicate: str = "error",
100
+ project_path: Optional[Path] = None,
101
+ ) -> dict:
102
+ """Import solutions from a CSV file into a :class:`Submission`.
103
+
104
+ The CSV must contain an ``event_id`` column along with either ``solution_id``
105
+ or ``solution_alias`` and a ``model_tags`` column. Parameter values can be
106
+ provided as individual columns or via a JSON-encoded ``parameters`` column.
107
+ Additional columns such as ``notes`` are also supported. The optional
108
+ ``parameter_map_file`` can map arbitrary CSV column names to the expected
109
+ attribute names.
110
+
111
+ Args:
112
+ submission: The active :class:`Submission` object.
113
+ csv_file: Path to the CSV file to read.
114
+ parameter_map_file: Optional YAML file that remaps CSV column names.
115
+ delimiter: CSV delimiter. If ``None`` the delimiter is automatically
116
+ detected.
117
+ dry_run: If ``True``, parse and validate the file but do not persist
118
+ any changes.
119
+ validate: If ``True``, run solution validation as each row is imported.
120
+ on_duplicate: Policy for handling duplicate alias keys: ``error``,
121
+ ``override``, or ``ignore``.
122
+ project_path: Project root used for resolving relative file paths.
123
+
124
+ Returns:
125
+ dict: Summary statistics describing the import operation.
126
+
127
+ Example:
128
+ >>> from microlens_submit.utils import load, import_solutions_from_csv
129
+ >>> sub = load("./project")
130
+ >>> stats = import_solutions_from_csv(
131
+ ... sub,
132
+ ... Path("solutions.csv"),
133
+ ... validate=True,
134
+ ... )
135
+ >>> print(stats["successful_imports"], "solutions imported")
136
+
137
+ Note:
138
+ This function performs no console output. Use the CLI wrapper
139
+ :func:`microlens_submit.cli.import_solutions` for user-facing messages.
140
+ """
141
+ if on_duplicate not in ["error", "override", "ignore"]:
142
+ raise ValueError(f"Invalid on_duplicate: {on_duplicate}")
143
+
144
+ if project_path is None:
145
+ project_path = Path(".")
146
+
147
+ # Load parameter mapping if provided
148
+ if parameter_map_file:
149
+ with open(parameter_map_file, "r", encoding="utf-8") as f:
150
+ # TODO: Implement parameter mapping functionality
151
+ pass
152
+
153
+ # Auto-detect delimiter if not specified
154
+ if not delimiter:
155
+ with open(csv_file, "r", encoding="utf-8") as f:
156
+ sample = f.read(1024)
157
+ if "\t" in sample:
158
+ delimiter = "\t"
159
+ elif ";" in sample:
160
+ delimiter = ";"
161
+ else:
162
+ delimiter = ","
163
+
164
+ stats = {
165
+ "total_rows": 0,
166
+ "successful_imports": 0,
167
+ "skipped_rows": 0,
168
+ "validation_errors": 0,
169
+ "duplicate_handled": 0,
170
+ "errors": [],
171
+ }
172
+
173
+ with open(csv_file, "r", newline="", encoding="utf-8") as f:
174
+ lines = f.readlines()
175
+ header_row = 0
176
+
177
+ for i, line in enumerate(lines):
178
+ if line.strip().startswith("#"):
179
+ header_row = i
180
+ break
181
+
182
+ header_line = lines[header_row].strip()
183
+ if header_line.startswith("# "):
184
+ header_line = header_line[2:]
185
+ elif header_line.startswith("#"):
186
+ header_line = header_line[1:]
187
+
188
+ reader = csv.DictReader(
189
+ [header_line] + lines[header_row + 1 :],
190
+ delimiter=delimiter,
191
+ )
192
+
193
+ for row_num, row in enumerate(reader, start=header_row + 2):
194
+ stats["total_rows"] += 1
195
+
196
+ try:
197
+ # Validate required fields
198
+ if not row.get("event_id"):
199
+ stats["skipped_rows"] += 1
200
+ stats["errors"].append(f"Row {row_num}: " f"Missing event_id")
201
+ continue
202
+
203
+ solution_id = row.get("solution_id")
204
+ solution_alias = row.get("solution_alias")
205
+
206
+ if not solution_id and not solution_alias:
207
+ stats["skipped_rows"] += 1
208
+ stats["errors"].append(f"Row {row_num}: " "Missing solution_id or solution_alias")
209
+ continue
210
+
211
+ if not row.get("model_tags"):
212
+ stats["skipped_rows"] += 1
213
+ stats["errors"].append(f"Row {row_num}: " f"Missing model_tags")
214
+ continue
215
+
216
+ # Parse model tags
217
+ try:
218
+ model_tags = json.loads(row["model_tags"])
219
+ if not isinstance(model_tags, list):
220
+ raise ValueError("model_tags must be a list")
221
+ except json.JSONDecodeError:
222
+ stats["skipped_rows"] += 1
223
+ stats["errors"].append(f"Row {row_num}: " f"Invalid model_tags JSON")
224
+ continue
225
+
226
+ # Extract model type and higher order effects
227
+ model_type = None
228
+ higher_order_effects = []
229
+ allowed_tags = ["1S1L", "1S2L", "2S1L", "2S2L", "1S3L", "2S3L", "other"]
230
+
231
+ for tag in model_tags:
232
+ if tag in allowed_tags:
233
+ if model_type:
234
+ stats["skipped_rows"] += 1
235
+ stats["errors"].append(f"Row {row_num}:" "Multiple model types specified")
236
+ continue
237
+ model_type = tag
238
+ elif tag in [
239
+ "parallax",
240
+ "finite-source",
241
+ "lens-orbital-motion",
242
+ "xallarap",
243
+ "gaussian-process",
244
+ "stellar-rotation",
245
+ "fitted-limb-darkening",
246
+ "other",
247
+ ]:
248
+ higher_order_effects.append(tag)
249
+
250
+ if not model_type:
251
+ stats["skipped_rows"] += 1
252
+ stats["errors"].append(f"Row {row_num}: " f"No valid model type found in model_tags")
253
+ continue
254
+
255
+ # Parse parameters
256
+ parameters = {}
257
+ for key, value in row.items():
258
+ if key not in [
259
+ "event_id",
260
+ "solution_id",
261
+ "solution_alias",
262
+ "model_tags",
263
+ "notes",
264
+ "parameters",
265
+ ]:
266
+ if isinstance(value, str) and value.strip():
267
+ try:
268
+ parameters[key] = float(value)
269
+ except ValueError:
270
+ parameters[key] = value
271
+ elif value and str(value).strip():
272
+ try:
273
+ parameters[key] = float(value)
274
+ except (ValueError, TypeError):
275
+ parameters[key] = str(value)
276
+
277
+ if not parameters and row.get("parameters"):
278
+ try:
279
+ parameters = json.loads(row["parameters"])
280
+ except json.JSONDecodeError:
281
+ stats["skipped_rows"] += 1
282
+ stats["errors"].append(f"Row {row_num}: " f"Invalid parameters JSON")
283
+ continue
284
+
285
+ # Handle notes
286
+ notes = row.get("notes", "").strip()
287
+ notes_path = None
288
+ notes_content = None
289
+
290
+ if notes:
291
+ notes_file = Path(notes)
292
+ if notes_file.exists() and notes_file.is_file():
293
+ notes_path = str(notes_file)
294
+ else:
295
+ # CSV files encode newlines as literal \n, so we convert
296
+ # them to real newlines here.
297
+ # We do NOT do this when reading .md files or in
298
+ # set_notes(), because users may want literal '\n'.
299
+ notes_content = notes.replace("\\n", "\n").replace("\\r", "\r")
300
+ else:
301
+ pass
302
+
303
+ # Get or create event
304
+ event = submission.get_event(row["event_id"])
305
+
306
+ # Check for duplicates
307
+ alias_key = f"{row['event_id']} {solution_alias or solution_id}"
308
+ existing_solution = None
309
+
310
+ if solution_alias:
311
+ existing_solution = submission.get_solution_by_alias(
312
+ row["event_id"],
313
+ solution_alias,
314
+ )
315
+ elif solution_id:
316
+ existing_solution = event.get_solution(solution_id)
317
+
318
+ if existing_solution:
319
+ if on_duplicate == "error":
320
+ stats["skipped_rows"] += 1
321
+ stats["errors"].append(f"Row {row_num}: " f"Duplicate alias key '{alias_key}'")
322
+ continue
323
+ elif on_duplicate == "ignore":
324
+ stats["duplicate_handled"] += 1
325
+ continue
326
+ elif on_duplicate == "override":
327
+ event.remove_solution(
328
+ existing_solution.solution_id,
329
+ force=True,
330
+ )
331
+ stats["duplicate_handled"] += 1
332
+
333
+ if not dry_run:
334
+ solution = event.add_solution(model_type, parameters)
335
+
336
+ if solution_alias:
337
+ solution.alias = solution_alias
338
+ elif solution_id:
339
+ solution.alias = solution_id
340
+
341
+ if higher_order_effects:
342
+ solution.higher_order_effects = higher_order_effects
343
+
344
+ if notes_path:
345
+ tmp_path = Path(project_path) / "tmp"
346
+ solution_notes_path = tmp_path / f"{solution.solution_id}.md"
347
+ solution_notes_path.parent.mkdir(
348
+ parents=True,
349
+ exist_ok=True,
350
+ )
351
+ shutil.copy2(notes_path, solution_notes_path)
352
+ solution.notes_path = str(solution_notes_path.relative_to(project_path))
353
+ elif notes_content:
354
+ solution.set_notes(
355
+ notes_content,
356
+ project_path,
357
+ convert_escapes=True,
358
+ )
359
+
360
+ if validate:
361
+ validation_messages = solution.run_validation()
362
+ if validation_messages:
363
+ stats["validation_errors"] += 1
364
+ for msg in validation_messages:
365
+ stats["errors"].append(f"Row {row_num} validation: " f"{msg}")
366
+
367
+ stats["successful_imports"] += 1
368
+
369
+ except Exception as e:
370
+ stats["errors"].append(f"Row {row_num}: {str(e)}")
371
+ continue
372
+
373
+ return stats