File indexing completed on 2026-04-28 07:12:52
0001 """Design configuration models for detector parameter spaces.
0002
0003 This module provides a flexible framework for defining and managing design parameter
0004 spaces, including parameter groups, constraints, and constraint validation. It supports
0005 loading configurations from YAML files with options for file-based or inline parameter
0006 definitions.
0007
0008 Key Classes:
0009 - ParameterGroup: Container for related parameters with shared properties.
0010 - ParameterConstraint: Mathematical constraint rule on design parameters.
0011 - DesignParameters: Collection of parameter groups.
0012 - DesignConfig: Complete design configuration with constraints and validation.
0013 - DesignConfigLoader: YAML file loader with path/inline resolution.
0014
0015 Typical Usage:
0016 >>> config = DesignConfigLoader.load('design.params')
0017 >>> param_names = config.get_parameter_names()
0018 >>> bounds = config.get_parameter_bounds('group.param_name')
0019 >>> is_valid, failures = config.validate_constraints(param_values)
0020 """
0021
0022 from typing import Dict, List, Optional, Tuple, Any
0023 from pydantic import BaseModel, Field, RootModel, model_validator
0024 from pathlib import Path
0025 import yaml
0026 import os
0027 import re
0028
0029 from .base_models import Parameter, BaseParameter
0030
0031
0032 class ParameterGroup(BaseModel):
0033 """Container for a group of related parameters.
0034
0035 Groups parameters that share common properties or contexts, such as detector
0036 subsystems (vertex_barrel, silicon_tracker, etc.). Parameters within a group
0037 are accessed via qualified names (group_name.param_name).
0038
0039 Attributes:
0040 parameters: Dictionary mapping parameter names to Parameter objects.
0041
0042 Example:
0043 >>> group = ParameterGroup(parameters={
0044 ... 'thickness': RangeParameter(value=0.35, bounds=[0.2, 0.6]),
0045 ... 'pitch': RangeParameter(value=25, bounds=[10, 50])
0046 ... })
0047 """
0048 parameters: Dict[str, Parameter]
0049
0050
0051 class ParameterConstraint(BaseModel):
0052 """Represents a mathematical constraint on design parameters.
0053
0054 Constraints are evaluated as boolean expressions over qualified parameter names
0055 and must evaluate to True for valid parameter configurations.
0056
0057 Attributes:
0058 name: Unique identifier for the constraint.
0059 description: Human-readable explanation of the constraint intent.
0060 rule: Mathematical expression using qualified parameter names,
0061 e.g., "group.param1 + group.param2 < 10.0".
0062
0063 Example:
0064 >>> constraint = ParameterConstraint(
0065 ... name="budget_limit",
0066 ... description="Total cost must not exceed budget",
0067 ... rule="tracker.cost + magnet.cost < 1000"
0068 ... )
0069 """
0070 name: str
0071 description: Optional[str] = None
0072 rule: str
0073
0074 def validate_constraint(self, param_values: Dict[str, float]) -> bool:
0075 """Validate constraint against parameter values.
0076
0077 Substitutes parameter names in the constraint rule with their values
0078 and evaluates the resulting mathematical expression.
0079
0080 Args:
0081 param_values: Dictionary mapping qualified parameter names
0082 (e.g., "group.param") to numeric values.
0083
0084 Returns:
0085 True if constraint is satisfied, False otherwise.
0086
0087 Raises:
0088 ValueError: If the constraint rule cannot be evaluated
0089 (e.g., missing parameters, syntax errors).
0090
0091 Example:
0092 >>> constraint = ParameterConstraint(
0093 ... name="test", rule="DTLZ2.x1 < 1.0"
0094 ... )
0095 >>> constraint.validate_constraint({"DTLZ2.x1": 0.5})
0096 True
0097 >>> constraint.validate_constraint({"DTLZ2.x1": 1.5})
0098 False
0099 """
0100
0101 expr = self.rule
0102 for param_name, value in param_values.items():
0103
0104 expr = re.sub(rf'\b{re.escape(param_name)}\b', str(value), expr)
0105
0106 try:
0107
0108 result = eval(expr)
0109 return bool(result)
0110 except Exception as e:
0111 raise ValueError(f"Failed to evaluate constraint '{self.name}': {e}")
0112
0113
0114 class DesignParameters(RootModel[Dict[str, ParameterGroup]]):
0115 """Collection of parameter groups for generic design spaces.
0116
0117 Manages a hierarchical organization of design parameters grouped by context
0118 (subsystems, regions, etc.). Automatically injects fully qualified parameter names
0119 in the format "group_name.parameter_name" for unique identification.
0120
0121 The root model contains a dictionary mapping group names to ParameterGroup instances.
0122
0123 Example:
0124 >>> params = DesignParameters(root={
0125 ... 'tracker': ParameterGroup(parameters={...}),
0126 ... 'magnet': ParameterGroup(parameters={...})
0127 ... })
0128
0129 Notes:
0130 - Qualified names are injected at validation time.
0131 - Parameter uniqueness is enforced through qualified naming.
0132 """
0133
0134 @model_validator(mode="before")
0135 @classmethod
0136 def inject_qualified_names(cls, values: Dict[str, dict]):
0137 """Inject fully qualified names into each parameter.
0138
0139 Modifies parameter objects in-place to add 'name' attribute in the format
0140 'group_name.parameter_name' if not already present. This ensures every
0141 parameter has a globally unique identifier within the design space.
0142
0143 Args:
0144 values: Dictionary mapping group names to group data dicts.
0145
0146 Returns:
0147 Modified values dict with injected qualified names.
0148
0149 Notes:
0150 This validator runs before model instantiation and is critical for
0151 the qualified naming system used throughout this module.
0152 """
0153 for group_name, group_data in values.items():
0154 param_dict = group_data.get("parameters", {})
0155 for param_name, param_data in param_dict.items():
0156 if isinstance(param_data, dict) and "name" not in param_data:
0157 param_data["name"] = f"{group_name}.{param_name}"
0158 return values
0159
0160
0161 class DesignConfig(BaseModel):
0162 """Complete design configuration with parameters and constraints.
0163
0164 Encapsulates a design space including all parameter groups, their bounds/choices,
0165 and constraints on valid parameter combinations. Provides query and validation
0166 methods for optimizer integration.
0167
0168 This is the base class for specialized configurations (e.g., EpicDesignConfig)
0169 and supports generic toy problems (DTLZ2, etc.).
0170
0171 Attributes:
0172 design_parameters: Collection of parameter groups defining the design space.
0173 parameter_constraints: List of constraints on valid parameter combinations.
0174
0175 Example:
0176 >>> config = DesignConfig(
0177 ... design_parameters=DesignParameters(...),
0178 ... parameter_constraints=[ParameterConstraint(...)]
0179 ... )
0180 >>> names = config.get_parameter_names()
0181 >>> bounds = config.get_parameter_bounds('group.param')
0182 >>> is_valid, failed = config.validate_constraints({...})
0183 """
0184 design_parameters: DesignParameters
0185 parameter_constraints: Optional[List[ParameterConstraint]] = Field(default_factory=list)
0186
0187 def get_flat_parameters(self) -> Dict[str, BaseParameter]:
0188 """Retrieve all parameters as a flat dictionary.
0189
0190 Flattens the hierarchical group structure into a single dictionary mapping
0191 qualified parameter names to parameter objects.
0192
0193 Returns:
0194 Dictionary mapping qualified names (e.g., "group.param")
0195 to BaseParameter objects.
0196
0197 Example:
0198 >>> flat = config.get_flat_parameters()
0199 >>> param = flat['tracker.thickness']
0200 """
0201 flat = {}
0202 for group in self.design_parameters.root.values():
0203 for param in group.parameters.values():
0204 flat[param.name] = param
0205 return flat
0206
0207 def get_parameter_names(self) -> List[str]:
0208 """Get all parameter qualified names in the design space.
0209
0210 Returns a list of all unique qualified parameter names in the format
0211 'group_name.parameter_name'.
0212
0213 Returns:
0214 Sorted list of qualified parameter names.
0215
0216 Example:
0217 >>> names = config.get_parameter_names()
0218 >>> print(names)
0219 ['group1.param1', 'group1.param2', 'group2.param1']
0220 """
0221 return list(self.get_flat_parameters().keys())
0222
0223 def get_parameter_bounds(self, param_name: str) -> Optional[Tuple[float, float]]:
0224 """Get bounds for a range parameter.
0225
0226 Retrieves the lower and upper bounds for a RangeParameter by its
0227 qualified name. Returns None if the parameter is not found or
0228 does not have bounds (e.g., ChoiceParameter).
0229
0230 Args:
0231 param_name: Qualified parameter name (e.g., "tracker.thickness").
0232
0233 Returns:
0234 Tuple of (lower_bound, upper_bound) or None if not applicable.
0235
0236 Raises:
0237 KeyError: If parameter name is not found (use get_flat_parameters
0238 to verify existence first).
0239
0240 Example:
0241 >>> bounds = config.get_parameter_bounds('tracker.thickness')
0242 >>> if bounds:
0243 ... print(f"Range: {bounds[0]} to {bounds[1]}")
0244 """
0245 flat = self.get_flat_parameters()
0246 param = flat.get(param_name)
0247 if param and hasattr(param, 'bounds'):
0248 return param.bounds
0249 return None
0250
0251 def get_parameter_choices(self, param_name: str) -> Optional[List[str]]:
0252 """Get choices for a choice parameter.
0253
0254 Retrieves the list of valid choices for a ChoiceParameter by its
0255 qualified name. Returns None if the parameter is not found or
0256 does not have choices (e.g., RangeParameter).
0257
0258 Args:
0259 param_name: Qualified parameter name (e.g., "detector.type").
0260
0261 Returns:
0262 List of choice strings or None if not applicable.
0263
0264 Example:
0265 >>> choices = config.get_parameter_choices('detector.type')
0266 >>> if choices:
0267 ... print(f"Available: {choices}")
0268 """
0269 flat = self.get_flat_parameters()
0270 param = flat.get(param_name)
0271 if param and hasattr(param, 'choices'):
0272 return param.choices
0273 return None
0274
0275 def validate_constraints(self, param_values: Dict[str, float]) -> Tuple[bool, List[str]]:
0276 """Validate all constraints against provided parameter values.
0277
0278 Evaluates each constraint rule with the given parameter values and
0279 returns whether all constraints are satisfied.
0280
0281 Args:
0282 param_values: Dictionary mapping qualified parameter names to
0283 numeric values.
0284
0285 Returns:
0286 Tuple of (all_valid, failed_constraint_names) where:
0287 - all_valid: True if all constraints passed, False otherwise.
0288 - failed_constraint_names: List of constraint names that failed
0289 or raised exceptions.
0290
0291 Example:
0292 >>> param_values = {
0293 ... 'tracker.thickness': 0.35,
0294 ... 'magnet.strength': 1.5
0295 ... }
0296 >>> is_valid, failures = config.validate_constraints(param_values)
0297 >>> if not is_valid:
0298 ... print(f"Failed constraints: {failures}")
0299
0300 Notes:
0301 - Returns (True, []) if no constraints are defined.
0302 - Exceptions during constraint evaluation are captured and
0303 reported in the failures list.
0304 """
0305 if not self.parameter_constraints:
0306 return True, []
0307
0308 failed = []
0309 for constraint in self.parameter_constraints:
0310 try:
0311 if not constraint.validate_constraint(param_values):
0312 failed.append(constraint.name)
0313 except Exception as e:
0314 failed.append(f"{constraint.name} (error: {e})")
0315
0316 return len(failed) == 0, failed
0317
0318
0319 class DesignConfigLoader:
0320 """Load design configurations from YAML files with flexible resolution.
0321
0322 Handles both file-based and inline design parameter definitions. Supports
0323 path-based loading (external file) or inline definition within the YAML
0324 structure, with comprehensive validation and error reporting.
0325
0326 The loader normalizes legacy schema formats for backward compatibility while
0327 supporting the new design_space structure with design_parameters and
0328 design_constraints.
0329
0330 Example:
0331 >>> # Load from file with external design.params
0332 >>> config = DesignConfigLoader.load('config.yml')
0333
0334 >>> # YAML structure (file-based)
0335 >>> # design_space:
0336 >>> # path: "./design.params"
0337
0338 >>> # YAML structure (inline)
0339 >>> # design_space:
0340 >>> # design_parameters:
0341 >>> # group:
0342 >>> # parameters: {...}
0343 >>> # design_constraints: [...]
0344 """
0345
0346 @staticmethod
0347 def _extract_design_space_payload(raw: Dict[str, Any]) -> Dict[str, Any]:
0348 """Extract and normalize design space payload from loaded data.
0349
0350 Handles both new design_space schema and legacy formats, extracting
0351 design_parameters and design_constraints/parameter_constraints into
0352 a normalized dictionary.
0353
0354 Args:
0355 raw: Dictionary loaded from YAML file or inline config.
0356
0357 Returns:
0358 Dictionary with keys 'design_parameters' and optionally
0359 'parameter_constraints'.
0360
0361 Raises:
0362 ValueError: If raw data is not a dict or lacks design_parameters.
0363
0364 Notes:
0365 - Supports both 'design_space' (new) and direct keys (legacy).
0366 - Maps 'design_constraints' → 'parameter_constraints'.
0367 - Provides clear error messages for missing required fields.
0368 """
0369 if not isinstance(raw, dict):
0370 raise ValueError("Design space content must be a mapping.")
0371 space = raw.get('design_space', raw)
0372 design_parameters = space.get('design_parameters') or raw.get('design_parameters')
0373 if design_parameters is None:
0374 raise ValueError("design_space must include 'design_parameters'.")
0375 parameter_constraints = (
0376 space.get('design_constraints')
0377 or space.get('parameter_constraints')
0378 or raw.get('design_constraints')
0379 or raw.get('parameter_constraints')
0380 )
0381 payload: Dict[str, Any] = {"design_parameters": design_parameters}
0382 if parameter_constraints is not None:
0383 payload["parameter_constraints"] = parameter_constraints
0384 return payload
0385
0386 @staticmethod
0387 def _resolve_design_space(design_space: Dict[str, Any], config_dir: str = ".") -> Dict[str, Any]:
0388 """Resolve design space from file path or inline definition.
0389
0390 Intelligently resolves design space configuration from either:
0391 1. An external file referenced by 'path' key, or
0392 2. Inline parameter definitions in the design_space dict.
0393
0394 Enforces that both path and inline definitions cannot coexist.
0395
0396 Args:
0397 design_space: Dictionary containing 'path' and/or inline definitions.
0398 config_dir: Base directory for relative path resolution.
0399
0400 Returns:
0401 Normalized dictionary with 'design_parameters' and optionally
0402 'parameter_constraints'.
0403
0404 Raises:
0405 ValueError: If both 'path' and inline definitions are present.
0406 FileNotFoundError: If referenced file does not exist.
0407
0408 Example:
0409 >>> # File-based resolution
0410 >>> payload = DesignConfigLoader._resolve_design_space(
0411 ... {'path': './design.params'},
0412 ... config_dir='/path/to/config'
0413 ... )
0414
0415 >>> # Inline resolution
0416 >>> payload = DesignConfigLoader._resolve_design_space(
0417 ... {'design_parameters': {...}}
0418 ... )
0419
0420 Notes:
0421 - Relative paths are resolved relative to config_dir.
0422 - Absolute paths are used as-is.
0423 - File not found errors include full resolved path in message.
0424 """
0425 has_path = 'path' in design_space
0426 has_inline = any(k != 'path' for k in design_space)
0427
0428 if has_path and has_inline:
0429 raise ValueError(
0430 "Cannot define both 'path' and inline design_space. Specify either a file path or inline groups."
0431 )
0432
0433 if has_path:
0434 file_path = design_space['path']
0435 full_path = Path(config_dir) / file_path if not Path(file_path).is_absolute() else Path(file_path)
0436 if not full_path.exists():
0437 raise FileNotFoundError(f"Design parameters file not found: {full_path}")
0438 with open(full_path, 'r') as f:
0439 loaded_data = yaml.safe_load(f)
0440 return DesignConfigLoader._extract_design_space_payload(loaded_data)
0441
0442 if has_inline:
0443 return DesignConfigLoader._extract_design_space_payload(design_space)
0444
0445 raise ValueError(
0446 "Design space must define either a 'path' to a file or inline design_parameters/design_constraints."
0447 )
0448
0449 @staticmethod
0450 def load(file_path: str) -> "DesignConfig":
0451 """Load design configuration from a YAML file.
0452
0453 Loads a configuration file and returns a DesignConfig instance.
0454 Supports both new 'design_space' schema and legacy 'design_parameters'
0455 formats. Handles file-based (external file reference) and inline
0456 parameter definitions seamlessly.
0457
0458 Args:
0459 file_path: Path to the YAML configuration file. Relative paths
0460 are resolved from the current working directory.
0461
0462 Returns:
0463 DesignConfig instance ready for use in optimization workflows.
0464
0465 Raises:
0466 FileNotFoundError: If the config file does not exist.
0467 ValueError: If config structure is invalid or references
0468 a non-existent design.params file.
0469 yaml.YAMLError: If the YAML syntax is invalid.
0470
0471 Example:
0472 >>> config = DesignConfigLoader.load('examples/design.yml')
0473 >>> print(config.get_parameter_names())
0474 >>> is_valid, failures = config.validate_constraints({...})
0475
0476 Notes:
0477 - The configuration file must be valid YAML.
0478 - Must contain either 'design_space' or 'design_parameters' key.
0479 - Directory of config_file is used as base for relative paths.
0480 - Backward compatible with pre-design_space YAML files.
0481 """
0482 path = Path(file_path)
0483 if not path.exists():
0484 raise FileNotFoundError(f"Configuration file not found: {file_path}")
0485
0486 config_dir = path.parent
0487 with open(path, 'r') as f:
0488 data = yaml.safe_load(f)
0489
0490 if 'design_space' in data:
0491 design_space = data['design_space']
0492 elif 'design_parameters' in data:
0493
0494 design_space = {
0495 'design_parameters': data['design_parameters'],
0496 }
0497 if 'parameter_constraints' in data:
0498 design_space['design_constraints'] = data['parameter_constraints']
0499 else:
0500 raise ValueError(f"Invalid configuration file format: {file_path}. Missing 'design_space'.")
0501
0502 resolved = DesignConfigLoader._resolve_design_space(design_space, config_dir=str(config_dir))
0503 data['design_parameters'] = resolved['design_parameters']
0504 if 'parameter_constraints' in resolved:
0505 data['parameter_constraints'] = resolved['parameter_constraints']
0506
0507 return DesignConfig(**data)