diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e954ab4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +steer_core/Data/database.db filter=lfs diff=lfs merge=lfs -text +/steer_core/Data/database.db filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index 23a86dc..15db6ef 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ data_scripts/ local_data/ Archive/ .vscode* +app_builds/ diff --git a/.vscode/settings.json b/.vscode/settings.json index a11728f..cb735a7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,15 @@ "test*.py" ], "python.testing.pytestEnabled": false, - "python.testing.unittestEnabled": true + "python.testing.unittestEnabled": true, + "python-envs.defaultEnvManager": "ms-python.python:conda", + "python-envs.defaultPackageManager": "ms-python.python:conda", + "python-envs.pythonProjects": [], + "python.analysis.extraPaths": [ + "/Users/nsiemons/Drive/Code_projects/STEER/steer-core", + "/Users/nsiemons/Drive/Code_projects/STEER/steer-opencell-data", + "/Users/nsiemons/Drive/Code_projects/STEER/steer-opencell-design", + "/Users/nsiemons/Drive/Code_projects/STEER/steer-materials", + "/Users/nsiemons/Drive/Code_projects/STEER/steer-dash-components" + ], } \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..41b6820 --- /dev/null +++ b/LICENSE @@ -0,0 +1,43 @@ +Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International + +Copyright (c) 2026 Nicholas Siemons + +This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. + +To view a copy of this license, visit: +http://creativecommons.org/licenses/by-nc-sa/4.0/ + +or send a letter to: +Creative Commons, PO Box 1866, Mountain View, CA 94042, USA. + +=============================================================================== + +You are free to: + + * Share — copy and redistribute the material in any medium or format + * Adapt — remix, transform, and build upon the material + +The licensor cannot revoke these freedoms as long as you follow the license terms. + +Under the following terms: + + * Attribution — You must give appropriate credit, provide a link to the license, + and indicate if changes were made. You may do so in any reasonable manner, but + not in any way that suggests the licensor endorses you or your use. + + * NonCommercial — You may not use the material for commercial purposes. + + * ShareAlike — If you remix, transform, or build upon the material, you must + distribute your contributions under the same license as the original. + + * No additional restrictions — You may not apply legal terms or technological + measures that legally restrict others from doing anything the license permits. + +Notices: + +You do not have to comply with the license for elements of the material in the +public domain or where your use is permitted by an applicable exception or limitation. + +No warranties are given. The license may not give you all of the permissions necessary +for your intended use. For example, other rights such as publicity, privacy, or moral +rights may limit how you use the material. diff --git a/README.md b/README.md index e69de29..46668a1 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,83 @@ +# steer-core + +Base utilities for the OpenCell platform: constants, mixins (Serializer, Validation, Plotter), decorators, and the DataManager REST API client. + +## Install + +```bash +pip install -e . +``` + +## Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `OPENCELL_ENV` | No | `production` | `development` = local SQLite, no auth. `production` = REST API + Cognito auth. | +| `API_URL` | In production | — | Base URL of the deployed REST API (e.g. `https://59xitvvsf2.execute-api.us-east-2.amazonaws.com/production`) | +| `API_TIMEOUT` | No | `30` | HTTP request timeout in seconds | + +## Development vs Production Mode + +Controlled by the `OPENCELL_ENV` environment variable. The helper `is_development()` from `steer_core.Data` is the single source of truth — use it anywhere you need to branch on mode. + +```python +from steer_core.Data import is_development + +if is_development(): + # local SQLite path +else: + # REST API path +``` + +### Development mode (`OPENCELL_ENV=development`) + +- `SerializerMixin.from_database()` uses the local SQLite database via `steer_opencell_data.DataManager` +- No network calls, works fully offline +- Requires `steer-opencell-data` installed with `database.db` +- Use this when developing new cells locally before publishing via the CLI migration tool (`steer-opencell-data` CLI) + +### Production mode (`OPENCELL_ENV=production` or unset) + +- `SerializerMixin.from_database()` uses the REST API via `steer_core.Data.DataManager` +- Requires `API_URL` pointing to the deployed Lambda endpoint +- JWT token passed automatically for authenticated operations (`DataManager.set_token()`) +- Logs API calls and S3 downloads to the `steer_core.DataManager` logger + +## DataManager REST Client + +`steer_core.Data.DataManager` — drop-in replacement for the SQLite-based DataManager. Same interface, talks to the REST API + S3 instead. + +### Key methods + +| Method | What it does | +|--------|-------------| +| `get_data(table, condition="name='X'")` | Fetch item + download blob from S3 presigned URL | +| `get_data(table)` (no condition) | List items — metadata only, no blob | +| `get_unique_values(table, column)` | List unique values from API | +| `get_{type}_materials(most_recent)` | 9 material-specific convenience methods | +| `insert_data(table, df)` | Upload blob to S3 via presigned URL | +| `remove_data(table, condition)` | Soft-delete via API | +| `fork_cell(table, name, new_name)` | Fork cell (auth required) | +| `publish_cell(table, name, new_name)` | Publish cell (admin only) | +| `check_name_available(name)` | Check name uniqueness across all cell tables | +| `set_token(token)` | Set JWT for authenticated requests | + +### Exceptions + +| Exception | HTTP Status | When | +|-----------|-------------|------| +| `DataManagerError` | — | Base class / missing `API_URL` | +| `APIError` | 5xx | Server error | +| `AuthenticationError` | 401 | Missing or invalid token | +| `ForbiddenError` | 403 | Insufficient permissions | +| `NotFoundError` | 404 | Resource not found | +| `ConflictError` | 409 | Name already taken (fork/publish) | + +### Logging + +API calls and S3 downloads are logged to the `steer_core.DataManager` logger: + +``` +[steer_core.DataManager] [API] GET /materials/tape_materials/Kapton -> 200 (164 ms) +[steer_core.DataManager] [S3] Downloaded 0.2 KB in 499 ms +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..eb4742a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,110 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "steer-core" +dynamic = ["version"] +description = "Modelling energy storage from cell to site - STEER OpenCell Design" +readme = "README.md" +requires-python = ">=3.10" +license = {text = "CC-BY-NC-SA-4.0"} +authors = [ + {name = "Nicholas Siemons", email = "nsiemons@stanford.edu"}, +] +maintainers = [ + {name = "Nicholas Siemons", email = "nsiemons@stanford.edu"}, +] +keywords = ["energy", "storage", "battery", "modeling", "simulation"] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: Other/Proprietary License", + "Operating System :: OS Independent", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Physics", +] +dependencies = [ + "pandas==2.3.3", + "numpy==2.2.6", + "datetime==5.5", + "plotly==6.2.0", + "scipy==1.15.3", + "msgpack==1.1.1", + "msgpack-numpy==0.4.8", + "requests>=2.31.0", + "nbformat==5.10.4", + "shapely==2.1.1", + "lz4==4.4.4" +] + +[project.urls] +Homepage = "https://github.com/nicholas9182/steer-core/" +Repository = "https://github.com/nicholas9182/steer-core/" +Issues = "https://github.com/nicholas9182/steer-core/issues" +Documentation = "https://github.com/nicholas9182/steer-core/" + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-cov", + "black", + "flake8", + "mypy", + "isort", +] +test = [ + "pytest>=7.0", + "pytest-cov", +] + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["."] +include = ["steer_core*"] + +[tool.setuptools.package-data] +"steer_core.Data" = ["*.py"] + +[tool.setuptools.dynamic] +version = {attr = "steer_core.__version__"} + +[tool.black] +line-length = 88 +target-version = ['py310'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist +)/ +''' + +[tool.isort] +profile = "black" +multi_line_output = 3 +line_length = 88 +known_first_party = ["steer_core"] + +[tool.pytest.ini_options] +minversion = "7.0" +addopts = "-ra -q --strict-markers" +testpaths = [ + "test", +] +python_files = [ + "test_*.py", + "*_test.py", +] diff --git a/setup.py b/setup.py deleted file mode 100644 index a12b634..0000000 --- a/setup.py +++ /dev/null @@ -1,33 +0,0 @@ -from setuptools import setup, find_packages -import pathlib -import re - -root = pathlib.Path(__file__).parent -init = root / "steer_core" / "__init__.py" -version = re.search(r'__version__\s*=\s*"([^"]+)"', init.read_text()).group(1) - -setup( - name="steer-core", - version=version, - description="Modelling energy storage from cell to site - STEER OpenCell Design", - author="Nicholas Siemons", - author_email="nsiemons@stanford.edu", - url="https://github.com/nicholas9182/steer-core/", - packages=find_packages(), - include_package_data=True, - install_requires=[ - "pandas==2.1.4", - "numpy==1.26.4", - "datetime==5.5", - "scipy==1.15.3", - "plotly==6.2.0", - ], - package_data={"steer_core.Data": ["database.db"]}, - scripts=[], - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - ], - python_requires=">=3.10", -) diff --git a/steer_core/Apps/Components/MaterialSelectors.py b/steer_core/Apps/Components/MaterialSelectors.py deleted file mode 100644 index 33727aa..0000000 --- a/steer_core/Apps/Components/MaterialSelectors.py +++ /dev/null @@ -1,836 +0,0 @@ -import dash as ds -from dash import Input, Output, dcc, html -from typing import Union, List, Dict -from .SliderComponents import SliderWithTextInput - - -class MaterialSelector: - """ - A custom Dash component that combines material selection controls in a horizontal layout. - - This component creates a horizontal row containing: - - A dropdown menu for selecting material names - - An input box for specifying weight fraction of the material - - Two SliderWithTextInput components for specific cost and density - - The component is designed for material composition interfaces where users need to - select materials and specify their properties and proportions. - - Attributes: - id_base (dict): Base identifier dictionary used to construct unique IDs for child components - material_options (List[Dict]): List of material options for the dropdown - default_material (str): Default selected material name - default_weight_fraction (float): Default weight fraction value - cost_config (dict): Configuration for the cost slider (min_val, max_val, step, etc.) - density_config (dict): Configuration for the density slider (min_val, max_val, step, etc.) - property_name (str): Property identifier used in component ID construction - title (str): Display title for the component - slider_disable (bool): Whether the sliders should be disabled - dropdown_id (dict): Computed ID for the dropdown component - weight_fraction_id (dict): Computed ID for the weight fraction input - cost_slider (SliderWithTextInput): Cost slider component - density_slider (SliderWithTextInput): Density slider component - - Example: - >>> material_selector = MaterialSelector( - ... id_base={'type': 'material', 'index': 0}, - ... material_options=[ - ... {'label': 'Aluminum', 'value': 'aluminum'}, - ... {'label': 'Steel', 'value': 'steel'}, - ... {'label': 'Carbon Fiber', 'value': 'carbon_fiber'} - ... ], - ... default_material='aluminum', - ... default_weight_fraction=0.5, - ... cost_config={ - ... 'min_val': 0.0, - ... 'max_val': 100.0, - ... 'step': 0.1, - ... 'mark_interval': 10.0, - ... 'default_val': 25.0, - ... 'title': 'Cost ($/kg)' - ... }, - ... density_config={ - ... 'min_val': 0.0, - ... 'max_val': 10.0, - ... 'step': 0.01, - ... 'mark_interval': 1.0, - ... 'default_val': 2.7, - ... 'title': 'Density (g/cm³)' - ... }, - ... property_name='material_1', - ... title='Material 1' - ... ) - >>> layout_element = material_selector() # Returns Dash HTML Div component - """ - - def __init__( - self, - id_base: dict, - material_options: List[Dict] = None, - slider_configs: dict = None, - cost_config: dict = None, - density_config: dict = None, - title: str = "Material", - default_material: str = None, - default_weight_percent: float = 0, - slider_disable: bool = False, - div_width: str = "100%", - hidden: bool = False, - ): - """ - Initialize the MaterialSelector component. - - Args: - id_base (dict): Base dictionary for generating component IDs. - material_options (List[Dict], optional): List of material options for dropdown. - Each dict should have 'label' and 'value' keys. - If None, defaults to empty list (no options). - slider_configs (dict, optional): Output from create_slider_config with arrays where: - - Index 0 = density configuration - - Index 1 = cost configuration - If provided, cost_config and density_config are ignored. - If empty dictionary {}, uses sensible defaults with preset values. - If None (default), uses sensible defaults with no initial values. - cost_config (dict, optional): Legacy cost slider configuration. Ignored if slider_config provided. - density_config (dict, optional): Legacy density slider configuration. Ignored if slider_config provided. - title (str, optional): Title displayed for the component. Defaults to "Material". - default_material (str, optional): Default selected material. If None, no material - will be initially selected in the dropdown. - default_weight_percent (float, optional): Default weight percentage (0.0-100.0). Defaults to 100.0. - slider_disable (bool, optional): Whether to disable sliders. Defaults to False. - div_width (str, optional): CSS width specification for the container div. - Defaults to '100%'. - hidden (bool, optional): Whether to hide the entire component. When True, - the component will be rendered with display: none. - Defaults to False. - """ - self.id_base = id_base - self.material_options = material_options or [] # Default to empty list if None - self.default_material = default_material # Keep as None if not provided - self.default_weight_percent = default_weight_percent - self.cost_config = cost_config - self.density_config = density_config - self.title = title - self.slider_disable = slider_disable - self.div_width = div_width - self.hidden = hidden - - # Generate component IDs - self.dropdown_id = self._make_id("dropdown") - self.weight_fraction_id = self._make_id("weight_fraction") - - # Normalize configurations to handle both legacy and create_slider_config formats - if slider_configs is not None and slider_configs: # Check if not empty - # Use slider_configs arrays (index 0 = density, index 1 = cost) - density_normalized = self._normalize_config_from_arrays( - slider_configs, 0, "Density (g/cm³)" - ) - cost_normalized = self._normalize_config_from_arrays( - slider_configs, 1, "Specific Cost ($/kg)" - ) - elif slider_configs is not None and not slider_configs: # Empty dictionary - # Use sensible defaults for materials - density_normalized = { - "min_val": 0.0, - "max_val": 0.1, - "step": 0.01, - "mark_interval": 0.1, - "title": "Density (g/cm³)", - "default_val": 0.05, - } - cost_normalized = { - "min_val": 0.0, - "max_val": 0.1, - "step": 0.01, - "mark_interval": 0.1, - "title": "Specific Cost ($/kg)", - "default_val": 0.05, - } - else: - # slider_configs is None - use sensible defaults with None values - density_normalized = { - "min_val": 0.0, - "max_val": 0.1, - "step": 0.01, - "mark_interval": 0.1, - "title": "Density (g/cm³)", - "default_val": None, # No initial value - } - cost_normalized = { - "min_val": 0.0, - "max_val": 0.1, - "step": 0.01, - "mark_interval": 0.1, - "title": "Specific Cost ($/kg)", - "default_val": None, # No initial value - } - - # Create slider components - cost_slider_kwargs = { - "id_base": id_base, - "min_val": cost_normalized["min_val"], - "max_val": cost_normalized["max_val"], - "step": cost_normalized["step"], - "mark_interval": cost_normalized["mark_interval"], - "property_name": "specific_cost", - "title": cost_normalized["title"], - "default_val": cost_normalized.get( - "default_val", cost_normalized["min_val"] - ), - "with_slider_titles": True, - "slider_disable": slider_disable, - "div_width": "100%", - } - # Add marks if available - if "marks" in cost_normalized: - cost_slider_kwargs["marks"] = cost_normalized["marks"] - - self.cost_slider = SliderWithTextInput(**cost_slider_kwargs) - - density_slider_kwargs = { - "id_base": id_base, - "min_val": density_normalized["min_val"], - "max_val": density_normalized["max_val"], - "step": density_normalized["step"], - "mark_interval": density_normalized["mark_interval"], - "property_name": "density", - "title": density_normalized["title"], - "default_val": density_normalized.get( - "default_val", density_normalized["min_val"] - ), - "with_slider_titles": True, - "slider_disable": slider_disable, - "div_width": "100%", - } - # Add marks if available - if "marks" in density_normalized: - density_slider_kwargs["marks"] = density_normalized["marks"] - - self.density_slider = SliderWithTextInput(**density_slider_kwargs) - - def _normalize_config_from_arrays( - self, config: dict, index: int, default_title: str - ) -> dict: - """ - Normalize slider configuration from create_slider_config output arrays. - - Args: - config (dict): create_slider_config output with array values - index (int): Index to extract from each array (0 = density, 1 = cost) - default_title (str): Default title if not provided - - Returns: - dict: Normalized configuration with min_val, max_val, step, mark_interval, title keys - """ - normalized = { - "min_val": config["min_vals"][index], - "max_val": config["max_vals"][index], - "step": config["step_vals"][index], - "title": default_title, # Use provided title - } - - # Get pre-computed marks if available, otherwise calculate mark_interval - if "mark_vals" in config and len(config["mark_vals"]) > index: - marks = config["mark_vals"][index] - normalized["marks"] = marks # Pass the actual marks dictionary - if len(marks) >= 2: - # Calculate interval from first two marks - mark_positions = sorted(marks.keys()) - normalized["mark_interval"] = mark_positions[1] - mark_positions[0] - else: - # Fallback: use step as mark interval - normalized["mark_interval"] = normalized["step"] - else: - normalized["mark_interval"] = normalized["step"] - - # Add default value if available from grid values - if "grid_slider_vals" in config and len(config["grid_slider_vals"]) > index: - normalized["default_val"] = config["grid_slider_vals"][index] - else: - normalized["default_val"] = normalized["min_val"] - - return normalized - - def _normalize_config(self, config: dict, config_type: str) -> dict: - """ - Normalize slider configuration to handle both legacy and create_slider_config formats. - - Args: - config (dict): Either legacy format or create_slider_config output - config_type (str): Type identifier ('cost' or 'density') for default title - - Returns: - dict: Normalized configuration with min_val, max_val, step, mark_interval, title keys - """ - # Check if this is a create_slider_config output (has min_vals, max_vals, etc.) - if "min_vals" in config and "max_vals" in config: - # This is create_slider_config output - use first element from arrays - normalized = { - "min_val": config["min_vals"][0], - "max_val": config["max_vals"][0], - "step": config["step_vals"][0], - "title": config_type.capitalize(), # Default title - } - - # Get pre-computed marks if available, otherwise calculate mark_interval - if "mark_vals" in config and len(config["mark_vals"]) > 0: - marks = config["mark_vals"][0] - normalized["marks"] = marks # Pass the actual marks dictionary - if len(marks) >= 2: - # Calculate interval from first two marks - mark_positions = sorted(marks.keys()) - normalized["mark_interval"] = mark_positions[1] - mark_positions[0] - else: - # Fallback: use step as mark interval - normalized["mark_interval"] = normalized["step"] - else: - normalized["mark_interval"] = normalized["step"] - - # Add default value if available from grid values - if "grid_slider_vals" in config and len(config["grid_slider_vals"]) > 0: - normalized["default_val"] = config["grid_slider_vals"][0] - else: - normalized["default_val"] = normalized["min_val"] - - else: - # This is legacy format - use as-is but ensure all required keys exist - normalized = config.copy() - if "title" not in normalized: - normalized["title"] = config_type.capitalize() - if "default_val" not in normalized: - normalized["default_val"] = normalized.get("min_val", 0) - - return normalized - - def _make_id(self, subtype: str): - """ - Generate a unique ID dictionary for component sub-elements. - - Args: - subtype (str): The specific component subtype. - - Returns: - dict: Complete ID dictionary containing base ID information plus subtype. - """ - return {**self.id_base, "subtype": subtype} - - def _make_dropdown(self): - """ - Create and configure the material selection dropdown. - - Returns: - dash.dcc.Dropdown: Configured dropdown component for material selection. - """ - return dcc.Dropdown( - id=self.dropdown_id, - options=self.material_options, - value=self.default_material, # Can be None for no initial selection - disabled=self.slider_disable, - style={"width": "100%", "margin-bottom": "10px"}, - ) - - def _make_weight_fraction_input(self): - """ - Create and configure the weight percentage input. - - Returns: - dash.dcc.Input: Configured numeric input for weight percentage. - """ - return dcc.Input( - id=self.weight_fraction_id, - type="number", - value=self.default_weight_percent, # Use percentage directly - min=0.0, - max=100.0, - step=0.1, - disabled=self.slider_disable, - style={"width": "100%", "margin-bottom": "10px"}, - ) - - def __call__(self): - """ - Generate the complete component layout as a callable object. - - Creates and returns a Dash HTML Div containing all components arranged - in a horizontal layout with proper spacing and styling. - - Returns: - dash.html.Div: Complete component layout with horizontal arrangement. - """ - return html.Div( - [ - # Main horizontal layout - html.Div( - [ - # Material selection column - html.Div( - [ - html.P( - "Material:", - style={ - "margin": "0px 0px 5px 0px", - "font-weight": "bold", - }, - ), - self._make_dropdown(), - ], - style={ - "width": "20%", - "display": "inline-block", - "vertical-align": "top", - "padding-right": "25px", - }, - ), - # Weight percentage column - html.Div( - [ - html.P( - "Weight (%):", - style={ - "margin": "0px 0px 5px 0px", - "font-weight": "bold", - }, - ), - self._make_weight_fraction_input(), - ], - style={ - "width": "15%", - "display": "inline-block", - "vertical-align": "top", - "padding-right": "25px", - }, - ), - # Density slider column - html.Div( - [self.density_slider()], - style={ - "width": "30%", - "display": "inline-block", - "vertical-align": "top", - "padding-right": "25px", - }, - ), - # Cost slider column - html.Div( - [self.cost_slider()], - style={ - "width": "30%", - "display": "inline-block", - "vertical-align": "top", - }, - ), - ], - style={ - "display": "flex", - "align-items": "flex-start", - "width": "100%", - "gap": "15px", - }, - ) - ], - id=self.id_base, - style={ - "border": "1px solid #ddd", - "border-radius": "5px", - "padding": "15px", - "margin": "10px 0px", - "background-color": "#f9f9f9", - "width": self.div_width, - "display": "none" if self.hidden else "block", - }, - ) - - @property - def components(self): - """ - Get a dictionary mapping component types to their IDs. - - Returns: - dict: Dictionary with component type keys mapping to their ID dictionaries. - """ - return { - "dropdown": self.dropdown_id, - "weight_fraction": self.weight_fraction_id, - "cost_slider": self.cost_slider.slider_id, - "cost_input": self.cost_slider.input_id, - "density_slider": self.density_slider.slider_id, - "density_input": self.density_slider.input_id, - } - - def get_all_inputs(self): - """ - Get Input objects for all component values. - - Returns: - list: List containing Input objects for all components. - """ - return [ - Input(self.dropdown_id, "value"), - Input(self.weight_fraction_id, "value"), - Input(self.cost_slider.slider_id, "value"), - Input(self.cost_slider.input_id, "value"), - Input(self.density_slider.slider_id, "value"), - Input(self.density_slider.input_id, "value"), - ] - - def get_all_outputs(self): - """ - Get Output objects for updating all component values. - - Returns: - list: List containing Output objects for all components. - """ - return [ - Output(self.dropdown_id, "value"), - Output(self.weight_fraction_id, "value"), - Output(self.cost_slider.slider_id, "value"), - Output(self.cost_slider.input_id, "value"), - Output(self.density_slider.slider_id, "value"), - Output(self.density_slider.input_id, "value"), - ] - - -class ActiveMaterialSelector(MaterialSelector): - """ - A custom Dash component for active material selection with capacity controls. - - This component extends MaterialSelector functionality by adding reversible and - irreversible capacity sliders alongside the standard density and cost controls. - It creates a horizontal row containing: - - A dropdown menu for selecting material names - - An input box for specifying weight percentage of the material - - Four SliderWithTextInput components for: - * Density - * Specific cost - * Reversible capacity scaling - * Irreversible capacity scaling - - This component is designed for battery active material interfaces where users need to - specify both physical properties (cost, density) and electrochemical scaling factors - (reversible/irreversible capacity scaling). - """ - - def __init__( - self, - id_base: dict, - material_options: List[Dict] = None, - slider_configs: dict = None, - title: str = "Active Material", - default_material: str = None, - default_weight_percent: float = 0, - slider_disable: bool = False, - div_width: str = "100%", - hidden: bool = False, - ): - """ - Initialize the ActiveMaterialSelector component. - - Args: - id_base (dict): Base dictionary for generating component IDs. - material_options (List[Dict], optional): List of material options for dropdown. - Each dict should have 'label' and 'value' keys. - If None, defaults to empty list (no options). - slider_configs (dict): Output from create_slider_config with arrays where: - - Index 0 = density configuration - - Index 1 = cost configuration - - Index 2 = reversible capacity scaling configuration - - Index 3 = irreversible capacity scaling configuration - If empty dictionary {}, uses sensible defaults with preset values. - If None (default), uses sensible defaults with no initial values. - title (str, optional): Title displayed for the component. Defaults to "Active Material". - default_material (str, optional): Default selected material. If None, no material - will be initially selected in the dropdown. - default_weight_percent (float, optional): Default weight percentage (0.0-100.0). Defaults to 100.0. - slider_disable (bool, optional): Whether to disable sliders. Defaults to False. - div_width (str, optional): CSS width specification for the container div. - Defaults to '100%'. - hidden (bool, optional): Whether to hide the entire component. When True, - the component will be rendered with display: none. - Defaults to False. - """ - # Handle different slider_configs scenarios - if slider_configs is not None and slider_configs: - # Use provided configuration - pass # slider_configs will be passed to parent - elif slider_configs is not None and not slider_configs: - # Empty dictionary - create sensible defaults with preset values - from ..Utils.SliderControls import create_slider_config - - min_vals = [ - 0.0, - 0.0, - 0.0, - 0.0, - ] # density, cost, rev_cap_scaling, irrev_cap_scaling - max_vals = [ - 0.1, - 0.1, - 0.1, - 0.1, - ] # density, cost, rev_cap_scaling, irrev_cap_scaling - default_vals = [ - 0.05, - 0.05, - 0.05, - 0.05, - ] # density, cost, rev_cap_scaling, irrev_cap_scaling - slider_configs = create_slider_config(min_vals, max_vals, default_vals) - else: - # slider_configs is None - create sensible defaults with None values - # Create manual config since create_slider_config doesn't handle None values - slider_configs = { - "min_vals": [0.0, 0.0, 0.0, 0.0], - "max_vals": [0.1, 0.1, 0.1, 0.1], - "step_vals": [0.01, 0.01, 0.01, 0.01], - "grid_slider_vals": [None, None, None, None], - "mark_vals": [ - {0.0: "", 0.1: ""}, # density - {0.0: "", 0.1: ""}, # cost - {0.0: "", 0.1: ""}, # rev_cap_scaling - {0.0: "", 0.1: ""}, # irrev_cap_scaling - ], - } - - # Initialize the parent MaterialSelector with first two sliders (density, cost) - super().__init__( - id_base=id_base, - material_options=material_options, - slider_configs=slider_configs, # Parent will use indices 0 and 1 - title=title, - default_material=default_material, - default_weight_percent=default_weight_percent, - slider_disable=slider_disable, - div_width=div_width, - hidden=hidden, - ) - - # Add the additional capacity sliders (indices 2 and 3) - reversible_capacity_normalized = self._normalize_config_from_arrays( - slider_configs, 2, "Reversible Capacity Scaling" - ) - irreversible_capacity_normalized = self._normalize_config_from_arrays( - slider_configs, 3, "Irreversible Capacity Scaling" - ) - - # Create additional slider components - reversible_capacity_slider_kwargs = { - "id_base": id_base, - "min_val": reversible_capacity_normalized["min_val"], - "max_val": reversible_capacity_normalized["max_val"], - "step": reversible_capacity_normalized["step"], - "mark_interval": reversible_capacity_normalized["mark_interval"], - "property_name": "reversible_capacity_scaling", - "title": reversible_capacity_normalized["title"], - "default_val": reversible_capacity_normalized.get( - "default_val", reversible_capacity_normalized["min_val"] - ), - "with_slider_titles": True, - "slider_disable": slider_disable, - "div_width": "100%", - } - if "marks" in reversible_capacity_normalized: - reversible_capacity_slider_kwargs["marks"] = reversible_capacity_normalized[ - "marks" - ] - self.reversible_capacity_slider = SliderWithTextInput( - **reversible_capacity_slider_kwargs - ) - - irreversible_capacity_slider_kwargs = { - "id_base": id_base, - "min_val": irreversible_capacity_normalized["min_val"], - "max_val": irreversible_capacity_normalized["max_val"], - "step": irreversible_capacity_normalized["step"], - "mark_interval": irreversible_capacity_normalized["mark_interval"], - "property_name": "irreversible_capacity_scaling", - "title": irreversible_capacity_normalized["title"], - "default_val": irreversible_capacity_normalized.get( - "default_val", irreversible_capacity_normalized["min_val"] - ), - "with_slider_titles": True, - "slider_disable": slider_disable, - "div_width": "100%", - } - if "marks" in irreversible_capacity_normalized: - irreversible_capacity_slider_kwargs[ - "marks" - ] = irreversible_capacity_normalized["marks"] - self.irreversible_capacity_slider = SliderWithTextInput( - **irreversible_capacity_slider_kwargs - ) - - def __call__(self): - """ - Generate the complete component layout as a callable object. - - Creates and returns a Dash HTML Div containing all components arranged - in a horizontal layout with proper spacing and styling. - - Returns: - dash.html.Div: Complete component layout with horizontal arrangement. - """ - return html.Div( - [ - # Main horizontal layout - html.Div( - [ - # Material selection column (15%) - html.Div( - [ - html.P( - "Material:", - style={ - "margin": "0px 0px 5px 0px", - "font-weight": "bold", - }, - ), - self._make_dropdown(), - ], - style={ - "width": "15%", - "display": "inline-block", - "vertical-align": "top", - "padding-right": "15px", - }, - ), - # Weight percentage column (15%) - html.Div( - [ - html.P( - "Weight (%):", - style={ - "margin": "0px 0px 5px 0px", - "font-weight": "bold", - }, - ), - self._make_weight_fraction_input(), - ], - style={ - "width": "15%", - "display": "inline-block", - "vertical-align": "top", - "padding-right": "15px", - }, - ), - # Density slider column (17.5%) - html.Div( - [self.density_slider()], - style={ - "width": "17.5%", - "display": "inline-block", - "vertical-align": "top", - "padding-right": "15px", - }, - ), - # Cost slider column (17.5%) - html.Div( - [self.cost_slider()], - style={ - "width": "17.5%", - "display": "inline-block", - "vertical-align": "top", - "padding-right": "15px", - }, - ), - # Reversible capacity slider column (17.5%) - html.Div( - [self.reversible_capacity_slider()], - style={ - "width": "17.5%", - "display": "inline-block", - "vertical-align": "top", - "padding-right": "15px", - }, - ), - # Irreversible capacity slider column (17.5%) - html.Div( - [self.irreversible_capacity_slider()], - style={ - "width": "17.5%", - "display": "inline-block", - "vertical-align": "top", - }, - ), - ], - style={ - "display": "flex", - "align-items": "flex-start", - "width": "100%", - "gap": "10px", - }, - ) - ], - id=self.id_base, - style={ - "border": "1px solid #ddd", - "border-radius": "5px", - "padding": "15px", - "margin": "10px 0px", - "background-color": "#f9f9f9", - "width": self.div_width, - "display": "none" if self.hidden else "block", - }, - ) - - @property - def components(self): - """ - Get a dictionary mapping component types to their IDs. - - Returns: - dict: Dictionary with component type keys mapping to their ID dictionaries. - """ - # Get base components from parent - base_components = super().components - - # Add the additional capacity slider components - base_components.update( - { - "reversible_capacity_scaling_slider": self.reversible_capacity_slider.slider_id, - "reversible_capacity_scaling_input": self.reversible_capacity_slider.input_id, - "irreversible_capacity_scaling_slider": self.irreversible_capacity_slider.slider_id, - "irreversible_capacity_scaling_input": self.irreversible_capacity_slider.input_id, - } - ) - - return base_components - - def get_all_inputs(self): - """ - Get Input objects for all component values. - - Returns: - list: List containing Input objects for all components. - """ - # Get base inputs from parent - base_inputs = super().get_all_inputs() - - # Add the additional capacity slider inputs - capacity_inputs = [ - Input(self.reversible_capacity_slider.slider_id, "value"), - Input(self.reversible_capacity_slider.input_id, "value"), - Input(self.irreversible_capacity_slider.slider_id, "value"), - Input(self.irreversible_capacity_slider.input_id, "value"), - ] - - return base_inputs + capacity_inputs - - def get_all_outputs(self): - """ - Get Output objects for updating all component values. - - Returns: - list: List containing Output objects for all components. - """ - # Get base outputs from parent - base_outputs = super().get_all_outputs() - - # Add the additional capacity slider outputs - capacity_outputs = [ - Output(self.reversible_capacity_slider.slider_id, "value"), - Output(self.reversible_capacity_slider.input_id, "value"), - Output(self.irreversible_capacity_slider.slider_id, "value"), - Output(self.irreversible_capacity_slider.input_id, "value"), - ] - - return base_outputs + capacity_outputs diff --git a/steer_core/Apps/Components/RangeSliderComponents.py b/steer_core/Apps/Components/RangeSliderComponents.py deleted file mode 100644 index c9b28cc..0000000 --- a/steer_core/Apps/Components/RangeSliderComponents.py +++ /dev/null @@ -1,552 +0,0 @@ -import dash as ds -import numpy as np -from typing import Union -from dash import Input, Output - - -class RangeSliderWithTextInput: - """ - A custom Dash component that combines a range slider with two text inputs for synchronized range control. - - This component creates a user interface element consisting of a range slider and two numeric input fields - that can be used together to set numeric ranges. The range slider provides visual feedback and easy - adjustment of both start and end values, while the text inputs allow for precise value entry. All - components are synchronized and share the same value constraints. - - The component is designed for use in Dash applications where users need to input numeric ranges - within a specified domain, with the flexibility of both visual (range slider) and precise (text inputs) - control methods. - - Attributes: - id_base (dict): Base identifier dictionary used to construct unique IDs for child components - min_val (float): Minimum allowed value for both slider and inputs - max_val (float): Maximum allowed value for both slider and inputs - default_val (list[float]): Default range values to display on initialization [start, end] - step (float): Step size for value increments/decrements - mark_interval (float): Interval between tick marks on the slider - property_name (str): Property identifier used in component ID construction - title (str): Display title for the component - with_slider_titles (bool): Whether to show the title above the slider - div_width (str): CSS width specification for the container div - slider_disable (bool): Whether the components should be disabled - message (str): Optional message displayed between title and slider - slider_id (dict): Computed ID for the range slider component - input_start_id (dict): Computed ID for the start value input component - input_end_id (dict): Computed ID for the end value input component - - Example: - >>> range_component = RangeSliderWithTextInput( - ... id_base={'type': 'parameter', 'index': 0}, - ... min_val=0.0, - ... max_val=100.0, - ... step=1.0, - ... mark_interval=10.0, - ... property_name='temperature_range', - ... title='Temperature Range (°C)', - ... default_val=[20.0, 30.0], - ... message='Select optimal temperature range' # Optional message - ... ) - >>> layout_element = range_component() # Returns Dash HTML Div component - """ - - def __init__( - self, - id_base: dict, - min_val: float, - max_val: float, - step: float, - mark_interval: float, - property_name: str, - title: str, - default_val: Union[list[float], None] = None, - with_slider_titles: bool = True, - slider_disable: bool = False, - div_width: str = "calc(90%)", - message: str = None, - ): - """ - Initialize the RangeSliderWithTextInput component. - - Args: - id_base (dict): Base dictionary for generating component IDs. Should contain - identifying information that will be extended with component-specific - subtypes and properties. - min_val (float): Minimum value that can be selected on the slider or entered - in the text inputs. - max_val (float): Maximum value that can be selected on the slider or entered - in the text inputs. - step (float): The granularity of value changes. Determines the smallest - increment/decrement possible. - mark_interval (float): The spacing between tick marks displayed on the slider. - Should be a multiple of step for best visual alignment. - property_name (str): A string identifier for this specific property, used - in ID generation and callbacks. - title (str): Human-readable title displayed above the component. - default_val (list[float], optional): Initial range values to display [start, end]. - If None, defaults to [min_val, max_val]. - with_slider_titles (bool, optional): If True, displays the title above - the slider. If False, shows a non-breaking - space to maintain layout. Defaults to True. - slider_disable (bool, optional): If True, disables slider and input - interactions. Defaults to False. - div_width (str, optional): CSS width specification for the container div. - Defaults to 'calc(90%)'. - message (str, optional): Optional message to display between the title - and slider. If None, no message is displayed. - Defaults to None. - - Raises: - ValueError: If min_val >= max_val, or if step <= 0, or if mark_interval <= 0, - or if default_val contains invalid range values. - TypeError: If default_val is provided but not a list of numeric values. - """ - - # Validate inputs - if min_val >= max_val: - raise ValueError( - f"min_val ({min_val}) must be less than max_val ({max_val})" - ) - if step <= 0: - raise ValueError(f"step ({step}) must be positive") - if mark_interval <= 0: - raise ValueError(f"mark_interval ({mark_interval}) must be positive") - - # Validate and set default values - if default_val is None: - default_val = [min_val, max_val] - else: - if not isinstance(default_val, list) or len(default_val) != 2: - raise TypeError( - "default_val must be a list of two numeric values [start, end]" - ) - try: - default_val = [float(default_val[0]), float(default_val[1])] - except (ValueError, TypeError): - raise TypeError("default_val must contain numeric values") - if default_val[0] > default_val[1]: - raise ValueError("default_val start value must be <= end value") - - self.id_base = id_base - self.min_val = min_val - self.max_val = max_val - self.default_val = self._validate_and_clamp_range(default_val) - self.step = step - self.mark_interval = mark_interval - self.property_name = property_name - self.title = title - self.with_slider_titles = with_slider_titles - self.div_width = div_width - self.slider_disable = slider_disable - self.message = message - - self.slider_id = self._make_id("rangeslider") - self.input_start_id = self._make_id("input_start") - self.input_end_id = self._make_id("input_end") - - def _make_id(self, subtype: str): - """ - Generate a unique ID dictionary for component sub-elements. - - Combines the base ID with component-specific subtype and property information - to create unique identifiers for Dash callbacks and component references. - - Args: - subtype (str): The specific component subtype (e.g., 'rangeslider', 'input_start', 'input_end'). - - Returns: - dict: Complete ID dictionary containing base ID information plus subtype - and property specifications. - - Example: - >>> component._make_id('rangeslider') - {'type': 'parameter', 'index': 0, 'subtype': 'rangeslider', 'property': 'temperature_range'} - """ - return {**self.id_base, "subtype": subtype, "property": self.property_name} - - def _make_range_slider(self): - """ - Create and configure the Dash range slider component. - - Generates a dcc.RangeSlider with the specified range, step size, default values, - and tick marks. The range slider provides visual feedback for range selection - and is synchronized with the text input components. - - Returns: - dash.dcc.RangeSlider: Configured range slider component with ID, value constraints, - tick marks, and styling options. - - Note: - - Tick marks are generated at intervals specified by mark_interval - - updatemode is set to 'mouseup' to reduce callback frequency - - The slider can be disabled via the slider_disable attribute - """ - return ds.dcc.RangeSlider( - id=self.slider_id, - min=self.min_val, - max=self.max_val, - value=self.default_val, - step=self.step, - disabled=self.slider_disable, - marks={ - int(i): "" - for i in np.arange( - self.min_val, self.max_val + self.mark_interval, self.mark_interval - ) - }, - updatemode="mouseup", - tooltip={"placement": "right", "always_visible": False} - ) - - def _make_start_input(self): - """ - Create and configure the Dash numeric input component for the start value. - - Generates a dcc.Input with number type for precise start value entry. - The input is synchronized with the range slider and provides an alternative - method for users to specify exact start values. - - Returns: - dash.dcc.Input: Configured numeric input component with ID, type, - value constraints, styling, and step specification. - - Note: - - Input type is set to 'number' for numeric validation - - Width and margin styling provides visual alignment - - Step size matches the slider for consistent granularity - """ - return ds.dcc.Input( - id=self.input_start_id, - type="number", - value=self.default_val[0], - step=self.step, - style={"width": "80px"}, - disabled=self.slider_disable, - ) - - def _make_end_input(self): - """ - Create and configure the Dash numeric input component for the end value. - - Generates a dcc.Input with number type for precise end value entry. - The input is synchronized with the range slider and provides an alternative - method for users to specify exact end values. - - Returns: - dash.dcc.Input: Configured numeric input component with ID, type, - value constraints, styling, and step specification. - - Note: - - Input type is set to 'number' for numeric validation - - Width and margin styling provides visual alignment - - Step size matches the slider for consistent granularity - """ - return ds.dcc.Input( - id=self.input_end_id, - type="number", - value=self.default_val[1], - step=self.step, - style={"margin-left": "10px", "width": "80px"}, - disabled=self.slider_disable, - ) - - def __call__(self): - """ - Generate the complete component layout as a callable object. - - Creates and returns a Dash HTML Div containing the title, optional message, - range slider, and input components arranged in a cohesive layout. This method allows - the class instance to be used as a callable that returns the complete - component structure. - - Returns: - dash.html.Div: Complete component layout containing: - - Title paragraph (conditional based on with_slider_titles) - - Optional message paragraph (if message is provided) - - Range slider component in a styled container - - Start and end numeric input components with labels - - Spacing elements (line breaks) - - Note: - - Title display is controlled by with_slider_titles attribute - - When title is hidden, a non-breaking space maintains layout - - Message is displayed only if provided during initialization - - Negative bottom margin on slider container reduces spacing - - Container width is controlled by div_width attribute - - Input labels provide clear indication of start/end values - """ - slider_title = self.title if self.with_slider_titles else "\u00A0" - - # Build the component list - components = [ - ds.html.P( - slider_title, style={"margin-left": "20px", "margin-bottom": "0px"} - ) - ] - - # Add optional message if provided - if self.message: - components.append( - ds.html.P( - self.message, - style={ - "margin-left": "20px", - "margin-bottom": "5px", - "margin-top": "2px", - "font-size": "0.9em", - "color": "#666666", - "font-style": "italic", - }, - ) - ) - - # Add slider and input components - components.extend( - [ - ds.html.Div( - [self._make_range_slider()], style={"margin-bottom": "-18px"} - ), - ds.html.Div( - [ - ds.html.Span( - "Start:", - style={"margin-left": "20px", "margin-right": "5px"}, - ), - self._make_start_input(), - ds.html.Span( - "End:", style={"margin-left": "15px", "margin-right": "5px"} - ), - self._make_end_input(), - ], - style={"display": "flex", "align-items": "center"}, - ), - ds.html.Br(), - ds.html.Br(), - ] - ) - - return ds.html.Div( - components, style={"width": self.div_width, "margin-left": "-20px"} - ) - - @property - def components(self): - """ - Get a dictionary mapping component types to their IDs. - - Provides easy access to the IDs of the range slider and input components - for use in Dash callbacks and component interactions. - - Returns: - dict: Dictionary with component type keys mapping to their ID dictionaries. - - Example: - >>> component.components - { - 'rangeslider': {'type': 'parameter', 'subtype': 'rangeslider', 'property': 'temp_range'}, - 'input_start': {'type': 'parameter', 'subtype': 'input_start', 'property': 'temp_range'}, - 'input_end': {'type': 'parameter', 'subtype': 'input_end', 'property': 'temp_range'} - } - - Note: - This property is particularly useful for setting up Dash callbacks - that need to reference the specific component IDs. - """ - return { - "rangeslider": self.slider_id, - "input_start": self.input_start_id, - "input_end": self.input_end_id, - } - - def get_value_inputs(self): - """ - Get Input objects for listening to component value changes. - - Returns a list of Dash Input objects that can be used in callbacks to - listen for value changes from the range slider or input components. - - Returns: - list: List containing [Input(slider_id, 'value'), Input(input_start_id, 'value'), Input(input_end_id, 'value')] - - Example: - >>> @app.callback( - ... Output('some-output', 'children'), - ... component.get_value_inputs() - ... ) - ... def update_display(slider_range, start_val, end_val): - ... return f"Range: {slider_range or [start_val, end_val]}" - """ - return [ - Input(self.slider_id, "value"), - Input(self.input_start_id, "value"), - Input(self.input_end_id, "value"), - ] - - def get_value_outputs(self): - """ - Get Output objects for updating component values. - - Returns a list of Dash Output objects that can be used in callbacks to - update the values of the range slider and input components. - - Returns: - list: List containing [Output(slider_id, 'value'), Output(input_start_id, 'value'), Output(input_end_id, 'value')] - - Example: - >>> @app.callback( - ... component.get_value_outputs(), - ... [Input('some-input', 'value')] - ... ) - ... def update_components(new_range): - ... return [new_range, new_range[0], new_range[1]] - """ - return [ - Output(self.slider_id, "value"), - Output(self.input_start_id, "value"), - Output(self.input_end_id, "value"), - ] - - def get_pattern_matching_value_inputs(self, property_name="ALL"): - """ - Get pattern-matching Input objects for listening to multiple component instances. - - Returns Input objects using pattern-matching to listen to all components - with the specified property name, useful for multi-component callbacks. - - Args: - property_name: The property to match. Use 'ALL' for all properties, - or specify a specific property name. - - Returns: - list: List containing pattern-matching Input objects - - Example: - >>> @app.callback( - ... Output('summary', 'children'), - ... component.get_pattern_matching_value_inputs('ALL') - ... ) - ... def update_summary(slider_ranges, start_values, end_values): - ... return f"Total range sliders: {len(slider_ranges)}" - """ - slider_pattern = { - "type": "parameter", - "subtype": "rangeslider", - "property": property_name, - } - start_pattern = { - "type": "parameter", - "subtype": "input_start", - "property": property_name, - } - end_pattern = { - "type": "parameter", - "subtype": "input_end", - "property": property_name, - } - - return [ - Input(slider_pattern, "value"), - Input(start_pattern, "value"), - Input(end_pattern, "value"), - ] - - def get_pattern_matching_value_outputs(self, property_name="ALL"): - """ - Get pattern-matching Output objects for updating multiple component instances. - - Returns Output objects using pattern-matching to update all components - with the specified property name. - - Args: - property_name: The property to match. Use 'ALL' for all properties, - or specify a specific property name. - - Returns: - list: List containing pattern-matching Output objects - - Example: - >>> @app.callback( - ... component.get_pattern_matching_value_outputs('ALL'), - ... [Input('reset-button', 'n_clicks')] - ... ) - ... def reset_all_components(n_clicks): - ... if n_clicks: - ... return [[[0, 100]] * len(ranges), [0] * len(starts), [100] * len(ends)] - ... return [dash.no_update] * 3 - """ - slider_pattern = { - "type": "parameter", - "subtype": "rangeslider", - "property": property_name, - } - start_pattern = { - "type": "parameter", - "subtype": "input_start", - "property": property_name, - } - end_pattern = { - "type": "parameter", - "subtype": "input_end", - "property": property_name, - } - - return [ - Output(slider_pattern, "value"), - Output(start_pattern, "value"), - Output(end_pattern, "value"), - ] - - def _validate_and_clamp_range(self, range_values): - """ - Validate and clamp range values to the component's valid domain. - - Ensures that range values are within the min_val to max_val domain, - maintains proper ordering (start <= end), and handles invalid values. - - Args: - range_values (list): The range values to validate and clamp [start, end]. - - Returns: - list[float]: The clamped range values within the valid domain, - with proper ordering maintained. - """ - if not isinstance(range_values, list) or len(range_values) != 2: - return [self.min_val, self.max_val] - - try: - start, end = float(range_values[0]), float(range_values[1]) - - # Clamp to valid domain - start = max(self.min_val, min(self.max_val, start)) - end = max(self.min_val, min(self.max_val, end)) - - # Ensure proper ordering - if start > end: - start, end = end, start - - return [start, end] - - except (ValueError, TypeError): - return [self.min_val, self.max_val] - - def _validate_and_clamp_value(self, value): - """ - Validate and clamp a single value to the component's valid range. - - Ensures that any input value is within the min_val to max_val range - and handles None values appropriately. - - Args: - value: The value to validate and clamp. - - Returns: - float: The clamped value within the valid range, or min_val - if the input value is invalid. - """ - if value is None: - return self.min_val - - try: - numeric_value = float(value) - return max(self.min_val, min(self.max_val, numeric_value)) - except (ValueError, TypeError): - return self.min_val diff --git a/steer_core/Apps/Components/SliderComponents.py b/steer_core/Apps/Components/SliderComponents.py deleted file mode 100644 index c4de132..0000000 --- a/steer_core/Apps/Components/SliderComponents.py +++ /dev/null @@ -1,693 +0,0 @@ -import dash as ds -import numpy as np -from typing import Union -from dash import Input, Output - - -class SliderWithTextInput: - """ - A custom Dash component that combines a slider and text input for synchronized value control. - - This component creates a user interface element consisting of a slider and a numeric input field - that can be used together to set numeric values. The slider provides visual feedback and easy - adjustment, while the text input allows for precise value entry. Both components are synchronized - and share the same value constraints. - - The component is designed for use in Dash applications where users need to input numeric values - within a specified range, with the flexibility of both visual (slider) and precise (text input) - control methods. - - Attributes: - id_base (dict): Base identifier dictionary used to construct unique IDs for child components - min_val (float): Minimum allowed value for both slider and input - max_val (float): Maximum allowed value for both slider and input - default_val (Union[float, list[float], None]): Default value to display on initialization - step (float): Step size for value increments/decrements - mark_interval (float): Interval between tick marks on the slider - property_name (str): Property identifier used in component ID construction - title (str): Display title for the component - with_slider_titles (bool): Whether to show the title above the slider - div_width (str): CSS width specification for the container div - slider_disable (bool): Whether the components should be disabled - message (str): Optional message displayed between title and slider - slider_id (dict): Computed ID for the slider component - input_id (dict): Computed ID for the input component - - Example: - >>> slider_component = SliderWithTextInput( - ... id_base={'type': 'parameter', 'index': 0}, - ... property_name='temperature', - ... title='Temperature (°C)', - ... min_val=0.0, - ... max_val=100.0, - ... step=1.0, - ... mark_interval=50.0, - ... default_val=25.0, - ... message='Optimal range is 20-30°C' # Optional message - ... ) - >>> layout_element = slider_component() # Returns Dash HTML Div component - """ - - def __init__( - self, - id_base: dict, - property_name: str, - title: str, - min_val: float = 0.0, - max_val: float = 100.0, - step: float = 1.0, - mark_interval: float = 50.0, - default_val: Union[float, list[float]] = None, - with_slider_titles: bool = True, - slider_disable: bool = False, - div_width: str = "calc(90%)", - message: str = None, - marks: dict = None, - ): - """ - Initialize the SliderWithTextInput component. - - Args: - id_base (dict): Base dictionary for generating component IDs. Should contain - identifying information that will be extended with component-specific - subtypes and properties. - property_name (str): A string identifier for this specific property, used - in ID generation and callbacks. - title (str): Human-readable title displayed above the component. - min_val (float, optional): Minimum value that can be selected on the slider or entered - in the text input. Defaults to 0.0. - max_val (float, optional): Maximum value that can be selected on the slider or entered - in the text input. Defaults to 100.0. - step (float, optional): The granularity of value changes. Determines the smallest - increment/decrement possible. Defaults to 1.0. - mark_interval (float, optional): The spacing between tick marks displayed on the slider. - Should be a multiple of step for best visual alignment. Defaults to 50.0. - default_val (Union[float, list[float]], optional): Initial value to display. - If None, defaults to min_val. Can be a single float or list - for compatibility with range sliders. - with_slider_titles (bool, optional): If True, displays the title above - the slider. If False, shows a non-breaking - space to maintain layout. Defaults to True. - slider_disable (bool, optional): If True, disables both slider and input - interactions. Defaults to False. - div_width (str, optional): CSS width specification for the container div. - Defaults to 'calc(90%)'. - message (str, optional): Optional message to display between the title - and slider. If None, no message is displayed. - Defaults to None. - marks (dict, optional): Pre-computed marks dictionary for the slider. - If provided, these marks will be used instead of - auto-generating marks from mark_interval. - Keys should be numeric positions, values should be labels. - Defaults to None. - - Raises: - ValueError: If min_val >= max_val, or if step <= 0, or if mark_interval <= 0. - TypeError: If default_val is provided but not numeric. - """ - - self.id_base = id_base - self.min_val = min_val - self.max_val = max_val - self.default_val = default_val - self.step = step - self.mark_interval = mark_interval - self.property_name = property_name - self.title = title - self.with_slider_titles = with_slider_titles - self.div_width = div_width - self.slider_disable = slider_disable - self.message = message - self.marks = marks - - self.slider_id = self._make_id("slider") - self.input_id = self._make_id("input") - - def _make_id(self, subtype: str): - """ - Generate a unique ID dictionary for component sub-elements. - - Combines the base ID with component-specific subtype and property information - to create unique identifiers for Dash callbacks and component references. - - Args: - subtype (str): The specific component subtype (e.g., 'slider', 'input'). - - Returns: - dict: Complete ID dictionary containing base ID information plus subtype - and property specifications. - - Example: - >>> component._make_id('slider') - {'type': 'parameter', 'index': 0, 'subtype': 'slider', 'property': 'temperature'} - """ - return {**self.id_base, "subtype": subtype, "property": self.property_name} - - def _make_slider(self): - """ - Create and configure the Dash slider component. - - Generates a dcc.Slider with the specified range, step size, default value, - and tick marks. The slider provides visual feedback for value selection - and is synchronized with the text input component. - - Returns: - dash.dcc.Slider: Configured slider component with ID, value constraints, - tick marks, and styling options. - - Note: - - Uses pre-computed marks if provided, otherwise generates tick marks - at intervals specified by mark_interval - - updatemode is set to 'mouseup' to reduce callback frequency - - The slider can be disabled via the slider_disable attribute - """ - # Use provided marks or generate them from mark_interval - if self.marks is not None: - slider_marks = self.marks - else: - slider_marks = { - int(i): "" - for i in np.arange( - self.min_val, self.max_val + self.mark_interval, self.mark_interval - ) - } - - return ds.dcc.Slider( - id=self.slider_id, - min=self.min_val, - max=self.max_val, - value=self.default_val, - step=self.step, - disabled=self.slider_disable, - marks=slider_marks, - updatemode="mouseup", - tooltip={"placement": "right", "always_visible": False} - ) - - def _make_input(self): - """ - Create and configure the Dash numeric input component. - - Generates a dcc.Input with number type for precise value entry. - The input is synchronized with the slider and provides an alternative - method for users to specify exact values. - - Returns: - dash.dcc.Input: Configured numeric input component with ID, type, - value constraints, styling, and step specification. - - Note: - - Input type is set to 'number' for numeric validation - - Left margin styling provides visual alignment with slider - - Step size matches the slider for consistent granularity - """ - return ds.dcc.Input( - id=self.input_id, - type="number", - value=self.default_val, - style={"margin-left": "20px"}, - step=self.step, - disabled=self.slider_disable, - ) - - def __call__(self): - """ - Generate the complete component layout as a callable object. - - Creates and returns a Dash HTML Div containing the title, optional message, - slider, and input components arranged in a cohesive layout. This method allows - the class instance to be used as a callable that returns the complete - component structure. - - Returns: - dash.html.Div: Complete component layout containing: - - Title paragraph (conditional based on with_slider_titles) - - Optional message paragraph (if message is provided) - - Slider component in a styled container - - Numeric input component - - Spacing elements (line breaks) - - Note: - - Title display is controlled by with_slider_titles attribute - - When title is hidden, a non-breaking space maintains layout - - Message is displayed only if provided during initialization - - Negative bottom margin on slider container reduces spacing - - Container width is controlled by div_width attribute - """ - slider_title = self.title if self.with_slider_titles else "\u00A0" - - # Build the component list - components = [ - ds.html.P( - slider_title, - style={ - "margin-left": "20px", - "margin-bottom": "0px", - "font-weight": "bold", - }, - ) - ] - - # Add optional message if provided - if self.message: - components.append( - ds.html.P( - self.message, - style={ - "margin-left": "20px", - "margin-bottom": "5px", - "margin-top": "2px", - "font-size": "0.9em", - "color": "#666666", - "font-style": "italic", - }, - ) - ) - - # Add slider, input, and breaks - components.extend( - [ - ds.html.Div([self._make_slider()], style={"margin-bottom": "-18px"}), - self._make_input(), - ds.html.Br(), - ds.html.Br(), - ] - ) - - return ds.html.Div( - components, style={"width": self.div_width, "margin-left": "-20px"} - ) - - @property - def components(self): - """ - Get a dictionary mapping component types to their IDs. - - Provides easy access to the IDs of the slider and input components - for use in Dash callbacks and component interactions. - - Returns: - dict: Dictionary with component type keys mapping to their ID dictionaries. - - Example: - >>> component.components - { - 'slider': {'type': 'parameter', 'subtype': 'slider', 'property': 'temperature'}, - 'input': {'type': 'parameter', 'subtype': 'input', 'property': 'temperature'} - } - - Note: - This property is particularly useful for setting up Dash callbacks - that need to reference the specific component IDs. - """ - return {"slider": self.slider_id, "input": self.input_id} - - def get_value_inputs(self): - """ - Get Input objects for listening to component value changes. - - Returns a list of Dash Input objects that can be used in callbacks to - listen for value changes from either the slider or input components. - - Returns: - list: List containing [Input(slider_id, 'value'), Input(input_id, 'value')] - - Example: - >>> @app.callback( - ... Output('some-output', 'children'), - ... component.get_value_inputs() - ... ) - ... def update_display(slider_val, input_val): - ... return f"Value: {slider_val or input_val}" - """ - return [Input(self.slider_id, "value"), Input(self.input_id, "value")] - - def get_value_outputs(self): - """ - Get Output objects for updating component values. - - Returns a list of Dash Output objects that can be used in callbacks to - update the values of both the slider and input components. - - Returns: - list: List containing [Output(slider_id, 'value'), Output(input_id, 'value')] - - Example: - >>> @app.callback( - ... component.get_value_outputs(), - ... [Input('some-input', 'value')] - ... ) - ... def update_components(new_value): - ... return [new_value, new_value] - """ - return [Output(self.slider_id, "value"), Output(self.input_id, "value")] - - def get_pattern_matching_value_inputs(self, property_name="ALL"): - """ - Get pattern-matching Input objects for listening to multiple component instances. - - Returns Input objects using pattern-matching to listen to all components - with the specified property name, useful for multi-component callbacks. - - Args: - property_name: The property to match. Use 'ALL' for all properties, - or specify a specific property name. - - Returns: - list: List containing pattern-matching Input objects - - Example: - >>> @app.callback( - ... Output('summary', 'children'), - ... component.get_pattern_matching_value_inputs('ALL') - ... ) - ... def update_summary(slider_values, input_values): - ... return f"Total sliders: {len(slider_values)}" - """ - pattern = {"type": "parameter", "subtype": "slider", "property": property_name} - input_pattern = { - "type": "parameter", - "subtype": "input", - "property": property_name, - } - - return [Input(pattern, "value"), Input(input_pattern, "value")] - - def get_pattern_matching_value_outputs(self, property_name="ALL"): - """ - Get pattern-matching Output objects for updating multiple component instances. - - Returns Output objects using pattern-matching to update all components - with the specified property name. - - Args: - property_name: The property to match. Use 'ALL' for all properties, - or specify a specific property name. - - Returns: - list: List containing pattern-matching Output objects - - Example: - >>> @app.callback( - ... component.get_pattern_matching_value_outputs('ALL'), - ... [Input('reset-button', 'n_clicks')] - ... ) - ... def reset_all_components(n_clicks): - ... if n_clicks: - ... return [[0] * len(slider_values), [0] * len(input_values)] - ... return [dash.no_update, dash.no_update] - """ - pattern = {"type": "parameter", "subtype": "slider", "property": property_name} - input_pattern = { - "type": "parameter", - "subtype": "input", - "property": property_name, - } - - return [Output(pattern, "value"), Output(input_pattern, "value")] - - def _validate_and_clamp_value(self, value): - """ - Validate and clamp a value to the component's valid range. - - Ensures that any input value is within the min_val to max_val range - and handles None values appropriately. - - Args: - value: The value to validate and clamp. - - Returns: - float: The clamped value within the valid range, or the current - default_val if the input value is invalid. - """ - if value is None: - return self.default_val if self.default_val is not None else self.min_val - - try: - numeric_value = float(value) - return max(self.min_val, min(self.max_val, numeric_value)) - except (ValueError, TypeError): - return self.default_val if self.default_val is not None else self.min_val - - -class SliderWithTextInputAndCheckbox(SliderWithTextInput): - """ - A custom Dash component that extends SliderWithTextInput by adding a checkbox with a message. - - This component inherits all functionality from SliderWithTextInput and adds a checkbox - positioned beneath the input field. The checkbox can be used to enable/disable features, - toggle options, or provide additional control over the parameter being adjusted. - - The checkbox includes an associated message that describes its purpose or provides - additional context for the user. The checkbox is larger than standard size and has - proper spacing between the checkbox and its label text. - - Attributes: - checkbox_message (str): The message displayed next to the checkbox - checkbox_default (bool): Default checked state of the checkbox - checkbox_id (dict): Computed ID for the checkbox component - - Note: - The checkbox value is [True] when checked, [] when unchecked. - - Example: - >>> slider_component = SliderWithTextInputAndCheckbox( - ... id_base={'type': 'parameter', 'index': 0}, - ... property_name='temperature', - ... title='Temperature (°C)', - ... checkbox_message='Use automatic temperature control', - ... min_val=0.0, - ... max_val=100.0, - ... step=1.0, - ... mark_interval=50.0, - ... default_val=25.0, - ... checkbox_default=True - ... ) - >>> layout_element = slider_component() # Returns Dash HTML Div component - """ - - def __init__( - self, - id_base: dict, - property_name: str, - title: str, - checkbox_message: str, - min_val: float = 0.0, - max_val: float = 100.0, - step: float = 1.0, - mark_interval: float = 50.0, - default_val: Union[float, list[float]] = None, - checkbox_default: bool = False, - with_slider_titles: bool = True, - slider_disable: bool = False, - div_width: str = "calc(90%)", - message: str = None, - ): - """ - Initialize the SliderWithTextInputAndCheckbox component. - - Args: - id_base (dict): Base dictionary for generating component IDs. - property_name (str): String identifier for this property. - title (str): Title displayed above the component. - checkbox_message (str): Message displayed next to the checkbox. - min_val (float, optional): Minimum value for the slider and input. Defaults to 0.0. - max_val (float, optional): Maximum value for the slider and input. Defaults to 100.0. - step (float, optional): Step size for value increments/decrements. Defaults to 1.0. - mark_interval (float, optional): Interval between tick marks on the slider. Defaults to 50.0. - default_val (Union[float, list[float]], optional): Initial value for slider/input. - checkbox_default (bool, optional): Initial checked state of checkbox. Defaults to False. - with_slider_titles (bool, optional): Whether to show title. Defaults to True. - slider_disable (bool, optional): Whether to disable interactions. Defaults to False. - div_width (str, optional): CSS width for container. Defaults to 'calc(90%)'. - message (str, optional): Optional message between title and slider. - """ - # Initialize parent class - super().__init__( - id_base=id_base, - property_name=property_name, - title=title, - min_val=min_val, - max_val=max_val, - step=step, - mark_interval=mark_interval, - default_val=default_val, - with_slider_titles=with_slider_titles, - slider_disable=slider_disable, - div_width=div_width, - message=message, - ) - - # Add checkbox-specific attributes - self.checkbox_message = checkbox_message - self.checkbox_default = checkbox_default - self.checkbox_id = self._make_id("checkbox") - - def _make_checkbox(self): - """ - Create and configure the Dash checkbox component. - - Returns: - dash.html.Div: Container with a larger checkbox and separated label text. - """ - return ds.html.Div( - [ - ds.dcc.Checklist( - id=self.checkbox_id, - options=[{"label": "", "value": True}], - value=[True] if self.checkbox_default else [], - style={ - "display": "inline-block", - "margin-right": "10px", - "transform": "scale(1.3)", # Make checkbox larger - "transform-origin": "left top", # Changed to 'top' for better alignment - "vertical-align": "baseline", # Use baseline alignment - }, - labelStyle={"margin": "0px"}, - ), - ds.html.Span( - self.checkbox_message, - style={ - "display": "inline-block", - "vertical-align": "baseline", # Match checkbox baseline - "margin-left": "5px", - "line-height": "1.3", # Slightly higher line height - "margin-top": "-4px", # Pull text up more aggressively - }, - ), - ], - style={"margin-left": "20px", "margin-top": "10px", "line-height": "1"}, - ) - - def __call__(self): - """ - Generate the complete component layout including the checkbox. - - Creates and returns a Dash HTML Div containing the title, optional message, - slider, input, and checkbox components arranged in a cohesive layout. - - Returns: - dash.html.Div: Complete component layout containing all elements plus checkbox. - """ - slider_title = self.title if self.with_slider_titles else "\u00A0" - - # Build the component list - components = [ - ds.html.P( - slider_title, style={"margin-left": "20px", "margin-bottom": "0px"} - ) - ] - - # Add optional message if provided - if self.message: - components.append( - ds.html.P( - self.message, - style={ - "margin-left": "20px", - "margin-bottom": "5px", - "margin-top": "2px", - "font-size": "0.9em", - "color": "#666666", - "font-style": "italic", - }, - ) - ) - - # Add slider and input - components.extend( - [ - ds.html.Div([self._make_slider()], style={"margin-bottom": "-18px"}), - self._make_input(), - ] - ) - - # Add checkbox beneath the input - components.extend([self._make_checkbox(), ds.html.Br(), ds.html.Br()]) - - return ds.html.Div( - components, style={"width": self.div_width, "margin-left": "-20px"} - ) - - @property - def components(self): - """ - Get a dictionary mapping component types to their IDs, including the checkbox. - - Returns: - dict: Dictionary with component type keys mapping to their ID dictionaries. - """ - return { - "slider": self.slider_id, - "input": self.input_id, - "checkbox": self.checkbox_id, - } - - def get_checkbox_input(self): - """ - Get Input object for listening to checkbox value changes. - - Returns: - Input: Dash Input object for the checkbox component. - - Note: - The checkbox value will be [True] when checked, [] when unchecked. - - Example: - >>> @app.callback( - ... Output('some-output', 'children'), - ... component.get_checkbox_input() - ... ) - ... def update_display(checkbox_value): - ... is_checked = True in (checkbox_value or []) - ... return f"Checkbox is {'checked' if is_checked else 'unchecked'}" - """ - return Input(self.checkbox_id, "value") - - def get_checkbox_output(self): - """ - Get Output object for updating checkbox value. - - Returns: - Output: Dash Output object for the checkbox component. - - Note: - To check the checkbox, return [True]. To uncheck, return []. - - Example: - >>> @app.callback( - ... component.get_checkbox_output(), - ... [Input('some-button', 'n_clicks')] - ... ) - ... def toggle_checkbox(n_clicks): - ... if n_clicks and n_clicks % 2 == 1: - ... return [True] - ... return [] - """ - return Output(self.checkbox_id, "value") - - def get_all_inputs(self): - """ - Get Input objects for all component values (slider, input, and checkbox). - - Returns: - list: List containing Input objects for slider, input, and checkbox. - - Note: - The checkbox value will be [True] when checked, [] when unchecked. - - Example: - >>> @app.callback( - ... Output('summary', 'children'), - ... component.get_all_inputs() - ... ) - ... def update_summary(slider_val, input_val, checkbox_val): - ... is_checked = True in (checkbox_val or []) - ... value = slider_val or input_val - ... return f"Value: {value}, Option enabled: {is_checked}" - """ - return [ - Input(self.slider_id, "value"), - Input(self.input_id, "value"), - Input(self.checkbox_id, "value"), - ] diff --git a/steer_core/Apps/Components/__init__.py b/steer_core/Apps/Components/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/steer_core/Apps/ContextManagers.py b/steer_core/Apps/ContextManagers.py deleted file mode 100644 index d6b34a1..0000000 --- a/steer_core/Apps/ContextManagers.py +++ /dev/null @@ -1,61 +0,0 @@ -import warnings -import time -from contextlib import contextmanager -from typing import List, Dict - - -@contextmanager -def capture_warnings( - existing_warnings: List[Dict] = None, - source: str = None, - clear_source_warnings: bool = True, -): - """ - Context manager to capture warnings and format them for Dash. - - Parameters - ---------- - existing_warnings : List[Dict], optional - Existing warnings to append to - source : str, optional - Source identifier for the warnings - clear_source_warnings : bool, optional - Whether to clear existing warnings from the same source before adding new ones - - Yields - ------ - List[Dict] - The combined list of warnings (existing + new) - """ - if existing_warnings is None: - existing_warnings = [] - - # Clear existing warnings from the same source if requested - if clear_source_warnings and source: - existing_warnings = [w for w in existing_warnings if w.get("source") != source] - - with warnings.catch_warnings(record=True) as warnings_list: - warnings.simplefilter("always") - - # Yield the cleaned warnings list - yield existing_warnings - - # Process any new warnings after the operation - new_warnings = [] - for w in warnings_list: - new_warnings.append( - { - "message": str(w.message), - "category": w.category.__name__, - "filename": w.filename, - "lineno": w.lineno, - "timestamp": time.time(), - "source": source or "unknown", - "id": f"{source}_{int(time.time())}" - if source - else f"warning_{int(time.time())}", - } - ) - - # Add new warnings - existing_warnings.extend(new_warnings) diff --git a/steer_core/Apps/Performance/CallbackTimer.py b/steer_core/Apps/Performance/CallbackTimer.py deleted file mode 100644 index 2e846c9..0000000 --- a/steer_core/Apps/Performance/CallbackTimer.py +++ /dev/null @@ -1,18 +0,0 @@ -from functools import wraps -import time - - -def timed_callback(func): - """Time callback execution for optimization.""" - - @wraps(func) - def wrapper(*args, **kwargs): - start = time.time() - result = func(*args, **kwargs) - duration = time.time() - start - - print(f"CALLBACK: {func.__name__} took {duration:.2f}s") - - return result - - return wrapper diff --git a/steer_core/Apps/Performance/__init__.py b/steer_core/Apps/Performance/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/steer_core/Apps/Utils/SliderControls.py b/steer_core/Apps/Utils/SliderControls.py deleted file mode 100644 index 0cdf232..0000000 --- a/steer_core/Apps/Utils/SliderControls.py +++ /dev/null @@ -1,728 +0,0 @@ -""" -Utility functions for slider controls and parameter management. - -This module provides helper functions for automatically determining appropriate -slider configurations based on parameter ranges. -""" - -import math -from typing import List, Union, Dict - - -def calculate_slider_steps( - min_values: List[float], max_values: List[float] -) -> List[float]: - """ - Calculate reasonable slider steps based on parameter ranges. - - This function analyzes the range of each parameter and suggests an appropriate - step size that provides good granularity. The step sizes are the same as input - steps to ensure consistent behavior between sliders and input fields: - - Ranges < 100: step = 0.01 - - Ranges 100-999: step = 0.1 - - Ranges 1000+: step = 1.0 - - Args: - min_values (List[float]): List of minimum values for each parameter - max_values (List[float]): List of maximum values for each parameter - - Returns: - List[float]: List of recommended step sizes for each parameter - - Raises: - ValueError: If lists have different lengths or if any max < min - - Examples: - >>> min_vals = [0, 0, 0, 0] - >>> max_vals = [10, 100, 1000, 0.1] - >>> steps = calculate_slider_steps(min_vals, max_vals) - >>> steps - [0.01, 0.1, 1.0, 0.01] - - >>> # Temperature range - >>> steps = calculate_slider_steps([20], [100]) - >>> steps[0] - 0.01 - """ - if len(min_values) != len(max_values): - raise ValueError("min_values and max_values must have the same length") - - steps = [] - - for min_val, max_val in zip(min_values, max_values): - if max_val < min_val: - raise ValueError( - f"max_value ({max_val}) cannot be less than min_value ({min_val})" - ) - - # Calculate the range - range_val = max_val - min_val - - if range_val == 0: - # If no range, use a very small step - steps.append(0.001) - continue - - # Calculate step based on range magnitude - step = _calculate_step_for_range(range_val) - steps.append(step) - - return steps - - -def _calculate_step_for_range(range_val: float) -> float: - """ - Calculate an appropriate step size for a given range. - - Uses the same step sizes as input steps to ensure sliders and inputs - have consistent granularity: - - Ranges < 100: step = 0.01 - - Ranges 100-999: step = 0.1 - - Ranges 1000+: step = 1.0 - - Args: - range_val (float): The range (max - min) for the parameter - - Returns: - float: Recommended step size - """ - if range_val <= 0: - return 0.01 - - # Use the same logic as input steps for consistency - if range_val < 100: - return 0.01 - elif range_val < 1000: - return 0.1 - else: - return 1.0 - - -def _calculate_input_step_for_range(range_val: float) -> float: - """ - Calculate an appropriate input step size for a given range. - - Input steps are finer than slider steps to allow more precise control: - - Ranges < 100: input step = 0.01 - - Ranges 100-999: input step = 0.1 - - Ranges 1000+: input step = 1.0 - - Args: - range_val (float): The range (max - min) for the parameter - - Returns: - float: Recommended input step size - """ - if range_val < 100: - return 0.01 - elif range_val < 1000: - return 0.1 - else: - return 1.0 - - -def calculate_input_steps( - min_values: List[float], max_values: List[float] -) -> List[float]: - """ - Calculate appropriate input step sizes based on parameter ranges. - - Input steps are typically finer than slider steps to allow precise adjustment. - - Args: - min_values (List[float]): List of minimum values for each parameter - max_values (List[float]): List of maximum values for each parameter - - Returns: - List[float]: List of recommended input step sizes for each parameter - """ - if len(min_values) != len(max_values): - raise ValueError("min_values and max_values must have the same length") - - input_steps = [] - - for min_val, max_val in zip(min_values, max_values): - if max_val < min_val: - raise ValueError( - f"max_value ({max_val}) cannot be less than min_value ({min_val})" - ) - - # Calculate the range - range_val = max_val - min_val - - # Calculate input step based on range magnitude - input_step = _calculate_input_step_for_range(range_val) - input_steps.append(input_step) - - return input_steps - - -def calculate_mark_intervals( - min_values: List[float], max_values: List[float], target_marks: int = 5 -) -> List[float]: - """ - Calculate mark intervals for sliders based on range magnitude. - - Creates marks at regular intervals that align with intuitive values while - avoiding overcrowding by ensuring no more than ~5-6 marks per slider: - - Range < 0.5: marks on every 0.1, but max 5 marks - - Range < 1: marks on every 0.2 (interval = 0.2) - - Range < 5: marks on every 1.0 (interval = 1.0) - - Range < 10: marks on every 2.0 (interval = 2.0) - - Range < 50: marks on every 10.0 (interval = 10.0) - - Range < 100: marks on every 20.0 (interval = 20.0) - - Range < 500: marks on every 100.0 (interval = 100.0) - - Range < 1000: marks on every 200.0 (interval = 200.0) - - Range >= 1000: marks on every 500.0 (interval = 500.0) - - Args: - min_values (List[float]): List of minimum values for each parameter - max_values (List[float]): List of maximum values for each parameter - target_marks (int): Target number of marks to display (unused, kept for compatibility) - - Returns: - List[float]: List of recommended mark intervals for each parameter - - Examples: - >>> min_vals = [0, 0, 0, 0] - >>> max_vals = [0.3, 2, 250, 2000] - >>> intervals = calculate_mark_intervals(min_vals, max_vals) - >>> intervals - [0.1, 1.0, 100.0, 500.0] - """ - if len(min_values) != len(max_values): - raise ValueError("min_values and max_values must have the same length") - - intervals = [] - - for min_val, max_val in zip(min_values, max_values): - range_val = max_val - min_val - - if range_val == 0: - intervals.append(1.0) - continue - - # Determine interval based on range magnitude to avoid overcrowding - if range_val < 0.5: - # Very small ranges: use 0.1 but ensure max 5 marks - interval = 0.1 - num_marks = range_val / interval - if num_marks > 5: - interval = range_val / 4 # Limit to ~4 marks - elif range_val < 1: - interval = 0.2 # Every 0.2 for ranges like 0.5-0.9 - elif range_val < 5: - interval = 1.0 # Every integer for small ranges - elif range_val < 10: - interval = 2.0 # Every 2 units - elif range_val < 50: - interval = 10.0 # Every multiple of 10 - elif range_val < 100: - interval = 20.0 # Every multiple of 20 - elif range_val < 500: - interval = 100.0 # Every multiple of 100 - elif range_val < 1000: - interval = 200.0 # Every multiple of 200 - else: - interval = 500.0 # Every multiple of 500 for very large ranges - - intervals.append(interval) - - return intervals - - -def create_slider_config( - min_values: List[float], - max_values: List[float], - property_values: List[float] = None, -) -> dict: - """ - Create complete slider configurations with automatically calculated steps and marks. - - This is a convenience function that combines step and mark interval calculations - to create ready-to-use slider configurations. If property values are provided, - they will be validated and adjusted to align with the calculated slider grid. - - Step sizes are calculated based on the magnitude of the maximum values (0 to max) - rather than the range (min to max), providing consistent granularity regardless - of the minimum value. - - Args: - min_values (List[float]): Minimum values for each slider - max_values (List[float]): Maximum values for each slider - property_values (List[float], optional): Current property values to validate - against slider grid. If provided, values - will be snapped to nearest valid step. - - Returns: - dict: Configuration dictionary with keys: - - 'min_vals' (List[float]): Minimum values for each slider (snapped to grid, >= original min) - - 'max_vals' (List[float]): Maximum values for each slider (snapped to grid, <= original max) - - 'step_vals' (List[float]): Calculated step values based on max value magnitude - - 'input_step_vals' (List[float]): Step values for inputs (same as slider steps) - - 'mark_vals' (List[dict]): Mark positions for each slider (string position → empty string mapping) - - 'grid_slider_vals' (List[float], optional): Property values snapped to slider grid if provided - - 'grid_input_vals' (List[float], optional): Property values snapped to input grid if provided - - Example: - >>> config = create_slider_config( - ... min_values=[0, 20, 0], - ... max_values=[100, 80, 1000], - ... property_values=[23.7, 45.3, 567.8] - ... ) - >>> # Step sizes based on max values: 100->0.1, 80->0.01, 1000->1.0 - >>> config['step_vals'] - [0.1, 0.01, 1.0] - >>> config['input_step_vals'] - [0.1, 0.01, 1.0] - >>> config['grid_vals'][0] # 23.7 snapped to grid - 23.7 - """ - # Validate inputs - if len(min_values) != len(max_values): - raise ValueError("min_values and max_values must have the same length") - - if property_values is not None and len(property_values) != len(min_values): - raise ValueError( - "property_values must have the same length as min_values and max_values" - ) - - # Calculate steps and mark intervals based on max values (0 to max) instead of range - steps = [] - input_steps = [] - for max_val in max_values: - # Calculate step based on maximum value magnitude (0 to max) - step = _calculate_step_for_range(max_val) - input_step = _calculate_input_step_for_range(max_val) - steps.append(step) - input_steps.append(input_step) - - # Snap min and max values to the slider grid (keeping within original range) - grid_min_values = [] - grid_max_values = [] - for i, (min_val, max_val, step) in enumerate(zip(min_values, max_values, steps)): - # For min value: round up to nearest grid point (ceil) to stay >= min_val - if min_val % step == 0: - grid_min = min_val - else: - grid_min = min_val + (step - (min_val % step)) - - # For max value: round down to nearest grid point (floor) to stay <= max_val - grid_max = max_val - (max_val % step) if max_val % step != 0 else max_val - - # Ensure we have a valid range (grid_min <= grid_max) - if grid_min > grid_max: - # If rounding inward creates invalid range, use original values - grid_min = min_val - grid_max = max_val - - grid_min_values.append(grid_min) - grid_max_values.append(grid_max) - - mark_intervals = calculate_mark_intervals(grid_min_values, grid_max_values) - - # Create mark dictionaries for each slider - mark_vals = [] - grid_slider_vals = [] - grid_input_vals = [] - - for i, (min_val, max_val, step, input_step, mark_interval) in enumerate( - zip(grid_min_values, grid_max_values, steps, input_steps, mark_intervals) - ): - # Generate marks dictionary for this slider (positions only, no labels) - marks = {} - - # Find the first mark position that's a multiple of mark_interval - # Start from the nearest multiple of mark_interval >= min_val - if mark_interval > 0: - first_mark = math.ceil(min_val / mark_interval) * mark_interval - current_mark = first_mark - - while current_mark <= max_val: - # Round to avoid floating-point precision issues - rounded_mark = round(current_mark, 10) # Round to 10 decimal places - marks[str(rounded_mark)] = "" # Convert float key to string, no label - current_mark += mark_interval - - # Ensure we don't exceed max_val due to floating point precision - if current_mark > max_val + mark_interval * 0.01: - break - - mark_vals.append(marks) - - # Handle property value if provided - if property_values is not None: - property_val = property_values[i] - # Snap to both slider grid (coarser) and input grid (finer) without clamping to range - slider_grid_value = snap_to_grid_no_clamp(property_val, min_val, step) - input_grid_value = snap_to_grid_no_clamp(property_val, min_val, input_step) - grid_slider_vals.append(slider_grid_value) - grid_input_vals.append(input_grid_value) - - # Create the configuration dictionary - config = { - "min_vals": grid_min_values, - "max_vals": grid_max_values, - "step_vals": steps, - "input_step_vals": input_steps, - "mark_vals": mark_vals, - } - - # Add grid values if property values were provided - if property_values is not None: - config["grid_slider_vals"] = grid_slider_vals - config["grid_input_vals"] = grid_input_vals - - return config - - -def create_range_slider_config( - min_values: List[float], - max_values: List[float], - lower_values: List[float] = None, - upper_values: List[float] = None, -) -> dict: - """ - Create complete range slider configurations with automatically calculated steps and marks. - - This is a convenience function that combines step and mark interval calculations - to create ready-to-use range slider configurations. If lower and upper values are provided, - they will be validated, adjusted to ensure proper ordering, and aligned with the - calculated slider grids. - - Args: - min_values (List[float]): Minimum values for each range slider - max_values (List[float]): Maximum values for each range slider - lower_values (List[float], optional): Current lower bound values for each range slider. - Will be snapped to grid and validated. - upper_values (List[float], optional): Current upper bound values for each range slider. - Will be snapped to grid and validated. - - Returns: - dict: Complete configuration dictionary containing: - - 'min_vals' (List[float]): Minimum values for each range slider (snapped to grid, >= original min) - - 'max_vals' (List[float]): Maximum values for each range slider (snapped to grid, <= original max) - - 'step_vals' (List[float]): Calculated step values for each range slider (same as input steps) - - 'input_step_vals' (List[float]): Step values for inputs (same as slider steps) - - 'mark_vals' (List[dict]): Mark positions for each range slider (position → '' mapping) - - 'grid_slider_vals' (List[tuple], optional): Range values snapped to slider grid if provided [(lower, upper), ...] - - 'grid_input_vals' (List[tuple], optional): Range values snapped to input grid if provided [(lower, upper), ...] - - Example: - >>> config = create_range_slider_config( - ... min_values=[0, 20, 0], - ... max_values=[100, 80, 1000], - ... lower_values=[23.7, 45.3, 167.8], - ... upper_values=[67.2, 55.1, 834.5] - ... ) - >>> config['step_vals'] - [0.01, 0.01, 1.0] - >>> config['input_step_vals'] - [0.01, 0.01, 1.0] - >>> config['grid_slider_vals'][0] # (23.7, 67.2) snapped to slider grid - (23.7, 67.2) - """ - # Validate inputs - if len(min_values) != len(max_values): - raise ValueError("min_values and max_values must have the same length") - - if lower_values is not None and len(lower_values) != len(min_values): - raise ValueError( - "lower_values must have the same length as min_values and max_values" - ) - - if upper_values is not None and len(upper_values) != len(min_values): - raise ValueError( - "upper_values must have the same length as min_values and max_values" - ) - - # Both lower_values and upper_values must be provided together or not at all - if (lower_values is None) != (upper_values is None): - raise ValueError( - "lower_values and upper_values must both be provided or both be None" - ) - - # Calculate steps and mark intervals - steps = calculate_slider_steps( - min_values, max_values - ) # Use the calculated steps directly - - input_steps = calculate_input_steps( - min_values, max_values - ) # Calculate input steps based on range - - # Snap min and max values to the slider grid (keeping within original range) - grid_min_values = [] - grid_max_values = [] - for i, (min_val, max_val, step) in enumerate(zip(min_values, max_values, steps)): - # For min value: round up to nearest grid point (ceil) to stay >= min_val - if min_val % step == 0: - grid_min = min_val - else: - grid_min = min_val + (step - (min_val % step)) - - # For max value: round down to nearest grid point (floor) to stay <= max_val - grid_max = max_val - (max_val % step) if max_val % step != 0 else max_val - - # Ensure we have a valid range (grid_min <= grid_max) - if grid_min > grid_max: - # If rounding inward creates invalid range, use original values - grid_min = min_val - grid_max = max_val - - grid_min_values.append(grid_min) - grid_max_values.append(grid_max) - - mark_intervals = calculate_mark_intervals(grid_min_values, grid_max_values) - - # Generate marks for each slider - mark_vals = [] - for min_val, max_val, interval in zip( - grid_min_values, grid_max_values, mark_intervals - ): - marks = {} - - # Find the first mark position that's a multiple of interval - # Start from the nearest multiple of interval >= min_val - if interval > 0: - first_mark = math.ceil(min_val / interval) * interval - current = first_mark - - while current <= max_val: - # Round to avoid floating-point precision issues - rounded_mark = round(current, 10) # Round to 10 decimal places - marks[str(rounded_mark)] = "" # Convert float key to string, empty string for clean appearance - current += interval - - mark_vals.append(marks) - - # Process property values if provided - grid_slider_vals = [] - grid_input_vals = [] - - if lower_values is not None and upper_values is not None: - for i, (min_val, max_val, step, input_step, lower_val, upper_val) in enumerate( - zip( - grid_min_values, - grid_max_values, - steps, - input_steps, - lower_values, - upper_values, - ) - ): - # Ensure proper ordering of lower/upper values - actual_lower = min(lower_val, upper_val) - actual_upper = max(lower_val, upper_val) - - # Snap to grids - lower_slider_grid = snap_to_grid_no_clamp(actual_lower, min_val, step) - upper_slider_grid = snap_to_grid_no_clamp(actual_upper, min_val, step) - lower_input_grid = snap_to_grid_no_clamp(actual_lower, min_val, input_step) - upper_input_grid = snap_to_grid_no_clamp(actual_upper, min_val, input_step) - - # Store as tuples - grid_slider_vals.append((lower_slider_grid, upper_slider_grid)) - grid_input_vals.append((lower_input_grid, upper_input_grid)) - - # Create the configuration dictionary - config = { - "min_vals": grid_min_values, - "max_vals": grid_max_values, - "step_vals": steps, - "input_step_vals": input_steps, - "mark_vals": mark_vals, - } - - # Add grid values if property values were provided - if lower_values is not None and upper_values is not None: - config["grid_slider_vals"] = grid_slider_vals - config["grid_input_vals"] = grid_input_vals - - return config - - -def snap_to_slider_grid( - value: float, min_val: float, max_val: float, step: float -) -> float: - """ - Snap a value to the nearest valid position on a slider grid. - - This ensures that property values align with the discrete steps of a slider, - preventing issues with values that fall between valid slider positions. - - Args: - value (float): The value to snap to the grid - min_val (float): Minimum value of the slider range - max_val (float): Maximum value of the slider range - step (float): Step size of the slider - - Returns: - float: Value snapped to the nearest valid slider position - - Examples: - >>> # Snap 23.7 to a slider with step 0.1 - >>> snap_to_slider_grid(23.7, 0, 100, 0.1) - 23.7 - - >>> # Snap 23.75 to a slider with step 0.1 - >>> snap_to_slider_grid(23.75, 0, 100, 0.1) - 23.8 - - >>> # Clamp value outside range - >>> snap_to_slider_grid(150, 0, 100, 1.0) - 100.0 - """ - # First clamp to range - clamped_value = max(min_val, min(max_val, value)) - - # Calculate offset from minimum - offset = clamped_value - min_val - - # Round to nearest step - num_steps = round(offset / step) - - # Calculate snapped value - snapped_value = min_val + (num_steps * step) - - # Ensure we stay within bounds (floating point precision issues) - snapped_value = max(min_val, min(max_val, snapped_value)) - - # Round to appropriate precision based on step size - if step >= 1: - return round(snapped_value) - else: - decimal_places = max(0, -math.floor(math.log10(step))) - return round(snapped_value, decimal_places) - - -def snap_to_grid_no_clamp(value: float, min_val: float, step: float) -> float: - """ - Snap a value to the nearest grid position without clamping to range. - - This function snaps values to the grid defined by min_val and step, - but does NOT clamp values to stay within a min/max range. This allows - property values outside the slider range to maintain their relative - position on the extended grid. - - Args: - value (float): The value to snap to the grid - min_val (float): Minimum value of the slider range (grid reference point) - step (float): Step size of the grid - - Returns: - float: Value snapped to the nearest valid grid position - - Examples: - >>> # Snap value within range - >>> snap_to_grid_no_clamp(23.75, 0, 0.1) - 23.8 - - >>> # Snap value outside range (not clamped) - >>> snap_to_grid_no_clamp(150.3, 0, 0.1) - 150.3 - - >>> # Snap value below range (not clamped) - >>> snap_to_grid_no_clamp(-5.67, 0, 0.1) - -5.7 - """ - # Calculate offset from minimum (no clamping) - offset = value - min_val - - # Round to nearest step - num_steps = round(offset / step) - - # Calculate snapped value - snapped_value = min_val + (num_steps * step) - - # Round to appropriate precision based on step size - if step >= 1: - return round(snapped_value) - else: - decimal_places = max(0, -math.floor(math.log10(step))) - return round(snapped_value, decimal_places) - - -def format_slider_value(value: float, step: float) -> str: - """ - Format a slider value for display based on the step size. - - Automatically determines appropriate decimal places based on the step size - to avoid displaying unnecessary precision. - - Args: - value (float): The value to format - step (float): The step size of the slider - - Returns: - str: Formatted value string - - Examples: - >>> format_slider_value(123.456, 1.0) - '123' - >>> format_slider_value(123.456, 0.1) - '123.5' - >>> format_slider_value(123.456, 0.01) - '123.46' - """ - if step >= 1: - return f"{value:.0f}" - else: - # Determine decimal places based on step size - decimal_places = max(0, -math.floor(math.log10(step))) - return f"{value:.{decimal_places}f}" - - -def are_slider_input_values_incompatible( - slider_value: float, input_value: float, slider_step: float, input_step: float -) -> bool: - """ - Check if slider and input values are incompatible with each other. - - Two values are considered incompatible if there is no single original value - that would snap to both the given slider grid value AND the given input grid value. - This typically happens when the slider value and input value represent different - original values that have been snapped to their respective grids. - - Args: - slider_value (float): The value from the slider grid - input_value (float): The value from the input grid - slider_step (float): Step size for the slider grid - input_step (float): Step size for the input grid - - Returns: - bool: True if the values are incompatible, False if they could represent - the same original value snapped to different grids - - Examples: - >>> # Compatible values: both could come from original value 12.36 - >>> are_slider_input_values_incompatible(12.4, 12.36, 0, 0.1, 0.01) - False - - >>> # Incompatible values: no single value could snap to both - >>> are_slider_input_values_incompatible(12.3, 12.36, 0, 0.1, 0.01) - True - - >>> # Compatible values: both could come from original value 12.35 - >>> are_slider_input_values_incompatible(12.4, 12.35, 0, 0.1, 0.01) - False - """ - # Calculate the range of original values that would snap to the slider_value - slider_half_step = slider_step / 2.0 - slider_min_original = slider_value - slider_half_step - slider_max_original = slider_value + slider_half_step - - # Calculate the range of original values that would snap to the input_value - input_half_step = input_step / 2.0 - input_min_original = input_value - input_half_step - input_max_original = input_value + input_half_step - - # Check if the ranges overlap - # If they overlap, there exists at least one original value that would snap to both - overlap_start = max(slider_min_original, input_min_original) - overlap_end = min(slider_max_original, input_max_original) - - # If overlap_start > overlap_end, there's no overlap (incompatible) - # Use a small tolerance for floating-point comparison - tolerance = 1e-12 - return overlap_start > overlap_end + tolerance diff --git a/steer_core/Apps/Utils/__init__.py b/steer_core/Apps/Utils/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/steer_core/Apps/__init__.py b/steer_core/Apps/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/steer_core/Constants/Format.py b/steer_core/Constants/Format.py new file mode 100644 index 0000000..41f5d65 --- /dev/null +++ b/steer_core/Constants/Format.py @@ -0,0 +1,6 @@ +## DateTime format strings +# Hourly-aligned datetime format (e.g., "2024-01-15-14") +DEFAULT_HOUR_FMT = "%Y-%m-%d-%H" + +# Daily-aligned datetime format (e.g., "2024-01-15") +DEFAULT_DAY_FMT = "%Y-%m-%d" diff --git a/steer_core/Constants/Units.py b/steer_core/Constants/Units.py index e1fc4ee..c6adbe4 100644 --- a/steer_core/Constants/Units.py +++ b/steer_core/Constants/Units.py @@ -11,6 +11,7 @@ MG_TO_KG = 1e-6 KG_TO_MG = 1e6 M_TO_UM = 1e6 +MM_TO_UM = 1e3 UM_TO_M = 1e-6 MM_TO_CM = 1e-1 CM_TO_MM = 1e1 @@ -19,6 +20,18 @@ G_TO_mG = 1e3 CM_TO_UM = 1e4 UM_TO_CM = 1e-4 +KG_TO_T = 1e-3 +T_TO_KG = 1e3 +T_TO_MT = 1e-6 +MT_TO_T = 1e6 +LB_TO_KG = 0.453592 +KG_TO_LB = 1 / 0.453592 +T_TO_SHORT_TON = 1.10231 +SHORT_TON_TO_T = 1 / 1.10231 +LB_TO_SHORT_TON = 1 / 2000 +SHORT_TON_TO_LB = 2000 +LB_TO_T = 1 / 2000 * 1/1.10231 + # Current units A_TO_mA = 1e3 @@ -31,9 +44,24 @@ MIN_TO_S = 60 S_TO_Y = 1 / (3600 * 24 * 365) Y_TO_S = 3600 * 24 * 365 +H_TO_Y = 1 / 8760 +Y_TO_H = 8760 +S_TO_D = 1 / (3600 * 24) +D_TO_S = 3600 * 24 +H_TO_D = 1 / 24 +H_TO_W = 1 / (24 * 7) +Y_TO_M = 12 + +D_TO_W = 1 / 7 +AVG_D_TO_MONTH = 12 / 365.25 +AVG_D_TO_Y = 1 / 365.25 +Y_TO_AVG_D = 365.25 + # Energy units W_TO_KW = 1e-3 +J_TO_WH = 1 / 3600 +KW_TO_W = 1e3 # Angle units DEG_TO_RAD = 0.017453292519943295 @@ -41,7 +69,23 @@ # Percentage units PERCENT_TO_FRACTION = 1e-2 FRACTION_TO_PERCENT = 1e2 +FRACTION_TO_PPM = 1e6 +PPM_TO_FRACTION = 1e-6 # Volume units L_TO_M3 = 1e-3 -M3_TO_L = 1e3 \ No newline at end of file +M3_TO_L = 1e3 +GAL_TO_L = 3.78541 +L_TO_GAL = 1 / 3.78541 +MMGAL_TO_GAL = 1e-6 +GAL_TO_MMGAL = 1e6 + +# Composite energy conversions +ENERGY_CONVERSION_FACTOR = S_TO_H # J/s → Wh +VOLUMETRIC_ENERGY_CONVERSION = S_TO_H / M_TO_DM**3 # J/m³ → Wh/L +NORMALISED_COST_CONVERSION = 1 / (ENERGY_CONVERSION_FACTOR * W_TO_KW) # → $/kWh + + + + + diff --git a/steer_core/Constants/Universal.py b/steer_core/Constants/Universal.py index e38839f..6c00cc4 100644 --- a/steer_core/Constants/Universal.py +++ b/steer_core/Constants/Universal.py @@ -1,2 +1,8 @@ ## Constants PI = 3.14159265358979323846 +TWO_PI = 2.0 * PI + +# Molar Masses +MW_G_PER_MOL_NO2 = 46.0055 +MW_G_PER_MOL_SO2 = 64.0638 +MW_G_PER_MOL_CO2 = 44.0095 \ No newline at end of file diff --git a/steer_core/ContextManagers/__init__.py b/steer_core/ContextManagers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/steer_core/Data/DataManager.py b/steer_core/Data/DataManager.py new file mode 100644 index 0000000..4844f7c --- /dev/null +++ b/steer_core/Data/DataManager.py @@ -0,0 +1,549 @@ +from __future__ import annotations + +import logging +import os +import re +import time +import urllib.parse +from pathlib import Path + +import pandas as pd +import requests + +from steer_core.Constants.Units import H_TO_S, mA_TO_A, G_TO_KG + +logger = logging.getLogger("steer_core.DataManager") +logger.setLevel(logging.DEBUG) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setFormatter(logging.Formatter("[%(name)s] %(message)s")) + logger.addHandler(_handler) + +MATERIAL_TABLES = { + "anode_materials", + "cathode_materials", + "binder_materials", + "conductive_additive_materials", + "current_collector_materials", + "insulation_materials", + "separator_materials", + "tape_materials", + "prismatic_container_materials", +} + +CELL_TABLES = {"cell_references", "teardowns", "user_designs", "cell_submissions"} + +ALL_TABLES = MATERIAL_TABLES | CELL_TABLES + +_MATERIAL_META_COLS = ["name", "date", "version", "reference"] +_CELL_META_COLS = [ + "name", + "form_factor", + "internal_construction", + "date_created", + "version", + "chemistry", + "visibility", + "owner_id", +] + +class DataManagerError(Exception): + """Base exception for DataManager errors.""" + +class APIError(DataManagerError): + """Unexpected API error (5xx or unrecognised status).""" + +class AuthenticationError(DataManagerError): + """401 — token missing or invalid.""" + +class ForbiddenError(DataManagerError): + """403 — insufficient permissions.""" + +class NotFoundError(DataManagerError): + """404 — resource does not exist.""" + +class ConflictError(DataManagerError): + """409 — name already taken.""" + +class DataManager: + """Drop-in replacement for the SQLite-based DataManager. + + Talks to the OpenCell REST API (Lambda) instead of a local database. + All existing ``from_database()`` call sites work without changes. + """ + + _token: str | None = None + + def __init__(self, jwt_token: str | None = None): + self._api_url = os.environ.get("API_URL") + if not self._api_url: + raise DataManagerError( + "API_URL environment variable is required. " + "Set it to the base URL of the OpenCell REST API " + "(e.g. https://api.opencell.example.com/production)." + ) + self._api_url = self._api_url.rstrip("/") + self._timeout = int(os.environ.get("API_TIMEOUT", "30")) + self._session = requests.Session() + if jwt_token: + DataManager._token = jwt_token + + # -- Context manager (no-op) ------------------------------------------- + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + return False + + def close(self) -> None: + pass + + # -- Token management -------------------------------------------------- + + @classmethod + def set_token(cls, token: str | None) -> None: + """Set the JWT token used for authenticated API requests.""" + cls._token = token + + # -- Internal helpers -------------------------------------------------- + + def _headers(self, auth_required: bool = False) -> dict: + headers: dict[str, str] = {} + if self._token: + headers["Authorization"] = f"Bearer {self._token}" + elif auth_required: + raise AuthenticationError( + "JWT token required for this operation. " + "Call DataManager.set_token(token) first." + ) + return headers + + @staticmethod + def _classify_table(table_name: str) -> str: + """Return ``'materials'`` or ``'cells'`` based on *table_name*.""" + if table_name in MATERIAL_TABLES: + return "materials" + if table_name in CELL_TABLES: + return "cells" + raise ValueError(f"Unknown table: {table_name}") + + @staticmethod + def _parse_condition(condition: str) -> tuple[str, str]: + """Parse ``"name = 'LFP'"`` → ``('name', 'LFP')``. + + This is the only condition format used by ``from_database()``. + """ + match = re.match(r"(\w+)\s*=\s*'([^']*)'", condition.strip()) + if not match: + raise ValueError(f"Cannot parse condition: {condition}") + return match.group(1), match.group(2) + + def _request(self, method: str, path: str, auth_required: bool = False, **kwargs): + """Make an HTTP request and return parsed JSON (or *None* for 204).""" + url = f"{self._api_url}{path}" + t0 = time.perf_counter() + resp = self._session.request( + method, + url, + headers=self._headers(auth_required), + timeout=self._timeout, + **kwargs, + ) + elapsed = (time.perf_counter() - t0) * 1000 + logger.info("[API] %s %s -> %d (%d ms)", method, path, resp.status_code, elapsed) + if resp.status_code == 204: + return None + if resp.status_code == 401: + raise AuthenticationError("Authentication required") + if resp.status_code == 403: + msg = "Forbidden" + try: + msg = resp.json().get("error", msg) + except (ValueError, KeyError): + pass + raise ForbiddenError(msg) + if resp.status_code == 404: + msg = "Not found" + try: + msg = resp.json().get("error", msg) + except (ValueError, KeyError): + pass + raise NotFoundError(msg) + if resp.status_code == 409: + msg = "Conflict" + try: + msg = resp.json().get("error", msg) + except (ValueError, KeyError): + pass + raise ConflictError(msg) + if resp.status_code >= 400: + raise APIError(f"HTTP {resp.status_code}: {resp.text}") + return resp.json() + + def _download_blob(self, download_url: str) -> bytes: + """Download serialized object bytes from a presigned S3 URL.""" + t0 = time.perf_counter() + resp = self._session.get(download_url, timeout=self._timeout) + resp.raise_for_status() + elapsed = (time.perf_counter() - t0) * 1000 + logger.info("[S3] Downloaded %.1f KB in %d ms", len(resp.content) / 1024, elapsed) + return resp.content + + @staticmethod + def _encode(name: str) -> str: + return urllib.parse.quote(name, safe="") + + # -- Read operations --------------------------------------------------- + + def get_data( + self, + table_name: str, + columns: list[str] | None = None, + condition: str | list[str] | None = None, + latest_column: str | None = None, + ) -> pd.DataFrame: + """Retrieve data from the API. + + When *condition* is provided (the ``from_database()`` path), fetches a + single item **including** the serialized object blob. The ``object`` + column contains raw ``bytes`` — identical to what SQLite returned. + + When called **without** a condition, returns metadata-only rows (no + ``object`` column). + """ + resource_type = self._classify_table(table_name) + auth = resource_type == "cells" + + if condition is not None: + return self._get_data_with_condition( + table_name, resource_type, columns, condition, latest_column, auth + ) + + # -- Listing (no condition) ---------------------------------------- + data = self._request("GET", f"/{resource_type}/{table_name}", auth_required=False) + items = data.get("items", []) + if not items: + return pd.DataFrame() + + df = pd.DataFrame(items) + + if latest_column and latest_column in df.columns: + df = df.sort_values(latest_column, ascending=False).head(1).reset_index(drop=True) + + if columns: + available = [c for c in columns if c in df.columns] + df = df[available] + + return df + + def _get_data_with_condition( + self, + table_name: str, + resource_type: str, + columns: list[str] | None, + condition: str | list[str], + latest_column: str | None, + auth: bool, + ) -> pd.DataFrame: + """Handle ``get_data()`` when a condition is supplied.""" + # Normalise condition list to a single dict of field→value + if isinstance(condition, list): + parsed = dict(self._parse_condition(c) for c in condition) + else: + field, value = self._parse_condition(condition) + parsed = {field: value} + + name = parsed.get("name") + if name is None: + raise ValueError( + f"Condition must include 'name': {condition}" + ) + + encoded_name = self._encode(name) + data = self._request( + "GET", + f"/{resource_type}/{table_name}/{encoded_name}", + auth_required=False, + ) + + # Download the object blob via presigned URL + download_url = data.get("download_url") + if not download_url: + raise APIError( + f"No download_url in response for {table_name}/{name}" + ) + blob = self._download_blob(download_url) + + # Build a single-row DataFrame matching the SQLite schema + row: dict = {"name": data["name"], "object": blob} + + if resource_type == "materials": + row["date"] = data.get("date") + row["version"] = data.get("version") + row["reference"] = data.get("reference") + else: + row["form_factor"] = data.get("form_factor") + row["internal_construction"] = data.get("internal_construction") + row["date_created"] = data.get("date_created") + row["version"] = data.get("version") + row["chemistry"] = data.get("chemistry") + + df = pd.DataFrame([row]) + + if latest_column and latest_column in df.columns: + df = df.sort_values(latest_column, ascending=False).head(1).reset_index(drop=True) + + if columns: + available = [c for c in columns if c in df.columns] + df = df[available] + + return df + + def get_unique_values(self, table_name: str, column_name: str) -> list: + """Return unique values for *column_name* from the listing endpoint.""" + resource_type = self._classify_table(table_name) + data = self._request("GET", f"/{resource_type}/{table_name}", auth_required=False) + items = data.get("items", []) + seen: set = set() + result: list = [] + for item in items: + val = item.get(column_name) + if val is not None and val not in seen: + seen.add(val) + result.append(val) + return result + + def get_table_names(self) -> list[str]: + return sorted(ALL_TABLES) + + # -- Material-specific getters ----------------------------------------- + + def _get_materials(self, table_name: str, most_recent: bool = True) -> pd.DataFrame: + params = {} + if not most_recent: + params["most_recent"] = "false" + data = self._request( + "GET", f"/materials/{table_name}", auth_required=False, params=params + ) + items = data.get("items", []) + if not items: + return pd.DataFrame(columns=_MATERIAL_META_COLS) + df = pd.DataFrame(items) + # Keep only the standard metadata columns (in order) + available = [c for c in _MATERIAL_META_COLS if c in df.columns] + return df[available].reset_index(drop=True) + + def get_current_collector_materials(self, most_recent: bool = True) -> pd.DataFrame: + return self._get_materials("current_collector_materials", most_recent) + + def get_insulation_materials(self, most_recent: bool = True) -> pd.DataFrame: + return self._get_materials("insulation_materials", most_recent) + + def get_cathode_materials(self, most_recent: bool = True) -> pd.DataFrame: + return self._get_materials("cathode_materials", most_recent) + + def get_anode_materials(self, most_recent: bool = True) -> pd.DataFrame: + return self._get_materials("anode_materials", most_recent) + + def get_binder_materials(self, most_recent: bool = True) -> pd.DataFrame: + return self._get_materials("binder_materials", most_recent) + + def get_conductive_additive_materials(self, most_recent: bool = True) -> pd.DataFrame: + return self._get_materials("conductive_additive_materials", most_recent) + + def get_separator_materials(self, most_recent: bool = True) -> pd.DataFrame: + return self._get_materials("separator_materials", most_recent) + + def get_tape_materials(self, most_recent: bool = True) -> pd.DataFrame: + return self._get_materials("tape_materials", most_recent) + + def get_prismatic_container_materials(self, most_recent: bool = True) -> pd.DataFrame: + return self._get_materials("prismatic_container_materials", most_recent) + + # -- Write operations -------------------------------------------------- + + def insert_data(self, table_name: str, data: pd.DataFrame) -> None: + """Save a cell to the API. + + Extracts ``name`` and ``object`` (serialized blob) from the DataFrame, + then uploads via presigned URL. + """ + if data.empty: + return + + columns = set(data.columns) + meta_cols = {"form_factor", "internal_construction", "chemistry", "version"} + + for row in data.to_dict("records"): + name = row["name"] + encoded_name = self._encode(name) + + body: dict = {"update_object": True} + for col in meta_cols & columns: + if pd.notna(row[col]): + body[col] = row[col] + + resp = self._request( + "PUT", + f"/cells/{table_name}/{encoded_name}", + auth_required=True, + json=body, + ) + + upload_url = resp.get("upload_url") + if upload_url and "object" in columns and row.get("object") is not None: + blob = row["object"] + if isinstance(blob, str): + blob = blob.encode("latin-1") + upload_resp = self._session.put( + upload_url, + data=blob, + headers={"Content-Type": "application/octet-stream"}, + timeout=self._timeout, + ) + upload_resp.raise_for_status() + + def remove_data(self, table_name: str, condition: str) -> None: + """Delete a cell via the API.""" + _, name = self._parse_condition(condition) + encoded_name = self._encode(name) + self._request( + "DELETE", + f"/cells/{table_name}/{encoded_name}", + auth_required=True, + ) + + def create_table(self, table_name: str, columns: dict): + raise NotImplementedError("create_table() is not supported by the REST API") + + def drop_table(self, table_name: str): + raise NotImplementedError("drop_table() is not supported by the REST API") + + # -- New operations (fork / publish / name check) ---------------------- + + def fork_cell(self, source_table: str, source_name: str, new_name: str) -> dict: + """Fork *source_name* into ``user_designs`` with *new_name*. + + Returns the new cell metadata dict. Raises ``ConflictError`` if + *new_name* is already taken. + """ + encoded = self._encode(source_name) + return self._request( + "POST", + f"/cells/{source_table}/{encoded}/fork", + auth_required=True, + json={"name": new_name}, + ) + + def publish_cell( + self, + source_table: str, + source_name: str, + new_name: str, + target_table: str | None = None, + ) -> dict: + """Publish *source_name* with *new_name*. + + Admin only. Raises ``ConflictError`` if *new_name* is taken. + + Parameters + ---------- + target_table : str, optional + Destination table (``"cell_references"`` or ``"teardowns"``). + Defaults to ``"cell_references"`` on the server side. + """ + encoded = self._encode(source_name) + body: dict = {"name": new_name} + if target_table: + body["target_table"] = target_table + return self._request( + "POST", + f"/cells/{source_table}/{encoded}/publish", + auth_required=True, + json=body, + ) + + def submit_cell(self, source_table: str, source_name: str) -> dict: + """Submit a cell from *source_table* for admin review. + + Copies the cell to ``cell_submissions``. Only the owner (non-admin) + can submit. Returns the new submission metadata. + """ + encoded = self._encode(source_name) + return self._request( + "POST", + f"/cells/{source_table}/{encoded}/submit", + auth_required=True, + ) + + def reject_cell(self, table_name: str, name: str) -> None: + """Reject (hard-delete) a submission from *table_name*. + + Admin only. The DynamoDB item and S3 object are permanently removed. + """ + encoded = self._encode(name) + self._request( + "POST", + f"/cells/{table_name}/{encoded}/reject", + auth_required=True, + ) + + def check_name_available(self, name: str) -> bool: + """Return ``True`` if *name* is available across all cell tables.""" + encoded = self._encode(name) + data = self._request("GET", f"/cells/check-name/{encoded}", auth_required=False) + return data.get("available", False) + + # -- Static utility (kept from SQLite DataManager) --------------------- + # steer-opencell-data/steer_opencell_data/DataManager.py + @staticmethod + def read_half_cell_curve(half_cell_path: str | Path) -> pd.DataFrame: + """Read a half-cell voltage curve from a local CSV file. + + Parameters + ---------- + half_cell_path : str or Path + Path to the CSV file. + + Returns + ------- + pd.DataFrame + Columns: specific_capacity, voltage, step_id. + """ + try: + data = pd.read_csv(half_cell_path) + except FileNotFoundError: + raise FileNotFoundError(f"Could not find the file at {half_cell_path}") + except Exception as e: + raise ValueError(f"Error reading file at {half_cell_path}: {str(e)}") + + if "Specific Capacity (mAh/g)" not in data.columns: + raise ValueError( + "The file must have a column named 'Specific Capacity (mAh/g)'" + ) + if "Voltage (V)" not in data.columns: + raise ValueError("The file must have a column named 'Voltage (V)'") + if "Step_ID" not in data.columns: + raise ValueError("The file must have a column named 'Step_ID'") + + data = ( + data.rename( + columns={ + "Specific Capacity (mAh/g)": "specific_capacity", + "Voltage (V)": "voltage", + "Step_ID": "step_id", + } + ) + .assign( + specific_capacity=lambda x: x["specific_capacity"] + * (H_TO_S * mA_TO_A / G_TO_KG) + ) + .filter(["specific_capacity", "voltage", "step_id"]) + .groupby(["specific_capacity", "step_id"], group_keys=False)["voltage"] + .max() + .reset_index() + .sort_values(["step_id", "specific_capacity"]) + ) + + return data diff --git a/steer_core/Data/__init__.py b/steer_core/Data/__init__.py index e69de29..0bd1e4d 100644 --- a/steer_core/Data/__init__.py +++ b/steer_core/Data/__init__.py @@ -0,0 +1,17 @@ +import os + +from .DataManager import DataManager + + +def is_development() -> bool: + """Check if running in development mode (local SQLite). + + Controlled by the ``OPENCELL_ENV`` environment variable: + + - ``development`` — local SQLite database via ``steer_opencell_data``, + no login button, guest-only functionality. Use this when developing + new cells locally before publishing via the CLI migration tool. + - ``production`` (default) — REST API via ``steer_core.Data.DataManager``, + requires ``API_URL`` env var. Full auth flow with Cognito. + """ + return os.environ.get("OPENCELL_ENV", "production").lower() == "development" diff --git a/steer_core/Data/database.db b/steer_core/Data/database.db deleted file mode 100644 index e53a1be..0000000 Binary files a/steer_core/Data/database.db and /dev/null differ diff --git a/steer_core/DataManager.py b/steer_core/DataManager.py deleted file mode 100644 index c310e84..0000000 --- a/steer_core/DataManager.py +++ /dev/null @@ -1,319 +0,0 @@ -import sqlite3 as sql -from pathlib import Path -import pandas as pd -import importlib.resources - -from steer_core.Constants.Units import * - - -class DataManager: - def __init__(self): - with importlib.resources.path("steer_core.Data", "database.db") as db_path: - self._db_path = db_path - self._connection = sql.connect(self._db_path) - self._cursor = self._connection.cursor() - - def create_table(self, table_name: str, columns: dict): - """ - Function to create a table in the database. - - :param table_name: Name of the table. - :param columns: Dictionary of columns and their types. - """ - columns_str = ", ".join([f"{k} {v}" for k, v in columns.items()]) - self._cursor.execute(f"CREATE TABLE IF NOT EXISTS {table_name} ({columns_str})") - self._connection.commit() - - def drop_table(self, table_name: str): - """ - Function to drop a table from the database. - - :param table_name: Name of the table. - """ - self._cursor.execute(f"DROP TABLE IF EXISTS {table_name}") - self._connection.commit() - - def get_table_names(self): - """ - Function to get the names of all tables in the database. - - :return: List of table names. - """ - self._cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") - return [row[0] for row in self._cursor.fetchall()] - - def insert_data(self, table_name: str, data: pd.DataFrame): - """ - Inserts data into the database only if it doesn’t already exist. - - :param table_name: Name of the table. - :param data: DataFrame containing the data to insert. - """ - for _, row in data.iterrows(): - conditions = " AND ".join([f"{col} = ?" for col in data.columns]) - check_query = f"SELECT COUNT(*) FROM {table_name} WHERE {conditions}" - - self._cursor.execute(check_query, tuple(row)) - if self._cursor.fetchone()[0] == 0: # If the row does not exist, insert it - insert_query = f"INSERT INTO {table_name} ({', '.join(data.columns)}) VALUES ({', '.join(['?'] * len(row))})" - self._cursor.execute(insert_query, tuple(row)) - - self._connection.commit() - - def get_data( - self, - table_name: str, - columns: list = None, - condition: str | list[str] = None, - latest_column: str = None, - ): - """ - Retrieve data from the database. - - :param table_name: Name of the table. - :param columns: List of columns to retrieve. If None, retrieves all columns. - :param condition: Optional condition (single string or list of conditions). - :param latest_column: Column name to find the most recent row. - """ - # If columns is not provided, get all columns from the table - if columns is None: - self._cursor.execute(f"PRAGMA table_info({table_name})") - columns_info = self._cursor.fetchall() - columns = [col[1] for col in columns_info] # Extract column names - if not columns: - raise ValueError( - f"Table '{table_name}' does not exist or has no columns." - ) - - columns_str = ", ".join(columns) - query = f"SELECT {columns_str} FROM {table_name}" - - # Add condition if specified - if condition: - if isinstance(condition, list): - condition_str = " AND ".join(condition) - else: - condition_str = condition - query += f" WHERE {condition_str}" - - # If latest_column is provided, get the most recent entry - if latest_column: - query += f" ORDER BY {latest_column} DESC LIMIT 1" - - # Execute and return the result - self._cursor.execute(query) - data = self._cursor.fetchall() - - return pd.DataFrame(data, columns=columns) - - def get_unique_values(self, table_name: str, column_name: str): - """ - Retrieves all unique values from a specified column. - - :param table_name: The name of the table. - :param column_name: The column to retrieve unique values from. - :return: A list of unique values. - """ - query = f"SELECT DISTINCT {column_name} FROM {table_name}" - self._cursor.execute(query) - return [row[0] for row in self._cursor.fetchall()] - - def get_current_collector_materials(self, most_recent: bool = True) -> pd.DataFrame: - """ - Retrieves current collector materials from the database. - - :param most_recent: If True, returns only the most recent entry. - :return: DataFrame with current collector materials. - """ - data = ( - self.get_data(table_name="current_collector_materials") - .groupby("name", group_keys=False) - .apply( - lambda x: x.sort_values("date", ascending=False).head(1) - if most_recent - else x - ) - .reset_index(drop=True) - ) - - return data - - def get_insulation_materials(self, most_recent: bool = True) -> pd.DataFrame: - """ - Retrieves insulation materials from the database. - - :param most_recent: If True, returns only the most recent entry. - :return: DataFrame with insulation materials. - """ - data = ( - self.get_data(table_name="insulation_materials") - .groupby("name", group_keys=False) - .apply( - lambda x: x.sort_values("date", ascending=False).head(1) - if most_recent - else x - ) - .reset_index(drop=True) - ) - - return data - - def get_cathode_materials(self, most_recent: bool = True) -> pd.DataFrame: - """ - Retrieves cathode materials from the database. - - :param most_recent: If True, returns only the most recent entry. - :return: DataFrame with cathode materials. - """ - data = ( - self.get_data(table_name="cathode_materials") - .groupby("name", group_keys=False) - .apply( - lambda x: x.sort_values("date", ascending=False).head(1) - if most_recent - else x - ) - .reset_index(drop=True) - ) - - return data - - def get_anode_materials(self, most_recent: bool = True) -> pd.DataFrame: - """ - Retrieves anode materials from the database. - - :param most_recent: If True, returns only the most recent entry. - :return: DataFrame with anode materials. - """ - data = ( - self.get_data(table_name="anode_materials") - .groupby("name", group_keys=False) - .apply( - lambda x: x.sort_values("date", ascending=False).head(1) - if most_recent - else x - ) - .reset_index(drop=True) - ) - - return data - - def get_binder_materials(self, most_recent: bool = True) -> pd.DataFrame: - """ - Retrieves binder materials from the database. - - :param most_recent: If True, returns only the most recent entry. - :return: DataFrame with binder materials. - """ - data = ( - self.get_data(table_name="binder_materials") - .groupby("name", group_keys=False) - .apply( - lambda x: x.sort_values("date", ascending=False).head(1) - if most_recent - else x - ) - .reset_index(drop=True) - ) - - return data - - def get_conductive_additive_materials( - self, most_recent: bool = True - ) -> pd.DataFrame: - """ - Retrieves conductive additives from the database. - - :param most_recent: If True, returns only the most recent entry. - :return: DataFrame with conductive additives. - """ - data = ( - self.get_data(table_name="conductive_additive_materials") - .groupby("name", group_keys=False) - .apply( - lambda x: x.sort_values("date", ascending=False).head(1) - if most_recent - else x - ) - .reset_index(drop=True) - ) - - return data - - def get_separator_materials(self, most_recent: bool = True) -> pd.DataFrame: - """ - Retrieves separator materials from the database. - - :param most_recent: If True, returns only the most recent entry. - :return: DataFrame with separator materials. - """ - data = ( - self.get_data(table_name="separator_materials") - .groupby("name", group_keys=False) - .apply( - lambda x: x.sort_values("date", ascending=False).head(1) - if most_recent - else x - ) - .reset_index(drop=True) - ) - - return data - - @staticmethod - def read_half_cell_curve(half_cell_path) -> pd.DataFrame: - """ - Function to read in a half cell curve for this active material - - :param half_cell_path: Path to the half cell data file. - :return: DataFrame with the specific capacity and voltage. - """ - try: - data = pd.read_csv(half_cell_path) - except: - raise FileNotFoundError(f"Could not find the file at {half_cell_path}") - - if "Specific Capacity (mAh/g)" not in data.columns: - raise ValueError( - "The file must have a column named 'Specific Capacity (mAh/g)'" - ) - - if "Voltage (V)" not in data.columns: - raise ValueError("The file must have a column named 'Voltage (V)'") - - if "Step_ID" not in data.columns: - raise ValueError("The file must have a column named 'Step_ID'") - - data = ( - data.rename( - columns={ - "Specific Capacity (mAh/g)": "specific_capacity", - "Voltage (V)": "voltage", - "Step_ID": "step_id", - } - ) - .assign( - specific_capacity=lambda x: x["specific_capacity"] - * (H_TO_S * mA_TO_A / G_TO_KG) - ) - .filter(["specific_capacity", "voltage", "step_id"]) - .groupby(["specific_capacity", "step_id"], group_keys=False)["voltage"] - .max() - .reset_index() - .sort_values(["step_id", "specific_capacity"]) - ) - - return data - - def remove_data(self, table_name: str, condition: str): - """ - Function to remove data from the database. - - :param table_name: Name of the table. - :param condition: Condition to remove rows. - """ - self._cursor.execute(f"DELETE FROM {table_name} WHERE {condition}") - self._connection.commit() - - def __del__(self): - self._connection.close() diff --git a/steer_core/Decorators/Coordinates.py b/steer_core/Decorators/Coordinates.py index 602de18..8d06e02 100644 --- a/steer_core/Decorators/Coordinates.py +++ b/steer_core/Decorators/Coordinates.py @@ -1,51 +1,5 @@ -from functools import wraps +from steer_core.Decorators.General import recalculate - -def calculate_coordinates(func): - """ - Decorator to recalculate spatial properties after a method call. - This is useful for methods that modify the geometry of a component. - """ - - @wraps(func) - def wrapper(self, *args, **kwargs): - result = func(self, *args, **kwargs) - if hasattr(self, "_update_properties") and self._update_properties: - self._calculate_coordinates() - return result - - return wrapper - - -def calculate_areas(func): - """ - Decorator to recalculate areas after a method call. - This is useful for methods that modify the geometry of a component. - """ - - @wraps(func) - def wrapper(self, *args, **kwargs): - result = func(self, *args, **kwargs) - if hasattr(self, "_update_properties") and self._update_properties: - self._calculate_coordinates() - self._calculate_areas() - return result - - return wrapper - - -def calculate_volumes(func): - """ - Decorator to recalculate volumes after a method call. - This is useful for methods that modify the geometry of a component. - """ - - @wraps(func) - def wrapper(self, *args, **kwargs): - result = func(self, *args, **kwargs) - if hasattr(self, "_update_properties") and self._update_properties: - self._calculate_bulk_properties() - self._calculate_coordinates() - return result - - return wrapper +calculate_coordinates = recalculate("coordinates") +calculate_areas = recalculate("coordinates", "areas") +calculate_volumes = recalculate("bulk_properties", "coordinates") diff --git a/steer_core/Decorators/Electrochemical.py b/steer_core/Decorators/Electrochemical.py deleted file mode 100644 index f8e55be..0000000 --- a/steer_core/Decorators/Electrochemical.py +++ /dev/null @@ -1,29 +0,0 @@ -from functools import wraps - -def calculate_half_cell_curve(func): - """ - Decorator to recalculate half-cell curve properties after a method call. - This is useful for methods that modify the half-cell curve data. - """ - @wraps(func) - def wrapper(self, *args, **kwargs): - result = func(self, *args, **kwargs) - if hasattr(self, '_update_properties') and self._update_properties: - self._calculate_half_cell_curve() - return result - return wrapper - - -def calculate_half_cell_curves_properties(func): - """ - Decorator to recalculate half-cell curves properties after a method call. - This is useful for methods that modify the half-cell curves data. - """ - @wraps(func) - def wrapper(self, *args, **kwargs): - result = func(self, *args, **kwargs) - if hasattr(self, '_update_properties') and self._update_properties: - self._calculate_half_cell_curves_properties() - return result - return wrapper - diff --git a/steer_core/Decorators/General.py b/steer_core/Decorators/General.py index c7ee74b..6a2eeac 100644 --- a/steer_core/Decorators/General.py +++ b/steer_core/Decorators/General.py @@ -1,34 +1,48 @@ from functools import wraps - - -def calculate_bulk_properties(func): - """ - Decorator to recalculate bulk properties after a method call. - This is useful for methods that modify the material properties. +from typing import Callable, Dict, Optional + + +def recalculate(*method_names: str, requires: Optional[Dict[str, Callable]] = None): + """Factory that creates a decorator to call ``self._calculate_()`` after the wrapped method. + + Parameters + ---------- + *method_names : str + One or more method suffixes. For each name, ``self._calculate_()`` is + called in order after the wrapped function executes. + requires : dict, optional + Extra attribute guards. Keys are attribute names (e.g. ``"_mass"``), values + are callables that receive the attribute value and must return ``True`` for the + recalculation to proceed. All guards must pass. + + Returns + ------- + Callable + A decorator (usable with ``@``) that wraps a method. + + Examples + -------- + >>> calculate_bulk_properties = recalculate("bulk_properties") + >>> calculate_areas = recalculate("coordinates", "areas") + >>> calculate_capacity_curve = recalculate("capacity_curve", + ... requires={"_mass": lambda v: v is not None}) """ - - @wraps(func) - def wrapper(self, *args, **kwargs): - result = func(self, *args, **kwargs) - if hasattr(self, "_update_properties") and self._update_properties: - self._calculate_bulk_properties() - return result - - return wrapper - - -def calculate_all_properties(func): - """ - Decorator to recalculate both spatial and bulk properties after a method call. - This is useful for methods that modify both geometry and material properties. - """ - - @wraps(func) - def wrapper(self, *args, **kwargs): - result = func(self, *args, **kwargs) - if hasattr(self, "_update_properties") and self._update_properties: - self._calculate_all_properties() - return result - - return wrapper + def decorator(func): + @wraps(func) + def wrapper(self, *args, **kwargs): + result = func(self, *args, **kwargs) + if hasattr(self, "_update_properties") and self._update_properties: + if requires: + for attr, check in requires.items(): + if not hasattr(self, attr) or not check(getattr(self, attr)): + return result + for name in method_names: + getattr(self, f"_calculate_{name}")() + return result + return wrapper + return decorator + + +calculate_bulk_properties = recalculate("bulk_properties") +calculate_all_properties = recalculate("all_properties") diff --git a/steer_core/Decorators/Objects.py b/steer_core/Decorators/Objects.py index 9883be6..da8862d 100644 --- a/steer_core/Decorators/Objects.py +++ b/steer_core/Decorators/Objects.py @@ -1,17 +1,3 @@ -from functools import wraps +from steer_core.Decorators.General import recalculate - -def calculate_weld_tab_properties(func): - """ - Decorator to recalculate weld tab properties after a method call. - This is useful for methods that modify the weld tab geometry or material. - """ - - @wraps(func) - def wrapper(self, *args, **kwargs): - result = func(self, *args, **kwargs) - if hasattr(self, "_update_properties") and self._update_properties: - self._calculate_weld_tab_properties() - return result - - return wrapper +calculate_weld_tab_properties = recalculate("weld_tab_properties") diff --git a/steer_core/Mixins/Colors.py b/steer_core/Mixins/Colors.py index a5152c4..3744a80 100644 --- a/steer_core/Mixins/Colors.py +++ b/steer_core/Mixins/Colors.py @@ -1,9 +1,6 @@ import numpy as np import plotly.colors as pc -import pandas as pd -import numpy as np - class ColorMixin: """ @@ -12,11 +9,12 @@ class ColorMixin: """ @staticmethod - def rgb_tuple_to_hex(rgb): + def rgb_tuple_to_hex(rgb: tuple[int, int, int]) -> str: + """Convert an ``(R, G, B)`` tuple to a hex colour string.""" return "#{:02x}{:02x}{:02x}".format(*rgb) @staticmethod - def get_colorway(color1, color2, n): + def get_colorway(color1: str, color2: str, n: int) -> list[str]: """ Generate a list of n hex colors interpolated between two HTML hex colors. diff --git a/steer_core/Mixins/Coordinates.py b/steer_core/Mixins/Coordinates.py index c6b67df..904521f 100644 --- a/steer_core/Mixins/Coordinates.py +++ b/steer_core/Mixins/Coordinates.py @@ -2,6 +2,8 @@ import pandas as pd from typing import Tuple +from shapely import Polygon, minimum_bounding_circle, Point + class CoordinateMixin: """ @@ -9,30 +11,177 @@ class CoordinateMixin: Provides methods for rotation, area calculation, and coordinate ordering. """ + _PLANE_MAPPING = { + 'xy': (0, 1), + 'xz': (0, 2), + 'yz': (1, 2), + } + @staticmethod + def get_radius_of_points(coords: np.ndarray) -> Tuple[float, Tuple[float, float]]: + """Calculate the radius of a spiral given its coordinates. + + Parameters + ---------- + coords : np.ndarray + Array of shape (N, 2) with columns [x, z] + + Returns + ------- + Tuple[float, Tuple[float, float]] + (radius, (center_x, center_z)) where radius is in meters + + Raises + ------ + ValueError + If input coordinates are invalid or insufficient valid points + """ + # Filter out rows with NaN or None values + valid_mask = ~(np.isnan(coords).any(axis=1) | pd.isna(coords).any(axis=1)) + valid_coords = coords[valid_mask] + + if len(valid_coords) < 3: + raise ValueError(f"Insufficient valid coordinates for polygon creation. Need at least 3, got {len(valid_coords)}") + + polygon = Polygon(valid_coords) + circle = minimum_bounding_circle(polygon) + center = circle.centroid + first_point = list(circle.exterior.coords)[0] + radius = Point(center).distance(Point(first_point)) + return radius, (center.x, center.y) + + @staticmethod + def _calculate_segment_center_line(x_coords: np.ndarray, z_coords: np.ndarray) -> np.ndarray: + """ + Calculate center line for a single segment of coordinates. + + Parameters + ---------- + x_coords : np.ndarray + X coordinates for the segment + z_coords : np.ndarray + Z coordinates for the segment + + Returns + ------- + np.ndarray + Array containing start and end points of the center line [[min_x, mean_z], [max_x, mean_z]] + """ + min_x = np.nanmin(x_coords) + max_x = np.nanmax(x_coords) + min_z = np.nanmin(z_coords) + max_z = np.nanmax(z_coords) + mean_z = max_z - (max_z - min_z) / 2 + + return np.array([[min_x, mean_z], [max_x, mean_z]]) + + @staticmethod + def get_xz_center_line(coordinates: np.ndarray) -> np.ndarray: + """ + Generate center line(s) for coordinate data, handling both single and multi-segment polygons. + + Parameters + ---------- + coordinates : np.ndarray + Array of 3D coordinates with shape (N, 3) where columns are [x, y, z]. + NaN values in x or z coordinates indicate breaks between polygon segments. + + Returns + ------- + np.ndarray + For single polygon: Array with shape (2, 2) containing start and end points. + For multiple segments: Array with center lines for each segment separated by [NaN, NaN]. + For empty coordinates: Empty array with shape (0, 2). + """ + if coordinates.size == 0: + return np.array([]).reshape(0, 2) + + x_coords = coordinates[:, 0] + z_coords = coordinates[:, 2] + + x_is_nan = np.isnan(x_coords) + + if np.any(x_is_nan): + # Handle multiple segments separated by NaN values + result_points = [] + + # Find NaN indices to split the segments + nan_indices = np.where(x_is_nan)[0] + start_idx = 0 + + # Process each segment + for nan_idx in nan_indices: + if nan_idx > start_idx: + segment_x = x_coords[start_idx:nan_idx] + segment_z = z_coords[start_idx:nan_idx] + + # Calculate center line for this segment if it has valid points + if len(segment_x) > 0 and not np.all(np.isnan(segment_x)): + segment_line = CoordinateMixin._calculate_segment_center_line(segment_x, segment_z) + result_points.extend(segment_line.tolist()) + result_points.append([np.nan, np.nan]) # Add separator + + start_idx = nan_idx + 1 + + # Handle the last segment if it exists + if start_idx < len(x_coords): + segment_x = x_coords[start_idx:] + segment_z = z_coords[start_idx:] + + if len(segment_x) > 0 and not np.all(np.isnan(segment_x)): + segment_line = CoordinateMixin._calculate_segment_center_line(segment_x, segment_z) + result_points.extend(segment_line.tolist()) + + # Remove trailing NaN separator if it exists + if result_points and np.isnan(result_points[-1][0]): + result_points.pop() + + return np.array(result_points) if result_points else np.array([]).reshape(0, 2) + + else: + # Single polygon - use helper function + return CoordinateMixin._calculate_segment_center_line(x_coords, z_coords) + @staticmethod def rotate_coordinates( - coords: np.ndarray, axis: str, angle: float, center: tuple = None + coords: np.ndarray, + axis: str, + angle: float, + center: tuple = None ) -> np.ndarray: """ - Rotate a (N, 3) NumPy array of 3D coordinates around the specified axis. + Rotate a NumPy array of coordinates around the specified axis. + Can handle 2D coordinates (N, 2) for x, y or 3D coordinates (N, 3) for x, y, z. Can handle coordinates with None values (preserves None positions). - :param coords: NumPy array of shape (N, 3), where columns are x, y, z - :param axis: Axis to rotate around ('x', 'y', or 'z') + :param coords: NumPy array of shape (N, 2) for 2D or (N, 3) for 3D coordinates + :param axis: Axis to rotate around ('x', 'y', or 'z'). For 2D arrays, only 'z' is valid. :param angle: Angle in degrees - :param center: Point to rotate around as (x, y, z) tuple. If None, rotates around origin. - :return: Rotated NumPy array of shape (N, 3) + :param center: Point to rotate around. For 2D: (x, y) tuple. For 3D: (x, y, z) tuple. + If None, rotates around origin. + :return: Rotated NumPy array with same shape as input """ - if coords.shape[1] != 3: + # Check if 2D or 3D coordinates + is_2d = coords.shape[1] == 2 + is_3d = coords.shape[1] == 3 + + if not (is_2d or is_3d): + raise ValueError( + "Input array must have shape (N, 2) for 2D or (N, 3) for 3D coordinates" + ) + + # For 2D arrays, only z-axis rotation is valid + if is_2d and axis != 'z': raise ValueError( - "Input array must have shape (N, 3) for x, y, z coordinates" + "For 2D coordinates (x, y), only 'z' axis rotation is supported" ) # Validate center parameter if center is not None: - if not isinstance(center, (tuple, list)) or len(center) != 3: + expected_len = 2 if is_2d else 3 + if not isinstance(center, (tuple, list)) or len(center) != expected_len: + coord_type = "(x, y)" if is_2d else "(x, y, z)" raise ValueError( - "Center must be a tuple or list of 3 coordinates (x, y, z)" + f"Center must be a tuple or list of {expected_len} coordinates {coord_type}" ) if not all(isinstance(coord, (int, float)) for coord in center): raise TypeError("All center coordinates must be numbers") @@ -82,10 +231,11 @@ def _rotate_around_center( """ Rotate coordinates around a specified center point. - :param coords: NumPy array of shape (N, 3) with valid coordinates + :param coords: NumPy array of shape (N, 2) or (N, 3) with valid coordinates :param axis: Axis to rotate around ('x', 'y', or 'z') :param angle: Angle in degrees - :param center: Center point as np.array of shape (3,). If None, rotates around origin. + :param center: Center point as np.array. Shape (2,) for 2D or (3,) for 3D. + If None, rotates around origin. :return: Rotated coordinates """ if center is None: @@ -121,47 +271,336 @@ def build_square_array( The width of the square. y_width : float The height of the square. + + Returns + ------- + Tuple[np.ndarray, np.ndarray] + Tuple containing x and y coordinate arrays defining the square/rectangle """ - x_coords = [x, x, x + x_width, x + x_width, x] - y_coords = [y, y + y_width, y + y_width, y, y] + x_coords = np.array([x, x, x + x_width, x + x_width, x]) + y_coords = np.array([y, y + y_width, y + y_width, y, y]) + return x_coords, y_coords + + @staticmethod + def build_circle_array( + center_x: float, center_y: float, radius: float, num_points: int = 64, anticlockwise: bool = True + ) -> Tuple[np.ndarray, np.ndarray]: + """ + Build a NumPy array representing a circle defined by its center and radius. + + Parameters + ---------- + center_x : float + The x-coordinate of the circle center. + center_y : float + The y-coordinate of the circle center. + radius : float + The radius of the circle. + num_points : int, optional + Number of points to use for the circle approximation, by default 64 + anticlockwise : bool, optional + If True, points are ordered anticlockwise; if False, clockwise, by default True + + Returns + ------- + Tuple[np.ndarray, np.ndarray] + Tuple containing x and y coordinate arrays defining the circle + """ + # Generate angles from 0 to 2π + angles = np.linspace(0, 2 * np.pi, num_points + 1) + + # Reverse angles for clockwise direction + if not anticlockwise: + angles = angles[::-1] + + # Calculate x and y coordinates + x_coords = center_x + radius * np.cos(angles) + y_coords = center_y + radius * np.sin(angles) + return x_coords, y_coords @staticmethod def order_coordinates_clockwise(df: pd.DataFrame, plane="xy") -> pd.DataFrame: + + df = df.copy() + axis_1 = plane[0] axis_2 = plane[1] - cx = df[axis_1].mean() - cy = df[axis_2].mean() + # Find column names that match the axis (case-insensitive, with or without units) + def find_column(axis_char: str) -> str: + axis_char_lower = axis_char.lower() + # First try exact match + if axis_char in df.columns: + return axis_char + # Try lowercase + if axis_char_lower in df.columns: + return axis_char_lower + # Try uppercase + if axis_char.upper() in df.columns: + return axis_char.upper() + # Try with units pattern like "X (mm)", "x (mm)", etc. + for col in df.columns: + col_stripped = col.split()[0].lower() if ' ' in col else col.lower() + if col_stripped == axis_char_lower: + return col + raise KeyError(f"Could not find column for axis '{axis_char}' in dataframe columns: {list(df.columns)}") + + axis_1_col = find_column(axis_1) + axis_2_col = find_column(axis_2) + + cx = df[axis_1_col].mean() + cy = df[axis_2_col].mean() - angles = np.arctan2(df[axis_2] - cy, df[axis_1] - cx) + angles = np.arctan2(df[axis_2_col] - cy, df[axis_1_col] - cx) df["angle"] = angles + df_sorted = ( df.sort_values(by="angle").drop(columns="angle").reset_index(drop=True) ) return df_sorted + @staticmethod + def order_coordinates_clockwise_numpy( + coords: np.ndarray, + plane: str = "xy" + ) -> np.ndarray: + """ + Order 3D coordinates in clockwise direction based on a specified plane. + Handles multiple coordinate blocks separated by NaN rows. + + Parameters + ---------- + coords : np.ndarray + Array of 3D coordinates with shape (N, 3) where columns are [x, y, z]. + NaN rows indicate separations between coordinate blocks. + plane : str, optional + Plane to use for ordering ('xy', 'xz', 'yz'), by default 'xy' + + Returns + ------- + np.ndarray + Sorted coordinates array with same shape as input, with each block + sorted clockwise and separated by NaN rows + + Raises + ------ + ValueError + If coords array doesn't have shape (N, 3) or plane is invalid + """ + if len(coords.shape) != 2 or coords.shape[1] != 3: + raise ValueError("coords must be a 2D array with 3 columns (x, y, z)") + + if coords.shape[0] < 2: + return coords.copy() + + if plane not in CoordinateMixin._PLANE_MAPPING: + raise ValueError(f"plane must be one of {list(CoordinateMixin._PLANE_MAPPING.keys())}, got '{plane}'") + + # Check if we have NaN rows (multiple coordinate blocks) + x_coords = coords[:, 0] + x_is_nan = np.isnan(x_coords) + + if not np.any(x_is_nan): + # Single block - use original logic + return CoordinateMixin._sort_single_coordinate_block(coords, plane) + + # Multiple blocks - extract and sort each block + segments = CoordinateMixin._extract_coordinate_blocks(coords) + sorted_blocks = [] + + for block in segments: + if len(block) > 1: # Only sort if block has more than 1 coordinate + sorted_block = CoordinateMixin._sort_single_coordinate_block(block, plane) + sorted_blocks.append(sorted_block) + elif len(block) == 1: # Single coordinate, keep as is + sorted_blocks.append(block) + + return CoordinateMixin._concatenate_coordinate_blocks_with_nans(sorted_blocks) + + @staticmethod + def concat_with_nan_separators(arrays: list) -> np.ndarray: + """ + Efficiently concatenate numpy arrays with NaN separators. + + Parameters + ---------- + arrays : list + List of numpy arrays to concatenate + + Returns + ------- + np.ndarray + Concatenated array with NaN separators + """ + if not arrays: + return np.array([]) + + if len(arrays) == 1: + return arrays[0] + + # Calculate total size needed + total_rows = sum(arr.shape[0] for arr in arrays) + len(arrays) - 1 + n_cols = arrays[0].shape[1] + + # Pre-allocate result array + result = np.empty((total_rows, n_cols)) + + current_row = 0 + for i, arr in enumerate(arrays): + # Copy array data + result[current_row:current_row + arr.shape[0]] = arr + current_row += arr.shape[0] + + # Add NaN separator (except after last array) + if i < len(arrays) - 1: + result[current_row] = np.nan + current_row += 1 + + return result + + @staticmethod + def _sort_single_coordinate_block( + coords: np.ndarray, + plane: str + ) -> np.ndarray: + """ + Sort a single coordinate block clockwise. + + Parameters + ---------- + coords : np.ndarray + Array of 3D coordinates with shape (N, 3) + plane : str + Plane to use for ordering ('xy', 'xz', 'yz') + + Returns + ------- + np.ndarray + Sorted coordinates array + """ + axis_1_idx, axis_2_idx = CoordinateMixin._PLANE_MAPPING[plane] + + # Extract the relevant coordinates for the specified plane + axis_1_coords = coords[:, axis_1_idx] + axis_2_coords = coords[:, axis_2_idx] + + # Calculate center point + cx = np.nanmean(axis_1_coords) + cy = np.nanmean(axis_2_coords) + + # Calculate angles from center to each point + angles = np.arctan2(axis_2_coords - cy, axis_1_coords - cx) + + # Sort by angle to get clockwise ordering + sorted_indices = np.argsort(angles) + + return coords[sorted_indices] + + @staticmethod + def _extract_coordinate_blocks(coords: np.ndarray) -> list: + """ + Extract coordinate blocks separated by NaN rows. + + Parameters + ---------- + coords : np.ndarray + Array of 3D coordinates with NaN row separators + + Returns + ------- + list + List of coordinate block arrays + """ + blocks = [] + x_coords = coords[:, 0] + x_is_nan = np.isnan(x_coords) + nan_indices = np.where(x_is_nan)[0] + start_idx = 0 + + # Process each block between NaN rows + for nan_idx in nan_indices: + if nan_idx > start_idx: + block = coords[start_idx:nan_idx] + if len(block) > 0: + blocks.append(block) + start_idx = nan_idx + 1 + + # Handle the last block if it exists + if start_idx < len(coords): + block = coords[start_idx:] + if len(block) > 0: + blocks.append(block) + + return blocks + + @staticmethod + def _concatenate_coordinate_blocks_with_nans(blocks: list) -> np.ndarray: + """ + Concatenate coordinate blocks with NaN row separators. + + Parameters + ---------- + blocks : list + List of coordinate block arrays + + Returns + ------- + np.ndarray + Concatenated array with NaN separators + """ + if not blocks: + return np.array([]).reshape(0, 3) + + result_parts = [] + + for i, block in enumerate(blocks): + result_parts.append(block) + + # Add NaN separator between blocks (except for the last one) + if i < len(blocks) - 1: + nan_row = np.full((1, 3), np.nan) + result_parts.append(nan_row) + + return np.vstack(result_parts) + @staticmethod def _rotate_valid_coordinates( coords: np.ndarray, axis: str, angle: float ) -> np.ndarray: """ Rotate coordinates without None values using rotation matrices. + Handles both 2D (N, 2) and 3D (N, 3) coordinate arrays. + + :param coords: NumPy array of shape (N, 2) or (N, 3) + :param axis: Axis to rotate around ('x', 'y', or 'z') + :param angle: Angle in degrees + :return: Rotated coordinates with same shape as input """ angle_rad = np.radians(angle) cos_a = np.cos(angle_rad) sin_a = np.sin(angle_rad) + + is_2d = coords.shape[1] == 2 - if axis == "x": - R = np.array([[1, 0, 0], [0, cos_a, -sin_a], [0, sin_a, cos_a]]) - elif axis == "y": - R = np.array([[cos_a, 0, sin_a], [0, 1, 0], [-sin_a, 0, cos_a]]) - elif axis == "z": - R = np.array([[cos_a, -sin_a, 0], [sin_a, cos_a, 0], [0, 0, 1]]) + if is_2d: + # For 2D coordinates, only z-axis rotation applies (rotation in xy plane) + if axis != 'z': + raise ValueError("For 2D coordinates, only 'z' axis rotation is supported") + # 2D rotation matrix + R = np.array([[cos_a, -sin_a], [sin_a, cos_a]]) else: - raise ValueError("Axis must be 'x', 'y', or 'z'.") + # 3D rotation matrices + if axis == "x": + R = np.array([[1, 0, 0], [0, cos_a, -sin_a], [0, sin_a, cos_a]]) + elif axis == "y": + R = np.array([[cos_a, 0, sin_a], [0, 1, 0], [-sin_a, 0, cos_a]]) + elif axis == "z": + R = np.array([[cos_a, -sin_a, 0], [sin_a, cos_a, 0], [0, 0, 1]]) + else: + raise ValueError("Axis must be 'x', 'y', or 'z'.") return coords @ R.T @@ -171,9 +610,7 @@ def _calculate_single_area(x: np.ndarray, y: np.ndarray) -> float: Calculate the area of a single closed shape using the shoelace formula. """ if len(x) < 3 or len(y) < 3: - raise ValueError( - "Trace must contain at least 3 points to form a closed shape." - ) + return 0.0 # Convert to float arrays to avoid object dtype issues x = np.asarray(x, dtype=float) @@ -241,7 +678,134 @@ def get_area_from_points(x: np.ndarray, y: np.ndarray) -> float: @staticmethod def extrude_footprint( - x: np.ndarray, y: np.ndarray, datum: np.ndarray, thickness: float + x: np.ndarray, + y: np.ndarray, + datum: np.ndarray, + thickness: float + ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """ + Extrude a 2D footprint to 3D, handling both single and multi-segment polygons. + + Parameters + ---------- + x : np.ndarray + Array of x coordinates. NaN values indicate segment separators. + y : np.ndarray + Array of y coordinates. NaN values indicate segment separators. + datum : np.ndarray + Datum point for extrusion (shape (3,)) + thickness : float + Thickness of the extrusion + + Returns + ------- + Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray] + Arrays of x, y, z coordinates and side labels, with NaN separators between segments + """ + if not np.isnan(x).any() and not np.isnan(y).any(): + return CoordinateMixin._extrude_single_footprint(x, y, datum, thickness) + + # Process segmented coordinates + segments = CoordinateMixin._extract_coordinate_segments(x, y) + extruded_sections = [] + + for segment_x, segment_y in segments: + if len(segment_x) > 0 and not np.all(np.isnan(segment_x)): + result = CoordinateMixin._extrude_single_footprint( + segment_x, segment_y, datum, thickness + ) + extruded_sections.append(result) + + return CoordinateMixin._concatenate_with_separators(extruded_sections) + + @staticmethod + def _extract_coordinate_segments(x: np.ndarray, y: np.ndarray, unify_xy: bool = False) -> list: + """ + Extract coordinate segments separated by NaN values. + + Parameters + ---------- + x : np.ndarray + X coordinates with NaN separators + y : np.ndarray + Y coordinates with NaN separators + + Returns + ------- + list + List of (segment_x, segment_y) tuples + """ + segments = [] + x_is_nan = np.isnan(x) + nan_indices = np.where(x_is_nan)[0] + start_idx = 0 + + # Process each segment between NaN values + for nan_idx in nan_indices: + if nan_idx > start_idx: + segments.append((x[start_idx:nan_idx], y[start_idx:nan_idx])) + start_idx = nan_idx + 1 + + # Handle the last segment if it exists + if start_idx < len(x): + segments.append((x[start_idx:], y[start_idx:])) + + if unify_xy: + unified_segments = [] + for i in range(len(segments)): + segment_x, segment_y = segments[i] + xy_array = np.column_stack((segment_x, segment_y)) + unified_segments.append(xy_array) + return np.array(unified_segments) + else: + return segments + + @staticmethod + def _concatenate_with_separators(sections: list) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """ + Concatenate extruded sections with NaN separators. + + Parameters + ---------- + sections : list + List of (x_ext, y_ext, z_ext, side_ext) tuples + + Returns + ------- + Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray] + Concatenated arrays with NaN separators + """ + if not sections: + return np.array([]), np.array([]), np.array([]), np.array([]) + + x_parts, y_parts, z_parts, side_parts = [], [], [], [] + + for i, (x_ext, y_ext, z_ext, side_ext) in enumerate(sections): + x_parts.append(x_ext) + y_parts.append(y_ext) + z_parts.append(z_ext) + side_parts.append(side_ext) + + # Add NaN separators between segments (except for the last one) + if i < len(sections) - 1: + x_parts.append(np.array([np.nan])) + y_parts.append(np.array([np.nan])) + z_parts.append(np.array([np.nan])) + side_parts.append(np.array([None], dtype=object)) + + return ( + np.concatenate(x_parts), + np.concatenate(y_parts), + np.concatenate(z_parts), + np.concatenate(side_parts) + ) + + @staticmethod + def _extrude_single_footprint( + x: np.ndarray, + y: np.ndarray, + datum: np.ndarray, + thickness: float ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """ Extrude the 2D footprint to 3D and label each point with its side ('a' or 'b'), with 'a' being the top side and 'b' the bottom side. @@ -273,6 +837,135 @@ def extrude_footprint( return x_full, y_full, z_full, side_full + @staticmethod + def get_coordinate_intersection( + coords1: np.ndarray, + coords2: np.ndarray + ) -> float: + """ + Calculate the intersection area between two sets of coordinates. + + Parameters + ---------- + coords1 : np.ndarray + First set of coordinates (N, 2) with columns [x, y] + coords2 : np.ndarray + Second set of coordinates (M, 2) with columns [x, y] + + Returns + ------- + float + Intersection area between the two polygons + + Raises + ------ + ValueError + If either coordinate set has insufficient valid points for polygon creation + """ + # Filter out rows with NaN or None values from coords1 + valid_mask1 = ~(np.isnan(coords1).any(axis=1) | pd.isna(coords1).any(axis=1)) + valid_coords1 = coords1[valid_mask1] + + if len(valid_coords1) < 3: + raise ValueError(f"Insufficient valid coordinates in coords1 for polygon creation. Need at least 3, got {len(valid_coords1)}") + + # Filter out rows with NaN or None values from coords2 + valid_mask2 = ~(np.isnan(coords2).any(axis=1) | pd.isna(coords2).any(axis=1)) + valid_coords2 = coords2[valid_mask2] + + if len(valid_coords2) < 3: + raise ValueError(f"Insufficient valid coordinates in coords2 for polygon creation. Need at least 3, got {len(valid_coords2)}") + + polygon1 = Polygon(valid_coords1) + polygon2 = Polygon(valid_coords2) + intersection = polygon1.intersection(polygon2) + return intersection.area + + @staticmethod + def insert_gaps_with_nans( + data: np.ndarray, + column_index: int, + tolerance_multiplier: float = 2.0 + ) -> np.ndarray: + """ + Insert rows of NaNs when gaps in a specified column exceed a tolerance threshold. + + Parameters + ---------- + data : np.ndarray + Input array with shape (N, M) where N is number of rows and M is number of columns + column_index : int + Index of the column to analyze for gaps (0-based indexing) + tolerance_multiplier : float, optional + Multiplier for average gap to determine tolerance threshold, by default 2.0 + + Returns + ------- + np.ndarray + Array with NaN rows inserted where gaps exceed the tolerance + + Raises + ------ + ValueError + If column_index is out of bounds for the array + IndexError + If data array is empty or has insufficient dimensions + + Examples + -------- + >>> data = np.array([[1, 10], [2, 20], [5, 50], [6, 60]]) + >>> result = CoordinateMixin.insert_gaps_with_nans(data, column_index=0) + >>> # Will insert NaN row between [2, 20] and [5, 50] if gap of 3 exceeds tolerance + """ + if data.size == 0: + return data.copy() + + if len(data.shape) != 2: + raise ValueError("Input array must be 2-dimensional") + + if column_index < 0 or column_index >= data.shape[1]: + raise ValueError(f"column_index {column_index} is out of bounds for array with {data.shape[1]} columns") + + if data.shape[0] < 2: + return data.copy() + + # Extract the column values + column_values = data[:, column_index] + + # Remove NaN values for gap calculation + valid_values = column_values[~np.isnan(column_values)] + + if len(valid_values) < 2: + return data.copy() + + # Calculate gaps between consecutive values + gaps = np.diff(valid_values) + + # Calculate average gap and tolerance + average_gap = np.mean(np.abs(gaps)) + tolerance = average_gap * tolerance_multiplier + + # Vectorised: identify consecutive-row pairs where both values are + # non-NaN and the gap exceeds the tolerance. + cur = column_values[:-1] + nxt = column_values[1:] + both_valid = ~(np.isnan(cur) | np.isnan(nxt)) + exceeds = both_valid & (np.abs(nxt - cur) > tolerance) + + insert_indices = np.where(exceeds)[0] + 1 # positions *after* which to insert + if insert_indices.size == 0: + return data.copy() + + nan_row = np.full((1, data.shape[1]), np.nan) + parts = [] + prev = 0 + for idx in insert_indices: + parts.append(data[prev:idx]) + parts.append(nan_row) + prev = idx + parts.append(data[prev:]) + return np.vstack(parts) + @staticmethod def remove_skip_coat_area( x_coords: np.ndarray, @@ -359,3 +1052,5 @@ def remove_skip_coat_area( y_result.append(np.nan) return np.array(x_result, dtype=float), np.array(y_result, dtype=float) + + diff --git a/steer_core/Mixins/Data.py b/steer_core/Mixins/Data.py index f07045c..67ea464 100644 --- a/steer_core/Mixins/Data.py +++ b/steer_core/Mixins/Data.py @@ -1,3 +1,4 @@ +from copy import deepcopy import numpy as np from scipy.interpolate import PchipInterpolator @@ -38,3 +39,75 @@ def enforce_monotonicity(array: np.ndarray) -> np.ndarray: new_array = np.minimum.accumulate(new_array) return new_array + + @staticmethod + def sum_breakdowns(components, breakdown_type: str): + """ + Aggregate breakdown dictionaries across multiple components. + If a component doesn't have the specified breakdown, use its fallback attribute instead. + + Parameters + ---------- + components : list + List of component objects + breakdown_type : str, optional + Type of breakdown to aggregate ('mass', 'cost', etc.), by default 'mass' + + Returns + ------- + dict or float + Aggregated breakdown dictionary with summed values maintaining structure, + or simple float sum if no components have the specified breakdown + """ + def add_dicts(dict1, dict2): + """Recursively add two dictionaries with matching structure.""" + result = dict1.copy() + + for key, value in dict2.items(): + if key in result: + if isinstance(result[key], dict) and isinstance(value, dict): + result[key] = add_dicts(result[key], value) + elif isinstance(result[key], (int, float)) and isinstance(value, (int, float)): + result[key] += value + else: + result[key] = value + + return result + + breakdown_attr = f'_{breakdown_type}_breakdown' + fallback_attr = f'_{breakdown_type}' + + aggregated_breakdown = {} + simple_sum = 0 + has_breakdown_components = False + + for component in components: + if hasattr(component, breakdown_attr): + breakdown_value = getattr(component, breakdown_attr) + if breakdown_value is not None: + has_breakdown_components = True + if not aggregated_breakdown: + # Initialize with first component's breakdown + aggregated_breakdown = deepcopy(breakdown_value) + else: + # Add subsequent breakdowns + aggregated_breakdown = add_dicts(aggregated_breakdown, breakdown_value) + elif hasattr(component, fallback_attr): + # Component only has fallback attribute + fallback_value = getattr(component, fallback_attr) + if fallback_value is not None: + simple_sum += fallback_value + + # If we have breakdown components, add the simple sum to the breakdown + if has_breakdown_components: + if simple_sum > 0: + total_key = f'total_{breakdown_type}' + if total_key in aggregated_breakdown: + aggregated_breakdown[total_key] += simple_sum + else: + aggregated_breakdown[total_key] = simple_sum + return aggregated_breakdown + else: + # No breakdown components, return simple sum + return simple_sum + \ No newline at end of file diff --git a/steer_core/Mixins/DateTime.py b/steer_core/Mixins/DateTime.py new file mode 100644 index 0000000..108a270 --- /dev/null +++ b/steer_core/Mixins/DateTime.py @@ -0,0 +1,287 @@ +# steer_core/Mixins/DateTime.py +""" +steer_core/Mixins/DateTime.py + +Lightweight, dependency-free datetime parsing and validation helpers. + +Goal: +- Provide shared utilities across STEER repos for parsing user-friendly datetime strings + into canonical `datetime` objects with enforced alignment (hour/day). +""" + +from __future__ import annotations + +import calendar +from datetime import datetime +from typing import Optional + +from steer_core.Constants.Format import DEFAULT_HOUR_FMT, DEFAULT_DAY_FMT + + +class DateTimeMixin: + """ + A mixin class to handle datetime parsing, validation, and alignment operations. + Provides utilities for converting strings to datetime objects with enforced temporal alignment. + """ + + @staticmethod + def str_to_datetime(value: str, *, fmt: Optional[str] = None) -> datetime: + """ + Parse a datetime string using a provided strptime format. + + If no format is specified, automatically detects and parses using either + the hour format ('%Y-%m-%d-%H') or day format ('%Y-%m-%d'). + + Parameters + ---------- + value : str + The datetime string to parse. + fmt : str, optional + The strptime format string (e.g., '%Y-%m-%d-%H'). + If None, automatically tries both DEFAULT_HOUR_FMT and DEFAULT_DAY_FMT. + + Returns + ------- + datetime + The parsed datetime object. + + Raises + ------ + TypeError + If value is not a string. + ValueError + If value cannot be parsed with the provided or detected format(s). + + Examples + -------- + >>> DateTimeMixin.str_to_datetime('2024-01-15-14') + datetime.datetime(2024, 1, 15, 14, 0, 0) + >>> DateTimeMixin.str_to_datetime('2024-01-15') + datetime.datetime(2024, 1, 15, 0, 0, 0) + """ + if not isinstance(value, str): + fmt_msg = fmt if fmt else f"'{DEFAULT_HOUR_FMT}' or '{DEFAULT_DAY_FMT}'" + raise TypeError(f"value must be a string matching format {fmt_msg}") + + # If format is specified, use it directly + if fmt is not None: + try: + return datetime.strptime(value, fmt) + except ValueError as e: + raise ValueError(f"value must match format '{fmt}' (got '{value}')") from e + + # Auto-detect format: try hour format first, then day format + for try_fmt in [DEFAULT_HOUR_FMT, DEFAULT_DAY_FMT]: + try: + return datetime.strptime(value, try_fmt) + except ValueError: + continue + + # If neither format worked, raise an error + raise ValueError( + f"value must match format '{DEFAULT_HOUR_FMT}' or '{DEFAULT_DAY_FMT}' (got '{value}')" + ) + + @staticmethod + def datetime_to_str(dt: datetime, *, fmt: Optional[str] = None) -> str: + """ + Format a datetime object into a user-friendly string. + + If no format is specified, uses the hour format ('%Y-%m-%d-%H'). + + Parameters + ---------- + dt : datetime + The datetime object to format. + fmt : str, optional + The strftime format string (e.g., '%Y-%m-%d-%H', '%Y-%m-%d'). + If None, defaults to DEFAULT_HOUR_FMT. + + Returns + ------- + str + The formatted datetime string. + + Raises + ------ + TypeError + If dt is not a datetime object. + + Examples + -------- + >>> dt = datetime(2024, 1, 15, 14, 0, 0) + >>> DateTimeMixin.datetime_to_str(dt) + '2024-01-15-14' + >>> dt = datetime(2024, 1, 15, 0, 0, 0) + >>> DateTimeMixin.datetime_to_str(dt) + '2024-01-15-00' + """ + if not isinstance(dt, datetime): + raise TypeError("dt must be a datetime object") + + # If format is specified, use it directly + if fmt is not None: + return dt.strftime(fmt) + + # Default to hour format + return dt.strftime(DEFAULT_HOUR_FMT) + + + @staticmethod + def shift_years(dt: datetime, years: int) -> datetime: + """ + Shift a datetime by a specified number of years (forward or backward). + + Handles the edge case of February 29 (leap day): if the original date is + Feb 29 and the target year is not a leap year, the result will be Feb 28. + + Parameters + ---------- + dt : datetime + The datetime object to shift. + years : int + The number of years to shift (positive to add, negative to subtract). + + Returns + ------- + datetime + A new datetime object with the years shifted. + + Raises + ------ + TypeError + If dt is not a datetime object or years is not an integer. + + Examples + -------- + >>> dt = datetime(2026, 2, 2, 14, 0, 0) + >>> DateTimeMixin.shift_years(dt, 2) + datetime.datetime(2028, 2, 2, 14, 0, 0) + >>> DateTimeMixin.shift_years(dt, -2) + datetime.datetime(2024, 2, 2, 14, 0, 0) + >>> dt = datetime(2024, 2, 29, 12, 0, 0) # Leap day + >>> DateTimeMixin.shift_years(dt, 1) # 2025 is not a leap year + datetime.datetime(2025, 2, 28, 12, 0, 0) + >>> DateTimeMixin.shift_years(dt, -1) # 2023 is not a leap year + datetime.datetime(2023, 2, 28, 12, 0, 0) + """ + if not isinstance(dt, datetime): + raise TypeError("dt must be a datetime object") + if not isinstance(years, int): + raise TypeError("years must be an integer") + + target_year = dt.year + years + + # Try to create the new datetime with the target year + try: + return dt.replace(year=target_year) + except ValueError: + # This happens when the original date is Feb 29 and target year is not a leap year + # Fall back to Feb 28 + return dt.replace(year=target_year, day=28) + + + @staticmethod + def shift_months(dt: datetime, months: int) -> datetime: + """ + Shift a datetime by a specified number of months (forward or backward). + + Handles month boundaries intelligently: if the original day doesn't exist in + the target month (e.g., Jan 31 + 1 month), the result will be the last valid + day of that month (e.g., Feb 28 or Feb 29). + + Parameters + ---------- + dt : datetime + The datetime object to shift. + months : int + The number of months to shift (positive to add, negative to subtract). + + Returns + ------- + datetime + A new datetime object with the months shifted. + + Raises + ------ + TypeError + If dt is not a datetime object or months is not an integer. + + Examples + -------- + >>> dt = datetime(2026, 2, 2, 14, 0, 0) + >>> DateTimeMixin.shift_months(dt, 2) + datetime.datetime(2026, 4, 2, 14, 0, 0) + >>> DateTimeMixin.shift_months(dt, -2) + datetime.datetime(2025, 12, 2, 14, 0, 0) + >>> dt = datetime(2026, 1, 31, 12, 0, 0) + >>> DateTimeMixin.shift_months(dt, 1) # Feb has only 28 days + datetime.datetime(2026, 2, 28, 12, 0, 0) + >>> dt = datetime(2026, 3, 31, 12, 0, 0) + >>> DateTimeMixin.shift_months(dt, -1) # Feb has only 28 days + datetime.datetime(2026, 2, 28, 12, 0, 0) + """ + if not isinstance(dt, datetime): + raise TypeError("dt must be a datetime object") + if not isinstance(months, int): + raise TypeError("months must be an integer") + + # Calculate target month and year + total_months = dt.month + months + target_year = dt.year + (total_months - 1) // 12 + target_month = ((total_months - 1) % 12) + 1 + + # Get the last day of the target month + _, last_day_of_month = calendar.monthrange(target_year, target_month) + + # Use the minimum of original day and last day of target month + target_day = min(dt.day, last_day_of_month) + + return dt.replace(year=target_year, month=target_month, day=target_day) + + + @staticmethod + def validate_end_after_start(start: datetime, end: datetime, *, strictly: bool = True) -> None: + """ + Validate that end datetime comes after start datetime. + + Parameters + ---------- + start : datetime + The start datetime. + end : datetime + The end datetime. + strictly : bool, optional + If True, end must be strictly after start (end > start). + If False, end can equal start (end >= start). + Default is True. + + Raises + ------ + TypeError + If start or end is not a datetime object. + ValueError + If end is not after start (or not >= start if strictly=False). + + Examples + -------- + >>> start = datetime(2024, 1, 15, 10, 0, 0) + >>> end = datetime(2024, 1, 15, 14, 0, 0) + >>> DateTimeMixin.validate_end_after_start(start, end) # OK + >>> DateTimeMixin.validate_end_after_start(start, start) # Raises ValueError + >>> DateTimeMixin.validate_end_after_start(start, start, strictly=False) # OK + """ + if not isinstance(start, datetime): + raise TypeError("start must be a datetime") + if not isinstance(end, datetime): + raise TypeError("end must be a datetime") + + if strictly: + if end <= start: + raise ValueError(f"end ({end}) must be strictly after start ({start})") + else: + if end < start: + raise ValueError(f"end ({end}) must be after or equal to start ({start})") + + + diff --git a/steer_core/Mixins/Datum.py b/steer_core/Mixins/Datum.py new file mode 100644 index 0000000..963a4d8 --- /dev/null +++ b/steer_core/Mixins/Datum.py @@ -0,0 +1,137 @@ +"""Mixin providing standard datum property interface with mm/m unit conversion.""" + +from typing import Tuple +import numpy as np + +from steer_core.Constants.Units import M_TO_MM, MM_TO_M + + +class DatumMixin: + """Mixin providing uniform datum coordinate properties. + + Expects: + - `_datum: Tuple[float, float, float]` instance attribute (stored in meters) + - `validate_datum(value)` method available (from ValidationMixin) + + Provides: + - `datum` property (getter/setter) in millimeters + - `datum_x`, `datum_y`, `datum_z` properties (getter/setter) + + Setter Customization: + - Override with `@DatumMixin.datum.setter` and add decorators like + `@calculate_coordinates` or `@calculate_all_properties` as needed + - For translation-based updates (e.g., assemblies with computed geometry), + use `_compute_datum_translation()` helper before updating `_datum` + + Examples + -------- + Recalculation pattern (most common): + + @DatumMixin.datum.setter + @calculate_coordinates + def datum(self, value): + self.validate_datum(value) + self._datum = tuple(float(v) * MM_TO_M for v in value) + + Translation pattern (for complex assemblies): + + @DatumMixin.datum.setter + def datum(self, value): + self.validate_datum(value) + translation = self._compute_datum_translation(value) + if translation: + self._translate_all_components(translation) + self._datum = tuple(float(v) * MM_TO_M for v in value) + """ + + _datum: Tuple[float, float, float] # Internal storage in meters + + # === MAIN DATUM PROPERTY === + + def _ensure_datum_exists(self) -> None: + """Ensure _datum attribute exists, initializing to origin if needed.""" + if not hasattr(self, '_datum') or self._datum is None: + self._datum = (0.0, 0.0, 0.0) + + @property + def datum(self) -> Tuple[float, float, float]: + """Datum position in millimeters.""" + self._ensure_datum_exists() + return ( + np.round(self._datum[0] * M_TO_MM, 2), + np.round(self._datum[1] * M_TO_MM, 2), + np.round(self._datum[2] * M_TO_MM, 2), + ) + + @datum.setter + def datum(self, value: Tuple[float, float, float]) -> None: + """Set datum position in millimeters (base implementation). + + This base setter only stores the value. Override with decorators + like @calculate_coordinates to trigger coordinate recalculation, + or add custom translation logic for complex assemblies. + """ + self.validate_datum(value) + self._datum = ( + float(value[0]) * MM_TO_M, + float(value[1]) * MM_TO_M, + float(value[2]) * MM_TO_M, + ) + + def _compute_datum_translation(self, new_datum_mm: Tuple[float, float, float]) -> Tuple[float, float, float]: + """Compute translation vector from current datum to new datum. + + Parameters + ---------- + new_datum_mm : Tuple[float, float, float] + New datum position in millimeters. + + Returns + ------- + Tuple[float, float, float] + Translation vector (dx, dy, dz) in meters, or (0, 0, 0) if no existing datum. + """ + if not hasattr(self, '_datum') or self._datum is None: + return (0.0, 0.0, 0.0) + + return ( + float(new_datum_mm[0]) * MM_TO_M - self._datum[0], + float(new_datum_mm[1]) * MM_TO_M - self._datum[1], + float(new_datum_mm[2]) * MM_TO_M - self._datum[2], + ) + + # === INDIVIDUAL COORDINATE PROPERTIES === + + @property + def datum_x(self) -> float: + """X-coordinate of datum in millimeters.""" + self._ensure_datum_exists() + return np.round(self._datum[0] * M_TO_MM, 2) + + @datum_x.setter + def datum_x(self, x: float) -> None: + """Set X-coordinate of datum in millimeters.""" + self.datum = (x, self.datum[1], self.datum[2]) + + @property + def datum_y(self) -> float: + """Y-coordinate of datum in millimeters.""" + self._ensure_datum_exists() + return np.round(self._datum[1] * M_TO_MM, 2) + + @datum_y.setter + def datum_y(self, y: float) -> None: + """Set Y-coordinate of datum in millimeters.""" + self.datum = (self.datum[0], y, self.datum[2]) + + @property + def datum_z(self) -> float: + """Z-coordinate of datum in millimeters.""" + self._ensure_datum_exists() + return np.round(self._datum[2] * M_TO_MM, 2) + + @datum_z.setter + def datum_z(self, z: float) -> None: + """Set Z-coordinate of datum in millimeters.""" + self.datum = (self.datum[0], self.datum[1], z) + diff --git a/steer_core/Mixins/Dunder.py b/steer_core/Mixins/Dunder.py index 2573c10..582755a 100644 --- a/steer_core/Mixins/Dunder.py +++ b/steer_core/Mixins/Dunder.py @@ -1,10 +1,34 @@ +import typing + import numpy as np +from steer_core.Utils import is_plotly_trace + +# Numeric types accepted by the float-property class-level introspection. +_NUMERIC_TYPES = (float, int, np.floating, np.integer) + class DunderMixin: + # ── instance methods (unchanged API) ─────────────────────────────── + + def _get_comparable_float_properties(self): + """Get all @property decorated attributes that return floats.""" + float_properties = [] + for cls in self.__class__.__mro__: + for name, value in cls.__dict__.items(): + if isinstance(value, property): + if not self._should_exclude_property_viewable_stat(name): + try: + prop_value = getattr(self, name) + if isinstance(prop_value, (float, np.floating, int, np.integer)): + float_properties.append(name) + except (AttributeError, TypeError): + continue # Skip properties that raise exceptions + return float_properties + def _get_comparable_properties(self): """Get all comparable properties from the class hierarchy.""" properties = [] @@ -16,21 +40,104 @@ def _get_comparable_properties(self): return properties def _should_exclude_property(self, name): + """Check if a property should be excluded from comparison.""" + return DunderMixin._should_exclude_property_static(name) + + def _should_exclude_property_viewable_stat(self, name): + """Check if a property should be excluded from viewable stats.""" + return DunderMixin._should_exclude_property_viewable_stat_static(name) + + # ── static helpers (no instance required) ────────────────────────── + + @staticmethod + def _should_exclude_property_static(name: str) -> bool: """Check if a property should be excluded from comparison.""" return ( name.endswith('_trace') or - name.endswith('_range') or + name.endswith('_range') or name in {'last_updated', 'properties'} ) - def _is_plotly_trace(self, obj): - """Check if object is a Plotly trace object.""" + @staticmethod + def _should_exclude_property_viewable_stat_static(name: str) -> bool: + """Check if a property should be excluded from viewable stats.""" return ( - hasattr(obj, '__module__') and - obj.__module__ and - obj.__module__.startswith('plotly.graph_objs') + name.endswith('_trace') or + name.endswith('_range') or + name in {'last_updated', 'properties'} or + 'datum' in name.lower() ) + @staticmethod + def _is_numeric_annotation(annotation) -> bool: + """Return True if *annotation* resolves to a numeric type. + + Handles plain types (``float``, ``int``), numpy scalar types, + ``Optional[float]``, ``Union[float, int]``, etc. + """ + if annotation is typing.Any: + return False + origin = getattr(annotation, '__origin__', None) + if origin is typing.Union: + # e.g. Optional[float] == Union[float, None] + return any( + DunderMixin._is_numeric_annotation(arg) + for arg in annotation.__args__ + if arg is not type(None) + ) + try: + if isinstance(annotation, type) and issubclass(annotation, _NUMERIC_TYPES): + return True + except TypeError: + pass + return False + + @classmethod + def _get_comparable_float_properties_from_class(cls) -> list[str]: + """Discover float-valued ``@property`` names by inspecting the class hierarchy. + + Unlike the instance method ``_get_comparable_float_properties`` this + does **not** require a live object. It walks the MRO, finds + ``property`` descriptors, applies the viewable-stat exclusion filter, + and then checks the ``fget`` return annotation. If a property has a + numeric return annotation it is included; if it has *no* annotation + at all it is included as well (permissive fallback). Properties + that are explicitly annotated as a non-numeric type are excluded. + """ + seen: set[str] = set() + float_properties: list[str] = [] + for klass in cls.__mro__: + for name, value in klass.__dict__.items(): + if name in seen: + continue + seen.add(name) + if not isinstance(value, property): + continue + if DunderMixin._should_exclude_property_viewable_stat_static(name): + continue + + # --- check return annotation of property.fget --- + fget = value.fget + if fget is None: + continue + try: + hints = typing.get_type_hints(fget) + except (AttributeError, TypeError, NameError): + hints = {} + ret = hints.get('return') + if ret is None: + # No annotation → include (permissive fallback). + float_properties.append(name) + elif DunderMixin._is_numeric_annotation(ret): + float_properties.append(name) + # else: explicitly non-numeric → skip + return float_properties + + @staticmethod + def _is_plotly_trace(obj): + """Check if object is a Plotly trace object.""" + return is_plotly_trace(obj) + def _compare_none_values(self, self_value, other_value): """Compare None values. Returns (should_continue, result).""" if self_value is None and other_value is None: @@ -64,16 +171,17 @@ def _compare_dataframes(self, self_value, other_value): def _compare_dictionaries(self, self_value, other_value): """Compare dictionaries by comparing keys and values separately. Returns (should_continue, result).""" if isinstance(self_value, dict) and isinstance(other_value, dict): - # Compare keys first (order-independent) - if list(self_value.keys()) == list(other_value.keys()) and list(self_value.values()) == list(other_value.values()): - return False, True # Quick path: both keys and values match in order - - # Compare values for each key - for key in self_value.keys(): - if self_value[key] != other_value[key]: + if len(self_value) != len(other_value): + return False, False + + # Compare ordered key-value pairs using __eq__ (not hash-based) + # so that objects with identity-based __hash__ still compare + # correctly when they are value-equal. + for (k1, v1), (k2, v2) in zip(self_value.items(), other_value.items()): + if k1 != k2 or v1 != v2: return False, False - - return False, True # Dictionaries are equal + + return False, True elif isinstance(self_value, dict) or isinstance(other_value, dict): return False, False # One is dict, other is not return True, None # Continue, not dictionaries @@ -135,30 +243,35 @@ def __eq__(self, other): return False break # Values are equal, continue to next property - except (AttributeError, Exception): - # If property doesn't exist or comparison fails + except (AttributeError, TypeError): return False return True def __hash__(self): + """Return a stable hash compatible with value-based equality. + + Instances using this mixin are mutable, so hashing their property + values would make their hash unstable. A constant hash preserves + the requirement that objects which compare equal have equal hashes + while allowing ``dict`` and ``set`` lookups to use ``__eq__``. + + The tradeoff is that hash-based lookups among these objects are + linear rather than constant-time. """ - Simple, robust hash based on object identity. - - Uses id() for a fast, guaranteed-unique hash that won't fail. - Objects are only equal if they're the same instance. - """ - return hash(id(self)) + return 0 def __str__(self): """ String representation of the instance showing all @property decorated attributes and their values. """ - return f"{self.__class__.__name__}, {self.__name__}" + if hasattr(self, 'name') and self.name: + return f"{self.__class__.__name__} ({self.name})" + else: + return f"{self.__class__.__name__}" def __repr__(self): """ Official string representation of the instance. """ return self.__str__() - diff --git a/steer_core/Mixins/Plotter.py b/steer_core/Mixins/Plotter.py index 025f46d..39e27b3 100644 --- a/steer_core/Mixins/Plotter.py +++ b/steer_core/Mixins/Plotter.py @@ -1,15 +1,216 @@ +from __future__ import annotations + +from typing import Any, Callable + +import pandas as pd +import plotly.express as px import plotly.graph_objects as go -from typing import Dict, Any, Tuple, List, Union + +from steer_core.Mixins.Colors import ColorMixin +from steer_core.Mixins.Coordinates import CoordinateMixin class PlotterMixin: - + + DEFAULT_PALETTE = [ + "#4C78A8", "#F58518", "#E45756", "#72B7B2", + "#54A24B", "#EECA3B", "#B279A2", "#FF9DA6", + "#9D755D", "#BAB0AC", + ] + + SCATTER_X_AXIS = dict( + showgrid=True, + gridcolor="rgba(128, 128, 128, 0.2)", + gridwidth=1, + zeroline=True, + zerolinecolor="rgba(0, 0, 0, 0.5)", + zerolinewidth=1, + ) + + SCATTER_Y_AXIS = dict( + showgrid=True, + gridcolor="rgba(128, 128, 128, 0.2)", + gridwidth=1, + zeroline=True, + zerolinecolor="rgba(0, 0, 0, 0.5)", + zerolinewidth=1, + ) + + SCHEMATIC_X_AXIS = dict( + zeroline=False, + scaleanchor="y", + title="X (mm)" + ) + + SCHEMATIC_Y_AXIS = dict( + zeroline=False, + title="Y (mm)" + ) + + SCHEMATIC_Z_AXIS = dict( + zeroline=False, + scaleanchor="x", + title="Z (mm)" + ) + + BOTTOM_LEGEND = dict( + orientation="h", + yanchor="top", + y=-0.3, + xanchor="center", + x=0.5, + ) + + # ── Colour utilities (partially delegated to ColorMixin) ──────────── + + @staticmethod + def hex_to_rgb(hex_color: str) -> tuple[int, int, int]: + """Convert a hex colour string (e.g. ``'#4C78A8'``) to an ``(R, G, B)`` tuple.""" + hex_color = hex_color.lstrip("#") + return tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) + + @staticmethod + def rgb_to_hex(rgb: tuple[int, int, int]) -> str: + """Convert an ``(R, G, B)`` tuple to a hex colour string.""" + return ColorMixin.rgb_tuple_to_hex(rgb) + + @staticmethod + def lighten_color(hex_color: str, factor: float) -> str: + """Lighten *hex_color* by blending towards white. + + Parameters + ---------- + hex_color : str + Hex colour string, e.g. ``'#4C78A8'``. + factor : float + Blend factor — ``0`` returns the original colour, ``1`` returns white. + """ + r, g, b = PlotterMixin.hex_to_rgb(hex_color) + r = int(r + (255 - r) * factor) + g = int(g + (255 - g) * factor) + b = int(b + (255 - b) * factor) + return ColorMixin.rgb_tuple_to_hex((r, g, b)) + + @staticmethod + def hex_to_rgba(hex_color: str, alpha: float = 1.0) -> str: + """Convert a hex colour string to an ``rgba()`` CSS string.""" + return ColorMixin._hex_to_rgba(hex_color, alpha) + + # ── Internal helpers ────────────────────────────────────────────── + + @staticmethod + def _apply_layout_defaults( + fig: go.Figure, + layout_defaults: dict[str, Any] | None = None, + ) -> go.Figure: + """Apply an optional dictionary of layout overrides to *fig*.""" + if layout_defaults and fig is not None and hasattr(fig, "update_layout"): + fig.update_layout(**layout_defaults) + return fig + + # ── Component trace (existing) ──────────────────────────────────── + + @staticmethod + def create_component_trace( + components: list | object, + coord_attr: str, + name: str, + line_width: float, + color_func: Callable, + unit_conversion_factor: float, + order_clockwise: str | None = None, + gl: bool = False, + ) -> go.Scatter | go.Scattergl | None: + """ + Create a single trace for a component or group of components with NaN separators. + + Parameters + ---------- + components : list or object + Single component or list of components to process + coord_attr : str + Attribute path for coordinates (e.g., '_a_side_coating_coordinates') + name : str + Name for the trace + line_width : float + Width of the trace line + color_func : callable + Function to get color from component + unit_conversion_factor : float + Factor to convert coordinates to desired units + order_clockwise : str or None, optional + Plane for clockwise ordering ('xy', 'xz', 'yz') or None to disable, by default None + + Returns + ------- + go.Scatter or None + Plotly scatter trace or None if no valid coordinates + """ + # Convert single component to list for uniform processing + if not isinstance(components, list): + components = [components] + + if not components: + return None + + # Extract coordinates using nested getattr for dot notation + coord_arrays = [] + for component in components: + coords = component + # Handle nested attributes like '_current_collector._body_coordinates' + for attr_part in coord_attr.split('.'): + coords = getattr(coords, attr_part) + + if coords is not None and len(coords) > 0: + coord_arrays.append(coords) + + if not coord_arrays: + return None + + # Concatenate coordinates with NaN separators + combined_coords = CoordinateMixin.concat_with_nan_separators(coord_arrays) + + # Order coordinates clockwise if requested + if order_clockwise is not None: + combined_coords = CoordinateMixin.order_coordinates_clockwise_numpy(combined_coords, plane=order_clockwise) + + # Convert to mm and extract y,z coordinates directly (avoid DataFrame overhead) + y_coords = combined_coords[:, 1] * unit_conversion_factor + z_coords = combined_coords[:, 2] * unit_conversion_factor + + # Create trace + if gl: + return go.Scattergl( + x=y_coords, + y=z_coords, + mode="lines", + name=name, + line={'width': line_width, 'color': "black"}, + fill="toself", + fillcolor=color_func(components[0]), + legendgroup=name, + showlegend=True, + ) + else: + return go.Scatter( + x=y_coords, + y=z_coords, + mode="lines", + name=name, + line={'width': line_width, 'color': "black"}, + fill="toself", + fillcolor=color_func(components[0]), + legendgroup=name, + showlegend=True, + ) + @staticmethod def plot_breakdown_sunburst( - breakdown_dict: Dict[str, Any], + breakdown_dict: dict[str, Any], title: str = "Breakdown", root_label: str = "Total", unit: str = "", + colorway: list[str] = None, **kwargs, ) -> go.Figure: """ @@ -17,7 +218,7 @@ def plot_breakdown_sunburst( Parameters ---------- - breakdown_dict : Dict[str, Any] + breakdown_dict : dict[str, Any] Nested dictionary where values can be either numbers or nested dictionaries. Each nesting level becomes a ring in the sunburst plot. title : str, optional @@ -26,14 +227,24 @@ def plot_breakdown_sunburst( Label for the root node. Defaults to "Total". unit : str, optional Unit string to display in hover text (e.g., "g", "kg", "%"). Defaults to "". + colorway : list[str], optional + List of colors to use for the inner ring. If None, uses Plotly's default colorway. + Defaults to None. Returns ------- go.Figure Plotly sunburst figure """ + + # Default Plotly colorway if none provided + if colorway is None: + colorway = [ + '#636EFA', '#EF553B', '#00CC96', '#AB63FA', '#FFA15A', + '#19D3F3', '#FF6692', '#B6E880', '#FF97FF', '#FECB52' + ] - def _flatten_breakdown_values(data: Dict[str, Any]) -> List[float]: + def _flatten_breakdown_values(data: dict[str, Any]) -> list[float]: """Recursively flatten all numeric values from nested breakdown dictionary""" values = [] for value in data.values(): @@ -43,7 +254,7 @@ def _flatten_breakdown_values(data: Dict[str, Any]) -> List[float]: values.append(float(value)) return values - def _calculate_subtotal(data: Dict[str, Any]) -> float: + def _calculate_subtotal(data: dict[str, Any]) -> float: """Calculate the total value for a dictionary (sum of all nested numeric values)""" total = 0.0 for value in data.values(): @@ -54,13 +265,14 @@ def _calculate_subtotal(data: Dict[str, Any]) -> float: return total def _prepare_sunburst_data( - data: Dict[str, Any], parent_id: str = "", current_path: str = "" - ) -> Tuple[List[str], List[str], List[str], List[float]]: + data: dict[str, Any], parent_id: str = "", current_path: str = "", depth: int = 1 + ) -> tuple[list[str], list[str], list[str], list[float], list[int]]: """Recursively prepare data for sunburst plot with proper hierarchy""" ids = [] labels = [] parents = [] values = [] + depths = [] for key, value in data.items(): # Create unique ID for this node @@ -69,6 +281,7 @@ def _prepare_sunburst_data( ids.append(node_id) labels.append(key) parents.append(parent_id) + depths.append(depth) if isinstance(value, dict): # This is a nested dictionary - calculate its total value @@ -81,8 +294,9 @@ def _prepare_sunburst_data( nested_labels, nested_parents, nested_values, + nested_depths, ) = _prepare_sunburst_data( - value, parent_id=node_id, current_path=node_id + value, parent_id=node_id, current_path=node_id, depth=depth + 1 ) # Add nested data to our lists @@ -90,18 +304,19 @@ def _prepare_sunburst_data( labels.extend(nested_labels) parents.extend(nested_parents) values.extend(nested_values) + depths.extend(nested_depths) elif isinstance(value, (int, float)): # This is a leaf node with a numeric value values.append(float(value)) - return ids, labels, parents, values + return ids, labels, parents, values, depths # Calculate total value for root node total_value = _calculate_subtotal(breakdown_dict) # Prepare hierarchical data starting with root - ids, labels, parents, values = _prepare_sunburst_data( + ids, labels, parents, values, depths = _prepare_sunburst_data( breakdown_dict, parent_id="" ) @@ -110,12 +325,56 @@ def _prepare_sunburst_data( labels.insert(0, root_label) parents.insert(0, "") values.insert(0, total_value) + depths.insert(0, 0) # Update parent references to point to root for i in range(1, len(parents)): if parents[i] == "": parents[i] = root_label + # Generate colors based on alphabetical ordering and depth + # Get first-level keys (children of root) and sort alphabetically + first_level_keys = sorted([key for key in breakdown_dict.keys()]) + + # Assign base colors to first-level keys + key_to_base_color = {} + for i, key in enumerate(first_level_keys): + key_to_base_color[key] = colorway[i % len(colorway)] + + # Assign colors to all nodes + marker_colors = [] + max_depth = max(depths) if depths else 0 + + for i, (node_id, label, parent, depth) in enumerate(zip(ids, labels, parents, depths)): + if depth == 0: + # Root node - use neutral color + marker_colors.append('#CCCCCC') + elif depth == 1: + # First level - use assigned base color + marker_colors.append(key_to_base_color[label]) + else: + # Deeper levels - find the first-level ancestor and lighten its color + # Trace back through parents to find first-level ancestor + current_parent = parent + ancestor_label = None + + for j, (check_id, check_label, check_depth) in enumerate(zip(ids, labels, depths)): + if check_id == current_parent: + if check_depth == 1: + ancestor_label = check_label + break + current_parent = parents[j] + + if ancestor_label and ancestor_label in key_to_base_color: + base_color = key_to_base_color[ancestor_label] + # Lighten based on depth (depth 2 gets 0.3, depth 3 gets 0.5, depth 4 gets 0.7, etc.) + lighten_factor = 0.2 + (depth - 1) * 0.25 + lighten_factor = min(lighten_factor, 0.85) # Cap at 0.85 to avoid too pale + marker_colors.append(PlotterMixin.lighten_color(base_color, lighten_factor)) + else: + # Fallback to neutral color + marker_colors.append('#DDDDDD') + # Create custom hover text with percentages hover_text = [] for i, (label, value) in enumerate(zip(labels, values)): @@ -139,6 +398,7 @@ def _prepare_sunburst_data( branchvalues="total", hovertemplate="%{customdata}", customdata=hover_text, + marker=dict(colors=marker_colors), ) ) @@ -147,3 +407,1108 @@ def _prepare_sunburst_data( ) return fig + + # ── Generic statistical plots ───────────────────────────────────── + + @staticmethod + def plot_scatter( + df: pd.DataFrame, + x: str, + y: str, + hover_name: str = None, + custom_data: list[str] | None = None, + color: str | None = None, + size: str | None = None, + size_max: int = 30, + template: str = "presentation", + layout_defaults: dict[str, Any] | None = None, + **kwargs, + ) -> go.Figure: + """Create a styled 2-D scatter plot. + + Parameters + ---------- + df : pd.DataFrame + DataFrame containing the columns referenced by *x*, *y*, etc. + x, y : str + Column names for the X and Y axes. + hover_name : str, optional + Column whose values appear as the main hover label. + custom_data : list of str, optional + Column names to include in ``customdata`` for click handlers. + color : str, optional + Column name for colour grouping. + size : str, optional + Column name for marker-size mapping. + size_max : int + Maximum marker size when *size* is set. + template : str + Plotly template name. + layout_defaults : dict, optional + Additional ``fig.update_layout`` overrides applied last. + + Returns + ------- + go.Figure + """ + fig = px.scatter( + df, + x=x, + y=y, + hover_name=hover_name, + custom_data=custom_data or [], + color=color, + size=size, + size_max=size_max if size else None, + color_discrete_map={} if color else None, + template=template, + **kwargs, + ) + fig.update_layout(xaxis_title=x, yaxis_title=y) + return PlotterMixin._apply_layout_defaults(fig, layout_defaults) + + @staticmethod + def plot_grouped_scatter( + groups, + x_values, + y_values, + x_label: str = "X", + y_label: str = "Y", + group_label: str = "Group", + palette: list[str] | None = None, + template: str = "presentation", + layout_defaults: dict[str, Any] | None = None, + ) -> go.Figure: + """Create a scatter plot of group means with std dev error bars. + + Parameters + ---------- + groups : array-like + Parallel list of group labels (one per observation). + x_values, y_values : array-like + Parallel lists of numeric values. + x_label, y_label : str + Human-readable axis labels. + group_label : str + Human-readable label for the grouping variable. + palette : list of str, optional + Hex colour palette. Falls back to ``DEFAULT_PALETTE``. + template : str + Plotly template name. + layout_defaults : dict, optional + Additional ``fig.update_layout`` overrides applied last. + + Returns + ------- + go.Figure + """ + palette = palette or PlotterMixin.DEFAULT_PALETTE + df = pd.DataFrame({"_group": groups, "_x": x_values, "_y": y_values}) + agg = df.groupby("_group", sort=True).agg( + x_mean=("_x", "mean"), x_std=("_x", "std"), + y_mean=("_y", "mean"), y_std=("_y", "std"), + count=("_x", "size"), + ).reset_index() + agg[["x_std", "y_std"]] = agg[["x_std", "y_std"]].fillna(0) + + fig = go.Figure() + for i, row in agg.iterrows(): + clr = palette[i % len(palette)] + fig.add_trace(go.Scatter( + x=[row["x_mean"]], + y=[row["y_mean"]], + error_x=dict(type="data", array=[row["x_std"]], visible=True), + error_y=dict(type="data", array=[row["y_std"]], visible=True), + mode="markers+text", + marker=dict(size=12, color=clr), + text=[f"n={int(row['count'])}"], + textposition="top center", + name=str(row["_group"]), + hovertemplate=( + f"{row['_group']}
" + f"{x_label}: %{{x:.4g}} \u00b1 {row['x_std']:.4g}
" + f"{y_label}: %{{y:.4g}} \u00b1 {row['y_std']:.4g}
" + f"n={int(row['count'])}" + ), + )) + fig.update_layout( + xaxis_title=x_label, + yaxis_title=y_label, + template=template, + ) + return PlotterMixin._apply_layout_defaults(fig, layout_defaults) + + @staticmethod + def plot_scatter_3d( + df: pd.DataFrame, + x: str, + y: str, + z: str, + hover_name: str = None, + custom_data: list[str] | None = None, + color: str | None = None, + size: str | None = None, + size_max: int = 30, + template: str = "presentation", + layout_defaults: dict[str, Any] | None = None, + **kwargs, + ) -> go.Figure: + """Create a styled 3-D scatter plot. + + Parameters + ---------- + df : pd.DataFrame + DataFrame containing the columns referenced by *x*, *y*, *z*, etc. + x, y, z : str + Column names for the three axes. + hover_name : str, optional + Column whose values appear as the main hover label. + custom_data : list of str, optional + Column names to include in ``customdata``. + color : str, optional + Column name for colour grouping. + size : str, optional + Column name for marker-size mapping. + size_max : int + Maximum marker size when *size* is set. + template : str + Plotly template name. + layout_defaults : dict, optional + Additional ``fig.update_layout`` overrides applied last. + + Returns + ------- + go.Figure + """ + fig = px.scatter_3d( + df, + x=x, + y=y, + z=z, + hover_name=hover_name, + custom_data=custom_data or [], + color=color, + size=size, + size_max=size_max if size else None, + color_discrete_map={} if color else None, + template=template, + **kwargs, + ) + fig.update_layout( + scene=dict( + xaxis_title=x, + yaxis_title=y, + zaxis_title=z, + ) + ) + return PlotterMixin._apply_layout_defaults(fig, layout_defaults) + + @staticmethod + def plot_grouped_scatter_3d( + groups, + x_values, + y_values, + z_values, + x_label: str = "X", + y_label: str = "Y", + z_label: str = "Z", + group_label: str = "Group", + palette: list[str] | None = None, + template: str = "presentation", + layout_defaults: dict[str, Any] | None = None, + ) -> go.Figure: + """Create a 3-D scatter plot of group means with std dev error bars. + + Parameters + ---------- + groups : array-like + Parallel list of group labels. + x_values, y_values, z_values : array-like + Parallel lists of numeric values. + x_label, y_label, z_label : str + Human-readable axis labels. + group_label : str + Human-readable label for the grouping variable. + palette : list of str, optional + Hex colour palette. Falls back to ``DEFAULT_PALETTE``. + template : str + Plotly template name. + layout_defaults : dict, optional + Additional ``fig.update_layout`` overrides applied last. + + Returns + ------- + go.Figure + """ + palette = palette or PlotterMixin.DEFAULT_PALETTE + df = pd.DataFrame({ + "_group": groups, "_x": x_values, + "_y": y_values, "_z": z_values, + }) + agg = df.groupby("_group", sort=True).agg( + x_mean=("_x", "mean"), x_std=("_x", "std"), + y_mean=("_y", "mean"), y_std=("_y", "std"), + z_mean=("_z", "mean"), z_std=("_z", "std"), + count=("_x", "size"), + ).reset_index() + agg[["x_std", "y_std", "z_std"]] = agg[["x_std", "y_std", "z_std"]].fillna(0) + + fig = go.Figure() + for i, row in agg.iterrows(): + clr = palette[i % len(palette)] + fig.add_trace(go.Scatter3d( + x=[row["x_mean"]], + y=[row["y_mean"]], + z=[row["z_mean"]], + error_x=dict(type="data", array=[row["x_std"]], visible=True), + error_y=dict(type="data", array=[row["y_std"]], visible=True), + error_z=dict(type="data", array=[row["z_std"]], visible=True), + mode="markers+text", + marker=dict(size=8, color=clr), + text=[f"{row['_group']} (n={int(row['count'])})"], + name=str(row["_group"]), + hovertemplate=( + f"{row['_group']}
" + f"{x_label}: %{{x:.4g}} \u00b1 {row['x_std']:.4g}
" + f"{y_label}: %{{y:.4g}} \u00b1 {row['y_std']:.4g}
" + f"{z_label}: %{{z:.4g}} \u00b1 {row['z_std']:.4g}
" + f"n={int(row['count'])}" + ), + )) + fig.update_layout( + scene=dict( + xaxis_title=x_label, + yaxis_title=y_label, + zaxis_title=z_label, + ), + template=template, + ) + return PlotterMixin._apply_layout_defaults(fig, layout_defaults) + + @staticmethod + def plot_bar( + x, + y, + custom_data=None, + x_label: str = "X", + y_label: str = "Y", + template: str = "presentation", + layout_defaults: dict[str, Any] | None = None, + **kwargs, + ) -> go.Figure: + """Create a styled bar chart. + + Parameters + ---------- + x, y : array-like + Values for the X and Y axes. + custom_data : array-like, optional + Custom data attached to each bar (for click handlers). + x_label, y_label : str + Human-readable axis labels. + template : str + Plotly template name. + layout_defaults : dict, optional + Additional ``fig.update_layout`` overrides applied last. + + Returns + ------- + go.Figure + """ + fig = px.bar( + x=x, + y=y, + custom_data=[custom_data] if custom_data is not None else None, + labels={"x": x_label, "y": y_label}, + template=template, + **kwargs, + ) + fig.update_layout(xaxis_title=x_label, yaxis_title=y_label) + return PlotterMixin._apply_layout_defaults(fig, layout_defaults) + + @staticmethod + def plot_grouped_bar( + groups, + values, + y_label: str = "Y", + group_label: str = "Group", + palette: list[str] | None = None, + template: str = "presentation", + layout_defaults: dict[str, Any] | None = None, + ) -> go.Figure: + """Create a bar chart showing mean +/- std dev per group. + + Parameters + ---------- + groups : array-like + Parallel list of group labels (one per observation). + values : array-like + Parallel list of numeric values (one per observation). + y_label : str + Human-readable Y-axis label. + group_label : str + Human-readable X-axis (group axis) label. + palette : list of str, optional + Hex colour palette. Falls back to ``DEFAULT_PALETTE``. + template : str + Plotly template name. + layout_defaults : dict, optional + Additional ``fig.update_layout`` overrides applied last. + + Returns + ------- + go.Figure + """ + palette = palette or PlotterMixin.DEFAULT_PALETTE + df = pd.DataFrame({"_group": groups, "_value": values}) + agg = ( + df.groupby("_group", sort=True)["_value"] + .agg(["mean", "std", "count"]) + .reset_index() + ) + agg["std"] = agg["std"].fillna(0) + colors = [palette[i % len(palette)] for i in range(len(agg))] + + fig = go.Figure(data=[ + go.Bar( + x=agg["_group"], + y=agg["mean"], + error_y=dict( + type="data", array=agg["std"].tolist(), visible=True, + ), + text=[f"n={n}" for n in agg["count"]], + textposition="outside", + marker_color=colors, + ) + ]) + fig.update_layout( + xaxis_title=group_label, + yaxis_title=y_label, + template=template, + ) + return PlotterMixin._apply_layout_defaults(fig, layout_defaults) + + @staticmethod + def plot_histogram( + x, + x_label: str = "Value", + template: str = "presentation", + layout_defaults: dict[str, Any] | None = None, + **kwargs, + ) -> go.Figure: + """Create a styled histogram. + + Parameters + ---------- + x : array-like + Values to bin. + x_label : str + Human-readable axis label. + template : str + Plotly template name. + layout_defaults : dict, optional + Additional ``fig.update_layout`` overrides applied last. + + Returns + ------- + go.Figure + """ + fig = px.histogram( + x=x, + labels={"x": x_label}, + template=template, + **kwargs, + ) + fig.update_layout(xaxis_title=x_label, yaxis_title="Count") + return PlotterMixin._apply_layout_defaults(fig, layout_defaults) + + @staticmethod + def plot_grouped_histogram( + groups, + values, + x_label: str = "Value", + palette: list[str] | None = None, + template: str = "presentation", + layout_defaults: dict[str, Any] | None = None, + ) -> go.Figure: + """Create overlaid histograms, one per group. + + Parameters + ---------- + groups : array-like + Parallel list of group labels. + values : array-like + Parallel list of numeric values. + x_label : str + Human-readable axis label. + palette : list of str, optional + Hex colour palette. Falls back to ``DEFAULT_PALETTE``. + template : str + Plotly template name. + layout_defaults : dict, optional + Additional ``fig.update_layout`` overrides applied last. + + Returns + ------- + go.Figure + """ + palette = palette or PlotterMixin.DEFAULT_PALETTE + df = pd.DataFrame({"_group": groups, "_value": values}) + fig = go.Figure() + for i, (grp, sub) in enumerate( + sorted(df.groupby("_group"), key=lambda x: x[0]) + ): + fig.add_trace(go.Histogram( + x=sub["_value"], + name=str(grp), + opacity=0.7, + marker_color=palette[i % len(palette)], + )) + fig.update_layout( + barmode="overlay", + xaxis_title=x_label, + yaxis_title="Count", + template=template, + ) + return PlotterMixin._apply_layout_defaults(fig, layout_defaults) + + @staticmethod + def plot_pdf( + values, + label: str = "Value", + color_groups: dict[str, list[float]] | None = None, + template: str = "presentation", + layout_defaults: dict[str, Any] | None = None, + ) -> go.Figure: + """Create a probability-density histogram (one trace per colour group). + + Parameters + ---------- + values : array-like + Values to bin (used when *color_groups* is ``None``). + label : str + Human-readable axis label. + color_groups : dict, optional + Mapping ``{group_name: [values]}`` for overlaid per-group traces. + template : str + Plotly template name. + layout_defaults : dict, optional + Additional ``fig.update_layout`` overrides applied last. + + Returns + ------- + go.Figure + """ + fig = go.Figure() + if color_groups: + for group_name, group_vals in color_groups.items(): + fig.add_trace( + go.Histogram( + x=group_vals, + histnorm="probability density", + name=group_name, + opacity=0.7, + ) + ) + fig.update_layout(barmode="overlay") + else: + fig.add_trace( + go.Histogram( + x=values, + histnorm="probability density", + name=label, + ) + ) + fig.update_layout( + xaxis_title=label, + yaxis_title="Probability Density", + template=template, + ) + return PlotterMixin._apply_layout_defaults(fig, layout_defaults) + + @staticmethod + def plot_grouped_pdf( + groups, + values, + label: str = "Value", + palette: list[str] | None = None, + template: str = "presentation", + layout_defaults: dict[str, Any] | None = None, + ) -> go.Figure: + """Create overlaid probability-density histograms, one per group. + + Parameters + ---------- + groups : array-like + Parallel list of group labels. + values : array-like + Parallel list of numeric values. + label : str + Human-readable axis label. + palette : list of str, optional + Hex colour palette. Falls back to ``DEFAULT_PALETTE``. + template : str + Plotly template name. + layout_defaults : dict, optional + Additional ``fig.update_layout`` overrides applied last. + + Returns + ------- + go.Figure + """ + palette = palette or PlotterMixin.DEFAULT_PALETTE + df = pd.DataFrame({"_group": groups, "_value": values}) + fig = go.Figure() + for i, (grp, sub) in enumerate( + sorted(df.groupby("_group"), key=lambda x: x[0]) + ): + fig.add_trace(go.Histogram( + x=sub["_value"], + histnorm="probability density", + name=str(grp), + opacity=0.7, + marker_color=palette[i % len(palette)], + )) + fig.update_layout( + barmode="overlay", + xaxis_title=label, + yaxis_title="Probability Density", + template=template, + ) + return PlotterMixin._apply_layout_defaults(fig, layout_defaults) + + @staticmethod + def plot_box( + y, + x=None, + custom_data=None, + y_label: str = "Y", + points: str = "all", + template: str = "presentation", + layout_defaults: dict[str, Any] | None = None, + **kwargs, + ) -> go.Figure: + """Create a styled box plot. + + Parameters + ---------- + y : array-like + Values for the Y axis. + x : array-like, optional + Category values for grouping along the X axis. + custom_data : array-like, optional + Custom data attached to each point (for click handlers). + y_label : str + Human-readable Y-axis label. + points : str + One of ``'all'``, ``'outliers'``, ``'suspectedoutliers'``, or ``False``. + template : str + Plotly template name. + layout_defaults : dict, optional + Additional ``fig.update_layout`` overrides applied last. + + Returns + ------- + go.Figure + """ + box_kwargs: dict = dict( + y=y, + labels={"y": y_label}, + points=points, + template=template, + **kwargs, + ) + if x is not None: + box_kwargs["x"] = x + if custom_data is not None: + box_kwargs["custom_data"] = [custom_data] + fig = px.box(**box_kwargs) + fig.update_layout(yaxis_title=y_label) + return PlotterMixin._apply_layout_defaults(fig, layout_defaults) + + @staticmethod + def plot_violin( + y, + x=None, + custom_data=None, + y_label: str = "Y", + points: str = "all", + box: bool = True, + template: str = "presentation", + layout_defaults: dict[str, Any] | None = None, + **kwargs, + ) -> go.Figure: + """Create a styled violin plot. + + Parameters + ---------- + y : array-like + Values for the Y axis. + x : array-like, optional + Category values for grouping along the X axis. + custom_data : array-like, optional + Custom data attached to each point. + y_label : str + Human-readable Y-axis label. + points : str + One of ``'all'``, ``'outliers'``, ``'suspectedoutliers'``, or ``False``. + box : bool + Whether to overlay an inner box-plot. + template : str + Plotly template name. + layout_defaults : dict, optional + Additional ``fig.update_layout`` overrides applied last. + + Returns + ------- + go.Figure + """ + violin_kwargs: dict = dict( + y=y, + labels={"y": y_label}, + points=points, + box=box, + template=template, + **kwargs, + ) + if x is not None: + violin_kwargs["x"] = x + if custom_data is not None: + violin_kwargs["custom_data"] = [custom_data] + fig = px.violin(**violin_kwargs) + fig.update_layout(yaxis_title=y_label) + return PlotterMixin._apply_layout_defaults(fig, layout_defaults) + + @staticmethod + def plot_strip( + y, + x=None, + hover_name=None, + custom_data=None, + y_label: str = "Y", + template: str = "presentation", + layout_defaults: dict[str, Any] | None = None, + **kwargs, + ) -> go.Figure: + """Create a styled strip (jitter) plot. + + Parameters + ---------- + y : array-like + Values for the Y axis. + x : array-like, optional + Category values for grouping along the X axis. + hover_name : array-like, optional + Values to display as the main hover label. + custom_data : array-like, optional + Custom data attached to each point. + y_label : str + Human-readable Y-axis label. + template : str + Plotly template name. + layout_defaults : dict, optional + Additional ``fig.update_layout`` overrides applied last. + + Returns + ------- + go.Figure + """ + strip_kwargs: dict = dict( + y=y, + labels={"y": y_label}, + template=template, + **kwargs, + ) + if x is not None: + strip_kwargs["x"] = x + if hover_name is not None: + strip_kwargs["hover_name"] = hover_name + if custom_data is not None: + strip_kwargs["custom_data"] = [custom_data] + fig = px.strip(**strip_kwargs) + fig.update_layout(yaxis_title=y_label) + return PlotterMixin._apply_layout_defaults(fig, layout_defaults) + + # ── Radar / spider chart ────────────────────────────────────────── + + @staticmethod + def plot_radar( + df: pd.DataFrame, + axis_labels: list[str], + inverted: dict[str, bool] | None = None, + color_column: str | None = None, + name_column: str = "name", + palette: list[str] | None = None, + template: str = "presentation", + layout_defaults: dict[str, Any] | None = None, + ) -> go.Figure: + """Create a normalised radar (spider) chart. + + Each row in *df* becomes a ``Scatterpolar`` trace. Values are + normalised to 0–1 per axis so properties with very different scales + are comparable. Per-spoke tick annotations show the original values. + + Parameters + ---------- + df : pd.DataFrame + Must contain columns named in *axis_labels* (raw values) and + *name_column*. Optionally contains *color_column*. + axis_labels : list of str + Column names in *df* for the radar axes. + inverted : dict, optional + ``{axis_label: True}`` for axes where *lower* raw values should + appear *further* from the centre (e.g. cost). Defaults to + inverting any axis whose label contains ``"cost"`` (case-insensitive). + color_column : str, optional + Column name for categorical colour grouping. + name_column : str + Column name used as the trace name / hover label. + palette : list of str, optional + Hex colour palette. Falls back to ``DEFAULT_PALETTE``. + template : str + Plotly template name. + layout_defaults : dict, optional + Additional ``fig.update_layout`` overrides applied last. + + Returns + ------- + go.Figure + """ + palette = palette or PlotterMixin.DEFAULT_PALETTE + + if inverted is None: + inverted = {col: "cost" in col.lower() for col in axis_labels} + + # ── Normalise each axis to 0–1 with padding ────────────────── + df = df.copy() + axis_ranges: dict[str, tuple[float, float]] = {} + for col in axis_labels: + col_min = df[col].min() + col_max = df[col].max() + pad_min = col_min * 0.8 if col_min >= 0 else col_min * 1.2 + pad_max = col_max * 1.2 if col_max >= 0 else col_max * 0.8 + if pad_max - pad_min == 0: + pad_min, pad_max = col_min - 1, col_max + 1 + axis_ranges[col] = (pad_min, pad_max) + rng = pad_max - pad_min + if inverted.get(col, False): + df[col + "_norm"] = (pad_max - df[col]) / rng + else: + df[col + "_norm"] = (df[col] - pad_min) / rng + + norm_cols = [c + "_norm" for c in axis_labels] + + # ── Data traces ─────────────────────────────────────────────── + fig = go.Figure() + use_color = color_column and color_column in df.columns + + if use_color: + categories = sorted(df[color_column].unique().tolist()) + cat_colors = { + c: palette[i % len(palette)] + for i, c in enumerate(categories) + } + for _, r in df.iterrows(): + vals = [r[nc] for nc in norm_cols] + [r[norm_cols[0]]] + raw_vals = [r[al] for al in axis_labels] + [r[axis_labels[0]]] + hover = [ + f"{al}: {rv:.4g}" for al, rv in zip(axis_labels, raw_vals[:-1]) + ] + hover.append(hover[0]) + cat = r[color_column] + clr = cat_colors[cat] + fig.add_trace( + go.Scatterpolar( + r=vals, + theta=axis_labels + [axis_labels[0]], + name=r[name_column], + fill="toself", + fillcolor=PlotterMixin.hex_to_rgba(clr, 0.15), + line=dict(color=clr, width=2), + marker=dict(size=4, color=clr), + customdata=[[r[name_column]] for _ in vals], + hovertemplate=( + "%{theta}
%{text}
Cell: " + + str(r[name_column]) + + "" + ), + text=hover, + legendgroup=str(cat), + legendgrouptitle_text=str(cat), + ) + ) + else: + for i, (_, r) in enumerate(df.iterrows()): + vals = [r[nc] for nc in norm_cols] + [r[norm_cols[0]]] + raw_vals = [r[al] for al in axis_labels] + [r[axis_labels[0]]] + hover = [ + f"{al}: {rv:.4g}" for al, rv in zip(axis_labels, raw_vals[:-1]) + ] + hover.append(hover[0]) + clr = palette[i % len(palette)] + fig.add_trace( + go.Scatterpolar( + r=vals, + theta=axis_labels + [axis_labels[0]], + name=r[name_column], + fill="toself", + fillcolor=PlotterMixin.hex_to_rgba(clr, 0.15), + line=dict(color=clr, width=2), + marker=dict(size=4, color=clr), + customdata=[[r[name_column]] for _ in vals], + hovertemplate=( + "%{theta}
%{text}
Cell: " + + str(r[name_column]) + + "" + ), + text=hover, + ) + ) + + # ── Per-spoke tick annotations ──────────────────────────────── + tick_fractions = [0.25, 0.5, 0.75, 1.0] + for col in axis_labels: + pad_min, pad_max = axis_ranges[col] + rng = pad_max - pad_min + for frac in tick_fractions: + if inverted.get(col, False): + raw = pad_max - frac * rng + else: + raw = pad_min + frac * rng + if abs(raw) >= 100: + txt = f"{raw:.0f}" + elif abs(raw) >= 1: + txt = f"{raw:.2f}" + else: + txt = f"{raw:.3g}" + fig.add_trace( + go.Scatterpolar( + r=[frac], + theta=[col], + mode="text", + text=[f" {txt}"], + textfont=dict(size=10, color="#555", family="Arial"), + showlegend=False, + hoverinfo="skip", + ) + ) + + # ── Layout ──────────────────────────────────────────────────── + fig.update_layout( + polar=dict( + bgcolor="rgba(248,248,250,1)", + radialaxis=dict( + visible=False, + range=[0, 1.12], + showticklabels=False, + showline=False, + showgrid=False, + ), + angularaxis=dict( + showline=False, + linewidth=0, + gridwidth=2, + gridcolor="rgba(90,90,90,0.35)", + tickfont=dict(size=13, color="#111", family="Arial Black"), + ), + ), + template=template, + showlegend=True, + legend=dict( + font=dict(size=11), + itemsizing="constant", + tracegroupgap=4, + ), + margin=dict(t=60, b=60, l=80, r=80), + paper_bgcolor="white", + ) + + return PlotterMixin._apply_layout_defaults(fig, layout_defaults) + + @staticmethod + def plot_grouped_radar( + df: pd.DataFrame, + axis_labels: list[str], + group_column: str, + inverted: dict[str, bool] | None = None, + palette: list[str] | None = None, + template: str = "presentation", + layout_defaults: dict[str, Any] | None = None, + ) -> go.Figure: + """Create a normalised radar chart showing group means with std dev bands. + + Rows are grouped by *group_column*; each group becomes a trace + showing the per-axis mean. A lighter shaded band shows +/- 1 std dev. + + Parameters + ---------- + df : pd.DataFrame + Must contain columns named in *axis_labels* and *group_column*. + axis_labels : list of str + Column names in *df* for the radar axes. + group_column : str + Column name used for grouping. + inverted : dict, optional + ``{axis_label: True}`` for axes where lower is better. + Defaults to inverting any axis whose label contains ``"cost"``. + palette : list of str, optional + Hex colour palette. Falls back to ``DEFAULT_PALETTE``. + template : str + Plotly template name. + layout_defaults : dict, optional + Additional ``fig.update_layout`` overrides applied last. + + Returns + ------- + go.Figure + """ + palette = palette or PlotterMixin.DEFAULT_PALETTE + + if inverted is None: + inverted = {col: "cost" in col.lower() for col in axis_labels} + + grouped = df.groupby(group_column) + group_stats: dict[str, dict] = {} + for grp_name, grp_df in sorted(grouped, key=lambda x: str(x[0])): + means = {col: grp_df[col].mean() for col in axis_labels} + stds = { + col: grp_df[col].std() if len(grp_df) > 1 else 0.0 + for col in axis_labels + } + group_stats[grp_name] = { + "mean": means, "std": stds, "count": len(grp_df), + } + + all_means = pd.DataFrame([s["mean"] for s in group_stats.values()]) + all_stds = pd.DataFrame([s["std"] for s in group_stats.values()]) + + axis_ranges: dict[str, tuple[float, float]] = {} + for col in axis_labels: + low = (all_means[col] - all_stds[col]).min() + high = (all_means[col] + all_stds[col]).max() + pad_min = low * 0.8 if low >= 0 else low * 1.2 + pad_max = high * 1.2 if high >= 0 else high * 0.8 + if pad_max - pad_min == 0: + pad_min, pad_max = low - 1, high + 1 + axis_ranges[col] = (pad_min, pad_max) + + def _normalise(val, col): + pad_min, pad_max = axis_ranges[col] + rng = pad_max - pad_min + if inverted.get(col, False): + return (pad_max - val) / rng + return (val - pad_min) / rng + + fig = go.Figure() + theta_closed = axis_labels + [axis_labels[0]] + + for i, (grp_name, stats) in enumerate(group_stats.items()): + clr = palette[i % len(palette)] + means = stats["mean"] + stds = stats["std"] + n = stats["count"] + + norm_means = [_normalise(means[c], c) for c in axis_labels] + norm_means_closed = norm_means + [norm_means[0]] + + _band_a = [_normalise(means[c] + stds[c], c) for c in axis_labels] + _band_b = [_normalise(means[c] - stds[c], c) for c in axis_labels] + norm_upper = [max(a, b) for a, b in zip(_band_a, _band_b)] + norm_upper_closed = norm_upper + [norm_upper[0]] + + norm_lower = [max(0, min(a, b)) for a, b in zip(_band_a, _band_b)] + norm_lower_closed = norm_lower + [norm_lower[0]] + + hover = [ + f"{al}: {means[al]:.4g} \u00b1 {stds[al]:.4g}" + for al in axis_labels + ] + hover_closed = hover + [hover[0]] + + fig.add_trace(go.Scatterpolar( + r=norm_upper_closed, + theta=theta_closed, + fill=None, + mode="lines", + line=dict( + color=PlotterMixin.hex_to_rgba(clr, 0.3), width=0, + ), + showlegend=False, + hoverinfo="skip", + legendgroup=str(grp_name), + )) + fig.add_trace(go.Scatterpolar( + r=norm_lower_closed, + theta=theta_closed, + fill="tonext", + fillcolor=PlotterMixin.hex_to_rgba(clr, 0.12), + mode="lines", + line=dict( + color=PlotterMixin.hex_to_rgba(clr, 0.3), width=0, + ), + showlegend=False, + hoverinfo="skip", + legendgroup=str(grp_name), + )) + fig.add_trace(go.Scatterpolar( + r=norm_means_closed, + theta=theta_closed, + name=f"{grp_name} (n={n})", + fill="none", + line=dict(color=clr, width=2), + marker=dict(size=5, color=clr), + text=hover_closed, + hovertemplate=( + "%{theta}
%{text}
" + f"Group: {grp_name} (n={n})" + ), + legendgroup=str(grp_name), + )) + + tick_fractions = [0.25, 0.5, 0.75, 1.0] + for col in axis_labels: + pad_min, pad_max = axis_ranges[col] + rng = pad_max - pad_min + for frac in tick_fractions: + if inverted.get(col, False): + raw = pad_max - frac * rng + else: + raw = pad_min + frac * rng + if abs(raw) >= 100: + txt = f"{raw:.0f}" + elif abs(raw) >= 1: + txt = f"{raw:.2f}" + else: + txt = f"{raw:.3g}" + fig.add_trace(go.Scatterpolar( + r=[frac], theta=[col], mode="text", + text=[f" {txt}"], + textfont=dict(size=10, color="#555", family="Arial"), + showlegend=False, hoverinfo="skip", + )) + + fig.update_layout( + polar=dict( + bgcolor="rgba(248,248,250,1)", + radialaxis=dict( + visible=False, range=[0, 1.12], + showticklabels=False, showline=False, showgrid=False, + ), + angularaxis=dict( + showline=False, linewidth=0, gridwidth=2, + gridcolor="rgba(90,90,90,0.35)", + tickfont=dict( + size=13, color="#111", family="Arial Black", + ), + ), + ), + template=template, + showlegend=True, + legend=dict( + font=dict(size=11), itemsizing="constant", tracegroupgap=4, + ), + margin=dict(t=60, b=60, l=80, r=80), + paper_bgcolor="white", + ) + + return PlotterMixin._apply_layout_defaults(fig, layout_defaults) diff --git a/steer_core/Mixins/Propagation.py b/steer_core/Mixins/Propagation.py new file mode 100644 index 0000000..d0b577b --- /dev/null +++ b/steer_core/Mixins/Propagation.py @@ -0,0 +1,376 @@ +"""Mixin for update propagation through hierarchical object trees.""" + +from typing import Optional, Any, Generator +from functools import wraps +from copy import deepcopy as do_deepcopy +from contextlib import contextmanager + + +def propagating_setter(attr_name: str = None, deepcopy: bool = False): + """ + Decorator for setters that manage parent-child relationships. + + This decorator: + 1. Clears the old child's parent reference (if replacing with different object) + 2. Optionally creates a deep copy of the value (for storing copies) + 3. Sets the new child's parent reference with the attribute name + + Parameters + ---------- + attr_name : str, optional + The attribute name to use for propagation. If not provided, + the decorator will derive it from the setter's name. + deepcopy : bool, default False + If True, creates a deep copy of the value before passing to the setter. + Useful when you want to store a copy rather than the original. + During propagation (re-assignment of the same stored object), no copy + is made to avoid unnecessary duplication. + + Example Usage + ------------- + # Standard usage (no copy): + @formulation.setter + @calculate_all_properties + @propagating_setter() + def formulation(self, formulation: _ElectrodeFormulation): + self.validate_type(formulation, _ElectrodeFormulation, "formulation") + self._formulation = formulation + + # With deepcopy (stores a copy): + @insulation_material.setter + @calculate_bulk_properties + @propagating_setter(deepcopy=True) + def insulation_material(self, insulation_material: InsulationMaterial | None): + self.validate_type(insulation_material, InsulationMaterial, "insulation material") + self._insulation_material = insulation_material # This is already a copy + """ + def decorator(func): + @wraps(func) + def wrapper(self, new_value): + # Derive attribute name from function name if not provided + name = attr_name if attr_name else func.__name__ + private_name = f'_{name}' + + # Get the old value if it exists + old_value = getattr(self, private_name, None) + + # Check if this is a propagation re-assignment (same object already parented to us) + is_propagation = ( + new_value is not None and + hasattr(new_value, '_get_parent') and + new_value._get_parent() is self and + getattr(new_value, '_parent_attr_name', None) == name + ) + + # Clear old parent reference only if replacing with a different object + if old_value is not None and old_value is not new_value: + if hasattr(old_value, '_set_parent'): + old_value._set_parent(None) + + # Make deepcopy only if requested AND not during propagation + if deepcopy and new_value is not None and not is_propagation: + new_value = do_deepcopy(new_value) + + # Call the actual setter + result = func(self, new_value) + + # Set parent reference on new value + if new_value is not None and hasattr(new_value, '_set_parent'): + new_value._set_parent(self, name) + + return result + return wrapper + return decorator + + +class PropagationMixin: + """ + Mixin providing update propagation through a hierarchical object tree. + + This mixin adds two methods: + - `update()`: Triggers the parent's setter for this object (one level up) + - `propagate_changes()`: Triggers parent setters all the way up to root + + Each object can have a parent reference. When a child is assigned to a parent, + the parent should call `child._set_parent(self, 'attribute_name')` to establish + the link. The attribute name is used during propagation to re-assign the child + to the parent via its setter. + + Python's cyclic garbage collector handles any circular references. + + Serialization Note + ------------------ + The `_parent` reference is skipped during serialization to avoid circular + serialization. Parent references are re-established when objects are + reassembled after deserialization (via setters that call `_set_parent`). + + Example Usage + ------------- + # Change property at any depth: + cell.reference_assembly.layout.cathode.current_collector.thickness = new_value + + # Option 1: Update current level by triggering parent's setter + cell.reference_assembly.layout.cathode.current_collector.update() + # Equivalent to: a.b.c.current_collector = a.b.c.current_collector + + # Option 2: Propagate changes all the way to root (triggers all parent setters) + cell.reference_assembly.layout.cathode.current_collector.propagate_changes() + # Equivalent to re-assigning at each level up to root + """ + + _parent: Optional[Any] = None + _parent_attr_name: Optional[str] = None + + # ------------------------------------------------------------------------- + # Parent reference management + # ------------------------------------------------------------------------- + + def _set_parent(self, parent: Optional[Any], attr_name: Optional[str] = None) -> None: + """ + Set the parent reference for this object. + + Parameters + ---------- + parent : Optional[Any] + The parent object, or None to clear the parent reference. + attr_name : Optional[str] + The attribute name on the parent that holds this object. + Used during propagation to re-assign via the parent's setter. + If not provided, propagation will fall back to calling + parent.propagate_changes() directly. + """ + self._parent = parent + self._parent_attr_name = attr_name if parent is not None else None + + def _get_parent(self) -> Optional[Any]: + """ + Get the parent object if set. + + Returns + ------- + Optional[Any] + The parent object, or None if no parent is set. + """ + return self._parent + + def update(self) -> None: + """ + Trigger the parent's setter for this object. + + This is equivalent to `parent.attr = self`, ensuring that all setter + logic (validation, coordinate calculation, etc.) is executed. + + If this object has no parent, falls back to calling + `_calculate_all_properties()` directly. + + Use `propagate_changes()` to bubble updates all the way to the root. + """ + parent = self._get_parent() + attr_name = getattr(self, '_parent_attr_name', None) + + if parent is not None and attr_name is not None: + # Trigger parent's setter for this object + setattr(parent, attr_name, self) + else: + # No parent or no attr_name - fall back to direct calculation + if hasattr(self, '_update_properties') and not self._update_properties: + return + if hasattr(self, '_calculate_all_properties'): + self._calculate_all_properties() + + @contextmanager + def batch_updates(self) -> Generator[None, None, None]: + """Context manager for efficient batch property updates. + + Temporarily disables automatic recalculation during property changes, + then performs a single recalculation when exiting the context. + + Use this when setting multiple properties at once to avoid redundant + recalculations that would otherwise occur after each property change. + + Examples + -------- + >>> with obj.batch_updates(): + ... obj.property_a = value_a + ... obj.property_b = value_b + # Single recalculation happens here + + Yields + ------ + None + """ + original_update_flag = getattr(self, '_update_properties', True) + self._update_properties = False + + try: + yield + finally: + self._update_properties = original_update_flag + if original_update_flag and hasattr(self, '_calculate_all_properties'): + self._calculate_all_properties() + + def propagate_changes(self) -> None: + """ + Propagate changes up to root by triggering parent setters. + + This method triggers the parent's setter by re-assigning this object + to its parent attribute. This ensures any parent setter logic + (validation, name modification, coordinate calculation, etc.) is + executed during propagation. + + The propagation continues recursively until reaching the root. + + Note: This does NOT call update() on the current object - that should + already have happened via the property setter's @calculate_all_properties + decorator when the property was modified. + + Use this when you want changes to bubble up automatically. Use + `update()` instead when you need to manually trigger recalculation. + """ + self._propagate_to_parent() + + def _propagate_to_parent(self) -> None: + """ + Propagate changes to parent by triggering its setter. + + If an attribute name was provided when _set_parent was called, + this method will re-assign this object to the parent via setattr, + triggering the parent's property setter. The setter is expected + to call _calculate_all_properties() and then this method continues + bubbling up. + + If no attribute name is available (backward compatibility), falls + back to calling parent.propagate_changes() directly. + """ + parent = self._get_parent() + if parent is None: + return + + attr_name = getattr(self, '_parent_attr_name', None) + + if attr_name is not None: + # Re-assign self to parent via setter, triggering setter logic + # The setter will call _calculate_all_properties() on the parent + setattr(parent, attr_name, self) + # Continue propagation up the tree + if hasattr(parent, '_propagate_to_parent'): + parent._propagate_to_parent() + else: + # Backward compatibility: no attr_name, just call propagate_changes + if hasattr(parent, 'propagate_changes'): + parent.propagate_changes() + + # ------------------------------------------------------------------------- + # Serialization support - restore parent references after deserialization + # ------------------------------------------------------------------------- + + @classmethod + def _from_dict(cls, data: dict): + """ + Reconstruct object from dictionary, restoring parent references. + + Chains with SerializerMixin's _from_dict, then walks through + child objects to re-establish parent references that were lost + during serialization. + + Parameters + ---------- + data : dict + Dictionary representation to reconstruct from. + + Returns + ------- + Reconstructed object instance with parent references restored. + """ + # Chain to SerializerMixin's _from_dict + if hasattr(super(), '_from_dict'): + obj = super()._from_dict(data) + else: + # Fallback: basic reconstruction + obj = cls.__new__(cls) + obj.__dict__.update(data) + + # Initialize own _parent to None (will be set by parent if applicable) + obj._parent = None + + # Restore parent references for all children + obj._restore_child_parent_refs() + + return obj + + def _restore_child_parent_refs(self) -> None: + """ + Walk through attributes and set this object as parent of children. + + Called after deserialization to re-establish the parent-child + relationships that were lost during serialization. Also stores + the attribute name for setter-based propagation. + """ + for key, value in self.__dict__.items(): + if key in ('_parent', '_parent_attr_name'): + continue + # Derive the public attribute name (strip leading underscore if present) + attr_name = key[1:] if key.startswith('_') else key + self._set_parent_on_value(value, attr_name) + + def _set_parent_on_value(self, value: Any, attr_name: Optional[str] = None) -> None: + """ + Recursively set parent references on a value and its contents. + + Parameters + ---------- + value : Any + The value to set parent references on. + attr_name : Optional[str] + The attribute name on this object that holds the value. + Used for setter-based propagation. + """ + if hasattr(value, '_set_parent'): + value._set_parent(self, attr_name) + elif isinstance(value, (list, tuple)): + # For collections, items don't have a direct setter path + for item in value: + if hasattr(item, '_set_parent'): + item._set_parent(self, attr_name) + elif isinstance(value, dict): + # Check both keys AND values - materials can be dict keys + # For dict items, they don't have a direct setter path + for item in value.keys(): + if hasattr(item, '_set_parent'): + item._set_parent(self, attr_name) + for item in value.values(): + if hasattr(item, '_set_parent'): + item._set_parent(self, attr_name) + + def __deepcopy__(self, memo): + """Create a deep copy with properly restored parent references.""" + import copy + + # Temporarily remove parent info (these shouldn't be copied) + old_parent = self._parent + old_parent_attr_name = getattr(self, '_parent_attr_name', None) + self._parent = None + self._parent_attr_name = None + + # Perform the copy + cls = self.__class__ + result = cls.__new__(cls) + memo[id(self)] = result + + for k, v in self.__dict__.items(): + if k in ('_parent', '_parent_attr_name'): + setattr(result, k, None) + else: + setattr(result, k, copy.deepcopy(v, memo)) + + # Restore original's parent info + self._parent = old_parent + self._parent_attr_name = old_parent_attr_name + + # Restore parent references in the copy + result._restore_child_parent_refs() + + return result + + + \ No newline at end of file diff --git a/steer_core/Mixins/Serializer.py b/steer_core/Mixins/Serializer.py index 25cbcee..9220701 100644 --- a/steer_core/Mixins/Serializer.py +++ b/steer_core/Mixins/Serializer.py @@ -1,43 +1,454 @@ -import base64 -from pickle import loads, dumps -from typing import Type -from copy import deepcopy +import importlib +import msgpack +import msgpack_numpy as m +import lz4.frame +import zlib +import numpy as np +from typing import TypeVar, Any +from datetime import datetime +from enum import Enum + +# Patch msgpack for numpy support once at module import +m.patch() + +# Module-level cache for imported modules (avoids repeated importlib calls) +_module_cache: dict[str, type] = {} + +T = TypeVar('T', bound='SerializerMixin') + + +def _get_class(class_path: str) -> type: + """Get class from module path with caching.""" + if class_path not in _module_cache: + module_name, class_name = class_path.rsplit('.', 1) + module = importlib.import_module(module_name) + _module_cache[class_path] = getattr(module, class_name) + return _module_cache[class_path] class SerializerMixin: - def serialize(self) -> str: + + # Compression markers for backward compatibility + _MARKER_LZ4 = b'\x02' + _MARKER_ZLIB = b'\x01' + _MARKER_NONE = b'\x00' + + def serialize(self, compress: bool = True) -> bytes: """ - Serialize an object to a string representation. + Serialize object using MessagePack with numpy support. Parameters ---------- - obj : Type - The object to serialize. + compress : bool, optional + Whether to compress the serialized data (default: True) Returns ------- - str - The serialized string representation of the object. + bytes + The serialized byte representation of the object. + """ + # Include class information for proper deserialization + obj_dict = { + '_class': f"{self.__class__.__module__}.{self.__class__.__name__}", + **self._to_dict() + } + data = msgpack.packb(obj_dict, use_bin_type=True) + + if compress: + # Use LZ4 for fast compression + return self._MARKER_LZ4 + lz4.frame.compress(data) + else: + return self._MARKER_NONE + data + + def _serialize_value(self, value: Any) -> Any: """ - pickled = dumps(self) - based = base64.b64encode(pickled).decode("utf-8") - return based + Recursively serialize a value, handling nested structures. + Optimized with type-based dispatch for common types. + + Parameters + ---------- + value : Any + The value to serialize. + + Returns + ------- + Any + The serialized representation. + """ + # Fast path: check type directly for common cases + value_type = type(value) + + # Primitive types - return immediately (most common case) + if value_type in (int, float, str, bool, bytes, type(None)): + return value + + # Skip functions/methods without _to_dict + if callable(value) and not hasattr(value, '_to_dict'): + return None + + # Objects with _to_dict method (SerializerMixin instances) + if hasattr(value, '_to_dict'): + return { + '__object__': True, + '_class': f"{value.__class__.__module__}.{value.__class__.__name__}", + **value._to_dict() + } + + # datetime handling + if value_type is datetime: + return {'__datetime__': value.isoformat()} + + # Pandas DataFrame handling (lazy import to avoid hard dependency) + try: + import pandas as pd + if isinstance(value, pd.DataFrame): + return self._serialize_dataframe(value) + except ImportError: + pass - @staticmethod - def deserialize(String: str) -> Type: + # Enum handling + if isinstance(value, Enum): + return { + '__enum__': True, + 'class': f"{value.__class__.__module__}.{value.__class__.__name__}", + 'value': value.value + } + + # Tuple handling + if value_type is tuple: + return { + '__tuple__': True, + 'items': [self._serialize_value(item) for item in value] + } + + # List handling + if value_type is list: + return [self._serialize_value(item) for item in value] + + # Dict handling - single pass detection and serialization + if value_type is dict: + return self._serialize_dict(value) + + # Fallback for other types (numpy arrays handled by msgpack_numpy) + return value + + def _serialize_dataframe(self, df) -> dict: + """ + Serialize a pandas DataFrame to a dictionary representation. + Stores columns as numpy arrays to leverage msgpack_numpy for + efficient dtype-preserving serialization. + + Parameters + ---------- + df : pandas.DataFrame + The DataFrame to serialize. + + Returns + ------- + dict + Dictionary with '__dataframe__' marker and column/index data. + """ + result = { + '__dataframe__': True, + 'columns': list(df.columns), + 'data': {col: df[col].to_numpy() for col in df.columns}, + 'index_values': df.index.to_numpy(), + 'index_name': df.index.name, + } + # Preserve MultiIndex if present + import pandas as pd + if isinstance(df.index, pd.MultiIndex): + result['__multiindex__'] = True + result['index_values'] = [level.tolist() for level in df.index.levels] + result['index_codes'] = [codes.tolist() for codes in df.index.codes] + result['index_names'] = list(df.index.names) + return result + + def _serialize_dict(self, d: dict) -> dict: + """ + Serialize dictionary in single pass, detecting object keys/values during iteration. + + Parameters + ---------- + d : dict + Dictionary to serialize. + + Returns + ------- + dict + Serialized dictionary representation. + """ + if not d: + return {} + + # Single-pass: serialize and detect objects simultaneously + items = [] + has_objects = False + + for k, v in d.items(): + k_serialized = self._serialize_value(k) + v_serialized = self._serialize_value(v) + + # Check if key or value became an object marker + k_is_obj = isinstance(k_serialized, dict) and k_serialized.get('__object__') + v_is_obj = isinstance(v_serialized, dict) and v_serialized.get('__object__') + + if k_is_obj or v_is_obj: + has_objects = True + + items.append((k, k_serialized, v_serialized)) + + if has_objects: + return { + '__object_dict__': True, + 'items': [{'key': k_ser, 'value': v_ser} for _, k_ser, v_ser in items] + } + else: + return {k: v_ser for k, _, v_ser in items} + + def _to_dict(self) -> dict: + """ + Convert object to dictionary for serialization. + Override this in subclasses to customize serialization behavior. + Single-pass implementation for efficiency. + + Returns + ------- + dict + Dictionary representation of the object. """ - Deserialize a string representation into an object. + result = {} + for key, value in self.__dict__.items(): + # Skip _parent - PropagationMixin rebuilds it via _from_dict + if key == '_parent': + continue + # Skip non-serializable callables + if callable(value) and not hasattr(value, '_to_dict'): + continue + result[key] = self._serialize_value(value) + return result + + @classmethod + def deserialize(cls: type[T], data: bytes) -> T: + """ + Deserialize byte data into an object. + Automatically detects and decompresses if needed. + Supports both LZ4 (new) and zlib (legacy) compression. Parameters ---------- - String : str - The string representation to deserialize. + data : bytes + The byte data to deserialize. Returns ------- - SerializerMixin + T + Instance of the class. + """ + # Check compression marker and decompress accordingly + marker = data[0:1] + if marker == cls._MARKER_LZ4: + data = lz4.frame.decompress(data[1:]) + elif marker == cls._MARKER_ZLIB: + # Backward compatibility with zlib-compressed data + data = zlib.decompress(data[1:]) + else: + data = data[1:] + + obj_dict = msgpack.unpackb(data, raw=False) + + # Use stored class information if available + if '_class' in obj_dict: + actual_cls = _get_class(obj_dict['_class']) + # Remove class marker before reconstructing + obj_data = {k: v for k, v in obj_dict.items() if k != '_class'} + return actual_cls._from_dict(obj_data) + else: + # Fallback for backward compatibility + return cls._from_dict(obj_dict) + + @classmethod + def _deserialize_value(cls, value: Any) -> Any: + """ + Recursively deserialize a value, handling nested structures. + Optimized with cached module lookups. + + Parameters + ---------- + value : Any + The value to deserialize. + + Returns + ------- + Any The deserialized object. """ - decoded = base64.b64decode(String.encode("utf-8")) - obj = deepcopy(loads(decoded)) + # Fast path for non-dict/list types + value_type = type(value) + + if value_type is dict: + # Check special markers in priority order (most common first) + if '__object__' in value: + # Reconstruct regular object using cached class lookup + obj_class = _get_class(value['_class']) + obj_data = {k: v for k, v in value.items() if k not in ('__object__', '_class')} + return obj_class._from_dict(obj_data) + elif '__datetime__' in value: + return datetime.fromisoformat(value['__datetime__']) + elif '__enum__' in value: + # Reconstruct enum using cached class lookup + enum_class = _get_class(value['class']) + return enum_class(value['value']) + elif '__tuple__' in value: + # Recursively reconstruct tuple items + return tuple(cls._deserialize_value(item) for item in value['items']) + elif '__dataframe__' in value: + import pandas as pd + if value.get('__multiindex__'): + index = pd.MultiIndex.from_arrays( + [np.array(lvl) for lvl in value['index_values']], + names=value.get('index_names'), + ) + # Reconstruct using codes if needed for proper ordering + if 'index_codes' in value: + index = pd.MultiIndex( + levels=[np.array(lvl) for lvl in value['index_values']], + codes=value['index_codes'], + names=value.get('index_names'), + ) + else: + index = pd.Index( + np.array(value['index_values']), + name=value.get('index_name'), + ) + data = {col: np.array(arr) for col, arr in value['data'].items()} + return pd.DataFrame(data, index=index, columns=value['columns']) + elif '__object_dict__' in value: + # Reconstruct dictionary with object keys or values + return { + cls._deserialize_value(item['key']): cls._deserialize_value(item['value']) + for item in value['items'] + } + else: + # Recursively deserialize regular dict values + return {k: cls._deserialize_value(v) for k, v in value.items()} + elif value_type is list: + # Recursively deserialize list items + return [cls._deserialize_value(item) for item in value] + else: + return value + + @classmethod + def _from_dict(cls: type[T], data: dict) -> T: + """ + Reconstruct object from dictionary. + Override in subclasses for custom deserialization. + + Parameters + ---------- + data : dict + Dictionary representation to reconstruct from. + + Returns + ------- + T + Reconstructed object instance. + """ + obj = cls.__new__(cls) + reconstructed = {} + for key, value in data.items(): + reconstructed[key] = cls._deserialize_value(value) + obj.__dict__.update(reconstructed) return obj + + @classmethod + def from_database(cls: type[T], name: str, table_name: str = None) -> T: + """ + Pull object from the database by name. + + Subclasses must define a '_table_name' class variable (str or list of str) + unless table_name is explicitly provided. + + The backend is selected by the ``OPENCELL_ENV`` environment variable: + + - ``development`` — uses the local SQLite database via + ``steer_opencell_data.DataManager``. No network calls, works offline. + Requires ``steer-opencell-data`` to be installed with ``database.db``. + - ``production`` (default) — uses the REST API via + ``steer_core.Data.DataManager``. Requires the ``API_URL`` env var + pointing to the deployed Lambda endpoint. + + Parameters + ---------- + name : str + Name of the object to retrieve. + table_name : str, optional + Specific table to search. If provided, '_table_name' is not required. + If None, uses class's _table_name. + + Returns + ------- + T + Instance of the class. + + Raises + ------ + NotImplementedError + If the subclass doesn't define '_table_name' and table_name is not provided. + ValueError + If the object name is not found in any of the tables. + """ + from steer_core.Data import is_development + _dev_mode = is_development() + if _dev_mode: + from steer_opencell_data.DataManager import DataManager + else: + from steer_core.Data.DataManager import DataManager + from steer_core.Data.DataManager import NotFoundError + + database = DataManager() + + # Get list of tables to search + if table_name: + tables_to_search = [table_name] + else: + if not hasattr(cls, '_table_name'): + raise NotImplementedError( + f"{cls.__name__} must define a '_table_name' class variable " + "or provide 'table_name' argument" + ) + + if isinstance(cls._table_name, (list, tuple)): + tables_to_search = cls._table_name + else: + tables_to_search = [cls._table_name] + + # In production mode, attempt get_data directly (single HTTP call) + # instead of listing all names first. In development mode (SQLite) + # we keep the list-then-fetch approach since it has no network cost. + for table in tables_to_search: + if _dev_mode: + available = database.get_unique_values(table, "name") + if name not in available: + continue + data = database.get_data(table, condition=f"name = '{name}'") + else: + try: + data = database.get_data(table, condition=f"name = '{name}'") + except NotFoundError: + continue + + serialized_bytes = data["object"].iloc[0] + return cls.deserialize(serialized_bytes) + + # Not found — build a helpful error message + all_available = [] + for table in tables_to_search: + all_available.extend(database.get_unique_values(table, "name")) + + raise ValueError( + f"'{name}' not found in tables {tables_to_search}. " + f"Available: {all_available}" + ) + + diff --git a/steer_core/Mixins/TypeChecker.py b/steer_core/Mixins/TypeChecker.py index e7136dd..86635be 100644 --- a/steer_core/Mixins/TypeChecker.py +++ b/steer_core/Mixins/TypeChecker.py @@ -1,8 +1,13 @@ -from typing import Type +from __future__ import annotations + +from typing import Type, Iterable +from enum import Enum +from datetime import datetime import pandas as pd import numpy as np -import plotly.graph_objects as go +from steer_core.Constants.Format import DEFAULT_HOUR_FMT, DEFAULT_DAY_FMT +from steer_core.Utils import is_plotly_trace as _is_plotly_trace ALLOWED_REFERENCE = ["Na/Na+", "Li/Li+"] @@ -10,49 +15,61 @@ class ValidationMixin: @staticmethod - def validate_plotly_trace(value: object, name: str) -> None: + def validate_plotly_trace(value: object, name: str) -> bool: """ - Validate that a value is a Plotly trace object. + Check whether *value* is a Plotly trace object. Parameters ---------- value : object The value to validate. name : str - The name of the parameter for error messages. + The name of the parameter (unused, kept for API compatibility). - Raises - ------ - TypeError - If the value is not a Plotly trace object. + Returns + ------- + bool + ``True`` if *value* is a Plotly trace, ``False`` otherwise. """ - return ( - hasattr(value, '__module__') and - value.__module__ and - value.__module__.startswith('plotly.graph_objs') - ) + return _is_plotly_trace(value) @staticmethod - def validate_type(value: Type, expected_type: Type, name: str) -> None: - """ - Validate that a value is of the expected type. + def validate_type(value, expected_type, name: str) -> None: + """Validate that a value is of the expected type or one of multiple allowed types. Parameters ---------- - value : Type + value : Any The value to validate. - expected_type : Type - The expected type of the value. + expected_type : Type | Iterable[Type] + A single expected type or an iterable (list/tuple/set) of acceptable types. name : str The name of the parameter for error messages. Raises ------ TypeError - If the value is not of the expected type. + If the value is not an instance of any of the expected types. + + Examples + -------- + >>> ValidationMixin.validate_type(5, int, 'count') # OK + >>> ValidationMixin.validate_type(5, (int, float), 'count') # OK + >>> ValidationMixin.validate_type('x', (int, float), 'count') # TypeError + >>> ValidationMixin.validate_type([1,2,3], list, 'items') # OK + >>> ValidationMixin.validate_type([1,2,3], [list, tuple], 'items') # OK """ - if not isinstance(value, expected_type): - raise TypeError(f"{name} must be of type {expected_type.__name__}. Provided: {type(value).__name__}.") + # Normalize expected types to a tuple for isinstance + if isinstance(expected_type, (list, set)): + expected_types = tuple(expected_type) + elif isinstance(expected_type, tuple): + expected_types = expected_type + else: + expected_types = (expected_type,) + + if not isinstance(value, expected_types): + type_names = ', '.join(t.__name__ for t in expected_types) + raise TypeError(f"{name} must be of type {type_names}. Provided: {type(value).__name__}.") @staticmethod def validate_percentage(value: float, name: str) -> None: @@ -103,7 +120,7 @@ def validate_fraction(value: float, name: str) -> None: @staticmethod def validate_pandas_dataframe( - df: pd.DataFrame, name: str, column_names: list = None + df: pd.DataFrame, name: str, column_names: list | None = None ) -> None: """ Validate that the input is a pandas DataFrame. @@ -169,7 +186,7 @@ def validate_datum(datum: np.ndarray) -> None: ValueError If the datum does not have exactly 3 coordinates. """ - if type(datum) is not tuple and len(datum) != 3: + if not isinstance(datum, (tuple, list, np.ndarray)) or len(datum) != 3: raise ValueError("Datum must be a 3D point with exactly 3 coordinates.") if not all(isinstance(coord, (int, float)) for coord in datum): @@ -192,9 +209,52 @@ def validate_positive_float(value: float, name: str) -> None: ValueError If the value is not a positive float. """ - if not isinstance(value, (int, float)): + if not isinstance(value, (int, float, np.int64, np.float64)): + raise TypeError(f"{name} must be a number. Provided: {type(value).__name__}.") + if value < 0: raise ValueError(f"{name} must be a positive float. Provided: {value}.") + @staticmethod + def validate_positive_int(value: int, name: str, strictly: bool = True) -> None: + """Validate that a value is a positive integer. + + Parameters + ---------- + value : int + The value to validate. + name : str + The name of the parameter for error messages. + strictly : bool, optional + If True (default), value must be strictly positive (> 0). + If False, value must be non-negative (>= 0). + + Raises + ------ + TypeError + If the value is not an integer (bool is rejected even though it is a subclass of int). + ValueError + If the integer does not meet the positivity requirement. + + Examples + -------- + >>> ValidationMixin.validate_positive_int(5, 'count') # OK + >>> ValidationMixin.validate_positive_int(0, 'count') # ValueError (strict mode) + >>> ValidationMixin.validate_positive_int(0, 'count', strictly=False) # OK (non-strict mode) + >>> ValidationMixin.validate_positive_int(-3, 'count') # ValueError + >>> ValidationMixin.validate_positive_int(True, 'flag') # TypeError (bool rejected) + >>> ValidationMixin.validate_positive_int(12_000, 'cycles') # OK + """ + # Reject bool explicitly (bool is subclass of int) + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{name} must be an integer. Provided: {value} (type: {type(value).__name__}).") + + if strictly: + if value <= 0: + raise ValueError(f"{name} must be a strictly positive integer (> 0). Provided: {value}.") + else: + if value < 0: + raise ValueError(f"{name} must be a non-negative integer (>= 0). Provided: {value}.") + @staticmethod def validate_string(value: str, name: str) -> None: """ @@ -276,3 +336,117 @@ def validate_positive_float_list(value: list, name: str) -> None: if len(value) == 0: raise ValueError(f"{name} must not be an empty list. Provided: {value}.") + + @staticmethod + def validate_enum_string(value, enum_class: Type[Enum], name: str) -> None: + """ + Validate that a string value belongs to an enum class. + + Parameters + ---------- + value : str + The string value to validate. + enum_class : Type[Enum] + The enum class to validate against. + name : str + The name of the parameter for error messages. + + Raises + ------ + TypeError + If the value is not a string. + ValueError + If the string value is not a valid member of the enum class. + + Examples + -------- + >>> from enum import Enum + >>> class AllocationShape(Enum): + ... CIRCLE = 'circle' + ... SQUARE = 'square' + >>> ValidationMixin.validate_enum_string('circle', AllocationShape, 'shape') # OK + >>> ValidationMixin.validate_enum_string('triangle', AllocationShape, 'shape') # Raises ValueError + """ + if not isinstance(value, str): + raise TypeError(f"{name} must be a string. Provided: {type(value).__name__}.") + + try: + enum_class(value) + except ValueError as e: + raise ValueError( + f"{name} must be one of {[member.value for member in enum_class]}" + ) from e + + @staticmethod + def validate_datetime_string(value: str, name: str) -> None: + """ + Validate that a string is in a valid datetime format. + Accepts either 'YYYY-MM-DD-HH' (hourly) or 'YYYY-MM-DD' (daily) formats. + + Parameters + ---------- + value : str + The datetime string to validate. + name : str + The name of the parameter for error messages. + + Raises + ------ + TypeError + If the value is not a string. + ValueError + If the string is not in a valid datetime format. + + Examples + -------- + >>> ValidationMixin.validate_datetime_string('2024-01-15-14', 'start_dt') # OK + >>> ValidationMixin.validate_datetime_string('2024-01-15', 'start_dt') # OK + >>> ValidationMixin.validate_datetime_string('2024/01/15', 'start_dt') # Raises ValueError + """ + if not isinstance(value, str): + raise TypeError(f"{name} must be a string. Provided: {type(value).__name__}.") + + # Try both formats + formats = [DEFAULT_HOUR_FMT, DEFAULT_DAY_FMT] + for fmt in formats: + try: + datetime.strptime(value, fmt) + return # Valid format found + except ValueError: + continue + + # If none of the formats worked, raise an error + raise ValueError( + f"{name} must be in format 'YYYY-MM-DD-HH' or 'YYYY-MM-DD'. Provided: {value}." + ) + + @staticmethod + def validate_number(value, name: str) -> None: + """ + Validate that a value is a numeric amount (int or float, but not bool). + + Parameters + ---------- + value : int or float + The numeric value to validate. + name : str + The name of the parameter for error messages. + + Raises + ------ + TypeError + If the value is a bool or not a numeric type (int or float). + + Examples + -------- + >>> ValidationMixin.validate_number(42, 'amount') # OK + >>> ValidationMixin.validate_number(3.14, 'amount') # OK + >>> ValidationMixin.validate_number(True, 'amount') # Raises TypeError + >>> ValidationMixin.validate_number('100', 'amount') # Raises TypeError + """ + if isinstance(value, bool): + raise TypeError(f"{name} must be a number (int or float), not bool.") + + if not isinstance(value, (int, float, np.int64)): + raise TypeError(f"{name} must be a number (int or float). Provided: {type(value).__name__}.") + diff --git a/steer_core/Utils/__init__.py b/steer_core/Utils/__init__.py new file mode 100644 index 0000000..224c078 --- /dev/null +++ b/steer_core/Utils/__init__.py @@ -0,0 +1,41 @@ +"""Utility functions for the steer-core package.""" + +from typing import Any + +import numpy as np + + +def is_plotly_trace(obj: object) -> bool: + """Return ``True`` if *obj* is a Plotly trace object.""" + return ( + hasattr(obj, '__module__') + and obj.__module__ + and obj.__module__.startswith('plotly.graph_objs') + ) + + +def round_dict_recursive( + obj: Any, precision: int = 2, unit_conversion: float = 1.0 +) -> Any: + """Recursively round values in a nested dictionary. + + Parameters + ---------- + obj : Any + Dictionary or numeric value to round. + precision : int, optional + Number of decimal places, by default 2. + unit_conversion : float, optional + Multiplicative factor applied before rounding, by default 1.0. + + Returns + ------- + Any + Rounded dictionary or value. + """ + if isinstance(obj, dict): + return { + k: round_dict_recursive(v, precision, unit_conversion) + for k, v in obj.items() + } + return np.round(obj * unit_conversion, precision) diff --git a/steer_core/__init__.py b/steer_core/__init__.py index f4bd716..d8e45a3 100644 --- a/steer_core/__init__.py +++ b/steer_core/__init__.py @@ -1 +1,10 @@ -__version__ = "0.1.21" +__version__ = "0.2.11" + +from .Mixins.Colors import ColorMixin +from .Mixins.Coordinates import CoordinateMixin +from .Mixins.Plotter import PlotterMixin +from .Mixins.TypeChecker import ValidationMixin +from .Mixins.Dunder import DunderMixin +from .Mixins.Serializer import SerializerMixin +from .Mixins.Data import DataMixin + diff --git a/test/test_dunder_mixin.py b/test/test_dunder_mixin.py new file mode 100644 index 0000000..ecc3497 --- /dev/null +++ b/test/test_dunder_mixin.py @@ -0,0 +1,23 @@ +from copy import deepcopy + +from steer_core.Mixins.Dunder import DunderMixin + + +class Example(DunderMixin): + def __init__(self, value): + self._value = value + + @property + def value(self): + return self._value + + +def test_equal_instances_have_constant_hash_and_support_dict_lookup(): + original = Example(3) + copied = deepcopy(original) + + values = {original: 7} + + assert original == copied + assert hash(original) == hash(copied) == 0 + assert values.get(copied, 0) == 7 diff --git a/test_refactored_dunder.py b/test_refactored_dunder.py deleted file mode 100644 index 488e6a3..0000000 --- a/test_refactored_dunder.py +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env python3 -"""Test script for the refactored DunderMixin functionality.""" - -import numpy as np -import pandas as pd -import sys -import os - -# Add the project root to the path -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from steer_core.Mixins.Dunder import DunderMixin - - -class TestClass(DunderMixin): - def __init__(self, name, value, data=None, array=None, dict_data=None): - self._name = name - self._value = value - self._data = data - self._array = array - self._dict_data = dict_data or {} - self._last_updated = "2023-01-01" # Should be excluded - - @property - def name(self): - return self._name - - @property - def value(self): - return self._value - - @property - def data(self): - return self._data - - @property - def array(self): - return self._array - - @property - def dict_data(self): - return self._dict_data - - @property - def last_updated(self): - return self._last_updated - - @property - def value_range(self): # Should be excluded - return (0, 100) - - -def test_all_comparison_types(): - """Test all comparison types to ensure they work correctly.""" - - # Test basic equality - obj1 = TestClass("test", 42) - obj2 = TestClass("test", 42) - obj3 = TestClass("test", 43) - - print("=== Basic Equality Tests ===") - print(f"obj1 == obj2: {obj1 == obj2} (should be True)") - print(f"obj1 == obj3: {obj1 == obj3} (should be False)") - print() - - # Test None values - obj_none1 = TestClass("test", None) - obj_none2 = TestClass("test", None) - obj_none3 = TestClass("test", 42) - - print("=== None Value Tests ===") - print(f"None == None: {obj_none1 == obj_none2} (should be True)") - print(f"None == Value: {obj_none1 == obj_none3} (should be False)") - print() - - # Test NumPy arrays - arr1 = np.array([1, 2, 3]) - arr2 = np.array([1, 2, 3]) - arr3 = np.array([1, 2, 4]) - - obj_arr1 = TestClass("test", 42, array=arr1) - obj_arr2 = TestClass("test", 42, array=arr2) - obj_arr3 = TestClass("test", 42, array=arr3) - - print("=== NumPy Array Tests ===") - print(f"Same arrays: {obj_arr1 == obj_arr2} (should be True)") - print(f"Different arrays: {obj_arr1 == obj_arr3} (should be False)") - print() - - # Test DataFrames - df1 = pd.DataFrame({'x': [1, 2, 3]}) - df2 = pd.DataFrame({'x': [1, 2, 3]}) - df3 = pd.DataFrame({'x': [1, 2, 4]}) - - obj_df1 = TestClass("test", 42, data=df1) - obj_df2 = TestClass("test", 42, data=df2) - obj_df3 = TestClass("test", 42, data=df3) - - print("=== DataFrame Tests ===") - print(f"Same DataFrames: {obj_df1 == obj_df2} (should be True)") - print(f"Different DataFrames: {obj_df1 == obj_df3} (should be False)") - print() - - # Test dictionaries - dict1 = {'a': 1, 'b': 2} - dict2 = {'a': 1, 'b': 2} - dict3 = {'a': 1, 'b': 3} - - obj_dict1 = TestClass("test", 42, dict_data=dict1) - obj_dict2 = TestClass("test", 42, dict_data=dict2) - obj_dict3 = TestClass("test", 42, dict_data=dict3) - - print("=== Dictionary Tests ===") - print(f"Same dicts: {obj_dict1 == obj_dict2} (should be True)") - print(f"Different dicts: {obj_dict1 == obj_dict3} (should be False)") - print() - - # Test mixed types (should be False) - obj_mixed1 = TestClass("test", 42, array=np.array([1, 2, 3])) - obj_mixed2 = TestClass("test", 42, array=[1, 2, 3]) # List instead of array - - print("=== Mixed Type Tests ===") - print(f"Array vs List: {obj_mixed1 == obj_mixed2} (should be False)") - print() - - # Test identity optimization - print("=== Identity Optimization Test ===") - print(f"Identity check: {obj1 is obj1} and {obj1 == obj1} (both should be True)") - print() - - -if __name__ == "__main__": - print("Testing refactored DunderMixin functionality...\n") - test_all_comparison_types() - print("All tests completed!") \ No newline at end of file