Back to home page

EIC code displayed by LXR

 
 

    


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  # Mathematical expression like "x1 + x2 < 10"
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         # Replace parameter names with their values
0101         expr = self.rule
0102         for param_name, value in param_values.items():
0103             # Use word boundaries to avoid partial matches
0104             expr = re.sub(rf'\b{re.escape(param_name)}\b', str(value), expr)
0105         
0106         try:
0107             # Evaluate the expression
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             # Backward compatibility: promote old schema
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)