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.
- 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 +503 -0
- microlens_submit/dossier/event_page.py +370 -0
- microlens_submit/dossier/full_report.py +330 -0
- microlens_submit/dossier/solution_page.py +534 -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.2.dist-info → microlens_submit-0.16.1.dist-info}/METADATA +52 -14
- microlens_submit-0.16.1.dist-info/RECORD +32 -0
- microlens_submit/api.py +0 -1257
- microlens_submit/cli.py +0 -1803
- microlens_submit/dossier.py +0 -1443
- microlens_submit-0.12.2.dist-info/RECORD +0 -13
- {microlens_submit-0.12.2.dist-info → microlens_submit-0.16.1.dist-info}/WHEEL +0 -0
- {microlens_submit-0.12.2.dist-info → microlens_submit-0.16.1.dist-info}/entry_points.txt +0 -0
- {microlens_submit-0.12.2.dist-info → microlens_submit-0.16.1.dist-info}/licenses/LICENSE +0 -0
- {microlens_submit-0.12.2.dist-info → microlens_submit-0.16.1.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
|