microlens-submit 0.12.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.
@@ -0,0 +1,639 @@
1
+ """
2
+ Parameter validation module for microlens-submit.
3
+
4
+ This module provides centralized validation logic for checking solution completeness
5
+ and parameter consistency against model definitions. It validates microlensing
6
+ solutions against predefined model types, higher-order effects, and parameter
7
+ constraints to ensure submissions are complete and physically reasonable.
8
+
9
+ The module defines:
10
+ - Model definitions with required parameters for each model type
11
+ - Higher-order effect definitions with associated parameters
12
+ - Parameter properties including types, units, and descriptions
13
+ - Validation functions for completeness, types, uncertainties, and consistency
14
+
15
+ **Supported Model Types:**
16
+ - 1S1L: Point Source, Single Point Lens (standard microlensing)
17
+ - 1S2L: Point Source, Binary Point Lens
18
+ - 2S1L: Binary Source, Single Point Lens
19
+ - 2S2L: Binary Source, Binary Point Lens (commented)
20
+ - 1S3L: Point Source, Triple Point Lens (commented)
21
+ - 2S3L: Binary Source, Triple Point Lens (commented)
22
+
23
+ **Supported Higher-Order Effects:**
24
+ - parallax: Microlens parallax effect
25
+ - finite-source: Finite source size effect
26
+ - lens-orbital-motion: Orbital motion of lens components
27
+ - xallarap: Source orbital motion
28
+ - gaussian-process: Gaussian process noise modeling
29
+ - stellar-rotation: Stellar rotation effects
30
+ - fitted-limb-darkening: Fitted limb darkening coefficients
31
+
32
+ Example:
33
+ >>> from microlens_submit.validate_parameters import check_solution_completeness
34
+ >>>
35
+ >>> # Validate a simple 1S1L solution
36
+ >>> parameters = {"t0": 2459123.5, "u0": 0.1, "tE": 20.0}
37
+ >>> messages = check_solution_completeness("1S1L", parameters)
38
+ >>> if not messages:
39
+ ... print("Solution is complete!")
40
+ >>> else:
41
+ ... print("Issues found:", messages)
42
+
43
+ >>> # Validate a binary lens with parallax
44
+ >>> parameters = {
45
+ ... "t0": 2459123.5, "u0": 0.1, "tE": 20.0,
46
+ ... "s": 1.2, "q": 0.5, "alpha": 45.0,
47
+ ... "piEN": 0.1, "piEE": 0.05
48
+ ... }
49
+ >>> effects = ["parallax"]
50
+ >>> messages = check_solution_completeness("1S2L", parameters, effects, t_ref=2459123.0)
51
+ >>> print("Validation messages:", messages)
52
+
53
+ Note:
54
+ All validation functions return lists of human-readable messages instead
55
+ of raising exceptions, allowing for comprehensive validation reporting.
56
+ Unknown parameters generate warnings rather than errors to accommodate
57
+ custom parameters and future model types.
58
+ """
59
+
60
+ from typing import Dict, List, Any, Optional, Union
61
+
62
+
63
+ MODEL_DEFINITIONS = {
64
+ # Single Source, Single Lens (PSPL)
65
+ "1S1L": {
66
+ "description": "Point Source, Single Point Lens (standard microlensing)",
67
+ "required_params_core": ["t0", "u0", "tE"],
68
+ },
69
+ # Single Source, Binary Lens
70
+ "1S2L": {
71
+ "description": "Point Source, Binary Point Lens",
72
+ "required_params_core": ["t0", "u0", "tE", "s", "q", "alpha"],
73
+ },
74
+ # Binary Source, Single Lens
75
+ "2S1L": {
76
+ "description": "Binary Source, Single Point Lens",
77
+ "required_params_core": ["t0", "u0", "tE"], # Core lens params
78
+ },
79
+ # Add other model types as needed:
80
+ # "2S2L": { "description": "Binary Source, Binary Point Lens", "required_params_core": ["t0", "u0", "tE", "s", "q", "alpha"]},
81
+ # "1S3L": { "description": "Point Source, Triple Point Lens", "required_params_core": ["t0", "u0", "tE", "s1", "q1", "alpha1", "s2", "q2", "alpha2"]},
82
+ # "2S3L": { "description": "Binary Source, Triple Point Lens", "required_params_core": ["t0", "u0", "tE", "s1", "q1", "alpha1", "s2", "q2", "alpha2"]},
83
+ }
84
+
85
+ HIGHER_ORDER_EFFECT_DEFINITIONS = {
86
+ "parallax": {
87
+ "description": "Microlens parallax effect",
88
+ "requires_t_ref": True, # A flag to check for the 't_ref' attribute
89
+ "required_higher_order_params": ["piEN", "piEE"], # These are often part of the main parameters if fitted
90
+ },
91
+ "finite-source": {
92
+ "description": "Finite source size effect",
93
+ "requires_t_ref": False,
94
+ "required_higher_order_params": ["rho"],
95
+ },
96
+ "lens-orbital-motion": {
97
+ "description": "Orbital motion of the lens components",
98
+ "requires_t_ref": True,
99
+ "required_higher_order_params": ["dsdt", "dadt"],
100
+ "optional_higher_order_params": ["dzdt"], # Relative radial rate of change of lenses (if needed)
101
+ },
102
+ "xallarap": {
103
+ "description": "Source orbital motion (xallarap)",
104
+ "requires_t_ref": True, # Xallarap often has a t_ref related to its epoch
105
+ "required_higher_order_params": [], # Specific parameters (e.g., orbital period, inclination) to be added here
106
+ },
107
+ "gaussian-process": {
108
+ "description": "Gaussian process model for time-correlated noise",
109
+ "requires_t_ref": False, # GP parameters are usually not time-referenced in this way
110
+ "required_higher_order_params": [], # Placeholder for common GP hyperparameters
111
+ "optional_higher_order_params": ["ln_K", "ln_lambda", "ln_period", "ln_gamma"], # Common GP params, or specific names like "amplitude", "timescale", "periodicity" etc.
112
+ },
113
+ "stellar-rotation": {
114
+ "description": "Effect of stellar rotation on the light curve (e.g., spots)",
115
+ "requires_t_ref": False, # Usually not time-referenced directly in this context
116
+ "required_higher_order_params": [], # Specific parameters (e.g., rotation period, inclination) to be added here
117
+ "optional_higher_order_params": ["v_rot_sin_i", "epsilon"], # Guessing common params: rotational velocity times sin(inclination), spot coverage
118
+ },
119
+ "fitted-limb-darkening": {
120
+ "description": "Limb darkening coefficients fitted as parameters",
121
+ "requires_t_ref": False,
122
+ "required_higher_order_params": [], # Parameters are usually u1, u2, etc. (linear, quadratic)
123
+ "optional_higher_order_params": ["u1", "u2", "u3", "u4"], # Common limb darkening coefficients (linear, quadratic, cubic, quartic)
124
+ },
125
+ # The "other" effect type is handled by allowing any other string in `higher_order_effects` list itself.
126
+ }
127
+
128
+ # This dictionary defines properties/constraints for each known parameter
129
+ # (e.g., expected type, units, a more detailed description, corresponding uncertainty field name)
130
+ PARAMETER_PROPERTIES = {
131
+ # Core Microlensing Parameters
132
+ "t0": {"type": "float", "units": "HJD", "description": "Time of closest approach"},
133
+ "u0": {"type": "float", "units": "thetaE", "description": "Minimum impact parameter"},
134
+ "tE": {"type": "float", "units": "days", "description": "Einstein radius crossing time"},
135
+ "s": {"type": "float", "units": "thetaE", "description": "Binary separation scaled by Einstein radius"},
136
+ "q": {"type": "float", "units": "mass ratio", "description": "Mass ratio M2/M1"},
137
+ "alpha": {"type": "float", "units": "rad", "description": "Angle of source trajectory relative to binary axis"},
138
+
139
+ # Higher-Order Effect Parameters
140
+ "rho": {"type": "float", "units": "thetaE", "description": "Source radius scaled by Einstein radius (Finite Source)"},
141
+ "piEN": {"type": "float", "units": "Einstein radius", "description": "Parallax vector component (North) (Parallax)"},
142
+ "piEE": {"type": "float", "units": "Einstein radius", "description": "Parallax vector component (East) (Parallax)"},
143
+ "dsdt": {"type": "float", "units": "thetaE/year", "description": "Rate of change of binary separation (Lens Orbital Motion)"},
144
+ "dadt": {"type": "float", "units": "rad/year", "description": "Rate of change of binary angle (Lens Orbital Motion)"},
145
+ "dzdt": {"type": "float", "units": "au/year", "description": "Relative radial rate of change of lenses (Lens Orbital Motion, if applicable)"}, # Example, may vary
146
+
147
+ # Flux Parameters (dynamically generated by get_required_flux_params)
148
+ # Ensure these names precisely match how they're generated by get_required_flux_params
149
+ "F0_S": {"type": "float", "units": "counts/s", "description": "Source flux in band 0"},
150
+ "F0_B": {"type": "float", "units": "counts/s", "description": "Blend flux in band 0"},
151
+ "F1_S": {"type": "float", "units": "counts/s", "description": "Source flux in band 1"},
152
+ "F1_B": {"type": "float", "units": "counts/s", "description": "Blend flux in band 1"},
153
+ "F2_S": {"type": "float", "units": "counts/s", "description": "Source flux in band 2"},
154
+ "F2_B": {"type": "float", "units": "counts/s", "description": "Blend flux in band 2"},
155
+
156
+ # Binary Source Flux Parameters (e.g., for "2S" models)
157
+ "F0_S1": {"type": "float", "units": "counts/s", "description": "Primary source flux in band 0"},
158
+ "F0_S2": {"type": "float", "units": "counts/s", "description": "Secondary source flux in band 0"},
159
+ "F1_S1": {"type": "float", "units": "counts/s", "description": "Primary source flux in band 1"},
160
+ "F1_S2": {"type": "float", "units": "counts/s", "description": "Secondary source flux in band 1"},
161
+ "F2_S1": {"type": "float", "units": "counts/s", "description": "Primary source flux in band 2"},
162
+ "F2_S2": {"type": "float", "units": "counts/s", "description": "Secondary source flux in band 2"},
163
+
164
+ # Gaussian Process parameters (examples, often ln-scaled)
165
+ "ln_K": {"type": "float", "units": "mag^2", "description": "Log-amplitude of the GP kernel (GP)"},
166
+ "ln_lambda": {"type": "float", "units": "days", "description": "Log-lengthscale of the GP kernel (GP)"},
167
+ "ln_period": {"type": "float", "units": "days", "description": "Log-period of the GP kernel (GP)"},
168
+ "ln_gamma": {"type": "float", "units": " ", "description": "Log-smoothing parameter of the GP kernel (GP)"}, # Specific interpretation varies by kernel
169
+
170
+ # Stellar Rotation parameters (examples)
171
+ "v_rot_sin_i": {"type": "float", "units": "km/s", "description": "Rotational velocity times sin(inclination) (Stellar Rotation)"},
172
+ "epsilon": {"type": "float", "units": " ", "description": "Spot coverage/brightness parameter (Stellar Rotation)"}, # Example, may vary
173
+
174
+ # Fitted Limb Darkening coefficients (examples)
175
+ "u1": {"type": "float", "units": " ", "description": "Linear limb darkening coefficient (Fitted Limb Darkening)"},
176
+ "u2": {"type": "float", "units": " ", "description": "Quadratic limb darkening coefficient (Fitted Limb Darkening)"},
177
+ "u3": {"type": "float", "units": " ", "description": "Cubic limb darkening coefficient (Fitted Limb Darkening)"},
178
+ "u4": {"type": "float", "units": " ", "description": "Quartic limb darkening coefficient (Fitted Limb Darkening)"},
179
+ }
180
+
181
+ def get_required_flux_params(model_type: str, bands: List[str]) -> List[str]:
182
+ """Get the required flux parameters for a given model type and bands.
183
+
184
+ Determines which flux parameters are required based on the model type
185
+ (single vs binary source) and the photometric bands used. For single
186
+ source models, each band requires source and blend flux parameters.
187
+ For binary source models, each band requires two source fluxes and
188
+ a common blend flux.
189
+
190
+ Args:
191
+ model_type: The type of microlensing model (e.g., "1S1L", "2S1L").
192
+ bands: List of band IDs as strings (e.g., ["0", "1", "2"]).
193
+
194
+ Returns:
195
+ List of required flux parameter names (e.g., ["F0_S", "F0_B", "F1_S", "F1_B"]).
196
+
197
+ Example:
198
+ >>> get_required_flux_params("1S1L", ["0", "1"])
199
+ ['F0_S', 'F0_B', 'F1_S', 'F1_B']
200
+
201
+ >>> get_required_flux_params("2S1L", ["0", "1"])
202
+ ['F0_S1', 'F0_S2', 'F0_B', 'F1_S1', 'F1_S2', 'F1_B']
203
+
204
+ >>> get_required_flux_params("1S1L", [])
205
+ []
206
+
207
+ Note:
208
+ This function handles the most common model types (1S and 2S).
209
+ For models with more than 2 sources, additional logic would be needed.
210
+ The function returns an empty list if no bands are specified.
211
+ """
212
+ flux_params = []
213
+ if not bands:
214
+ return flux_params # No bands, no flux parameters
215
+
216
+ for band in bands:
217
+ if model_type.startswith("1S"): # Single source models
218
+ flux_params.append(f"F{band}_S") # Source flux for this band
219
+ flux_params.append(f"F{band}_B") # Blend flux for this band
220
+ elif model_type.startswith("2S"): # Binary source models
221
+ flux_params.append(f"F{band}_S1") # First source flux for this band
222
+ flux_params.append(f"F{band}_S2") # Second source flux for this band
223
+ flux_params.append(f"F{band}_B") # Blend flux for this band (common for binary sources)
224
+ # Add more source types (e.g., 3S) if necessary in the future
225
+ return flux_params
226
+
227
+
228
+ def check_solution_completeness(
229
+ model_type: str,
230
+ parameters: Dict[str, Any],
231
+ higher_order_effects: Optional[List[str]] = None,
232
+ bands: Optional[List[str]] = None,
233
+ t_ref: Optional[float] = None,
234
+ **kwargs
235
+ ) -> List[str]:
236
+ """Check if a solution has all required parameters based on its model type and effects.
237
+
238
+ This function validates that all required parameters are present for the given
239
+ model type and any higher-order effects. It returns a list of human-readable
240
+ warning or error messages instead of raising exceptions immediately.
241
+
242
+ The validation checks:
243
+ - Required core parameters for the model type
244
+ - Required parameters for each higher-order effect
245
+ - Flux parameters for specified photometric bands
246
+ - Reference time requirements for time-dependent effects
247
+ - Recognition of unknown parameters (warnings only)
248
+
249
+ Args:
250
+ model_type: The type of microlensing model (e.g., '1S1L', '1S2L').
251
+ parameters: Dictionary of model parameters with parameter names as keys.
252
+ higher_order_effects: List of higher-order effects (e.g., ['parallax', 'finite-source']).
253
+ If None, no higher-order effects are assumed.
254
+ bands: List of photometric bands used (e.g., ["0", "1", "2"]).
255
+ If None, no band-specific parameters are required.
256
+ t_ref: Reference time for time-dependent effects (Julian Date).
257
+ Required for effects that specify requires_t_ref=True.
258
+ **kwargs: Additional solution attributes to validate (currently unused).
259
+
260
+ Returns:
261
+ List of validation messages. Empty list if all validations pass.
262
+ Messages indicate missing required parameters, unknown effects,
263
+ missing reference times, or unrecognized parameters.
264
+
265
+ Example:
266
+ >>> # Simple 1S1L solution - should pass
267
+ >>> params = {"t0": 2459123.5, "u0": 0.1, "tE": 20.0}
268
+ >>> messages = check_solution_completeness("1S1L", params)
269
+ >>> print(messages)
270
+ []
271
+
272
+ >>> # Missing required parameter
273
+ >>> params = {"t0": 2459123.5, "u0": 0.1} # Missing tE
274
+ >>> messages = check_solution_completeness("1S1L", params)
275
+ >>> print(messages)
276
+ ["Missing required core parameter 'tE' for model type '1S1L'"]
277
+
278
+ >>> # Binary lens with parallax
279
+ >>> params = {
280
+ ... "t0": 2459123.5, "u0": 0.1, "tE": 20.0,
281
+ ... "s": 1.2, "q": 0.5, "alpha": 45.0,
282
+ ... "piEN": 0.1, "piEE": 0.05
283
+ ... }
284
+ >>> messages = check_solution_completeness("1S2L", params, ["parallax"], t_ref=2459123.0)
285
+ >>> print(messages)
286
+ []
287
+
288
+ >>> # Missing reference time for parallax
289
+ >>> messages = check_solution_completeness("1S2L", params, ["parallax"])
290
+ >>> print(messages)
291
+ ["Reference time (t_ref) required for effect 'parallax'"]
292
+
293
+ Note:
294
+ This function is designed to be comprehensive but not overly strict.
295
+ Unknown parameters generate warnings rather than errors to accommodate
296
+ custom parameters and future model types. The function validates against
297
+ the predefined MODEL_DEFINITIONS and HIGHER_ORDER_EFFECT_DEFINITIONS.
298
+ """
299
+ messages = []
300
+
301
+ # Validate model type
302
+ if model_type not in MODEL_DEFINITIONS:
303
+ messages.append(f"Unknown model type: '{model_type}'. Valid types: {list(MODEL_DEFINITIONS.keys())}")
304
+ return messages
305
+
306
+ model_def = MODEL_DEFINITIONS[model_type]
307
+
308
+ # Check required core parameters
309
+ required_core_params = model_def.get('required_params_core', [])
310
+ for param in required_core_params:
311
+ if param not in parameters:
312
+ messages.append(f"Missing required core parameter '{param}' for model type '{model_type}'")
313
+
314
+ # Validate higher-order effects
315
+ if higher_order_effects:
316
+ for effect in higher_order_effects:
317
+ if effect not in HIGHER_ORDER_EFFECT_DEFINITIONS:
318
+ messages.append(f"Unknown higher-order effect: '{effect}'. Valid effects: {list(HIGHER_ORDER_EFFECT_DEFINITIONS.keys())}")
319
+ continue
320
+
321
+ effect_def = HIGHER_ORDER_EFFECT_DEFINITIONS[effect]
322
+
323
+ # Check required parameters for this effect
324
+ effect_required = effect_def.get('required_higher_order_params', [])
325
+ for param in effect_required:
326
+ if param not in parameters:
327
+ messages.append(f"Missing required parameter '{param}' for effect '{effect}'")
328
+
329
+ # Check optional parameters for this effect
330
+ effect_optional = effect_def.get('optional_higher_order_params', [])
331
+ for param in effect_optional:
332
+ if param not in parameters:
333
+ messages.append(f"Warning: Optional parameter '{param}' not provided for effect '{effect}'")
334
+
335
+ # Check if t_ref is required for this effect
336
+ if effect_def.get('requires_t_ref', False) and t_ref is None:
337
+ messages.append(f"Reference time (t_ref) required for effect '{effect}'")
338
+
339
+ # Validate band-specific parameters
340
+ if bands:
341
+ required_flux_params = get_required_flux_params(model_type, bands)
342
+ for param in required_flux_params:
343
+ if param not in parameters:
344
+ messages.append(f"Missing required flux parameter '{param}' for bands {bands}")
345
+
346
+ # Check for invalid parameters (not in any definition)
347
+ all_valid_params = set()
348
+
349
+ # Add core model parameters
350
+ all_valid_params.update(required_core_params)
351
+
352
+ # Add higher-order effect parameters
353
+ if higher_order_effects:
354
+ for effect in higher_order_effects:
355
+ if effect in HIGHER_ORDER_EFFECT_DEFINITIONS:
356
+ effect_def = HIGHER_ORDER_EFFECT_DEFINITIONS[effect]
357
+ all_valid_params.update(effect_def.get('required_higher_order_params', []))
358
+ all_valid_params.update(effect_def.get('optional_higher_order_params', []))
359
+
360
+ # Add band-specific parameters if bands are specified
361
+ if bands:
362
+ all_valid_params.update(get_required_flux_params(model_type, bands))
363
+
364
+ # Check for invalid parameters
365
+ invalid_params = set(parameters.keys()) - all_valid_params
366
+ for param in invalid_params:
367
+ messages.append(f"Warning: Parameter '{param}' not recognized for model type '{model_type}'")
368
+
369
+ return messages
370
+
371
+
372
+ def validate_parameter_types(
373
+ parameters: Dict[str, Any],
374
+ model_type: str
375
+ ) -> List[str]:
376
+ """Validate parameter types and value ranges against expected types.
377
+
378
+ Checks that parameters have the correct data types as defined in
379
+ PARAMETER_PROPERTIES. Currently supports validation of float, int,
380
+ and string types. Parameters not defined in PARAMETER_PROPERTIES
381
+ are skipped (no validation performed).
382
+
383
+ Args:
384
+ parameters: Dictionary of model parameters with parameter names as keys.
385
+ model_type: The type of microlensing model (used for context in messages).
386
+
387
+ Returns:
388
+ List of validation messages. Empty list if all validations pass.
389
+ Messages indicate type mismatches for known parameters.
390
+
391
+ Example:
392
+ >>> # Valid parameters
393
+ >>> params = {"t0": 2459123.5, "u0": 0.1, "tE": 20.0}
394
+ >>> messages = validate_parameter_types(params, "1S1L")
395
+ >>> print(messages)
396
+ []
397
+
398
+ >>> # Invalid type for t0
399
+ >>> params = {"t0": "2459123.5", "u0": 0.1, "tE": 20.0} # t0 is string
400
+ >>> messages = validate_parameter_types(params, "1S1L")
401
+ >>> print(messages)
402
+ ["Parameter 't0' should be numeric, got str"]
403
+
404
+ >>> # Unknown parameter (no validation performed)
405
+ >>> params = {"t0": 2459123.5, "custom_param": "value"}
406
+ >>> messages = validate_parameter_types(params, "1S1L")
407
+ >>> print(messages)
408
+ []
409
+
410
+ Note:
411
+ This function only validates parameters that are defined in
412
+ PARAMETER_PROPERTIES. Unknown parameters are ignored to allow
413
+ for custom parameters and future extensions. The validation
414
+ is currently limited to basic type checking (float, int, str).
415
+ """
416
+ messages = []
417
+
418
+ if model_type not in MODEL_DEFINITIONS:
419
+ return [f"Unknown model type: '{model_type}'"]
420
+
421
+ for param, value in parameters.items():
422
+ if param in PARAMETER_PROPERTIES:
423
+ prop = PARAMETER_PROPERTIES[param]
424
+
425
+ # Check type
426
+ expected_type = prop.get('type')
427
+ if expected_type == 'float' and not isinstance(value, (int, float)):
428
+ messages.append(f"Parameter '{param}' should be numeric, got {type(value).__name__}")
429
+ elif expected_type == 'int' and not isinstance(value, int):
430
+ messages.append(f"Parameter '{param}' should be integer, got {type(value).__name__}")
431
+ elif expected_type == 'str' and not isinstance(value, str):
432
+ messages.append(f"Parameter '{param}' should be string, got {type(value).__name__}")
433
+
434
+ return messages
435
+
436
+
437
+ def validate_parameter_uncertainties(
438
+ parameters: Dict[str, Any],
439
+ uncertainties: Optional[Dict[str, Any]] = None
440
+ ) -> List[str]:
441
+ """Validate parameter uncertainties for reasonableness and consistency.
442
+
443
+ Performs comprehensive validation of parameter uncertainties, including:
444
+ - Format validation (single value or [lower, upper] pairs)
445
+ - Sign validation (uncertainties must be positive)
446
+ - Consistency checks (lower ≤ upper for asymmetric uncertainties)
447
+ - Reasonableness checks (relative uncertainty between 0.1% and 50%)
448
+
449
+ Args:
450
+ parameters: Dictionary of model parameters with parameter names as keys.
451
+ uncertainties: Dictionary of parameter uncertainties. Can be None if
452
+ no uncertainties are provided. Supports two formats:
453
+ - Single value: {"param": 0.1} (symmetric uncertainty)
454
+ - Asymmetric bounds: {"param": [0.05, 0.15]} (lower, upper)
455
+
456
+ Returns:
457
+ List of validation messages. Empty list if all validations pass.
458
+ Messages indicate format errors, sign issues, consistency problems,
459
+ or warnings about very large/small relative uncertainties.
460
+
461
+ Example:
462
+ >>> # Valid symmetric uncertainties
463
+ >>> params = {"t0": 2459123.5, "u0": 0.1, "tE": 20.0}
464
+ >>> unc = {"t0": 0.1, "u0": 0.01, "tE": 0.5}
465
+ >>> messages = validate_parameter_uncertainties(params, unc)
466
+ >>> print(messages)
467
+ []
468
+
469
+ >>> # Valid asymmetric uncertainties
470
+ >>> unc = {"t0": [0.05, 0.15], "u0": [0.005, 0.015]}
471
+ >>> messages = validate_parameter_uncertainties(params, unc)
472
+ >>> print(messages)
473
+ []
474
+
475
+ >>> # Invalid format
476
+ >>> unc = {"t0": [0.1, 0.2, 0.3]} # Too many values
477
+ >>> messages = validate_parameter_uncertainties(params, unc)
478
+ >>> print(messages)
479
+ ["Uncertainty for 't0' should be [lower, upper] or single value"]
480
+
481
+ >>> # Inconsistent bounds
482
+ >>> unc = {"t0": [0.2, 0.1]} # Lower > upper
483
+ >>> messages = validate_parameter_uncertainties(params, unc)
484
+ >>> print(messages)
485
+ ["Lower uncertainty for 't0' (0.2) > upper uncertainty (0.1)"]
486
+
487
+ >>> # Very large relative uncertainty
488
+ >>> unc = {"t0": 1000.0} # Very large uncertainty
489
+ >>> messages = validate_parameter_uncertainties(params, unc)
490
+ >>> print(messages)
491
+ ["Warning: Uncertainty for 't0' is very large (40.8% of parameter value)"]
492
+
493
+ Note:
494
+ This function provides warnings rather than errors for very large
495
+ or very small relative uncertainties, as these might be legitimate
496
+ in some cases. The 0.1% to 50% range is a guideline based on
497
+ typical microlensing parameter uncertainties.
498
+ """
499
+ messages = []
500
+
501
+ if not uncertainties:
502
+ return messages
503
+
504
+ for param_name, uncertainty in uncertainties.items():
505
+ if param_name not in parameters:
506
+ messages.append(f"Uncertainty provided for unknown parameter '{param_name}'")
507
+ continue
508
+
509
+ param_value = parameters[param_name]
510
+
511
+ # Handle different uncertainty formats
512
+ if isinstance(uncertainty, (list, tuple)):
513
+ # [lower, upper] format
514
+ if len(uncertainty) != 2:
515
+ messages.append(f"Uncertainty for '{param_name}' should be [lower, upper] or single value")
516
+ continue
517
+ lower, upper = uncertainty
518
+ if not (isinstance(lower, (int, float)) and isinstance(upper, (int, float))):
519
+ messages.append(f"Uncertainty bounds for '{param_name}' must be numeric")
520
+ continue
521
+ if lower < 0 or upper < 0:
522
+ messages.append(f"Uncertainty bounds for '{param_name}' must be positive")
523
+ continue
524
+ if lower > upper:
525
+ messages.append(f"Lower uncertainty for '{param_name}' ({lower}) > upper uncertainty ({upper})")
526
+ continue
527
+ else:
528
+ # Single value format
529
+ if not isinstance(uncertainty, (int, float)):
530
+ messages.append(f"Uncertainty for '{param_name}' must be numeric")
531
+ continue
532
+ if uncertainty < 0:
533
+ messages.append(f"Uncertainty for '{param_name}' must be positive")
534
+ continue
535
+ lower = upper = uncertainty
536
+
537
+ # Check if uncertainty is reasonable relative to parameter value
538
+ if isinstance(param_value, (int, float)) and param_value != 0:
539
+ # Calculate relative uncertainty
540
+ if isinstance(uncertainty, (list, tuple)):
541
+ rel_uncertainty = max(abs(lower/param_value), abs(upper/param_value))
542
+ else:
543
+ rel_uncertainty = abs(uncertainty/param_value)
544
+
545
+ # Warn if uncertainty is very large (>50%) or very small (<0.1%)
546
+ if rel_uncertainty > 0.5:
547
+ messages.append(f"Warning: Uncertainty for '{param_name}' is very large ({rel_uncertainty:.1%} of parameter value)")
548
+ elif rel_uncertainty < 0.001:
549
+ messages.append(f"Warning: Uncertainty for '{param_name}' is very small ({rel_uncertainty:.1%} of parameter value)")
550
+
551
+ return messages
552
+
553
+
554
+ def validate_solution_consistency(
555
+ model_type: str,
556
+ parameters: Dict[str, Any],
557
+ **kwargs: Any,
558
+ ) -> List[str]:
559
+ """Validate internal consistency of solution parameters.
560
+
561
+ Performs physical consistency checks on microlensing parameters to
562
+ identify potentially problematic values. This includes range validation,
563
+ physical constraints, and model-specific consistency checks.
564
+
565
+ Args:
566
+ model_type: The type of microlensing model (e.g., '1S1L', '1S2L').
567
+ parameters: Dictionary of model parameters with parameter names as keys.
568
+ **kwargs: Additional solution attributes. Currently supports:
569
+ relative_probability: Probability value for range checking (0-1).
570
+
571
+ Returns:
572
+ List of validation messages. Empty list if all validations pass.
573
+ Messages indicate physical inconsistencies, range violations,
574
+ or warnings about unusual parameter combinations.
575
+
576
+ Example:
577
+ >>> # Valid parameters
578
+ >>> params = {"t0": 2459123.5, "u0": 0.1, "tE": 20.0}
579
+ >>> messages = validate_solution_consistency("1S1L", params)
580
+ >>> print(messages)
581
+ []
582
+
583
+ >>> # Invalid tE (must be positive)
584
+ >>> params = {"t0": 2459123.5, "u0": 0.1, "tE": -5.0}
585
+ >>> messages = validate_solution_consistency("1S1L", params)
586
+ >>> print(messages)
587
+ ["Einstein crossing time (tE) must be positive"]
588
+
589
+ >>> # Invalid mass ratio
590
+ >>> params = {"t0": 2459123.5, "u0": 0.1, "tE": 20.0, "q": 1.5}
591
+ >>> messages = validate_solution_consistency("1S2L", params)
592
+ >>> print(messages)
593
+ ["Mass ratio (q) should be between 0 and 1"]
594
+
595
+ >>> # Invalid relative probability
596
+ >>> messages = validate_solution_consistency("1S1L", params, relative_probability=1.5)
597
+ >>> print(messages)
598
+ ["Relative probability should be between 0 and 1"]
599
+
600
+ >>> # Binary lens with unusual separation
601
+ >>> params = {"t0": 2459123.5, "u0": 0.1, "tE": 20.0, "s": 0.1, "q": 0.5}
602
+ >>> messages = validate_solution_consistency("1S2L", params)
603
+ >>> print(messages)
604
+ ["Warning: Separation (s) outside typical caustic crossing range (0.5-2.0)"]
605
+
606
+ Note:
607
+ This function focuses on physical consistency rather than statistical
608
+ validation. Warnings are provided for unusual but not impossible
609
+ parameter combinations. The caustic crossing range check for binary
610
+ lenses is a guideline based on typical microlensing events.
611
+ """
612
+ messages = []
613
+
614
+ # Check for physically impossible values
615
+ if 'tE' in parameters and parameters['tE'] <= 0:
616
+ messages.append("Einstein crossing time (tE) must be positive")
617
+
618
+ if 'q' in parameters and (parameters['q'] <= 0 or parameters['q'] > 1):
619
+ messages.append("Mass ratio (q) should be between 0 and 1")
620
+
621
+ if 's' in parameters and parameters['s'] <= 0:
622
+ messages.append("Separation (s) must be positive")
623
+
624
+ rel_prob = kwargs.get("relative_probability")
625
+ if rel_prob is not None and not 0 <= rel_prob <= 1:
626
+ messages.append("Relative probability should be between 0 and 1")
627
+
628
+ # Check for binary lens specific consistency (1S2L, 2S2L models)
629
+ if model_type in ['1S2L', '2S2L']:
630
+ if 'q' in parameters and 's' in parameters:
631
+ # Check for caustic crossing conditions
632
+ q = parameters['q']
633
+ s = parameters['s']
634
+
635
+ # Simple caustic crossing check
636
+ if s < 0.5 or s > 2.0:
637
+ messages.append("Warning: Separation (s) outside typical caustic crossing range (0.5-2.0)")
638
+
639
+ return messages