From e62730f1fa8f2953ca6c81e7eb827acb2966ec41 Mon Sep 17 00:00:00 2001 From: ptlin84 Date: Wed, 5 Aug 2026 10:49:18 -0700 Subject: [PATCH 1/7] feat: add thickness-aware notch alignment --- README.md | 4 + .../Components/CurrentCollectors/Notched.py | 160 ++++++++++-- .../ElectrodeAssemblies/JellyRolls.py | 237 ++++++++++++++++-- .../ElectrodeAssemblies/SpiralUtils.py | 77 +++++- test/test_assembly.py | 104 ++++++-- test/test_current_collectors.py | 206 +++++++++++---- test/test_spiral_utils.py | 69 +++-- 7 files changed, 733 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index aecbc7ca..76b001b4 100644 --- a/README.md +++ b/README.md @@ -775,6 +775,8 @@ Shared by all assemblies (`WoundJellyRoll`, `FlatWoundJellyRoll`, `PunchedStack` |---|---|---| | `thickness` | mm | Overall jelly roll thickness | | `width` | mm | Overall jelly roll width | +| `cathode_notch_alignment_angle` | rad or `None` | Same-phase cathode notch alignment; `None` keeps scalar spacing | +| `anode_notch_alignment_angle` | rad or `None` | Same-phase anode notch alignment; `None` keeps scalar spacing | **`FlatWoundJellyRoll` — additional read-only:** @@ -782,6 +784,7 @@ Shared by all assemblies (`WoundJellyRoll`, `FlatWoundJellyRoll`, `PunchedStack` |---|---|---| | `pressed_radius` | mm | Pressed mandrel radius | | `pressed_straight_length` | mm | Pressed mandrel straight length | +| `thickness_aware_notch_data` | dict | Calculated centers, pitches, and gaps by electrode | **`PunchedStack` / `ZFoldStack` — additional settable:** @@ -943,6 +946,7 @@ Shared by all current collector types. | `bare_lengths_b_side` | (mm, mm) | (start, end) bare region on B-side | | `a_side_coated_section` | (mm, mm) | (start, end) of A-side coating | | `b_side_coated_section` | (mm, mm) | (start, end) of B-side coating | +| `tab_center_positions` | list of mm or `None` | Optional uneven centers measured from the foil leading edge | **Tabbed CCs** (`NotchedCurrentCollector`, `TabWeldedCurrentCollector`, `PunchedCurrentCollector`) — **additional settable:** diff --git a/steer_opencell_design/Components/CurrentCollectors/Notched.py b/steer_opencell_design/Components/CurrentCollectors/Notched.py index eb535479..3554807f 100644 --- a/steer_opencell_design/Components/CurrentCollectors/Notched.py +++ b/steer_opencell_design/Components/CurrentCollectors/Notched.py @@ -3,19 +3,24 @@ """Notched current collector for tabless wound cells.""" -# import core decorators -from steer_core.Decorators.General import calculate_all_properties +from collections.abc import Iterable +from typing import Optional, Tuple + +import numpy as np # import core units from steer_core.Constants.Units import * -# import materials -from steer_opencell_design.Materials.Other import CurrentCollectorMaterial +# import core decorators +from steer_core.Decorators.General import calculate_all_properties -from typing import Tuple, Optional -import numpy as np +from steer_opencell_design.Components.CurrentCollectors.Base import ( + _TabbedCurrentCollector, + _TapeCurrentCollector, +) -from steer_opencell_design.Components.CurrentCollectors.Base import _TabbedCurrentCollector, _TapeCurrentCollector +# import materials +from steer_opencell_design.Materials.Other import CurrentCollectorMaterial class NotchedCurrentCollector(_TabbedCurrentCollector, _TapeCurrentCollector): @@ -118,6 +123,7 @@ def __init__( insulation_width: Optional[float] = 0, name: Optional[str] = "Notched Current Collector", datum: Optional[Tuple[float, float, float]] = (0, 0, 0), + tab_center_positions: Optional[Iterable[float]] = None, ) -> None: """ Initialize an object that represents a notched current collector. @@ -138,6 +144,10 @@ def __init__( Spacing between the tabs in mm. tab_height : float Height of the tabs in mm. + tab_center_positions : iterable of float, optional + Explicit tab center positions measured from the leading edge of the + foil in mm. When provided, these thickness-aware or otherwise + custom positions take precedence over ``tab_spacing``. coated_tab_height : float Height of the coated tab on the top side in mm. bare_lengths_a_side : Tuple[float, float] @@ -151,6 +161,10 @@ def __init__( datum : Optional[Tuple[float, float, float]], default=(0, 0, 0) Datum of the current collector in mm. """ + # Must exist before the base-class initialization invokes coordinate + # hooks through this class's MRO. + self._tab_center_positions = None + super().__init__( material=material, x_foil_length=length, @@ -167,6 +181,7 @@ def __init__( ) self.tab_spacing = tab_spacing + self.tab_center_positions = tab_center_positions self._calculate_all_properties() self._update_properties = True @@ -175,7 +190,9 @@ def from_tabless(cls, tabless) -> "NotchedCurrentCollector": """ Create a NotchedCurrentCollector from a TablessCurrentCollector. """ - from steer_opencell_design.Components.CurrentCollectors.Tabless import TablessCurrentCollector + from steer_opencell_design.Components.CurrentCollectors.Tabless import ( + TablessCurrentCollector, + ) # validate type cls.validate_type(tabless, TablessCurrentCollector, "tabless") @@ -210,7 +227,9 @@ def from_tab_welded(cls, tab_welded) -> "NotchedCurrentCollector": """ Create a NotchedCurrentCollector from a TabWeldedCurrentCollector. """ - from steer_opencell_design.Components.CurrentCollectors.Tabbed import TabWeldedCurrentCollector + from steer_opencell_design.Components.CurrentCollectors.Tabbed import ( + TabWeldedCurrentCollector, + ) # validate type cls.validate_type(tab_welded, TabWeldedCurrentCollector, "tab_welded") @@ -245,6 +264,19 @@ def _calculate_tab_positions(self) -> None: Function to calculate the positions of the tabs along the length of the current collector. """ x_min = self._datum[0] - self._x_foil_length / 2 + + explicit_centers = getattr(self, "_tab_center_positions", None) + if explicit_centers is not None: + self._validate_tab_center_positions(explicit_centers) + centers = x_min + explicit_centers + self._tab_positions = np.column_stack( + ( + centers - self._tab_width / 2, + centers + self._tab_width / 2, + ) + ) + return + x_max = self._datum[0] + self._x_foil_length / 2 + self._tab_spacing number_of_tabs = 1 @@ -272,6 +304,28 @@ def _calculate_tab_positions(self) -> None: self._tab_positions = np.column_stack((tab_starts, tab_ends)) + def _validate_tab_center_positions(self, positions: np.ndarray) -> None: + """Validate explicit tab centers expressed in internal meter units.""" + if positions.ndim != 1: + raise ValueError("tab_center_positions must be a one-dimensional sequence.") + if not np.all(np.isfinite(positions)): + raise ValueError("tab_center_positions must contain only finite values.") + if len(positions) == 0: + return + + minimum_center = self._tab_width / 2 + maximum_center = self._x_foil_length - self._tab_width / 2 + if positions[0] < minimum_center or positions[-1] > maximum_center: + raise ValueError( + "Each tab center must keep the full tab within the foil length." + ) + + pitches = np.diff(positions) + if np.any(pitches <= 0): + raise ValueError("tab_center_positions must be strictly increasing.") + if np.any(pitches < self._tab_width): + raise ValueError("Explicit tabs cannot overlap.") + def _calculate_coordinates(self): self._calculate_tab_positions() super()._calculate_coordinates() @@ -292,7 +346,9 @@ def _get_footprint( """ # Default values y_depth = self._y_foil_length if y_depth is None else y_depth - y_start = self._datum[1] - self._y_foil_length / 2 if y_start is None else y_start + y_start = ( + self._datum[1] - self._y_foil_length / 2 if y_start is None else y_start + ) notch = self._tab_height if notch_height is None else notch_height # Convert bare lengths to meters (they come in mm according to docstring) @@ -377,12 +433,14 @@ def _get_insulation_coordinates(self, side: str = "a") -> np.ndarray: y_ins_end = y_ins_start + self._insulation_width # Compute x bounds of coated region - bare_left, bare_right = self._bare_lengths_a_side if side == "a" else self._bare_lengths_b_side - + bare_left, bare_right = ( + self._bare_lengths_a_side if side == "a" else self._bare_lengths_b_side + ) + # Check if bare lengths exceed foil length - return empty arrays if so if bare_left + bare_right >= self._x_foil_length: return np.empty((0, 3)) - + x_start = self._datum[0] - self._x_foil_length / 2 + bare_left x_end = self._datum[0] + self._x_foil_length / 2 - bare_right @@ -403,7 +461,9 @@ def _get_insulation_coordinates(self, side: str = "a") -> np.ndarray: e = min(te, x_end) # Get coordinates for this tab's insulation rectangle - tab_x, tab_y = self.build_square_array(x_width=e - s, y_width=self._insulation_width, x=s, y=y_ins_start) + tab_x, tab_y = self.build_square_array( + x_width=e - s, y_width=self._insulation_width, x=s, y=y_ins_start + ) # Add to lists all_x.extend(tab_x) @@ -448,7 +508,7 @@ def _get_insulation_coordinates(self, side: str = "a") -> np.ndarray: # Create z array with proper numeric dtype z = np.full_like(x, z_val, dtype=float) - + # Handle None values by converting to NaN for numeric arrays none_mask = np.array([val is None for val in x]) if np.any(none_mask): @@ -461,6 +521,44 @@ def _get_insulation_coordinates(self, side: str = "a") -> np.ndarray: def tab_positions(self) -> list: return [(start * M_TO_MM, end * M_TO_MM) for start, end in self._tab_positions] + @property + def tab_center_positions(self) -> Optional[list]: + """Return configured explicit centers from the foil leading edge in mm.""" + positions = getattr(self, "_tab_center_positions", None) + if positions is None: + return None + return (positions * M_TO_MM).tolist() + + @property + def calculated_tab_center_positions(self) -> list: + """Return all calculated tab centers in the collector coordinate system.""" + if len(self._tab_positions) == 0: + return [] + centers = self._tab_positions.mean(axis=1) + return (centers * M_TO_MM).tolist() + + @property + def tab_pitches(self) -> list: + """Return consecutive center-to-center pitches in mm.""" + if len(self._tab_positions) < 2: + return [] + centers = self._tab_positions.mean(axis=1) + return (np.diff(centers) * M_TO_MM).tolist() + + @property + def tab_gaps(self) -> list: + """Return consecutive edge-to-edge notch gaps in mm.""" + if len(self._tab_positions) < 2: + return [] + return ( + (self._tab_positions[1:, 0] - self._tab_positions[:-1, 1]) * M_TO_MM + ).tolist() + + @property + def number_of_tabs(self) -> int: + """Return the number of complete tabs in the current pattern.""" + return len(self._tab_positions) + @property def tab_spacing(self) -> float: return self._tab_spacing * M_TO_MM @@ -508,12 +606,16 @@ def tab_width_range(self) -> Tuple[float, float]: @tab_spacing.setter @calculate_all_properties def tab_spacing(self, tab_spacing: float) -> None: - + self.validate_positive_float(tab_spacing, "tab_spacing") self._tab_spacing = float(tab_spacing) * MM_TO_M self._tab_gap = self._tab_spacing - self._tab_width + # Explicit positions and scalar spacing are mutually exclusive modes. + if hasattr(self, "_tab_center_positions"): + self._tab_center_positions = None + if self._tab_gap < 0: raise ValueError("Tab spacing cannot be less than the tab width.") @@ -539,4 +641,30 @@ def tab_gap(self, tab_gap: float) -> None: # Update internal values self._tab_gap = tab_gap_m self._tab_spacing = new_tab_spacing + self._tab_center_positions = None + + @tab_center_positions.setter + @calculate_all_properties + def tab_center_positions( + self, tab_center_positions: Optional[Iterable[float]] + ) -> None: + """Set explicit centers from the foil leading edge, in millimeters.""" + if tab_center_positions is None: + self._tab_center_positions = None + return + if isinstance(tab_center_positions, (str, bytes)) or not isinstance( + tab_center_positions, Iterable + ): + raise TypeError( + "tab_center_positions must be an iterable of numbers or None." + ) + + try: + positions = np.asarray(list(tab_center_positions), dtype=float) * MM_TO_M + except (TypeError, ValueError) as exc: + raise TypeError( + "tab_center_positions must be an iterable of numbers or None." + ) from exc + self._validate_tab_center_positions(positions) + self._tab_center_positions = positions diff --git a/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py b/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py index ed27e3af..40473244 100644 --- a/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py +++ b/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py @@ -3,40 +3,46 @@ """Jelly roll electrode assemblies wound around cylindrical or flat mandrels.""" -from typing import Union, Dict, Tuple, Any, Optional from abc import ABC, abstractmethod from copy import copy, deepcopy -import pandas as pd -import numpy as np -from scipy.optimize import brentq -import plotly.graph_objects as go from enum import Enum +from typing import Any, Dict, Optional, Tuple, Union -from steer_opencell_design.Constructions.Layups.Laminate import Laminate +import numpy as np +import pandas as pd +import plotly.graph_objects as go +from scipy.optimize import brentq from steer_core.Constants.Units import * from steer_core.Constants.Universal import PI, TWO_PI +from steer_core.Decorators.Coordinates import calculate_coordinates from steer_core.Decorators.General import ( calculate_all_properties, calculate_bulk_properties, recalculate, ) -from steer_core.Decorators.Coordinates import calculate_coordinates from steer_core.Mixins.Propagation import propagating_setter + +from steer_opencell_design.Components.CurrentCollectors.Notched import ( + NotchedCurrentCollector, +) +from steer_opencell_design.Components.CurrentCollectors.Tabbed import ( + TabWeldedCurrentCollector, +) +from steer_opencell_design.Components.CurrentCollectors.Tabless import ( + TablessCurrentCollector, +) from steer_opencell_design.Constructions.ElectrodeAssemblies.Base import ( _ElectrodeAssembly, ) -from steer_opencell_design.Constructions.ElectrodeAssemblies.WindingEquipment import ( - RoundMandrel, - FlatMandrel, -) from steer_opencell_design.Constructions.ElectrodeAssemblies.SpiralUtils import ( SpiralCalculator, ) from steer_opencell_design.Constructions.ElectrodeAssemblies.Tape import Tape -from steer_opencell_design.Components.CurrentCollectors.Tabbed import ( - TabWeldedCurrentCollector, +from steer_opencell_design.Constructions.ElectrodeAssemblies.WindingEquipment import ( + FlatMandrel, + RoundMandrel, ) - +from steer_opencell_design.Constructions.Layups.Laminate import Laminate # Constants for array column indices THETA_COL = 0 @@ -3472,6 +3478,8 @@ def __init__( additional_tape_wraps: float = 0, collector_tab_crumple_factor: float = 50.0, name: str = "Flat Wound Jelly Roll", + cathode_notch_alignment_angle: Optional[float] = None, + anode_notch_alignment_angle: Optional[float] = None, ) -> None: """Initialize flat wound jelly roll electrode assembly. @@ -3481,6 +3489,10 @@ def __init__( The layup structure to be wound mandrel : FlatMandrel Flat mandrel for racetrack winding + cathode_notch_alignment_angle : float, optional + Winding phase in radians at which cathode notch centers align. + anode_notch_alignment_angle : float, optional + Winding phase in radians at which anode notch centers align. Raises ------ @@ -3490,6 +3502,14 @@ def __init__( if not isinstance(mandrel, FlatMandrel): raise TypeError(f"mandrel must be FlatMandrel, got {type(mandrel)}") + self._cathode_notch_alignment_angle = self._validate_notch_alignment_angle( + cathode_notch_alignment_angle, "cathode_notch_alignment_angle" + ) + self._anode_notch_alignment_angle = self._validate_notch_alignment_angle( + anode_notch_alignment_angle, "anode_notch_alignment_angle" + ) + self._thickness_aware_notch_electrodes = [] + super().__init__( laminate=laminate, mandrel=mandrel, @@ -3526,6 +3546,7 @@ def _calculate_roll( self, laminate_x_spacing=0.004, initial_rotation_angle: Optional[float] = None, + apply_notch_alignment: bool = True, **kwargs, ): super()._calculate_roll(laminate_x_spacing, **kwargs) @@ -3536,6 +3557,87 @@ def _calculate_roll( initial_angle=initial_rotation_angle ) self._center_spirals() + if apply_notch_alignment: + self._apply_thickness_aware_notches() + + @staticmethod + def _validate_notch_alignment_angle( + value: Optional[float], name: str + ) -> Optional[float]: + """Validate an optional notch-stack phase angle in radians.""" + if value is None: + return None + try: + angle = float(value) + except (TypeError, ValueError) as exc: + raise TypeError(f"{name} must be a finite float or None.") from exc + if not np.isfinite(angle): + raise ValueError(f"{name} must be finite.") + return angle + + def _apply_thickness_aware_notches(self) -> None: + """Apply configured same-phase notch centers to notched collectors. + + The component spiral supplies the authoritative mapping between winding + angle and unwrapped sheet length. Collector coordinates are refreshed + directly to avoid recursively invoking parent propagation while the + jelly roll itself is being calculated. + """ + generated = set(self._thickness_aware_notch_electrodes) + configurations = { + "cathode": self._cathode_notch_alignment_angle, + "anode": self._anode_notch_alignment_angle, + } + + for electrode_name, alignment_angle in configurations.items(): + electrode = getattr(self._layup, f"_{electrode_name}") + collector = electrode._current_collector + + if alignment_angle is None: + if electrode_name in generated: + collector._tab_center_positions = None + collector._calculate_all_properties() + generated.remove(electrode_name) + continue + + if not isinstance(collector, NotchedCurrentCollector) or isinstance( + collector, TablessCurrentCollector + ): + raise TypeError( + f"{electrode_name}_notch_alignment_angle requires a " + "NotchedCurrentCollector." + ) + + spiral = self._component_spirals[f"{electrode_name}_current_collector"] + centers_global = SpiralCalculator.aligned_positions_from_spiral( + spiral=spiral, + alignment_angle=alignment_angle, + tab_width=collector._tab_width, + ) + + leading_edge = collector._datum[0] - collector._x_foil_length / 2 + centers_local = centers_global - leading_edge + collector._validate_tab_center_positions(centers_local) + collector._tab_center_positions = centers_local + collector._calculate_all_properties() + generated.add(electrode_name) + + self._thickness_aware_notch_electrodes = sorted(generated) + + def _clear_generated_notches_on_layup(self, layup: Laminate) -> None: + """Clear assembly-generated patterns before changing a copied layup length.""" + configurations = { + "cathode": self._cathode_notch_alignment_angle, + "anode": self._anode_notch_alignment_angle, + } + for electrode_name, alignment_angle in configurations.items(): + if alignment_angle is None: + continue + collector = getattr(layup, f"_{electrode_name}")._current_collector + if isinstance(collector, NotchedCurrentCollector) and not isinstance( + collector, TablessCurrentCollector + ): + collector._tab_center_positions = None def _get_tape_geometry_parameters(self, spirals_x_z: np.ndarray) -> Dict[str, Any]: """Get geometry parameters for racetrack tape calculation. @@ -3733,6 +3835,7 @@ def _calculate_thickness_width_range( # get the thickness minimum bound small_layup = deepcopy(self._layup) + self._clear_generated_notches_on_layup(small_layup) small_layup.length = min_layup_length small_layup = self.position_layup_on_mandrel(small_layup, self._mandrel) small_layup.calculate_flattened_center_lines() @@ -3760,6 +3863,7 @@ def _calculate_thickness_width_range( # get the thickness maximum bound big_layup = deepcopy(self._layup) + self._clear_generated_notches_on_layup(big_layup) big_layup.length = big_layup.length_range[1] big_layup = self.position_layup_on_mandrel(big_layup, self._mandrel) big_layup.calculate_flattened_center_lines() @@ -4127,6 +4231,103 @@ def pressed_straight_length(self) -> float: """Return the pressed mandrel straight length in mm.""" return self._pressed_straight_length * M_TO_MM + @property + def cathode_notch_alignment_angle(self) -> Optional[float]: + """Return the cathode notch-stack phase angle in radians.""" + return self._cathode_notch_alignment_angle + + @cathode_notch_alignment_angle.setter + @calculate_all_properties + def cathode_notch_alignment_angle(self, value: Optional[float]) -> None: + self._cathode_notch_alignment_angle = self._validate_notch_alignment_angle( + value, "cathode_notch_alignment_angle" + ) + + @property + def anode_notch_alignment_angle(self) -> Optional[float]: + """Return the anode notch-stack phase angle in radians.""" + return self._anode_notch_alignment_angle + + @anode_notch_alignment_angle.setter + @calculate_all_properties + def anode_notch_alignment_angle(self, value: Optional[float]) -> None: + self._anode_notch_alignment_angle = self._validate_notch_alignment_angle( + value, "anode_notch_alignment_angle" + ) + + @property + def thickness_aware_notch_data(self) -> Dict[str, Dict[str, Any]]: + """Return configured notch centers, pitches, and gaps for each electrode.""" + result: Dict[str, Dict[str, Any]] = {} + for electrode_name in ("cathode", "anode"): + angle = getattr(self, f"_{electrode_name}_notch_alignment_angle") + if angle is None: + continue + collector = getattr(self._layup, f"_{electrode_name}")._current_collector + result[electrode_name] = { + "alignment_angle": angle, + "centers": collector.tab_center_positions, + "pitches": collector.tab_pitches, + "gaps": collector.tab_gaps, + } + return result + + def plot_notch_alignment(self, layered: bool = False, **kwargs: Any) -> go.Figure: + """Plot the flat-wound cross-section with aligned notch-center markers. + + Markers at increasing radii should form one radial stack for each + configured electrode, demonstrating that every notch has the same + winding phase. + """ + figure = self.plot_spiral(layered=layered, **kwargs) + colors = {"cathode": "#d62728", "anode": "#1f77b4"} + + for electrode_name in ("cathode", "anode"): + angle = getattr(self, f"_{electrode_name}_notch_alignment_angle") + if angle is None: + continue + + collector = getattr(self._layup, f"_{electrode_name}")._current_collector + centers = collector.tab_center_positions + if not centers: + continue + + leading_edge = collector._datum[0] - collector._x_foil_length / 2 + centers_global = leading_edge + np.asarray(centers) * MM_TO_M + spiral = self._component_spirals[f"{electrode_name}_current_collector"] + valid = np.isfinite(spiral[:, X_UNWRAPPED_COL]) + component = spiral[valid] + order = np.argsort(component[:, X_UNWRAPPED_COL]) + component = component[order] + x_markers = np.interp( + centers_global, + component[:, X_UNWRAPPED_COL], + component[:, X_COORD_COL], + ) + z_markers = np.interp( + centers_global, + component[:, X_UNWRAPPED_COL], + component[:, Z_COORD_COL], + ) + figure.add_trace( + go.Scatter( + x=x_markers * M_TO_MM, + y=z_markers * M_TO_MM, + mode="markers", + name=f"{electrode_name.title()} notch centers", + marker={"size": 8, "color": colors[electrode_name]}, + customdata=np.column_stack( + (np.asarray(centers), np.arange(len(centers))) + ), + hovertemplate=( + "Turn %{customdata[1]:.0f}
" + "Unwrapped center: %{customdata[0]:.2f} mm" + ), + ) + ) + + return figure + @property def thickness(self) -> float: """Return the overall jelly roll thickness in millimeters.""" @@ -4187,6 +4388,7 @@ def thickness(self, target_thickness: float) -> None: # iterations. The original ``self._layup`` is untouched until the # final ``self.layup = self._layup`` reassignment after Brent. template_layup = deepcopy(self._layup) + self._clear_generated_notches_on_layup(template_layup) # The optimal rotation angle barely shifts between outer-Brent # iterations; cache it and warm-start the inner Brent each time. rotation_state: Dict[str, Optional[float]] = {"angle": None} @@ -4202,7 +4404,8 @@ def objective_function(length: float) -> float: template_layup, assembly_copy._mandrel ) assembly_copy._calculate_roll( - initial_rotation_angle=rotation_state["angle"] + initial_rotation_angle=rotation_state["angle"], + apply_notch_alignment=False, ) rotation_state["angle"] = getattr( assembly_copy, "_last_rotation_angle", None @@ -4255,6 +4458,7 @@ def width(self, target_width: float) -> None: # iterations. The original ``self._layup`` is untouched until the # final ``self.layup = self._layup`` reassignment after Brent. template_layup = deepcopy(self._layup) + self._clear_generated_notches_on_layup(template_layup) # The optimal rotation angle barely shifts between outer-Brent # iterations; cache it and warm-start the inner Brent each time. rotation_state: Dict[str, Optional[float]] = {"angle": None} @@ -4270,7 +4474,8 @@ def objective_function(length: float) -> float: template_layup, assembly_copy._mandrel ) assembly_copy._calculate_roll( - initial_rotation_angle=rotation_state["angle"] + initial_rotation_angle=rotation_state["angle"], + apply_notch_alignment=False, ) rotation_state["angle"] = getattr( assembly_copy, "_last_rotation_angle", None diff --git a/steer_opencell_design/Constructions/ElectrodeAssemblies/SpiralUtils.py b/steer_opencell_design/Constructions/ElectrodeAssemblies/SpiralUtils.py index 2dc2c25d..6e280666 100644 --- a/steer_opencell_design/Constructions/ElectrodeAssemblies/SpiralUtils.py +++ b/steer_opencell_design/Constructions/ElectrodeAssemblies/SpiralUtils.py @@ -3,14 +3,15 @@ """Static utility methods for spiral and racetrack geometry calculations used in jelly roll winding.""" -from steer_opencell_design.Constructions.Layups.Laminate import Laminate -from steer_core.Constants.Universal import PI, TWO_PI -from steer_core.Constants.Units import * -import numpy as np -import pandas as pd import math from typing import Optional +import numpy as np +import pandas as pd +from steer_core.Constants.Units import * +from steer_core.Constants.Universal import PI, TWO_PI + +from steer_opencell_design.Constructions.Layups.Laminate import Laminate try: from numba import njit @@ -1391,6 +1392,72 @@ def _build_grad_factor_grid(t_grid: np.ndarray, x_grid: np.ndarray) -> np.ndarra max_grad = float(np.max(np.abs(dt_dx))) + 1e-12 return (1.0 + 5.0 * (np.abs(dt_dx) / max_grad)).astype(np.float64) + @staticmethod + def aligned_positions_from_spiral( + spiral: np.ndarray, + alignment_angle: float, + tab_width: float = 0.0, + minimum_gap: float = 0.0, + ) -> np.ndarray: + """Return unwrapped centers that recur at one angular winding phase. + + Length inputs and outputs use meters; ``alignment_angle`` uses radians. + A center is emitted for every integer turn satisfying + ``theta = alignment_angle + 2*pi*k`` where the complete tab fits within + the supplied spiral's unwrapped-length extent. + """ + spiral = np.asarray(spiral, dtype=float) + if spiral.ndim != 2 or spiral.shape[1] <= X_UNWRAPPED_COL: + raise ValueError( + "spiral must be a two-dimensional array containing theta and " + "unwrapped-length columns." + ) + if not np.isfinite(alignment_angle): + raise ValueError("alignment_angle must be finite.") + if not np.isfinite(tab_width) or tab_width < 0: + raise ValueError("tab_width must be a finite non-negative value.") + if not np.isfinite(minimum_gap) or minimum_gap < 0: + raise ValueError("minimum_gap must be a finite non-negative value.") + + theta = spiral[:, THETA_COL] + x_unwrapped = spiral[:, X_UNWRAPPED_COL] + valid = np.isfinite(theta) & np.isfinite(x_unwrapped) + theta = theta[valid] + x_unwrapped = x_unwrapped[valid] + if len(theta) < 2: + return np.empty(0, dtype=float) + + order = np.argsort(theta) + theta = theta[order] + x_unwrapped = x_unwrapped[order] + theta, unique_indices = np.unique(theta, return_index=True) + x_unwrapped = x_unwrapped[unique_indices] + if len(theta) < 2: + return np.empty(0, dtype=float) + + first_turn = int(np.ceil((theta[0] - alignment_angle) / TWO_PI)) + last_turn = int(np.floor((theta[-1] - alignment_angle) / TWO_PI)) + if last_turn < first_turn: + return np.empty(0, dtype=float) + + target_theta = alignment_angle + TWO_PI * np.arange( + first_turn, last_turn + 1, dtype=float + ) + centers = np.interp(target_theta, theta, x_unwrapped) + + half_width = tab_width / 2 + x_min = float(np.min(x_unwrapped)) + x_max = float(np.max(x_unwrapped)) + fits = (centers - half_width >= x_min) & (centers + half_width <= x_max) + centers = np.sort(centers[fits]) + + if len(centers) > 1 and np.any(np.diff(centers) < tab_width + minimum_gap): + raise ValueError( + "Aligned tab positions overlap or violate the requested minimum gap." + ) + + return centers + @staticmethod def calculate_variable_thickness_spiral( laminate: Laminate, diff --git a/test/test_assembly.py b/test/test_assembly.py index b108e7c8..010838d7 100644 --- a/test/test_assembly.py +++ b/test/test_assembly.py @@ -3,39 +3,47 @@ import time import unittest +from copy import deepcopy + +import numpy as np import pandas as pd import plotly.graph_objects as go -from copy import deepcopy +from steer_core.Constants.Units import MM_TO_M +from steer_core.Constants.Universal import TWO_PI from steer_opencell_design import ( - CathodeFormulation, + Anode, AnodeFormulation, + AnodeMaterial, + Binder, Cathode, - Anode, - Separator, + CathodeFormulation, + CathodeMaterial, + ConductiveAdditive, + CurrentCollectorMaterial, + FlatMandrel, + FlatWoundJellyRoll, + InsulationMaterial, + Laminate, + MonoLayer, NotchedCurrentCollector, PunchedCurrentCollector, + PunchedStack, + RoundMandrel, + Separator, + SeparatorMaterial, TablessCurrentCollector, TabWeldedCurrentCollector, + Tape, + TapeMaterial, WeldTab, - PunchedStack, - ZFoldStack, WoundJellyRoll, - FlatWoundJellyRoll, - RoundMandrel, - FlatMandrel, - Tape, - MonoLayer, ZFoldMonoLayer, - Laminate, - CathodeMaterial, - AnodeMaterial, - Binder, - ConductiveAdditive, - CurrentCollectorMaterial, - SeparatorMaterial, - InsulationMaterial, - TapeMaterial, + ZFoldStack, +) +from steer_opencell_design.Constructions.ElectrodeAssemblies.JellyRolls import ( + THETA_COL, + X_UNWRAPPED_COL, ) @@ -628,6 +636,62 @@ def test_basics(self): self.assertAlmostEqual(self.my_jellyroll.width_range[0], 104.48, 1) self.assertAlmostEqual(self.my_jellyroll.width_range[1], 125.95, 1) + def test_thickness_aware_cathode_notches_align_by_winding_phase(self): + self.my_jellyroll.cathode_notch_alignment_angle = 0.0 + + collector = self.my_jellyroll.layup.cathode.current_collector + self.assertIsNotNone(collector.tab_center_positions) + self.assertGreater(collector.number_of_tabs, 2) + self.assertTrue(np.all(np.diff(collector.tab_pitches) > 0)) + + spiral = self.my_jellyroll._component_spirals["cathode_current_collector"] + valid = np.isfinite(spiral[:, THETA_COL]) & np.isfinite( + spiral[:, X_UNWRAPPED_COL] + ) + theta = spiral[valid, THETA_COL] + unwrapped = spiral[valid, X_UNWRAPPED_COL] + order = np.argsort(unwrapped) + leading_edge = collector._datum[0] - collector._x_foil_length / 2 + centers_global = ( + leading_edge + np.asarray(collector.tab_center_positions) * MM_TO_M + ) + center_angles = np.interp(centers_global, unwrapped[order], theta[order]) + phase_error = np.abs(center_angles - TWO_PI * np.rint(center_angles / TWO_PI)) + self.assertTrue(np.all(phase_error < 1e-8)) + + data = self.my_jellyroll.thickness_aware_notch_data["cathode"] + self.assertEqual(data["centers"], collector.tab_center_positions) + self.assertEqual(data["gaps"], collector.tab_gaps) + + figure = self.my_jellyroll.plot_notch_alignment() + marker_trace = next( + trace for trace in figure.data if trace.name == "Cathode notch centers" + ) + self.assertEqual(len(marker_trace.x), collector.number_of_tabs) + + def test_disabling_alignment_restores_scalar_spacing(self): + self.my_jellyroll.cathode_notch_alignment_angle = 0.0 + self.my_jellyroll.cathode_notch_alignment_angle = None + + collector = self.my_jellyroll.layup.cathode.current_collector + self.assertIsNone(collector.tab_center_positions) + # The legacy pattern may clip its final tab at the foil boundary. + for pitch in collector.tab_pitches[:-1]: + self.assertAlmostEqual(pitch, collector.tab_spacing) + + def test_alignment_configuration_serializes_for_both_electrodes(self): + self.my_jellyroll.cathode_notch_alignment_angle = 0.0 + self.my_jellyroll.anode_notch_alignment_angle = np.pi + + restored = FlatWoundJellyRoll.deserialize(self.my_jellyroll.serialize()) + + self.assertEqual(restored.cathode_notch_alignment_angle, 0.0) + self.assertEqual(restored.anode_notch_alignment_angle, np.pi) + self.assertGreater(restored.layup.cathode.current_collector.number_of_tabs, 2) + self.assertGreater(restored.layup.anode.current_collector.number_of_tabs, 2) + self.assertIn("cathode", restored.thickness_aware_notch_data) + self.assertIn("anode", restored.thickness_aware_notch_data) + def test_serialization(self): serialized = self.my_jellyroll.serialize() deserialized = FlatWoundJellyRoll.deserialize(serialized) diff --git a/test/test_current_collectors.py b/test/test_current_collectors.py index 436c3eef..ac419acd 100644 --- a/test/test_current_collectors.py +++ b/test/test_current_collectors.py @@ -1,24 +1,32 @@ # SPDX-FileCopyrightText: 2024-2026 Stanford University # SPDX-License-Identifier: AGPL-3.0-or-later +import os import unittest -from pickle import loads, dumps from base64 import b64decode, b64encode from copy import deepcopy +from pickle import dumps, loads + +import plotly.graph_objects as go -from steer_opencell_design.Materials.Other import CurrentCollectorMaterial from steer_opencell_design.Components.CurrentCollectors.Base import ( _TabbedCurrentCollector, _TapeCurrentCollector, ) -from steer_opencell_design.Components.CurrentCollectors.Punched import PunchedCurrentCollector -from steer_opencell_design.Components.CurrentCollectors.Notched import NotchedCurrentCollector -from steer_opencell_design.Components.CurrentCollectors.Tabbed import TabWeldedCurrentCollector, WeldTab -from steer_opencell_design.Components.CurrentCollectors.Tabless import TablessCurrentCollector - -import plotly.graph_objects as go - -import os +from steer_opencell_design.Components.CurrentCollectors.Notched import ( + NotchedCurrentCollector, +) +from steer_opencell_design.Components.CurrentCollectors.Punched import ( + PunchedCurrentCollector, +) +from steer_opencell_design.Components.CurrentCollectors.Tabbed import ( + TabWeldedCurrentCollector, + WeldTab, +) +from steer_opencell_design.Components.CurrentCollectors.Tabless import ( + TablessCurrentCollector, +) +from steer_opencell_design.Materials.Other import CurrentCollectorMaterial os.environ["OPENCELL_ENV"] = "development" @@ -42,12 +50,18 @@ def test_notched_mro_order(self): idx_tape = mro_names.index("_TapeCurrentCollector") idx_base = mro_names.index("_CurrentCollector") - self.assertLess(idx_tabbed, idx_tape, - "_TabbedCurrentCollector must resolve before " - "_TapeCurrentCollector in NotchedCurrentCollector's MRO") - self.assertLess(idx_tape, idx_base, - "_TapeCurrentCollector must resolve before the shared " - "_CurrentCollector base") + self.assertLess( + idx_tabbed, + idx_tape, + "_TabbedCurrentCollector must resolve before " + "_TapeCurrentCollector in NotchedCurrentCollector's MRO", + ) + self.assertLess( + idx_tape, + idx_base, + "_TapeCurrentCollector must resolve before the shared " + "_CurrentCollector base", + ) self.assertEqual(mro_names[0], "NotchedCurrentCollector") def test_notched_inherits_both_branches(self): @@ -61,18 +75,73 @@ def test_tabless_inherits_notched_diamond(self): self.assertTrue(issubclass(TablessCurrentCollector, _TapeCurrentCollector)) +class TestExplicitNotchPattern(unittest.TestCase): + def setUp(self): + material = CurrentCollectorMaterial( + name="Aluminum", specific_cost=5, density=2.7, color="#AAAAAA" + ) + self.collector = NotchedCurrentCollector( + material=material, + length=500, + width=100, + thickness=10, + tab_width=20, + tab_spacing=100, + tab_height=10, + tab_center_positions=[50, 155, 270, 395], + ) + + def test_explicit_centers_produce_uneven_pitches_and_gaps(self): + self.assertEqual(self.collector.tab_center_positions, [50, 155, 270, 395]) + self.assertEqual(self.collector.number_of_tabs, 4) + for actual, expected in zip(self.collector.tab_pitches, [105, 115, 125]): + self.assertAlmostEqual(actual, expected) + for actual, expected in zip(self.collector.tab_gaps, [85, 95, 105]): + self.assertAlmostEqual(actual, expected) + + def test_positions_are_relative_to_leading_edge(self): + positions_before = self.collector.tab_center_positions + absolute_before = self.collector.calculated_tab_center_positions + + self.collector.datum = (100, 0, 0) + + self.assertEqual(self.collector.tab_center_positions, positions_before) + for before, after in zip( + absolute_before, self.collector.calculated_tab_center_positions + ): + self.assertAlmostEqual(after - before, 100) + + def test_setting_spacing_restores_uniform_mode(self): + self.collector.tab_spacing = 80 + + self.assertIsNone(self.collector.tab_center_positions) + for pitch in self.collector.tab_pitches: + self.assertAlmostEqual(pitch, 80) + + def test_rejects_overlapping_or_out_of_bounds_tabs(self): + with self.assertRaises(ValueError): + self.collector.tab_center_positions = [50, 60] + with self.assertRaises(ValueError): + self.collector.tab_center_positions = [5, 100] + with self.assertRaises(ValueError): + self.collector.tab_center_positions = [100, 495] + + def test_serialization_preserves_explicit_pattern(self): + restored = NotchedCurrentCollector.deserialize(self.collector.serialize()) + + self.assertEqual(restored.tab_center_positions, [50, 155, 270, 395]) + self.assertEqual(restored.number_of_tabs, 4) + + class TestPunchedCurrentCollector(unittest.TestCase): def setUp(self): """ Set up """ self.material = CurrentCollectorMaterial( - name="Copper", - density=8.96, - specific_cost=18.1, - color="#B87333" + name="Copper", density=8.96, specific_cost=18.1, color="#B87333" ) - + self.current_collector = PunchedCurrentCollector( material=self.material, width=160, @@ -214,14 +283,22 @@ def test_current_collector(self): self.assertEqual(round(self.current_collector._tab_width, 6), 0.03) self.assertEqual(round(self.current_collector._tab_spacing, 6), 0.05) self.assertEqual(round(self.current_collector._tab_height, 6), 0.007) - self.assertEqual(round(self.current_collector._bare_lengths_a_side[0], 6), 0.015) + self.assertEqual( + round(self.current_collector._bare_lengths_a_side[0], 6), 0.015 + ) self.assertEqual(round(self.current_collector._bare_lengths_a_side[1], 6), 0.08) self.assertEqual(round(self.current_collector._bare_lengths_b_side[0], 6), 0.02) self.assertEqual(round(self.current_collector._bare_lengths_b_side[1], 6), 0.08) self.assertEqual(round(self.current_collector._coated_tab_height, 6), 0.002) - self.assertAlmostEqual(round(self.current_collector.foil_area, 1), 6732, places=0) - self.assertAlmostEqual(round(self.current_collector.coated_area, 1), 3079.3 + 3074, places=0) - self.assertAlmostEqual(round(self.current_collector.insulation_area, 1), 185.8, places=0) + self.assertAlmostEqual( + round(self.current_collector.foil_area, 1), 6732, places=0 + ) + self.assertAlmostEqual( + round(self.current_collector.coated_area, 1), 3079.3 + 3074, places=0 + ) + self.assertAlmostEqual( + round(self.current_collector.insulation_area, 1), 185.8, places=0 + ) self.assertAlmostEqual(self.current_collector.material.cost, 0.04, places=2) self.assertAlmostEqual(self.current_collector.material.mass, 13.63, places=2) @@ -235,19 +312,27 @@ def test_figures(self): # fig_d.show(renderer='browser') def test_setters(self): - self.current_collector.material = CurrentCollectorMaterial.from_database(name="Copper") + self.current_collector.material = CurrentCollectorMaterial.from_database( + name="Copper" + ) self.assertEqual(self.current_collector.material.name, "Copper") self.assertAlmostEqual(self.current_collector.material.mass, 45.23904, places=5) - self.assertAlmostEqual(self.current_collector.material.cost, 0.70120512, places=5) + self.assertAlmostEqual( + self.current_collector.material.cost, 0.70120512, places=5 + ) self.current_collector.thickness = 10 self.assertEqual(self.current_collector.thickness, 10) self.assertAlmostEqual(self.current_collector.material.mass, 30.15936, places=5) - self.assertAlmostEqual(self.current_collector.material.cost, 0.46747008, places=5) + self.assertAlmostEqual( + self.current_collector.material.cost, 0.46747008, places=5 + ) self.current_collector.bare_lengths_a_side = (100, 100) self.assertAlmostEqual(self.current_collector.material.mass, 30.15936, places=5) - self.assertAlmostEqual(self.current_collector.material.cost, 0.46747008, places=5) + self.assertAlmostEqual( + self.current_collector.material.cost, 0.46747008, places=5 + ) fig_a = self.current_collector.plot_a_side_view() fig_b = self.current_collector.plot_b_side_view() @@ -267,7 +352,9 @@ def test_datum_shifter(self): # figure1.show() def test_to_tabless(self): - new_current_collector = TablessCurrentCollector.from_notched(self.current_collector) + new_current_collector = TablessCurrentCollector.from_notched( + self.current_collector + ) self.assertIsInstance(new_current_collector, TablessCurrentCollector) def test_flip_and_set_datum(self): @@ -287,7 +374,7 @@ def test_equality(self): def test_serialization_preserves_property_dependencies(self): """Test that property dependencies work correctly after serialization/deserialization. - + Tests that changing length affects mass and cost both before and after serialization. """ @@ -295,33 +382,33 @@ def test_serialization_preserves_property_dependencies(self): original_length = self.current_collector.length original_mass = self.current_collector.mass original_cost = self.current_collector.cost - + # Double the length new_length = original_length * 2 self.current_collector.length = new_length - + # Verify mass and cost approximately doubled self.assertEqual(self.current_collector.length, new_length) self.assertGreater(self.current_collector.mass, original_mass) self.assertGreater(self.current_collector.cost, original_cost) modified_mass = self.current_collector.mass - + # Reset self.current_collector.length = original_length self.assertEqual(self.current_collector.length, original_length) self.assertAlmostEqual(self.current_collector.mass, original_mass, places=2) - + # Serialize and deserialize serialized = self.current_collector.serialize() deserialized_cc = NotchedCurrentCollector.deserialize(serialized) - + # Verify deserialized has same properties self.assertEqual(deserialized_cc.length, original_length) self.assertAlmostEqual(deserialized_cc.mass, original_mass, places=2) - + # Double length on deserialized object deserialized_cc.length = new_length - + # Verify mass increased on deserialized object self.assertEqual(deserialized_cc.length, new_length) self.assertAlmostEqual(deserialized_cc.mass, modified_mass, places=2) @@ -371,14 +458,22 @@ def test_current_collector(self): self.assertEqual(round(self.current_collector._tab_width, 6), 0.03) self.assertEqual(round(self.current_collector._tab_spacing, 6), 0.05) self.assertEqual(round(self.current_collector._tab_height, 6), 0.007) - self.assertEqual(round(self.current_collector._bare_lengths_a_side[0], 6), 0.015) + self.assertEqual( + round(self.current_collector._bare_lengths_a_side[0], 6), 0.015 + ) self.assertEqual(round(self.current_collector._bare_lengths_a_side[1], 6), 0.08) self.assertEqual(round(self.current_collector._bare_lengths_b_side[0], 6), 0.02) self.assertEqual(round(self.current_collector._bare_lengths_b_side[1], 6), 0.08) self.assertEqual(round(self.current_collector._coated_tab_height, 6), 0.004) - self.assertAlmostEqual(round(self.current_collector.foil_area, 1), 6732, places=0) - self.assertAlmostEqual(round(self.current_collector.coated_area, 1), 6339.1, places=0) - self.assertAlmostEqual(round(self.current_collector.insulation_area, 1), 69.7, places=0) + self.assertAlmostEqual( + round(self.current_collector.foil_area, 1), 6732, places=0 + ) + self.assertAlmostEqual( + round(self.current_collector.coated_area, 1), 6339.1, places=0 + ) + self.assertAlmostEqual( + round(self.current_collector.insulation_area, 1), 69.7, places=0 + ) def test_figures(self): fig_a = self.current_collector.plot_a_side_view() @@ -511,7 +606,9 @@ def test_tab_height_setter(self): # fig2.show() def test_to_notched(self): - new_current_collector = NotchedCurrentCollector.from_tabless(self.current_collector) + new_current_collector = NotchedCurrentCollector.from_tabless( + self.current_collector + ) self.assertIsInstance(new_current_collector, NotchedCurrentCollector) @@ -522,7 +619,9 @@ def setUp(self): """ self.material = CurrentCollectorMaterial.from_database(name="Copper") - self.weldtab = WeldTab(material=self.material, width=5, length=115, thickness=20) + self.weldtab = WeldTab( + material=self.material, width=5, length=115, thickness=20 + ) def test_equality(self): copy_cc = deepcopy(self.weldtab) @@ -554,7 +653,7 @@ def test_plots(self): class TestTabWeldedCurrentCollector(unittest.TestCase): - + def setUp(self): """ Set up @@ -562,7 +661,9 @@ def setUp(self): self.tab_material = CurrentCollectorMaterial.from_database(name="Copper") self.cc_material = CurrentCollectorMaterial.from_database(name="Aluminum") - self.weld_tab = WeldTab(material=self.tab_material, width=5, length=115, thickness=20) + self.weld_tab = WeldTab( + material=self.tab_material, width=5, length=115, thickness=20 + ) self.current_collector = TabWeldedCurrentCollector( material=self.cc_material, @@ -599,7 +700,9 @@ def test_current_collector(self): self.assertEqual(round(self.current_collector._weld_tab_positions[1], 6), 0.1) self.assertEqual(round(self.current_collector._skip_coat_width, 6), 0.02) self.assertEqual(round(self.current_collector._tab_overhang, 6), 0.01) - self.assertEqual(round(self.current_collector._bare_lengths_a_side[0], 6), 0.015) + self.assertEqual( + round(self.current_collector._bare_lengths_a_side[0], 6), 0.015 + ) self.assertEqual(round(self.current_collector._bare_lengths_a_side[1], 6), 0.08) self.assertEqual(round(self.current_collector._bare_lengths_b_side[0], 6), 0.02) self.assertEqual(round(self.current_collector._bare_lengths_b_side[1], 6), 0.08) @@ -635,7 +738,9 @@ def test_material_setter(self): # fig1.show() def test_to_notched(self): - new_current_collector = NotchedCurrentCollector.from_tab_welded(self.current_collector) + new_current_collector = NotchedCurrentCollector.from_tab_welded( + self.current_collector + ) self.assertIsInstance(new_current_collector, NotchedCurrentCollector) fig1 = new_current_collector.plot_top_down_view() @@ -648,7 +753,9 @@ def test_to_notched(self): # fig2.show() def test_to_tabless(self): - new_current_collector = TablessCurrentCollector.from_tab_welded(self.current_collector) + new_current_collector = TablessCurrentCollector.from_tab_welded( + self.current_collector + ) self.assertIsInstance(new_current_collector, TablessCurrentCollector) def test_flip(self): @@ -668,4 +775,3 @@ def test_flip_and_set_datum(self): figure1 = go.Figure(data=fig11.data + fig21.data) # figure1.show() - diff --git a/test/test_spiral_utils.py b/test/test_spiral_utils.py index ebb37a4f..b3b7148e 100644 --- a/test/test_spiral_utils.py +++ b/test/test_spiral_utils.py @@ -157,8 +157,8 @@ def _is_on_racetrack(self, x: float, z: float, tol: float = 1e-9) -> bool: on_top = abs(z - self.radius) < tol and -L_half - tol <= x <= L_half + tol on_bot = abs(z + self.radius) < tol and -L_half - tol <= x <= L_half + tol # Right semicircle is centred at (+L/2, 0); left at (-L/2, 0). - on_right = abs((x - L_half) ** 2 + z ** 2 - self.radius ** 2) < tol - on_left = abs((x + L_half) ** 2 + z ** 2 - self.radius ** 2) < tol + on_right = abs((x - L_half) ** 2 + z**2 - self.radius**2) < tol + on_left = abs((x + L_half) ** 2 + z**2 - self.radius**2) < tol return on_top or on_bot or on_right or on_left def test_returns_tuple_of_two_floats(self): @@ -240,9 +240,7 @@ def test_batch_matches_scalar_at_random_angles(self): straight_length = 0.06 radii = np.full_like(thetas, radius) - x_batch, z_batch = _racetrack_positions_batch( - thetas, radii, straight_length - ) + x_batch, z_batch = _racetrack_positions_batch(thetas, radii, straight_length) for i, theta in enumerate(thetas): x_scalar, z_scalar = SpiralCalculator.racetrack_position( @@ -290,33 +288,25 @@ def test_zero_radius_returns_zero(self): class TestRacetrackSpanHelpers(unittest.TestCase): def test_thickness_is_z_span(self): - coords = np.array( - [[0.0, -2.0], [1.0, 0.0], [2.0, 3.0], [3.0, -1.0]] - ) + coords = np.array([[0.0, -2.0], [1.0, 0.0], [2.0, 3.0], [3.0, -1.0]]) self.assertAlmostEqual( SpiralCalculator.get_thickness_of_racetrack(coords), 5.0, places=10 ) def test_thickness_ignores_nan(self): - coords = np.array( - [[0.0, -2.0], [1.0, np.nan], [2.0, 3.0], [3.0, np.nan]] - ) + coords = np.array([[0.0, -2.0], [1.0, np.nan], [2.0, 3.0], [3.0, np.nan]]) self.assertAlmostEqual( SpiralCalculator.get_thickness_of_racetrack(coords), 5.0, places=10 ) def test_width_is_x_span(self): - coords = np.array( - [[-1.0, 0.0], [0.0, 1.0], [4.0, 2.0], [2.0, 3.0]] - ) + coords = np.array([[-1.0, 0.0], [0.0, 1.0], [4.0, 2.0], [2.0, 3.0]]) self.assertAlmostEqual( SpiralCalculator.get_width_of_racetrack(coords), 5.0, places=10 ) def test_width_ignores_nan(self): - coords = np.array( - [[-1.0, 0.0], [np.nan, 1.0], [4.0, 2.0], [np.nan, 3.0]] - ) + coords = np.array([[-1.0, 0.0], [np.nan, 1.0], [4.0, 2.0], [np.nan, 3.0]]) self.assertAlmostEqual( SpiralCalculator.get_width_of_racetrack(coords), 5.0, places=10 ) @@ -356,5 +346,50 @@ def test_interior_point_is_linear_interpolation(self): self.assertAlmostEqual(value, x, places=10, msg=f"x={x}") +class TestAlignedPositionsFromSpiral(unittest.TestCase): + def test_returns_same_phase_positions_with_increasing_pitch(self): + theta = np.linspace(0.0, 8.0 * np.pi, 1001) + # Monotonic synthetic winding whose length per turn grows outward. + x_unwrapped = 0.02 * theta + 0.0002 * theta**2 + spiral = np.column_stack( + ( + theta, + x_unwrapped, + np.ones_like(theta), + np.zeros_like(theta), + np.zeros_like(theta), + theta / TWO_PI, + ) + ) + + centers = SpiralCalculator.aligned_positions_from_spiral( + spiral, alignment_angle=0.0 + ) + + np.testing.assert_allclose( + centers, + 0.02 * (TWO_PI * np.arange(5)) + 0.0002 * (TWO_PI * np.arange(5)) ** 2, + ) + self.assertTrue(np.all(np.diff(np.diff(centers)) > 0)) + + def test_omits_centers_where_full_tab_does_not_fit(self): + theta = np.linspace(0.0, 4.0 * np.pi, 101) + spiral = np.column_stack( + (theta, theta / 100, theta, theta, theta, theta / TWO_PI) + ) + + centers = SpiralCalculator.aligned_positions_from_spiral( + spiral, alignment_angle=0.0, tab_width=0.02 + ) + + self.assertEqual(len(centers), 1) + self.assertAlmostEqual(centers[0], TWO_PI / 100) + + def test_rejects_non_finite_angle(self): + spiral = np.zeros((2, 6)) + with self.assertRaises(ValueError): + SpiralCalculator.aligned_positions_from_spiral(spiral, np.nan) + + if __name__ == "__main__": unittest.main() From c94afc3336619c4636a2710ffd4be04de6086968 Mon Sep 17 00:00:00 2001 From: ptlin84 Date: Sat, 8 Aug 2026 22:54:28 -0700 Subject: [PATCH 2/7] chore: minimize import diff --- .../Components/CurrentCollectors/Notched.py | 28 ++++------- .../ElectrodeAssemblies/JellyRolls.py | 35 ++++++-------- .../ElectrodeAssemblies/SpiralUtils.py | 11 ++--- test/test_assembly.py | 47 +++++++++---------- test/test_current_collectors.py | 28 ++++------- 5 files changed, 63 insertions(+), 86 deletions(-) diff --git a/steer_opencell_design/Components/CurrentCollectors/Notched.py b/steer_opencell_design/Components/CurrentCollectors/Notched.py index 3554807f..27860be8 100644 --- a/steer_opencell_design/Components/CurrentCollectors/Notched.py +++ b/steer_opencell_design/Components/CurrentCollectors/Notched.py @@ -3,25 +3,21 @@ """Notched current collector for tabless wound cells.""" -from collections.abc import Iterable -from typing import Optional, Tuple - -import numpy as np - -# import core units -from steer_core.Constants.Units import * - # import core decorators from steer_core.Decorators.General import calculate_all_properties -from steer_opencell_design.Components.CurrentCollectors.Base import ( - _TabbedCurrentCollector, - _TapeCurrentCollector, -) +# import core units +from steer_core.Constants.Units import * # import materials from steer_opencell_design.Materials.Other import CurrentCollectorMaterial +from collections.abc import Iterable +from typing import Tuple, Optional +import numpy as np + +from steer_opencell_design.Components.CurrentCollectors.Base import _TabbedCurrentCollector, _TapeCurrentCollector + class NotchedCurrentCollector(_TabbedCurrentCollector, _TapeCurrentCollector): """ @@ -190,9 +186,7 @@ def from_tabless(cls, tabless) -> "NotchedCurrentCollector": """ Create a NotchedCurrentCollector from a TablessCurrentCollector. """ - from steer_opencell_design.Components.CurrentCollectors.Tabless import ( - TablessCurrentCollector, - ) + from steer_opencell_design.Components.CurrentCollectors.Tabless import TablessCurrentCollector # validate type cls.validate_type(tabless, TablessCurrentCollector, "tabless") @@ -227,9 +221,7 @@ def from_tab_welded(cls, tab_welded) -> "NotchedCurrentCollector": """ Create a NotchedCurrentCollector from a TabWeldedCurrentCollector. """ - from steer_opencell_design.Components.CurrentCollectors.Tabbed import ( - TabWeldedCurrentCollector, - ) + from steer_opencell_design.Components.CurrentCollectors.Tabbed import TabWeldedCurrentCollector # validate type cls.validate_type(tab_welded, TabWeldedCurrentCollector, "tab_welded") diff --git a/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py b/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py index 40473244..5b3f081a 100644 --- a/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py +++ b/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py @@ -3,46 +3,41 @@ """Jelly roll electrode assemblies wound around cylindrical or flat mandrels.""" +from typing import Union, Dict, Tuple, Any, Optional from abc import ABC, abstractmethod from copy import copy, deepcopy -from enum import Enum -from typing import Any, Dict, Optional, Tuple, Union - -import numpy as np import pandas as pd -import plotly.graph_objects as go +import numpy as np from scipy.optimize import brentq +import plotly.graph_objects as go +from enum import Enum + +from steer_opencell_design.Constructions.Layups.Laminate import Laminate from steer_core.Constants.Units import * from steer_core.Constants.Universal import PI, TWO_PI -from steer_core.Decorators.Coordinates import calculate_coordinates from steer_core.Decorators.General import ( calculate_all_properties, calculate_bulk_properties, recalculate, ) +from steer_core.Decorators.Coordinates import calculate_coordinates from steer_core.Mixins.Propagation import propagating_setter - -from steer_opencell_design.Components.CurrentCollectors.Notched import ( - NotchedCurrentCollector, -) -from steer_opencell_design.Components.CurrentCollectors.Tabbed import ( - TabWeldedCurrentCollector, -) -from steer_opencell_design.Components.CurrentCollectors.Tabless import ( - TablessCurrentCollector, -) from steer_opencell_design.Constructions.ElectrodeAssemblies.Base import ( _ElectrodeAssembly, ) +from steer_opencell_design.Constructions.ElectrodeAssemblies.WindingEquipment import ( + RoundMandrel, + FlatMandrel, +) from steer_opencell_design.Constructions.ElectrodeAssemblies.SpiralUtils import ( SpiralCalculator, ) from steer_opencell_design.Constructions.ElectrodeAssemblies.Tape import Tape -from steer_opencell_design.Constructions.ElectrodeAssemblies.WindingEquipment import ( - FlatMandrel, - RoundMandrel, +from steer_opencell_design.Components.CurrentCollectors.Tabbed import ( + TabWeldedCurrentCollector, ) -from steer_opencell_design.Constructions.Layups.Laminate import Laminate +from steer_opencell_design.Components.CurrentCollectors.Notched import NotchedCurrentCollector +from steer_opencell_design.Components.CurrentCollectors.Tabless import TablessCurrentCollector # Constants for array column indices THETA_COL = 0 diff --git a/steer_opencell_design/Constructions/ElectrodeAssemblies/SpiralUtils.py b/steer_opencell_design/Constructions/ElectrodeAssemblies/SpiralUtils.py index 6e280666..656edc19 100644 --- a/steer_opencell_design/Constructions/ElectrodeAssemblies/SpiralUtils.py +++ b/steer_opencell_design/Constructions/ElectrodeAssemblies/SpiralUtils.py @@ -3,15 +3,14 @@ """Static utility methods for spiral and racetrack geometry calculations used in jelly roll winding.""" -import math -from typing import Optional - +from steer_opencell_design.Constructions.Layups.Laminate import Laminate +from steer_core.Constants.Universal import PI, TWO_PI +from steer_core.Constants.Units import * import numpy as np import pandas as pd -from steer_core.Constants.Units import * -from steer_core.Constants.Universal import PI, TWO_PI +import math +from typing import Optional -from steer_opencell_design.Constructions.Layups.Laminate import Laminate try: from numba import njit diff --git a/test/test_assembly.py b/test/test_assembly.py index 010838d7..fb5432e1 100644 --- a/test/test_assembly.py +++ b/test/test_assembly.py @@ -3,44 +3,43 @@ import time import unittest -from copy import deepcopy - -import numpy as np import pandas as pd import plotly.graph_objects as go -from steer_core.Constants.Units import MM_TO_M -from steer_core.Constants.Universal import TWO_PI +from copy import deepcopy +import numpy as np from steer_opencell_design import ( - Anode, + CathodeFormulation, AnodeFormulation, - AnodeMaterial, - Binder, Cathode, - CathodeFormulation, - CathodeMaterial, - ConductiveAdditive, - CurrentCollectorMaterial, - FlatMandrel, - FlatWoundJellyRoll, - InsulationMaterial, - Laminate, - MonoLayer, + Anode, + Separator, NotchedCurrentCollector, PunchedCurrentCollector, - PunchedStack, - RoundMandrel, - Separator, - SeparatorMaterial, TablessCurrentCollector, TabWeldedCurrentCollector, - Tape, - TapeMaterial, WeldTab, + PunchedStack, + ZFoldStack, WoundJellyRoll, + FlatWoundJellyRoll, + RoundMandrel, + FlatMandrel, + Tape, + MonoLayer, ZFoldMonoLayer, - ZFoldStack, + Laminate, + CathodeMaterial, + AnodeMaterial, + Binder, + ConductiveAdditive, + CurrentCollectorMaterial, + SeparatorMaterial, + InsulationMaterial, + TapeMaterial, ) +from steer_core.Constants.Units import MM_TO_M +from steer_core.Constants.Universal import TWO_PI from steer_opencell_design.Constructions.ElectrodeAssemblies.JellyRolls import ( THETA_COL, X_UNWRAPPED_COL, diff --git a/test/test_current_collectors.py b/test/test_current_collectors.py index ac419acd..c8bcd3de 100644 --- a/test/test_current_collectors.py +++ b/test/test_current_collectors.py @@ -1,32 +1,24 @@ # SPDX-FileCopyrightText: 2024-2026 Stanford University # SPDX-License-Identifier: AGPL-3.0-or-later -import os import unittest +from pickle import loads, dumps from base64 import b64decode, b64encode from copy import deepcopy -from pickle import dumps, loads - -import plotly.graph_objects as go +from steer_opencell_design.Materials.Other import CurrentCollectorMaterial from steer_opencell_design.Components.CurrentCollectors.Base import ( _TabbedCurrentCollector, _TapeCurrentCollector, ) -from steer_opencell_design.Components.CurrentCollectors.Notched import ( - NotchedCurrentCollector, -) -from steer_opencell_design.Components.CurrentCollectors.Punched import ( - PunchedCurrentCollector, -) -from steer_opencell_design.Components.CurrentCollectors.Tabbed import ( - TabWeldedCurrentCollector, - WeldTab, -) -from steer_opencell_design.Components.CurrentCollectors.Tabless import ( - TablessCurrentCollector, -) -from steer_opencell_design.Materials.Other import CurrentCollectorMaterial +from steer_opencell_design.Components.CurrentCollectors.Punched import PunchedCurrentCollector +from steer_opencell_design.Components.CurrentCollectors.Notched import NotchedCurrentCollector +from steer_opencell_design.Components.CurrentCollectors.Tabbed import TabWeldedCurrentCollector, WeldTab +from steer_opencell_design.Components.CurrentCollectors.Tabless import TablessCurrentCollector + +import plotly.graph_objects as go + +import os os.environ["OPENCELL_ENV"] = "development" From 2f57462c15fc8766fb3e9bf85b5e15b39cdd4408 Mon Sep 17 00:00:00 2001 From: ptlin84 Date: Sat, 8 Aug 2026 23:12:04 -0700 Subject: [PATCH 3/7] chore: remove unrelated formatting changes --- .../Components/CurrentCollectors/Notched.py | 12 +-- test/test_current_collectors.py | 99 ++++++------------- test/test_spiral_utils.py | 24 +++-- 3 files changed, 49 insertions(+), 86 deletions(-) diff --git a/steer_opencell_design/Components/CurrentCollectors/Notched.py b/steer_opencell_design/Components/CurrentCollectors/Notched.py index 27860be8..531570b9 100644 --- a/steer_opencell_design/Components/CurrentCollectors/Notched.py +++ b/steer_opencell_design/Components/CurrentCollectors/Notched.py @@ -338,9 +338,7 @@ def _get_footprint( """ # Default values y_depth = self._y_foil_length if y_depth is None else y_depth - y_start = ( - self._datum[1] - self._y_foil_length / 2 if y_start is None else y_start - ) + y_start = self._datum[1] - self._y_foil_length / 2 if y_start is None else y_start notch = self._tab_height if notch_height is None else notch_height # Convert bare lengths to meters (they come in mm according to docstring) @@ -425,9 +423,7 @@ def _get_insulation_coordinates(self, side: str = "a") -> np.ndarray: y_ins_end = y_ins_start + self._insulation_width # Compute x bounds of coated region - bare_left, bare_right = ( - self._bare_lengths_a_side if side == "a" else self._bare_lengths_b_side - ) + bare_left, bare_right = self._bare_lengths_a_side if side == "a" else self._bare_lengths_b_side # Check if bare lengths exceed foil length - return empty arrays if so if bare_left + bare_right >= self._x_foil_length: @@ -453,9 +449,7 @@ def _get_insulation_coordinates(self, side: str = "a") -> np.ndarray: e = min(te, x_end) # Get coordinates for this tab's insulation rectangle - tab_x, tab_y = self.build_square_array( - x_width=e - s, y_width=self._insulation_width, x=s, y=y_ins_start - ) + tab_x, tab_y = self.build_square_array(x_width=e - s, y_width=self._insulation_width, x=s, y=y_ins_start) # Add to lists all_x.extend(tab_x) diff --git a/test/test_current_collectors.py b/test/test_current_collectors.py index c8bcd3de..2fffdb4f 100644 --- a/test/test_current_collectors.py +++ b/test/test_current_collectors.py @@ -42,18 +42,12 @@ def test_notched_mro_order(self): idx_tape = mro_names.index("_TapeCurrentCollector") idx_base = mro_names.index("_CurrentCollector") - self.assertLess( - idx_tabbed, - idx_tape, - "_TabbedCurrentCollector must resolve before " - "_TapeCurrentCollector in NotchedCurrentCollector's MRO", - ) - self.assertLess( - idx_tape, - idx_base, - "_TapeCurrentCollector must resolve before the shared " - "_CurrentCollector base", - ) + self.assertLess(idx_tabbed, idx_tape, + "_TabbedCurrentCollector must resolve before " + "_TapeCurrentCollector in NotchedCurrentCollector's MRO") + self.assertLess(idx_tape, idx_base, + "_TapeCurrentCollector must resolve before the shared " + "_CurrentCollector base") self.assertEqual(mro_names[0], "NotchedCurrentCollector") def test_notched_inherits_both_branches(self): @@ -131,7 +125,10 @@ def setUp(self): Set up """ self.material = CurrentCollectorMaterial( - name="Copper", density=8.96, specific_cost=18.1, color="#B87333" + name="Copper", + density=8.96, + specific_cost=18.1, + color="#B87333" ) self.current_collector = PunchedCurrentCollector( @@ -275,22 +272,14 @@ def test_current_collector(self): self.assertEqual(round(self.current_collector._tab_width, 6), 0.03) self.assertEqual(round(self.current_collector._tab_spacing, 6), 0.05) self.assertEqual(round(self.current_collector._tab_height, 6), 0.007) - self.assertEqual( - round(self.current_collector._bare_lengths_a_side[0], 6), 0.015 - ) + self.assertEqual(round(self.current_collector._bare_lengths_a_side[0], 6), 0.015) self.assertEqual(round(self.current_collector._bare_lengths_a_side[1], 6), 0.08) self.assertEqual(round(self.current_collector._bare_lengths_b_side[0], 6), 0.02) self.assertEqual(round(self.current_collector._bare_lengths_b_side[1], 6), 0.08) self.assertEqual(round(self.current_collector._coated_tab_height, 6), 0.002) - self.assertAlmostEqual( - round(self.current_collector.foil_area, 1), 6732, places=0 - ) - self.assertAlmostEqual( - round(self.current_collector.coated_area, 1), 3079.3 + 3074, places=0 - ) - self.assertAlmostEqual( - round(self.current_collector.insulation_area, 1), 185.8, places=0 - ) + self.assertAlmostEqual(round(self.current_collector.foil_area, 1), 6732, places=0) + self.assertAlmostEqual(round(self.current_collector.coated_area, 1), 3079.3 + 3074, places=0) + self.assertAlmostEqual(round(self.current_collector.insulation_area, 1), 185.8, places=0) self.assertAlmostEqual(self.current_collector.material.cost, 0.04, places=2) self.assertAlmostEqual(self.current_collector.material.mass, 13.63, places=2) @@ -304,27 +293,19 @@ def test_figures(self): # fig_d.show(renderer='browser') def test_setters(self): - self.current_collector.material = CurrentCollectorMaterial.from_database( - name="Copper" - ) + self.current_collector.material = CurrentCollectorMaterial.from_database(name="Copper") self.assertEqual(self.current_collector.material.name, "Copper") self.assertAlmostEqual(self.current_collector.material.mass, 45.23904, places=5) - self.assertAlmostEqual( - self.current_collector.material.cost, 0.70120512, places=5 - ) + self.assertAlmostEqual(self.current_collector.material.cost, 0.70120512, places=5) self.current_collector.thickness = 10 self.assertEqual(self.current_collector.thickness, 10) self.assertAlmostEqual(self.current_collector.material.mass, 30.15936, places=5) - self.assertAlmostEqual( - self.current_collector.material.cost, 0.46747008, places=5 - ) + self.assertAlmostEqual(self.current_collector.material.cost, 0.46747008, places=5) self.current_collector.bare_lengths_a_side = (100, 100) self.assertAlmostEqual(self.current_collector.material.mass, 30.15936, places=5) - self.assertAlmostEqual( - self.current_collector.material.cost, 0.46747008, places=5 - ) + self.assertAlmostEqual(self.current_collector.material.cost, 0.46747008, places=5) fig_a = self.current_collector.plot_a_side_view() fig_b = self.current_collector.plot_b_side_view() @@ -344,9 +325,7 @@ def test_datum_shifter(self): # figure1.show() def test_to_tabless(self): - new_current_collector = TablessCurrentCollector.from_notched( - self.current_collector - ) + new_current_collector = TablessCurrentCollector.from_notched(self.current_collector) self.assertIsInstance(new_current_collector, TablessCurrentCollector) def test_flip_and_set_datum(self): @@ -450,22 +429,14 @@ def test_current_collector(self): self.assertEqual(round(self.current_collector._tab_width, 6), 0.03) self.assertEqual(round(self.current_collector._tab_spacing, 6), 0.05) self.assertEqual(round(self.current_collector._tab_height, 6), 0.007) - self.assertEqual( - round(self.current_collector._bare_lengths_a_side[0], 6), 0.015 - ) + self.assertEqual(round(self.current_collector._bare_lengths_a_side[0], 6), 0.015) self.assertEqual(round(self.current_collector._bare_lengths_a_side[1], 6), 0.08) self.assertEqual(round(self.current_collector._bare_lengths_b_side[0], 6), 0.02) self.assertEqual(round(self.current_collector._bare_lengths_b_side[1], 6), 0.08) self.assertEqual(round(self.current_collector._coated_tab_height, 6), 0.004) - self.assertAlmostEqual( - round(self.current_collector.foil_area, 1), 6732, places=0 - ) - self.assertAlmostEqual( - round(self.current_collector.coated_area, 1), 6339.1, places=0 - ) - self.assertAlmostEqual( - round(self.current_collector.insulation_area, 1), 69.7, places=0 - ) + self.assertAlmostEqual(round(self.current_collector.foil_area, 1), 6732, places=0) + self.assertAlmostEqual(round(self.current_collector.coated_area, 1), 6339.1, places=0) + self.assertAlmostEqual(round(self.current_collector.insulation_area, 1), 69.7, places=0) def test_figures(self): fig_a = self.current_collector.plot_a_side_view() @@ -598,9 +569,7 @@ def test_tab_height_setter(self): # fig2.show() def test_to_notched(self): - new_current_collector = NotchedCurrentCollector.from_tabless( - self.current_collector - ) + new_current_collector = NotchedCurrentCollector.from_tabless(self.current_collector) self.assertIsInstance(new_current_collector, NotchedCurrentCollector) @@ -611,9 +580,7 @@ def setUp(self): """ self.material = CurrentCollectorMaterial.from_database(name="Copper") - self.weldtab = WeldTab( - material=self.material, width=5, length=115, thickness=20 - ) + self.weldtab = WeldTab(material=self.material, width=5, length=115, thickness=20) def test_equality(self): copy_cc = deepcopy(self.weldtab) @@ -653,9 +620,7 @@ def setUp(self): self.tab_material = CurrentCollectorMaterial.from_database(name="Copper") self.cc_material = CurrentCollectorMaterial.from_database(name="Aluminum") - self.weld_tab = WeldTab( - material=self.tab_material, width=5, length=115, thickness=20 - ) + self.weld_tab = WeldTab(material=self.tab_material, width=5, length=115, thickness=20) self.current_collector = TabWeldedCurrentCollector( material=self.cc_material, @@ -692,9 +657,7 @@ def test_current_collector(self): self.assertEqual(round(self.current_collector._weld_tab_positions[1], 6), 0.1) self.assertEqual(round(self.current_collector._skip_coat_width, 6), 0.02) self.assertEqual(round(self.current_collector._tab_overhang, 6), 0.01) - self.assertEqual( - round(self.current_collector._bare_lengths_a_side[0], 6), 0.015 - ) + self.assertEqual(round(self.current_collector._bare_lengths_a_side[0], 6), 0.015) self.assertEqual(round(self.current_collector._bare_lengths_a_side[1], 6), 0.08) self.assertEqual(round(self.current_collector._bare_lengths_b_side[0], 6), 0.02) self.assertEqual(round(self.current_collector._bare_lengths_b_side[1], 6), 0.08) @@ -730,9 +693,7 @@ def test_material_setter(self): # fig1.show() def test_to_notched(self): - new_current_collector = NotchedCurrentCollector.from_tab_welded( - self.current_collector - ) + new_current_collector = NotchedCurrentCollector.from_tab_welded(self.current_collector) self.assertIsInstance(new_current_collector, NotchedCurrentCollector) fig1 = new_current_collector.plot_top_down_view() @@ -745,9 +706,7 @@ def test_to_notched(self): # fig2.show() def test_to_tabless(self): - new_current_collector = TablessCurrentCollector.from_tab_welded( - self.current_collector - ) + new_current_collector = TablessCurrentCollector.from_tab_welded(self.current_collector) self.assertIsInstance(new_current_collector, TablessCurrentCollector) def test_flip(self): diff --git a/test/test_spiral_utils.py b/test/test_spiral_utils.py index b3b7148e..df60dd2f 100644 --- a/test/test_spiral_utils.py +++ b/test/test_spiral_utils.py @@ -157,8 +157,8 @@ def _is_on_racetrack(self, x: float, z: float, tol: float = 1e-9) -> bool: on_top = abs(z - self.radius) < tol and -L_half - tol <= x <= L_half + tol on_bot = abs(z + self.radius) < tol and -L_half - tol <= x <= L_half + tol # Right semicircle is centred at (+L/2, 0); left at (-L/2, 0). - on_right = abs((x - L_half) ** 2 + z**2 - self.radius**2) < tol - on_left = abs((x + L_half) ** 2 + z**2 - self.radius**2) < tol + on_right = abs((x - L_half) ** 2 + z ** 2 - self.radius ** 2) < tol + on_left = abs((x + L_half) ** 2 + z ** 2 - self.radius ** 2) < tol return on_top or on_bot or on_right or on_left def test_returns_tuple_of_two_floats(self): @@ -240,7 +240,9 @@ def test_batch_matches_scalar_at_random_angles(self): straight_length = 0.06 radii = np.full_like(thetas, radius) - x_batch, z_batch = _racetrack_positions_batch(thetas, radii, straight_length) + x_batch, z_batch = _racetrack_positions_batch( + thetas, radii, straight_length + ) for i, theta in enumerate(thetas): x_scalar, z_scalar = SpiralCalculator.racetrack_position( @@ -288,25 +290,33 @@ def test_zero_radius_returns_zero(self): class TestRacetrackSpanHelpers(unittest.TestCase): def test_thickness_is_z_span(self): - coords = np.array([[0.0, -2.0], [1.0, 0.0], [2.0, 3.0], [3.0, -1.0]]) + coords = np.array( + [[0.0, -2.0], [1.0, 0.0], [2.0, 3.0], [3.0, -1.0]] + ) self.assertAlmostEqual( SpiralCalculator.get_thickness_of_racetrack(coords), 5.0, places=10 ) def test_thickness_ignores_nan(self): - coords = np.array([[0.0, -2.0], [1.0, np.nan], [2.0, 3.0], [3.0, np.nan]]) + coords = np.array( + [[0.0, -2.0], [1.0, np.nan], [2.0, 3.0], [3.0, np.nan]] + ) self.assertAlmostEqual( SpiralCalculator.get_thickness_of_racetrack(coords), 5.0, places=10 ) def test_width_is_x_span(self): - coords = np.array([[-1.0, 0.0], [0.0, 1.0], [4.0, 2.0], [2.0, 3.0]]) + coords = np.array( + [[-1.0, 0.0], [0.0, 1.0], [4.0, 2.0], [2.0, 3.0]] + ) self.assertAlmostEqual( SpiralCalculator.get_width_of_racetrack(coords), 5.0, places=10 ) def test_width_ignores_nan(self): - coords = np.array([[-1.0, 0.0], [np.nan, 1.0], [4.0, 2.0], [np.nan, 3.0]]) + coords = np.array( + [[-1.0, 0.0], [np.nan, 1.0], [4.0, 2.0], [np.nan, 3.0]] + ) self.assertAlmostEqual( SpiralCalculator.get_width_of_racetrack(coords), 5.0, places=10 ) From 698606f4e835a54f9673c31bb0caa2e3d1890242 Mon Sep 17 00:00:00 2001 From: ptlin84 Date: Sat, 15 Aug 2026 18:34:24 -0700 Subject: [PATCH 4/7] refactor: clarify explicit notch position handling --- README.md | 2 +- .../Components/CurrentCollectors/Notched.py | 55 ++++++++++++------- .../ElectrodeAssemblies/JellyRolls.py | 6 +- test/test_assembly.py | 14 ++--- test/test_current_collectors.py | 14 +++-- 5 files changed, 53 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 76b001b4..56dcddfb 100644 --- a/README.md +++ b/README.md @@ -784,7 +784,7 @@ Shared by all assemblies (`WoundJellyRoll`, `FlatWoundJellyRoll`, `PunchedStack` |---|---|---| | `pressed_radius` | mm | Pressed mandrel radius | | `pressed_straight_length` | mm | Pressed mandrel straight length | -| `thickness_aware_notch_data` | dict | Calculated centers, pitches, and gaps by electrode | +| `thickness_aware_notch_data` | dict | Calculated centers, center spacings, and gaps by electrode | **`PunchedStack` / `ZFoldStack` — additional settable:** diff --git a/steer_opencell_design/Components/CurrentCollectors/Notched.py b/steer_opencell_design/Components/CurrentCollectors/Notched.py index 531570b9..0318cd4f 100644 --- a/steer_opencell_design/Components/CurrentCollectors/Notched.py +++ b/steer_opencell_design/Components/CurrentCollectors/Notched.py @@ -83,7 +83,7 @@ class NotchedCurrentCollector(_TabbedCurrentCollector, _TapeCurrentCollector): ... bare_lengths_a_side=(15.0, 15.0), # Tape connection option ... bare_lengths_b_side=(10.0, 10.0) ... ) - >>> print(f"Number of tabs: {collector.number_of_tabs}") + >>> print(f"Number of tabs: {collector.n_tabs}") >>> print(f"Total tab area: {collector.total_tab_area:.1f} mm²") >>> print(f"Effective resistance: {collector.effective_resistance:.6f} Ω") @@ -252,32 +252,45 @@ def from_tab_welded(cls, tab_welded) -> "NotchedCurrentCollector": return new_current_collector def _calculate_tab_positions(self) -> None: - """ - Function to calculate the positions of the tabs along the length of the current collector. - """ - x_min = self._datum[0] - self._x_foil_length / 2 - + """Calculate tab positions for the configured spacing mode.""" explicit_centers = getattr(self, "_tab_center_positions", None) if explicit_centers is not None: - self._validate_tab_center_positions(explicit_centers) - centers = x_min + explicit_centers - self._tab_positions = np.column_stack( - ( - centers - self._tab_width / 2, - centers + self._tab_width / 2, - ) - ) + self._calculate_explicit_tab_positions(explicit_centers) return + self._calculate_regular_tab_positions() + + def _calculate_explicit_tab_positions( + self, centers_from_leading_edge: np.ndarray + ) -> None: + """Calculate tab edges from explicit centers in internal meter units.""" + self._validate_explicit_tab_center_positions(centers_from_leading_edge) + + # Collector coordinates are centered on the datum, while explicit tab + # centers are measured from the foil's leading (minimum-x) edge. + x_min = self._datum[0] - self._x_foil_length / 2 + centers = x_min + centers_from_leading_edge + self._tab_positions = np.column_stack( + ( + centers - self._tab_width / 2, + centers + self._tab_width / 2, + ) + ) + + def _calculate_regular_tab_positions(self) -> None: + """Calculate tab edges using the configured uniform center spacing.""" + # Convert the datum-centered foil bounds into absolute x-coordinates. + x_min = self._datum[0] - self._x_foil_length / 2 + + # Search one spacing beyond the trailing edge; the clipping logic below + # then retains or trims the final tab according to the legacy behavior. x_max = self._datum[0] + self._x_foil_length / 2 + self._tab_spacing - number_of_tabs = 1 tab_positions = [x_min + self._tab_spacing / 2] tab_starts = [tab_positions[0] - self._tab_width / 2] tab_ends = [tab_positions[0] + self._tab_width / 2] while tab_positions[-1] < x_max: - number_of_tabs += 1 next_tab_position = tab_positions[-1] + self._tab_spacing if next_tab_position + self._tab_width / 2 > x_max: @@ -296,7 +309,7 @@ def _calculate_tab_positions(self) -> None: self._tab_positions = np.column_stack((tab_starts, tab_ends)) - def _validate_tab_center_positions(self, positions: np.ndarray) -> None: + def _validate_explicit_tab_center_positions(self, positions: np.ndarray) -> None: """Validate explicit tab centers expressed in internal meter units.""" if positions.ndim != 1: raise ValueError("tab_center_positions must be a one-dimensional sequence.") @@ -524,8 +537,8 @@ def calculated_tab_center_positions(self) -> list: return (centers * M_TO_MM).tolist() @property - def tab_pitches(self) -> list: - """Return consecutive center-to-center pitches in mm.""" + def tab_center_spacings(self) -> list: + """Return consecutive center-to-center tab spacings in mm.""" if len(self._tab_positions) < 2: return [] centers = self._tab_positions.mean(axis=1) @@ -541,7 +554,7 @@ def tab_gaps(self) -> list: ).tolist() @property - def number_of_tabs(self) -> int: + def n_tabs(self) -> int: """Return the number of complete tabs in the current pattern.""" return len(self._tab_positions) @@ -652,5 +665,5 @@ def tab_center_positions( "tab_center_positions must be an iterable of numbers or None." ) from exc - self._validate_tab_center_positions(positions) + self._validate_explicit_tab_center_positions(positions) self._tab_center_positions = positions diff --git a/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py b/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py index 5b3f081a..1fa028fe 100644 --- a/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py +++ b/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py @@ -3612,7 +3612,7 @@ def _apply_thickness_aware_notches(self) -> None: leading_edge = collector._datum[0] - collector._x_foil_length / 2 centers_local = centers_global - leading_edge - collector._validate_tab_center_positions(centers_local) + collector._validate_explicit_tab_center_positions(centers_local) collector._tab_center_positions = centers_local collector._calculate_all_properties() generated.add(electrode_name) @@ -4252,7 +4252,7 @@ def anode_notch_alignment_angle(self, value: Optional[float]) -> None: @property def thickness_aware_notch_data(self) -> Dict[str, Dict[str, Any]]: - """Return configured notch centers, pitches, and gaps for each electrode.""" + """Return configured notch centers, spacings, and gaps for each electrode.""" result: Dict[str, Dict[str, Any]] = {} for electrode_name in ("cathode", "anode"): angle = getattr(self, f"_{electrode_name}_notch_alignment_angle") @@ -4262,7 +4262,7 @@ def thickness_aware_notch_data(self) -> Dict[str, Dict[str, Any]]: result[electrode_name] = { "alignment_angle": angle, "centers": collector.tab_center_positions, - "pitches": collector.tab_pitches, + "center_spacings": collector.tab_center_spacings, "gaps": collector.tab_gaps, } return result diff --git a/test/test_assembly.py b/test/test_assembly.py index fb5432e1..1ebff6a7 100644 --- a/test/test_assembly.py +++ b/test/test_assembly.py @@ -640,8 +640,8 @@ def test_thickness_aware_cathode_notches_align_by_winding_phase(self): collector = self.my_jellyroll.layup.cathode.current_collector self.assertIsNotNone(collector.tab_center_positions) - self.assertGreater(collector.number_of_tabs, 2) - self.assertTrue(np.all(np.diff(collector.tab_pitches) > 0)) + self.assertGreater(collector.n_tabs, 2) + self.assertTrue(np.all(np.diff(collector.tab_center_spacings) > 0)) spiral = self.my_jellyroll._component_spirals["cathode_current_collector"] valid = np.isfinite(spiral[:, THETA_COL]) & np.isfinite( @@ -666,7 +666,7 @@ def test_thickness_aware_cathode_notches_align_by_winding_phase(self): marker_trace = next( trace for trace in figure.data if trace.name == "Cathode notch centers" ) - self.assertEqual(len(marker_trace.x), collector.number_of_tabs) + self.assertEqual(len(marker_trace.x), collector.n_tabs) def test_disabling_alignment_restores_scalar_spacing(self): self.my_jellyroll.cathode_notch_alignment_angle = 0.0 @@ -675,8 +675,8 @@ def test_disabling_alignment_restores_scalar_spacing(self): collector = self.my_jellyroll.layup.cathode.current_collector self.assertIsNone(collector.tab_center_positions) # The legacy pattern may clip its final tab at the foil boundary. - for pitch in collector.tab_pitches[:-1]: - self.assertAlmostEqual(pitch, collector.tab_spacing) + for spacing in collector.tab_center_spacings[:-1]: + self.assertAlmostEqual(spacing, collector.tab_spacing) def test_alignment_configuration_serializes_for_both_electrodes(self): self.my_jellyroll.cathode_notch_alignment_angle = 0.0 @@ -686,8 +686,8 @@ def test_alignment_configuration_serializes_for_both_electrodes(self): self.assertEqual(restored.cathode_notch_alignment_angle, 0.0) self.assertEqual(restored.anode_notch_alignment_angle, np.pi) - self.assertGreater(restored.layup.cathode.current_collector.number_of_tabs, 2) - self.assertGreater(restored.layup.anode.current_collector.number_of_tabs, 2) + self.assertGreater(restored.layup.cathode.current_collector.n_tabs, 2) + self.assertGreater(restored.layup.anode.current_collector.n_tabs, 2) self.assertIn("cathode", restored.thickness_aware_notch_data) self.assertIn("anode", restored.thickness_aware_notch_data) diff --git a/test/test_current_collectors.py b/test/test_current_collectors.py index 2fffdb4f..613e36e5 100644 --- a/test/test_current_collectors.py +++ b/test/test_current_collectors.py @@ -77,10 +77,12 @@ def setUp(self): tab_center_positions=[50, 155, 270, 395], ) - def test_explicit_centers_produce_uneven_pitches_and_gaps(self): + def test_explicit_centers_produce_uneven_spacings_and_gaps(self): self.assertEqual(self.collector.tab_center_positions, [50, 155, 270, 395]) - self.assertEqual(self.collector.number_of_tabs, 4) - for actual, expected in zip(self.collector.tab_pitches, [105, 115, 125]): + self.assertEqual(self.collector.n_tabs, 4) + for actual, expected in zip( + self.collector.tab_center_spacings, [105, 115, 125] + ): self.assertAlmostEqual(actual, expected) for actual, expected in zip(self.collector.tab_gaps, [85, 95, 105]): self.assertAlmostEqual(actual, expected) @@ -101,8 +103,8 @@ def test_setting_spacing_restores_uniform_mode(self): self.collector.tab_spacing = 80 self.assertIsNone(self.collector.tab_center_positions) - for pitch in self.collector.tab_pitches: - self.assertAlmostEqual(pitch, 80) + for spacing in self.collector.tab_center_spacings: + self.assertAlmostEqual(spacing, 80) def test_rejects_overlapping_or_out_of_bounds_tabs(self): with self.assertRaises(ValueError): @@ -116,7 +118,7 @@ def test_serialization_preserves_explicit_pattern(self): restored = NotchedCurrentCollector.deserialize(self.collector.serialize()) self.assertEqual(restored.tab_center_positions, [50, 155, 270, 395]) - self.assertEqual(restored.number_of_tabs, 4) + self.assertEqual(restored.n_tabs, 4) class TestPunchedCurrentCollector(unittest.TestCase): From 63cd892108729bbe6ac12780d067036c8aa9b35a Mon Sep 17 00:00:00 2001 From: ptlin84 Date: Sat, 15 Aug 2026 19:30:48 -0700 Subject: [PATCH 5/7] feat: align flat-wound notches by physical position --- README.md | 4 +- .../ElectrodeAssemblies/JellyRolls.py | 237 +++++++++++++----- .../ElectrodeAssemblies/SpiralUtils.py | 121 ++++++--- test/test_assembly.py | 129 ++++++++-- test/test_spiral_utils.py | 87 ++++--- 5 files changed, 431 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index 56dcddfb..9019130e 100644 --- a/README.md +++ b/README.md @@ -775,8 +775,8 @@ Shared by all assemblies (`WoundJellyRoll`, `FlatWoundJellyRoll`, `PunchedStack` |---|---|---| | `thickness` | mm | Overall jelly roll thickness | | `width` | mm | Overall jelly roll width | -| `cathode_notch_alignment_angle` | rad or `None` | Same-phase cathode notch alignment; `None` keeps scalar spacing | -| `anode_notch_alignment_angle` | rad or `None` | Same-phase anode notch alignment; `None` keeps scalar spacing | +| `cathode_notch_alignment_position` | mm or `None` | Cathode notch position from the unrotated pressed mandrel's minimum-x outer edge; `None` keeps scalar spacing | +| `anode_notch_alignment_position` | mm or `None` | Anode notch position from the same mandrel edge; arrangement follows `laminate.electrode_orientation` | **`FlatWoundJellyRoll` — additional read-only:** diff --git a/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py b/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py index 1fa028fe..d32005e8 100644 --- a/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py +++ b/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py @@ -3473,8 +3473,8 @@ def __init__( additional_tape_wraps: float = 0, collector_tab_crumple_factor: float = 50.0, name: str = "Flat Wound Jelly Roll", - cathode_notch_alignment_angle: Optional[float] = None, - anode_notch_alignment_angle: Optional[float] = None, + cathode_notch_alignment_position: Optional[float] = None, + anode_notch_alignment_position: Optional[float] = None, ) -> None: """Initialize flat wound jelly roll electrode assembly. @@ -3484,10 +3484,14 @@ def __init__( The layup structure to be wound mandrel : FlatMandrel Flat mandrel for racetrack winding - cathode_notch_alignment_angle : float, optional - Winding phase in radians at which cathode notch centers align. - anode_notch_alignment_angle : float, optional - Winding phase in radians at which anode notch centers align. + cathode_notch_alignment_position : float, optional + Cathode notch position in mm from the unrotated pressed mandrel's + minimum-x outer edge. The complete tab must lie on a straight + racetrack section. + anode_notch_alignment_position : float, optional + Anode notch position in mm from the same pressed-mandrel edge. + Cathode/anode transverse or longitudinal arrangement is controlled + by ``laminate.electrode_orientation``. Raises ------ @@ -3497,11 +3501,21 @@ def __init__( if not isinstance(mandrel, FlatMandrel): raise TypeError(f"mandrel must be FlatMandrel, got {type(mandrel)}") - self._cathode_notch_alignment_angle = self._validate_notch_alignment_angle( - cathode_notch_alignment_angle, "cathode_notch_alignment_angle" + self._validate_notch_alignment_position( + cathode_notch_alignment_position, "cathode_notch_alignment_position" ) - self._anode_notch_alignment_angle = self._validate_notch_alignment_angle( - anode_notch_alignment_angle, "anode_notch_alignment_angle" + self._cathode_notch_alignment_position = ( + None + if cathode_notch_alignment_position is None + else float(cathode_notch_alignment_position) * MM_TO_M + ) + self._validate_notch_alignment_position( + anode_notch_alignment_position, "anode_notch_alignment_position" + ) + self._anode_notch_alignment_position = ( + None + if anode_notch_alignment_position is None + else float(anode_notch_alignment_position) * MM_TO_M ) self._thickness_aware_notch_electrodes = [] @@ -3544,7 +3558,15 @@ def _calculate_roll( apply_notch_alignment: bool = True, **kwargs, ): + # Newly generated racetrack coordinates use the pressed mandrel center + # as their origin. Subsequent centering and rotation update this point. + self._pressed_mandrel_center_xz = np.zeros(2) super()._calculate_roll(laminate_x_spacing, **kwargs) + if apply_notch_alignment: + # Solve notch positions while x is still the pressed mandrel's + # intrinsic longitudinal axis. The rigid transforms below carry + # the selected points into the final display coordinates. + self._apply_thickness_aware_notches() # ``initial_rotation_angle`` warm-starts the inner Brent in # ``_rotate_spirals_to_minimize_thickness`` from the previous outer # iteration's optimum. Used by the thickness/width setter loops. @@ -3552,43 +3574,82 @@ def _calculate_roll( initial_angle=initial_rotation_angle ) self._center_spirals() - if apply_notch_alignment: - self._apply_thickness_aware_notches() @staticmethod - def _validate_notch_alignment_angle( + def _validate_notch_alignment_position( value: Optional[float], name: str - ) -> Optional[float]: - """Validate an optional notch-stack phase angle in radians.""" + ) -> None: + """Validate an optional notch-stack position in millimeters.""" if value is None: - return None + return try: - angle = float(value) + position = float(value) except (TypeError, ValueError) as exc: raise TypeError(f"{name} must be a finite float or None.") from exc - if not np.isfinite(angle): + if not np.isfinite(position): raise ValueError(f"{name} must be finite.") - return angle + if position < 0: + raise ValueError(f"{name} must be non-negative.") + + def _notch_alignment_target_x( + self, + position: float, + collector: NotchedCurrentCollector, + name: str, + ) -> float: + """Convert a mandrel-edge position to an unrotated x-coordinate.""" + if not hasattr(self, "_pressed_mandrel_center_xz"): + raise ValueError("Cannot align notches before positioning the mandrel.") + mandrel_center_x = float(self._pressed_mandrel_center_xz[0]) + + # Work entirely on the pressed mandrel's intrinsic longitudinal axis. + # Neither outer-turn thickness nor the later display rotation belongs + # in the physical alignment definition. + minimum_position = self._pressed_radius + collector._tab_width / 2 + maximum_position = ( + self._pressed_radius + + self._pressed_straight_length + - collector._tab_width / 2 + ) + if minimum_position > maximum_position: + raise ValueError( + f"{name} cannot fit because the tab is wider than the straight " + "racetrack section." + ) + + if position < minimum_position or position > maximum_position: + raise ValueError( + f"{name} must keep the complete tab on a straight racetrack " + f"section; valid range is {minimum_position * M_TO_MM:.2f} to " + f"{maximum_position * M_TO_MM:.2f} mm." + ) + + mandrel_x_min = ( + mandrel_center_x + - self._pressed_straight_length / 2 + - self._pressed_radius + ) + return mandrel_x_min + position def _apply_thickness_aware_notches(self) -> None: - """Apply configured same-phase notch centers to notched collectors. + """Apply configured same-position notch centers to notched collectors. - The component spiral supplies the authoritative mapping between winding - angle and unwrapped sheet length. Collector coordinates are refreshed - directly to avoid recursively invoking parent propagation while the - jelly roll itself is being calculated. + The component spiral supplies the authoritative mapping from a physical + x-coordinate to unwrapped sheet length on each turn. Collector + coordinates are refreshed directly to avoid recursively invoking parent + propagation while the jelly roll itself is being calculated. """ generated = set(self._thickness_aware_notch_electrodes) configurations = { - "cathode": self._cathode_notch_alignment_angle, - "anode": self._anode_notch_alignment_angle, + "cathode": self._cathode_notch_alignment_position, + "anode": self._anode_notch_alignment_position, } - for electrode_name, alignment_angle in configurations.items(): + for electrode_name, alignment_position in configurations.items(): electrode = getattr(self._layup, f"_{electrode_name}") collector = electrode._current_collector - if alignment_angle is None: + if alignment_position is None: if electrode_name in generated: collector._tab_center_positions = None collector._calculate_all_properties() @@ -3599,15 +3660,25 @@ def _apply_thickness_aware_notches(self) -> None: collector, TablessCurrentCollector ): raise TypeError( - f"{electrode_name}_notch_alignment_angle requires a " + f"{electrode_name}_notch_alignment_position requires a " "NotchedCurrentCollector." ) spiral = self._component_spirals[f"{electrode_name}_current_collector"] - centers_global = SpiralCalculator.aligned_positions_from_spiral( + property_name = f"{electrode_name}_notch_alignment_position" + target_x = self._notch_alignment_target_x( + alignment_position, collector, property_name + ) + centers_global = SpiralCalculator.aligned_positions_at_x( spiral=spiral, - alignment_angle=alignment_angle, + target_x=target_x, tab_width=collector._tab_width, + straight_x_bounds=( + self._pressed_mandrel_center_xz[0] + - self._pressed_straight_length / 2, + self._pressed_mandrel_center_xz[0] + + self._pressed_straight_length / 2, + ), ) leading_edge = collector._datum[0] - collector._x_foil_length / 2 @@ -3619,20 +3690,22 @@ def _apply_thickness_aware_notches(self) -> None: self._thickness_aware_notch_electrodes = sorted(generated) - def _clear_generated_notches_on_layup(self, layup: Laminate) -> None: - """Clear assembly-generated patterns before changing a copied layup length.""" + def _copy_layup_for_dimension_calculation(self) -> Laminate: + """Copy the layup without assembly-generated explicit notch patterns.""" + layup = deepcopy(self._layup) configurations = { - "cathode": self._cathode_notch_alignment_angle, - "anode": self._anode_notch_alignment_angle, + "cathode": self._cathode_notch_alignment_position, + "anode": self._anode_notch_alignment_position, } - for electrode_name, alignment_angle in configurations.items(): - if alignment_angle is None: + for electrode_name, alignment_position in configurations.items(): + if alignment_position is None: continue collector = getattr(layup, f"_{electrode_name}")._current_collector if isinstance(collector, NotchedCurrentCollector) and not isinstance( collector, TablessCurrentCollector ): collector._tab_center_positions = None + return layup def _get_tape_geometry_parameters(self, spirals_x_z: np.ndarray) -> Dict[str, Any]: """Get geometry parameters for racetrack tape calculation. @@ -3829,8 +3902,7 @@ def _calculate_thickness_width_range( straight_length = self._pressed_straight_length # get the thickness minimum bound - small_layup = deepcopy(self._layup) - self._clear_generated_notches_on_layup(small_layup) + small_layup = self._copy_layup_for_dimension_calculation() small_layup.length = min_layup_length small_layup = self.position_layup_on_mandrel(small_layup, self._mandrel) small_layup.calculate_flattened_center_lines() @@ -3857,8 +3929,7 @@ def _calculate_thickness_width_range( ) # get the thickness maximum bound - big_layup = deepcopy(self._layup) - self._clear_generated_notches_on_layup(big_layup) + big_layup = self._copy_layup_for_dimension_calculation() big_layup.length = big_layup.length_range[1] big_layup = self.position_layup_on_mandrel(big_layup, self._mandrel) big_layup.calculate_flattened_center_lines() @@ -4055,6 +4126,13 @@ def _center_spirals(self) -> None: center_x = (max_x + min_x) / 2 center_z = (max_z + min_z) / 2 + # Track the pressed mandrel center through the same final translation. + # This stays a fixed alignment datum even when an incomplete outer turn + # makes the wound geometry's left and right extents asymmetric. + self._pressed_mandrel_center_xz = self._pressed_mandrel_center_xz - np.array( + [center_x, center_z] + ) + self._spiral, self._component_spirals, self._extruded_spirals = ( self._translate_spirals_xz(x_shift=-center_x, z_shift=-center_z) ) @@ -4124,10 +4202,29 @@ def _rotate_spirals_to_minimize_thickness( ) # Rotate all spirals using the helper function from SpiralCalculator + rotation_points = np.vstack( + [ + value[:, [X_COORD_COL, Z_COORD_COL]] + for value in all_spirals.values() + if value is not None and value.size > 0 + ] + ) + rotation_points = rotation_points[np.isfinite(rotation_points).all(axis=1)] + rotation_centroid = rotation_points.mean(axis=0) + _, optimal_angle = SpiralCalculator.rotate_spiral_to_minimize_thickness( all_spirals, initial_angle=initial_angle ) + # The spiral helper rotates around ``rotation_centroid``. Apply that + # same transform to the current pressed-mandrel center. + cosine = np.cos(optimal_angle) + sine = np.sin(optimal_angle) + rotation_matrix = np.array([[cosine, -sine], [sine, cosine]]) + self._pressed_mandrel_center_xz = ( + self._pressed_mandrel_center_xz - rotation_centroid + ) @ rotation_matrix.T + rotation_centroid + return optimal_angle def _get_high_resolution_params(self) -> Dict[str, Any]: @@ -4227,27 +4324,37 @@ def pressed_straight_length(self) -> float: return self._pressed_straight_length * M_TO_MM @property - def cathode_notch_alignment_angle(self) -> Optional[float]: - """Return the cathode notch-stack phase angle in radians.""" - return self._cathode_notch_alignment_angle + def cathode_notch_alignment_position(self) -> Optional[float]: + """Return the cathode notch position on the pressed mandrel axis in mm.""" + if self._cathode_notch_alignment_position is None: + return None + return self._cathode_notch_alignment_position * M_TO_MM - @cathode_notch_alignment_angle.setter + @cathode_notch_alignment_position.setter @calculate_all_properties - def cathode_notch_alignment_angle(self, value: Optional[float]) -> None: - self._cathode_notch_alignment_angle = self._validate_notch_alignment_angle( - value, "cathode_notch_alignment_angle" + def cathode_notch_alignment_position(self, value: Optional[float]) -> None: + self._validate_notch_alignment_position( + value, "cathode_notch_alignment_position" + ) + self._cathode_notch_alignment_position = ( + None if value is None else float(value) * MM_TO_M ) @property - def anode_notch_alignment_angle(self) -> Optional[float]: - """Return the anode notch-stack phase angle in radians.""" - return self._anode_notch_alignment_angle + def anode_notch_alignment_position(self) -> Optional[float]: + """Return the anode notch position on the pressed mandrel axis in mm.""" + if self._anode_notch_alignment_position is None: + return None + return self._anode_notch_alignment_position * M_TO_MM - @anode_notch_alignment_angle.setter + @anode_notch_alignment_position.setter @calculate_all_properties - def anode_notch_alignment_angle(self, value: Optional[float]) -> None: - self._anode_notch_alignment_angle = self._validate_notch_alignment_angle( - value, "anode_notch_alignment_angle" + def anode_notch_alignment_position(self, value: Optional[float]) -> None: + self._validate_notch_alignment_position( + value, "anode_notch_alignment_position" + ) + self._anode_notch_alignment_position = ( + None if value is None else float(value) * MM_TO_M ) @property @@ -4255,12 +4362,12 @@ def thickness_aware_notch_data(self) -> Dict[str, Dict[str, Any]]: """Return configured notch centers, spacings, and gaps for each electrode.""" result: Dict[str, Dict[str, Any]] = {} for electrode_name in ("cathode", "anode"): - angle = getattr(self, f"_{electrode_name}_notch_alignment_angle") - if angle is None: + position = getattr(self, f"_{electrode_name}_notch_alignment_position") + if position is None: continue collector = getattr(self._layup, f"_{electrode_name}")._current_collector result[electrode_name] = { - "alignment_angle": angle, + "alignment_position": position * M_TO_MM, "centers": collector.tab_center_positions, "center_spacings": collector.tab_center_spacings, "gaps": collector.tab_gaps, @@ -4270,16 +4377,14 @@ def thickness_aware_notch_data(self) -> Dict[str, Dict[str, Any]]: def plot_notch_alignment(self, layered: bool = False, **kwargs: Any) -> go.Figure: """Plot the flat-wound cross-section with aligned notch-center markers. - Markers at increasing radii should form one radial stack for each - configured electrode, demonstrating that every notch has the same - winding phase. + Markers should form one constant-x stack for each configured electrode. """ figure = self.plot_spiral(layered=layered, **kwargs) colors = {"cathode": "#d62728", "anode": "#1f77b4"} for electrode_name in ("cathode", "anode"): - angle = getattr(self, f"_{electrode_name}_notch_alignment_angle") - if angle is None: + position = getattr(self, f"_{electrode_name}_notch_alignment_position") + if position is None: continue collector = getattr(self._layup, f"_{electrode_name}")._current_collector @@ -4382,8 +4487,7 @@ def thickness(self, target_thickness: float) -> None: # Deepcopy the layup once, then mutate ``length`` in place across # iterations. The original ``self._layup`` is untouched until the # final ``self.layup = self._layup`` reassignment after Brent. - template_layup = deepcopy(self._layup) - self._clear_generated_notches_on_layup(template_layup) + template_layup = self._copy_layup_for_dimension_calculation() # The optimal rotation angle barely shifts between outer-Brent # iterations; cache it and warm-start the inner Brent each time. rotation_state: Dict[str, Optional[float]] = {"angle": None} @@ -4452,8 +4556,7 @@ def width(self, target_width: float) -> None: # Deepcopy the layup once, then mutate ``length`` in place across # iterations. The original ``self._layup`` is untouched until the # final ``self.layup = self._layup`` reassignment after Brent. - template_layup = deepcopy(self._layup) - self._clear_generated_notches_on_layup(template_layup) + template_layup = self._copy_layup_for_dimension_calculation() # The optimal rotation angle barely shifts between outer-Brent # iterations; cache it and warm-start the inner Brent each time. rotation_state: Dict[str, Optional[float]] = {"angle": None} diff --git a/steer_opencell_design/Constructions/ElectrodeAssemblies/SpiralUtils.py b/steer_opencell_design/Constructions/ElectrodeAssemblies/SpiralUtils.py index 656edc19..88461691 100644 --- a/steer_opencell_design/Constructions/ElectrodeAssemblies/SpiralUtils.py +++ b/steer_opencell_design/Constructions/ElectrodeAssemblies/SpiralUtils.py @@ -1392,64 +1392,117 @@ def _build_grad_factor_grid(t_grid: np.ndarray, x_grid: np.ndarray) -> np.ndarra return (1.0 + 5.0 * (np.abs(dt_dx) / max_grad)).astype(np.float64) @staticmethod - def aligned_positions_from_spiral( + def aligned_positions_at_x( spiral: np.ndarray, - alignment_angle: float, + target_x: float, tab_width: float = 0.0, minimum_gap: float = 0.0, + straight_x_bounds: Optional[tuple[float, float]] = None, ) -> np.ndarray: - """Return unwrapped centers that recur at one angular winding phase. + """Return one unwrapped center per turn at a fixed x-coordinate. - Length inputs and outputs use meters; ``alignment_angle`` uses radians. - A center is emitted for every integer turn satisfying - ``theta = alignment_angle + 2*pi*k`` where the complete tab fits within - the supplied spiral's unwrapped-length extent. + Length inputs and outputs use meters. ``target_x`` is expressed in the + supplied spiral's unrotated racetrack coordinate system. The turn + column, which is derived from winding phase, separates successive + turns. Within each turn, the method selects the increasing-x straight + branch and interpolates where it crosses ``target_x``. """ spiral = np.asarray(spiral, dtype=float) - if spiral.ndim != 2 or spiral.shape[1] <= X_UNWRAPPED_COL: + if spiral.ndim != 2 or spiral.shape[1] <= TURNS_COL: raise ValueError( - "spiral must be a two-dimensional array containing theta and " - "unwrapped-length columns." + "spiral must be a two-dimensional array containing unwrapped " + "length, x-coordinate, and turn columns." ) - if not np.isfinite(alignment_angle): - raise ValueError("alignment_angle must be finite.") + if not np.isfinite(target_x): + raise ValueError("target_x must be finite.") if not np.isfinite(tab_width) or tab_width < 0: raise ValueError("tab_width must be a finite non-negative value.") if not np.isfinite(minimum_gap) or minimum_gap < 0: raise ValueError("minimum_gap must be a finite non-negative value.") + if straight_x_bounds is not None: + bounds = np.asarray(straight_x_bounds, dtype=float) + if bounds.shape != (2,) or not np.all(np.isfinite(bounds)): + raise ValueError("straight_x_bounds must contain two finite values.") + if bounds[0] > bounds[1]: + raise ValueError( + "straight_x_bounds must be ordered from minimum to maximum." + ) - theta = spiral[:, THETA_COL] x_unwrapped = spiral[:, X_UNWRAPPED_COL] - valid = np.isfinite(theta) & np.isfinite(x_unwrapped) - theta = theta[valid] - x_unwrapped = x_unwrapped[valid] - if len(theta) < 2: + x_coordinate = spiral[:, X_COORD_COL] + turns = spiral[:, TURNS_COL] + if len(spiral) < 2: return np.empty(0, dtype=float) - order = np.argsort(theta) - theta = theta[order] - x_unwrapped = x_unwrapped[order] - theta, unique_indices = np.unique(theta, return_index=True) - x_unwrapped = x_unwrapped[unique_indices] - if len(theta) < 2: + finite = ( + np.isfinite(x_unwrapped) + & np.isfinite(x_coordinate) + & np.isfinite(turns) + ) + valid_pairs = finite[:-1] & finite[1:] + x_start = x_coordinate[:-1] + x_end = x_coordinate[1:] + + # Each x-coordinate occurs on both straight sections. Their traversal + # directions are opposite, so increasing x selects one consistent side. + crossings = ( + valid_pairs + & (x_end > x_start) + & (x_start <= target_x) + & (target_x <= x_end) + ) + crossing_indices = np.flatnonzero(crossings) + if len(crossing_indices) == 0: return np.empty(0, dtype=float) - first_turn = int(np.ceil((theta[0] - alignment_angle) / TWO_PI)) - last_turn = int(np.floor((theta[-1] - alignment_angle) / TWO_PI)) - if last_turn < first_turn: - return np.empty(0, dtype=float) + centers_by_turn: dict[int, float] = {} + for index in crossing_indices: + turn = int(np.floor((turns[index] + turns[index + 1]) / 2 + 1e-12)) + fraction = (target_x - x_start[index]) / (x_end[index] - x_start[index]) + center = x_unwrapped[index] + fraction * ( + x_unwrapped[index + 1] - x_unwrapped[index] + ) + centers_by_turn.setdefault(turn, center) - target_theta = alignment_angle + TWO_PI * np.arange( - first_turn, last_turn + 1, dtype=float - ) - centers = np.interp(target_theta, theta, x_unwrapped) + centers = np.sort(np.asarray(list(centers_by_turn.values()), dtype=float)) half_width = tab_width / 2 - x_min = float(np.min(x_unwrapped)) - x_max = float(np.max(x_unwrapped)) - fits = (centers - half_width >= x_min) & (centers + half_width <= x_max) + finite_unwrapped = x_unwrapped[np.isfinite(x_unwrapped)] + if len(finite_unwrapped) == 0: + return np.empty(0, dtype=float) + minimum_unwrapped = float(np.min(finite_unwrapped)) + maximum_unwrapped = float(np.max(finite_unwrapped)) + fits = (centers - half_width >= minimum_unwrapped) & ( + centers + half_width <= maximum_unwrapped + ) centers = np.sort(centers[fits]) + if straight_x_bounds is not None and len(centers) > 0: + # Tab width is measured along the unwrapped foil. Map both physical + # endpoints back onto the wound path rather than assuming that foil + # distance and x-distance are identical near a curved-end tangent. + path_finite = np.isfinite(x_unwrapped) & np.isfinite(x_coordinate) + path_unwrapped = x_unwrapped[path_finite] + path_x = x_coordinate[path_finite] + order = np.argsort(path_unwrapped) + endpoint_unwrapped = np.column_stack( + (centers - half_width, centers + half_width) + ) + endpoint_x = np.interp( + endpoint_unwrapped, + path_unwrapped[order], + path_x[order], + ) + x_min, x_max = straight_x_bounds + tolerance = 1e-12 + if np.any(endpoint_x < x_min - tolerance) or np.any( + endpoint_x > x_max + tolerance + ): + raise ValueError( + "Aligned tab endpoints must remain on a straight racetrack " + "section." + ) + if len(centers) > 1 and np.any(np.diff(centers) < tab_width + minimum_gap): raise ValueError( "Aligned tab positions overlap or violate the requested minimum gap." diff --git a/test/test_assembly.py b/test/test_assembly.py index 1ebff6a7..59b0cd4a 100644 --- a/test/test_assembly.py +++ b/test/test_assembly.py @@ -42,7 +42,9 @@ from steer_core.Constants.Universal import TWO_PI from steer_opencell_design.Constructions.ElectrodeAssemblies.JellyRolls import ( THETA_COL, + X_COORD_COL, X_UNWRAPPED_COL, + Z_COORD_COL, ) @@ -635,8 +637,9 @@ def test_basics(self): self.assertAlmostEqual(self.my_jellyroll.width_range[0], 104.48, 1) self.assertAlmostEqual(self.my_jellyroll.width_range[1], 125.95, 1) - def test_thickness_aware_cathode_notches_align_by_winding_phase(self): - self.my_jellyroll.cathode_notch_alignment_angle = 0.0 + def test_thickness_aware_cathode_notches_align_at_physical_position(self): + alignment_position = 50.0 + self.my_jellyroll.cathode_notch_alignment_position = alignment_position collector = self.my_jellyroll.layup.cathode.current_collector self.assertIsNotNone(collector.tab_center_positions) @@ -644,21 +647,54 @@ def test_thickness_aware_cathode_notches_align_by_winding_phase(self): self.assertTrue(np.all(np.diff(collector.tab_center_spacings) > 0)) spiral = self.my_jellyroll._component_spirals["cathode_current_collector"] - valid = np.isfinite(spiral[:, THETA_COL]) & np.isfinite( - spiral[:, X_UNWRAPPED_COL] - ) - theta = spiral[valid, THETA_COL] - unwrapped = spiral[valid, X_UNWRAPPED_COL] + valid = np.isfinite(spiral[:, X_UNWRAPPED_COL]) + component = spiral[valid] + unwrapped = component[:, X_UNWRAPPED_COL] order = np.argsort(unwrapped) + component = component[order] leading_edge = collector._datum[0] - collector._x_foil_length / 2 centers_global = ( leading_edge + np.asarray(collector.tab_center_positions) * MM_TO_M ) - center_angles = np.interp(centers_global, unwrapped[order], theta[order]) - phase_error = np.abs(center_angles - TWO_PI * np.rint(center_angles / TWO_PI)) - self.assertTrue(np.all(phase_error < 1e-8)) + marker_x = np.interp( + centers_global, + component[:, X_UNWRAPPED_COL], + component[:, X_COORD_COL], + ) + marker_z = np.interp( + centers_global, + component[:, X_UNWRAPPED_COL], + component[:, Z_COORD_COL], + ) + rotation_angle = self.my_jellyroll._last_rotation_angle + mandrel_axis = np.array( + [np.cos(rotation_angle), np.sin(rotation_angle)] + ) + marker_coordinates = np.column_stack((marker_x, marker_z)) + marker_axis_positions = ( + marker_coordinates - self.my_jellyroll._pressed_mandrel_center_xz + ) @ mandrel_axis + expected_axis_position = ( + -self.my_jellyroll._pressed_straight_length / 2 + - self.my_jellyroll._pressed_radius + + alignment_position * MM_TO_M + ) + np.testing.assert_allclose( + marker_axis_positions, expected_axis_position, atol=1e-10 + ) + + # The physical x-coordinate is fixed, while the normalized phase shifts + # as the racetrack radius and perimeter grow between turns. + center_angles = np.interp( + centers_global, + component[:, X_UNWRAPPED_COL], + component[:, THETA_COL], + ) + center_phases = np.mod(center_angles, TWO_PI) + self.assertGreater(np.ptp(center_phases), 1e-3) data = self.my_jellyroll.thickness_aware_notch_data["cathode"] + self.assertEqual(data["alignment_position"], alignment_position) self.assertEqual(data["centers"], collector.tab_center_positions) self.assertEqual(data["gaps"], collector.tab_gaps) @@ -669,8 +705,8 @@ def test_thickness_aware_cathode_notches_align_by_winding_phase(self): self.assertEqual(len(marker_trace.x), collector.n_tabs) def test_disabling_alignment_restores_scalar_spacing(self): - self.my_jellyroll.cathode_notch_alignment_angle = 0.0 - self.my_jellyroll.cathode_notch_alignment_angle = None + self.my_jellyroll.cathode_notch_alignment_position = 50.0 + self.my_jellyroll.cathode_notch_alignment_position = None collector = self.my_jellyroll.layup.cathode.current_collector self.assertIsNone(collector.tab_center_positions) @@ -679,18 +715,79 @@ def test_disabling_alignment_restores_scalar_spacing(self): self.assertAlmostEqual(spacing, collector.tab_spacing) def test_alignment_configuration_serializes_for_both_electrodes(self): - self.my_jellyroll.cathode_notch_alignment_angle = 0.0 - self.my_jellyroll.anode_notch_alignment_angle = np.pi + self.my_jellyroll.cathode_notch_alignment_position = 50.0 + self.my_jellyroll.anode_notch_alignment_position = 65.0 restored = FlatWoundJellyRoll.deserialize(self.my_jellyroll.serialize()) - self.assertEqual(restored.cathode_notch_alignment_angle, 0.0) - self.assertEqual(restored.anode_notch_alignment_angle, np.pi) + self.assertEqual(restored.cathode_notch_alignment_position, 50.0) + self.assertEqual(restored.anode_notch_alignment_position, 65.0) self.assertGreater(restored.layup.cathode.current_collector.n_tabs, 2) self.assertGreater(restored.layup.anode.current_collector.n_tabs, 2) self.assertIn("cathode", restored.thickness_aware_notch_data) self.assertIn("anode", restored.thickness_aware_notch_data) + def test_alignment_position_rejects_curved_racetrack_ends(self): + with self.assertRaisesRegex(ValueError, "straight racetrack section"): + self.my_jellyroll.cathode_notch_alignment_position = 0.0 + + collector = self.my_jellyroll.layup.cathode.current_collector + minimum_position = ( + self.my_jellyroll._pressed_radius + collector._tab_width / 2 + ) / MM_TO_M + self.my_jellyroll.cathode_notch_alignment_position = minimum_position + self.assertAlmostEqual( + self.my_jellyroll.cathode_notch_alignment_position, minimum_position + ) + + def test_alignment_target_ignores_outer_turn_radius_and_rotation(self): + collector = self.my_jellyroll.layup.cathode.current_collector + position = 50.0 * MM_TO_M + target_before = self.my_jellyroll._notch_alignment_target_x( + position, collector, "cathode_notch_alignment_position" + ) + + # Model a new asymmetric outer point extending farther to the left. + non_tape_spirals = { + name: spiral + for name, spiral in self.my_jellyroll._component_spirals.items() + if name != "tape" + } + component_name = min( + non_tape_spirals, + key=lambda name: np.nanmin(non_tape_spirals[name][:, X_COORD_COL]), + ) + row_index = int( + np.nanargmin(non_tape_spirals[component_name][:, X_COORD_COL]) + ) + self.my_jellyroll._component_spirals[component_name][ + row_index, X_COORD_COL + ] -= 1.0 * MM_TO_M + self.my_jellyroll._last_rotation_angle = np.pi / 3 + + target_after = self.my_jellyroll._notch_alignment_target_x( + position, collector, "cathode_notch_alignment_position" + ) + self.assertAlmostEqual(target_after, target_before) + + def test_dimension_layup_copy_clears_only_generated_pattern(self): + anode_collector = self.my_jellyroll.layup.anode.current_collector + anode_collector.tab_center_positions = [100.0, 300.0] + self.my_jellyroll.cathode_notch_alignment_position = 50.0 + + copied_layup = self.my_jellyroll._copy_layup_for_dimension_calculation() + + self.assertIsNotNone( + self.my_jellyroll.layup.cathode.current_collector.tab_center_positions + ) + self.assertIsNone( + copied_layup.cathode.current_collector.tab_center_positions + ) + self.assertEqual( + copied_layup.anode.current_collector.tab_center_positions, + [100.0, 300.0], + ) + def test_serialization(self): serialized = self.my_jellyroll.serialize() deserialized = FlatWoundJellyRoll.deserialize(serialized) diff --git a/test/test_spiral_utils.py b/test/test_spiral_utils.py index df60dd2f..4557dd8a 100644 --- a/test/test_spiral_utils.py +++ b/test/test_spiral_utils.py @@ -30,6 +30,10 @@ from steer_opencell_design.Constructions.ElectrodeAssemblies.SpiralUtils import ( SpiralCalculator, + TURNS_COL, + X_COORD_COL, + X_UNWRAPPED_COL, + Z_COORD_COL, _racetrack_positions_batch, _thickness_at_jit, ) @@ -356,49 +360,76 @@ def test_interior_point_is_linear_interpolation(self): self.assertAlmostEqual(value, x, places=10, msg=f"x={x}") -class TestAlignedPositionsFromSpiral(unittest.TestCase): - def test_returns_same_phase_positions_with_increasing_pitch(self): - theta = np.linspace(0.0, 8.0 * np.pi, 1001) - # Monotonic synthetic winding whose length per turn grows outward. - x_unwrapped = 0.02 * theta + 0.0002 * theta**2 - spiral = np.column_stack( - ( - theta, - x_unwrapped, - np.ones_like(theta), - np.zeros_like(theta), - np.zeros_like(theta), - theta / TWO_PI, - ) +class TestAlignedPositionsAtX(unittest.TestCase): + def setUp(self): + self.spiral = SpiralCalculator.calculate_simple_racetrack( + n_turns=4, + start_radius=0.005, + straight_length=0.05, + thickness=0.001, + points_per_turn=400, ) - centers = SpiralCalculator.aligned_positions_from_spiral( - spiral, alignment_angle=0.0 - ) + def test_returns_one_positive_z_crossing_per_turn_at_fixed_x(self): + target_x = 0.01 + centers = SpiralCalculator.aligned_positions_at_x(self.spiral, target_x) - np.testing.assert_allclose( + self.assertEqual(len(centers), 4) + marker_x = np.interp( + centers, + self.spiral[:, X_UNWRAPPED_COL], + self.spiral[:, X_COORD_COL], + ) + marker_z = np.interp( + centers, + self.spiral[:, X_UNWRAPPED_COL], + self.spiral[:, Z_COORD_COL], + ) + marker_turns = np.interp( centers, - 0.02 * (TWO_PI * np.arange(5)) + 0.0002 * (TWO_PI * np.arange(5)) ** 2, + self.spiral[:, X_UNWRAPPED_COL], + self.spiral[:, TURNS_COL], ) + np.testing.assert_allclose(marker_x, target_x, atol=1e-12) + self.assertTrue(np.all(marker_z > 0)) + np.testing.assert_array_equal(np.floor(marker_turns).astype(int), range(4)) self.assertTrue(np.all(np.diff(np.diff(centers)) > 0)) def test_omits_centers_where_full_tab_does_not_fit(self): - theta = np.linspace(0.0, 4.0 * np.pi, 101) - spiral = np.column_stack( - (theta, theta / 100, theta, theta, theta, theta / TWO_PI) + tab_width = 0.1 + centers = SpiralCalculator.aligned_positions_at_x( + self.spiral, target_x=0.0, tab_width=tab_width ) - centers = SpiralCalculator.aligned_positions_from_spiral( - spiral, alignment_angle=0.0, tab_width=0.02 + minimum = np.min(self.spiral[:, X_UNWRAPPED_COL]) + maximum = np.max(self.spiral[:, X_UNWRAPPED_COL]) + self.assertEqual(len(centers), 3) + self.assertTrue(np.all(centers - tab_width / 2 >= minimum)) + self.assertTrue(np.all(centers + tab_width / 2 <= maximum)) + + def test_rejects_tab_endpoints_on_curved_section(self): + with self.assertRaisesRegex(ValueError, "straight racetrack section"): + SpiralCalculator.aligned_positions_at_x( + self.spiral, + target_x=-0.024, + tab_width=0.01, + straight_x_bounds=(-0.025, 0.025), + ) + + def test_accepts_tab_endpoints_at_straight_section_boundaries(self): + centers = SpiralCalculator.aligned_positions_at_x( + self.spiral, + target_x=-0.02, + tab_width=0.01, + straight_x_bounds=(-0.025, 0.025), ) - self.assertEqual(len(centers), 1) - self.assertAlmostEqual(centers[0], TWO_PI / 100) + self.assertEqual(len(centers), 4) - def test_rejects_non_finite_angle(self): + def test_rejects_non_finite_target_x(self): spiral = np.zeros((2, 6)) with self.assertRaises(ValueError): - SpiralCalculator.aligned_positions_from_spiral(spiral, np.nan) + SpiralCalculator.aligned_positions_at_x(spiral, np.nan) if __name__ == "__main__": From 448cf77685f6e837bc804aa16973000e6e4d226c Mon Sep 17 00:00:00 2001 From: ptlin84 Date: Sun, 16 Aug 2026 09:20:38 -0700 Subject: [PATCH 6/7] feat: visualize flat-wound notch positions --- .../ElectrodeAssemblies/JellyRolls.py | 135 +++++++++++++++++- test/test_assembly.py | 87 +++++++++++ 2 files changed, 217 insertions(+), 5 deletions(-) diff --git a/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py b/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py index d32005e8..2cd462fc 100644 --- a/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py +++ b/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py @@ -3575,6 +3575,88 @@ def _calculate_roll( ) self._center_spirals() + def _calculate_top_down_coordinates(self) -> None: + """Add aligned notch-stack footprints to the standard top-down view.""" + super()._calculate_top_down_coordinates() + for electrode_name in ("cathode", "anode"): + if ( + getattr(self, f"_{electrode_name}_notch_alignment_position") + is not None + ): + self._calculate_notch_stack_top_down_coords(electrode_name) + + def _calculate_notch_stack_top_down_coords(self, electrode_name: str) -> None: + """Build one x-y footprint for an aligned stack of overlapping tabs.""" + electrode = getattr(self._layup, f"_{electrode_name}") + collector = electrode._current_collector + if not isinstance(collector, NotchedCurrentCollector) or isinstance( + collector, TablessCurrentCollector + ): + return + + collector_key = f"{electrode_name}_current_collector" + body_coords = self._component_top_down_coordinates.get(collector_key) + if body_coords is None: + return + + # The legacy schematic represents integral tabs as a full-width band. + # For an aligned pattern, keep the collector body rectangular and draw + # the actual tab stack separately at its physical x-position. + foil_y_min = collector._datum[1] - collector._y_foil_length / 2 + foil_y_max = collector._datum[1] + collector._y_foil_length / 2 + body_x_min = float(np.min(body_coords[:, 0])) + body_x_max = float(np.max(body_coords[:, 0])) + body_x, body_y = self.build_square_array( + body_x_min, + foil_y_min, + body_x_max - body_x_min, + collector._y_foil_length, + ) + self._component_top_down_coordinates[collector_key] = np.column_stack( + (body_x, body_y) + ) + + visible_tab_height = collector._tab_height * ( + 1.0 - self._collector_tab_crumple_factor + ) + if visible_tab_height <= 0: + return + + # Electrode orientation is already expressed by flipping the collector + # coordinates. Infer the protruding side from that geometry so this view + # follows the same source of truth as punched-current-collector plots. + collector_y = collector._foil_coordinates[:, 1] + collector_y = collector_y[np.isfinite(collector_y)] + negative_extension = foil_y_min - float(np.min(collector_y)) + positive_extension = float(np.max(collector_y)) - foil_y_max + if positive_extension >= negative_extension: + stack_y_min = foil_y_max + else: + stack_y_min = foil_y_min - visible_tab_height + + position = getattr(self, f"_{electrode_name}_notch_alignment_position") + axis_position = self._notch_alignment_axis_position(position) + rotation_angle = self._last_rotation_angle + mandrel_axis = np.array( + [np.cos(rotation_angle), np.sin(rotation_angle)] + ) + # The top-down view is an axis-aligned schematic, like the punched + # collector view. Project the transformed mandrel center back onto its + # longitudinal axis so increasing edge distance always reads left-to-right, + # independent of an equivalent 180-degree cross-section rotation. + stack_x_center = ( + np.dot(self._pressed_mandrel_center_xz, mandrel_axis) + axis_position + ) + stack_x, stack_y = self.build_square_array( + stack_x_center - collector._tab_width / 2, + stack_y_min, + collector._tab_width, + visible_tab_height, + ) + self._component_top_down_coordinates[f"{electrode_name}_notch_stack"] = ( + np.column_stack((stack_x, stack_y)) + ) + @staticmethod def _validate_notch_alignment_position( value: Optional[float], name: str @@ -3624,12 +3706,13 @@ def _notch_alignment_target_x( f"{maximum_position * M_TO_MM:.2f} mm." ) - mandrel_x_min = ( - mandrel_center_x - - self._pressed_straight_length / 2 - - self._pressed_radius + return mandrel_center_x + self._notch_alignment_axis_position(position) + + def _notch_alignment_axis_position(self, position: float) -> float: + """Convert an edge distance to the mandrel-centered longitudinal axis.""" + return ( + -self._pressed_straight_length / 2 - self._pressed_radius + position ) - return mandrel_x_min + position def _apply_thickness_aware_notches(self) -> None: """Apply configured same-position notch centers to notched collectors. @@ -4428,6 +4511,48 @@ def plot_notch_alignment(self, layered: bool = False, **kwargs: Any) -> go.Figur return figure + def _notch_stack_top_down_trace( + self, electrode_name: str + ) -> Optional[go.Scatter]: + """Return the top-down footprint trace for one aligned notch stack.""" + coordinate_key = f"{electrode_name}_notch_stack" + if coordinate_key not in self._component_top_down_coordinates: + return None + + collector = getattr( + self._layup, f"_{electrode_name}" + )._current_collector + coords = self._component_top_down_coordinates[coordinate_key] + position = getattr(self, f"_{electrode_name}_notch_alignment_position") + customdata = np.tile( + [position * M_TO_MM, collector.n_tabs], (len(coords), 1) + ) + return go.Scatter( + x=coords[:, 0] * M_TO_MM, + y=coords[:, 1] * M_TO_MM, + mode="lines", + fill="toself", + fillcolor=collector.material._color, + line=dict(color="black", width=1), + name=f"{electrode_name.title()} notch stack", + customdata=customdata, + hovertemplate=( + f"{electrode_name.title()} notch stack
" + "Mandrel-edge position: %{customdata[0]:.2f} mm
" + "Aligned tabs: %{customdata[1]:.0f}" + ), + ) + + def plot_top_down_view(self, opacity: float = 0.5, **kwargs) -> go.Figure: + """Plot the x-y view, including physically positioned notch stacks.""" + figure = super().plot_top_down_view(opacity=opacity, **kwargs) + for electrode_name in ("cathode", "anode"): + trace = self._notch_stack_top_down_trace(electrode_name) + if trace is not None: + self.adjust_trace_opacity(trace, opacity) + figure.add_trace(trace) + return figure + @property def thickness(self) -> float: """Return the overall jelly roll thickness in millimeters.""" diff --git a/test/test_assembly.py b/test/test_assembly.py index 59b0cd4a..a3959b22 100644 --- a/test/test_assembly.py +++ b/test/test_assembly.py @@ -704,6 +704,93 @@ def test_thickness_aware_cathode_notches_align_at_physical_position(self): ) self.assertEqual(len(marker_trace.x), collector.n_tabs) + def test_top_down_notch_stacks_use_physical_positions_and_transverse_sides(self): + unconfigured_names = { + trace.name for trace in self.my_jellyroll.plot_top_down_view().data + } + self.assertNotIn("Cathode notch stack", unconfigured_names) + self.assertNotIn("Anode notch stack", unconfigured_names) + + cathode_position = 50.0 + anode_position = 65.0 + self.my_jellyroll.cathode_notch_alignment_position = cathode_position + self.my_jellyroll.anode_notch_alignment_position = anode_position + + figure = self.my_jellyroll.plot_top_down_view() + cathode_stack = next( + trace for trace in figure.data if trace.name == "Cathode notch stack" + ) + anode_stack = next( + trace for trace in figure.data if trace.name == "Anode notch stack" + ) + cathode_body = next( + trace + for trace in figure.data + if trace.name == "Cathode Current Collector" + ) + anode_body = next( + trace + for trace in figure.data + if trace.name == "Anode Current Collector" + ) + + rotation_angle = self.my_jellyroll._last_rotation_angle + mandrel_axis = np.array( + [np.cos(rotation_angle), np.sin(rotation_angle)] + ) + mandrel_axis_center = ( + np.dot(self.my_jellyroll._pressed_mandrel_center_xz, mandrel_axis) + / MM_TO_M + ) + common_offset = ( + -self.my_jellyroll._pressed_straight_length / 2 + - self.my_jellyroll._pressed_radius + ) / MM_TO_M + cathode_stack_center = (min(cathode_stack.x) + max(cathode_stack.x)) / 2 + anode_stack_center = (min(anode_stack.x) + max(anode_stack.x)) / 2 + self.assertAlmostEqual( + cathode_stack_center, + mandrel_axis_center + common_offset + cathode_position, + ) + self.assertAlmostEqual( + anode_stack_center, + mandrel_axis_center + common_offset + anode_position, + ) + self.assertAlmostEqual( + anode_stack_center - cathode_stack_center, + anode_position - cathode_position, + ) + self.assertGreaterEqual(min(cathode_stack.y), max(cathode_body.y)) + self.assertLessEqual(max(anode_stack.y), min(anode_body.y)) + + def test_top_down_notch_stacks_share_side_when_longitudinal(self): + layup = self.my_jellyroll.layup + layup.electrode_orientation = "longitudinal" + self.my_jellyroll.layup = layup + self.my_jellyroll.cathode_notch_alignment_position = 50.0 + self.my_jellyroll.anode_notch_alignment_position = 65.0 + + figure = self.my_jellyroll.plot_top_down_view() + cathode_stack = next( + trace for trace in figure.data if trace.name == "Cathode notch stack" + ) + anode_stack = next( + trace for trace in figure.data if trace.name == "Anode notch stack" + ) + cathode_body = next( + trace + for trace in figure.data + if trace.name == "Cathode Current Collector" + ) + anode_body = next( + trace + for trace in figure.data + if trace.name == "Anode Current Collector" + ) + + self.assertGreaterEqual(min(cathode_stack.y), max(cathode_body.y)) + self.assertGreaterEqual(min(anode_stack.y), max(anode_body.y)) + def test_disabling_alignment_restores_scalar_spacing(self): self.my_jellyroll.cathode_notch_alignment_position = 50.0 self.my_jellyroll.cathode_notch_alignment_position = None From ec637c0c5052cf39b42ed6770a53d928b2c60a6a Mon Sep 17 00:00:00 2001 From: ptlin84 Date: Sun, 16 Aug 2026 10:01:40 -0700 Subject: [PATCH 7/7] fix: harden thickness-aware notch alignment --- README.md | 10 ++++ .../Components/CurrentCollectors/Notched.py | 8 +-- .../ElectrodeAssemblies/JellyRolls.py | 50 ++++++++++++++----- .../ElectrodeAssemblies/SpiralUtils.py | 6 ++- test/test_assembly.py | 34 +++++++++++++ test/test_current_collectors.py | 26 +++++----- 6 files changed, 103 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 9019130e..35b9a7f1 100644 --- a/README.md +++ b/README.md @@ -946,6 +946,11 @@ Shared by all current collector types. | `bare_lengths_b_side` | (mm, mm) | (start, end) bare region on B-side | | `a_side_coated_section` | (mm, mm) | (start, end) of A-side coating | | `b_side_coated_section` | (mm, mm) | (start, end) of B-side coating | + +**`NotchedCurrentCollector` — additional settable:** + +| Property | Unit | Description | +|---|---|---| | `tab_center_positions` | list of mm or `None` | Optional uneven centers measured from the foil leading edge | **Tabbed CCs** (`NotchedCurrentCollector`, `TabWeldedCurrentCollector`, `PunchedCurrentCollector`) — **additional settable:** @@ -969,6 +974,10 @@ Shared by all current collector types. | `insulation_area` | cm² | Total insulation area | | `top_side` | str | Which side ('a'/'b') faces up | | `total_height` | mm | Total height including tab (tabbed types) | +| `calculated_tab_center_positions` | list of mm | Calculated centers in the collector coordinate system (`NotchedCurrentCollector`) | +| `tab_center_spacings` | list of mm | Consecutive center-to-center spacings (`NotchedCurrentCollector`) | +| `tab_gaps` | list of mm | Consecutive edge-to-edge gaps (`NotchedCurrentCollector`) | +| `n_tabs` | int | Number of complete tabs (`NotchedCurrentCollector`) | ### Separator Properties @@ -1065,6 +1074,7 @@ All visualization methods return [Plotly](https://plotly.com/python/) `go.Figure | Method | Availability | Description | |---|---|---| | `get_spiral_plot()` | `WoundJellyRoll`, `FlatWoundJellyRoll` | Spiral winding path visualization | +| `plot_notch_alignment()` | `FlatWoundJellyRoll` | Racetrack cross-section with aligned notch centers | | `get_top_down_view()` | All assemblies | Top-down view of the assembly | | `get_side_view()` | All assemblies | Side view of the assembly | | `get_capacity_plot()` | All assemblies | Assembly-level capacity curves | diff --git a/steer_opencell_design/Components/CurrentCollectors/Notched.py b/steer_opencell_design/Components/CurrentCollectors/Notched.py index 0318cd4f..e4f83e4a 100644 --- a/steer_opencell_design/Components/CurrentCollectors/Notched.py +++ b/steer_opencell_design/Components/CurrentCollectors/Notched.py @@ -437,11 +437,11 @@ def _get_insulation_coordinates(self, side: str = "a") -> np.ndarray: # Compute x bounds of coated region bare_left, bare_right = self._bare_lengths_a_side if side == "a" else self._bare_lengths_b_side - + # Check if bare lengths exceed foil length - return empty arrays if so if bare_left + bare_right >= self._x_foil_length: return np.empty((0, 3)) - + x_start = self._datum[0] - self._x_foil_length / 2 + bare_left x_end = self._datum[0] + self._x_foil_length / 2 - bare_right @@ -507,7 +507,7 @@ def _get_insulation_coordinates(self, side: str = "a") -> np.ndarray: # Create z array with proper numeric dtype z = np.full_like(x, z_val, dtype=float) - + # Handle None values by converting to NaN for numeric arrays none_mask = np.array([val is None for val in x]) if np.any(none_mask): @@ -605,7 +605,7 @@ def tab_width_range(self) -> Tuple[float, float]: @tab_spacing.setter @calculate_all_properties def tab_spacing(self, tab_spacing: float) -> None: - + self.validate_positive_float(tab_spacing, "tab_spacing") self._tab_spacing = float(tab_spacing) * MM_TO_M diff --git a/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py b/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py index 2cd462fc..92735b4d 100644 --- a/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py +++ b/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py @@ -4414,13 +4414,11 @@ def cathode_notch_alignment_position(self) -> Optional[float]: return self._cathode_notch_alignment_position * M_TO_MM @cathode_notch_alignment_position.setter - @calculate_all_properties def cathode_notch_alignment_position(self, value: Optional[float]) -> None: - self._validate_notch_alignment_position( - value, "cathode_notch_alignment_position" - ) - self._cathode_notch_alignment_position = ( - None if value is None else float(value) * MM_TO_M + self._set_notch_alignment_position( + "_cathode_notch_alignment_position", + value, + "cathode_notch_alignment_position", ) @property @@ -4431,15 +4429,33 @@ def anode_notch_alignment_position(self) -> Optional[float]: return self._anode_notch_alignment_position * M_TO_MM @anode_notch_alignment_position.setter - @calculate_all_properties def anode_notch_alignment_position(self, value: Optional[float]) -> None: - self._validate_notch_alignment_position( - value, "anode_notch_alignment_position" - ) - self._anode_notch_alignment_position = ( - None if value is None else float(value) * MM_TO_M + self._set_notch_alignment_position( + "_anode_notch_alignment_position", + value, + "anode_notch_alignment_position", ) + def _set_notch_alignment_position( + self, attribute: str, value: Optional[float], name: str + ) -> None: + """Set and recalculate an alignment position, restoring it on failure.""" + self._validate_notch_alignment_position(value, name) + previous = getattr(self, attribute) + converted = None if value is None else float(value) * MM_TO_M + setattr(self, attribute, converted) + if not self._update_properties: + return + + try: + self._calculate_all_properties() + except Exception: + # Recalculation updates generated collector patterns in place, so + # recalculate the previous configuration as part of the rollback. + setattr(self, attribute, previous) + self._calculate_all_properties() + raise + @property def thickness_aware_notch_data(self) -> Dict[str, Dict[str, Any]]: """Return configured notch centers, spacings, and gaps for each electrode.""" @@ -4492,6 +4508,14 @@ def plot_notch_alignment(self, layered: bool = False, **kwargs: Any) -> go.Figur component[:, X_UNWRAPPED_COL], component[:, Z_COORD_COL], ) + marker_turns = np.floor( + np.interp( + centers_global, + component[:, X_UNWRAPPED_COL], + component[:, TURNS_COL], + ) + + 1e-12 + ) figure.add_trace( go.Scatter( x=x_markers * M_TO_MM, @@ -4500,7 +4524,7 @@ def plot_notch_alignment(self, layered: bool = False, **kwargs: Any) -> go.Figur name=f"{electrode_name.title()} notch centers", marker={"size": 8, "color": colors[electrode_name]}, customdata=np.column_stack( - (np.asarray(centers), np.arange(len(centers))) + (np.asarray(centers), marker_turns) ), hovertemplate=( "Turn %{customdata[1]:.0f}
" diff --git a/steer_opencell_design/Constructions/ElectrodeAssemblies/SpiralUtils.py b/steer_opencell_design/Constructions/ElectrodeAssemblies/SpiralUtils.py index 88461691..b03e274d 100644 --- a/steer_opencell_design/Constructions/ElectrodeAssemblies/SpiralUtils.py +++ b/steer_opencell_design/Constructions/ElectrodeAssemblies/SpiralUtils.py @@ -1494,7 +1494,11 @@ def aligned_positions_at_x( path_x[order], ) x_min, x_max = straight_x_bounds - tolerance = 1e-12 + # A center exactly at a legal tangent boundary can acquire a + # sub-micron overshoot when both crossings are interpolated from + # the sampled spiral. The public position check still enforces the + # exact geometric range; this tolerance covers interpolation only. + tolerance = 1e-6 if np.any(endpoint_x < x_min - tolerance) or np.any( endpoint_x > x_max + tolerance ): diff --git a/test/test_assembly.py b/test/test_assembly.py index a3959b22..5af8ea25 100644 --- a/test/test_assembly.py +++ b/test/test_assembly.py @@ -42,6 +42,7 @@ from steer_core.Constants.Universal import TWO_PI from steer_opencell_design.Constructions.ElectrodeAssemblies.JellyRolls import ( THETA_COL, + TURNS_COL, X_COORD_COL, X_UNWRAPPED_COL, Z_COORD_COL, @@ -703,6 +704,17 @@ def test_thickness_aware_cathode_notches_align_at_physical_position(self): trace for trace in figure.data if trace.name == "Cathode notch centers" ) self.assertEqual(len(marker_trace.x), collector.n_tabs) + np.testing.assert_array_equal( + np.asarray(marker_trace.customdata)[:, 1], + np.floor( + np.interp( + centers_global, + component[:, X_UNWRAPPED_COL], + component[:, TURNS_COL], + ) + + 1e-12 + ), + ) def test_top_down_notch_stacks_use_physical_positions_and_transverse_sides(self): unconfigured_names = { @@ -815,9 +827,21 @@ def test_alignment_configuration_serializes_for_both_electrodes(self): self.assertIn("anode", restored.thickness_aware_notch_data) def test_alignment_position_rejects_curved_racetrack_ends(self): + original_position = self.my_jellyroll.cathode_notch_alignment_position + original_centers = list( + self.my_jellyroll.layup.cathode.current_collector.tab_positions + ) with self.assertRaisesRegex(ValueError, "straight racetrack section"): self.my_jellyroll.cathode_notch_alignment_position = 0.0 + self.assertEqual( + self.my_jellyroll.cathode_notch_alignment_position, original_position + ) + self.assertEqual( + self.my_jellyroll.layup.cathode.current_collector.tab_positions, + original_centers, + ) + collector = self.my_jellyroll.layup.cathode.current_collector minimum_position = ( self.my_jellyroll._pressed_radius + collector._tab_width / 2 @@ -827,6 +851,16 @@ def test_alignment_position_rejects_curved_racetrack_ends(self): self.my_jellyroll.cathode_notch_alignment_position, minimum_position ) + maximum_position = ( + self.my_jellyroll._pressed_radius + + self.my_jellyroll._pressed_straight_length + - collector._tab_width / 2 + ) / MM_TO_M + self.my_jellyroll.cathode_notch_alignment_position = maximum_position + self.assertAlmostEqual( + self.my_jellyroll.cathode_notch_alignment_position, maximum_position + ) + def test_alignment_target_ignores_outer_turn_radius_and_rotation(self): collector = self.my_jellyroll.layup.cathode.current_collector position = 50.0 * MM_TO_M diff --git a/test/test_current_collectors.py b/test/test_current_collectors.py index 613e36e5..487e6e19 100644 --- a/test/test_current_collectors.py +++ b/test/test_current_collectors.py @@ -127,12 +127,12 @@ def setUp(self): Set up """ self.material = CurrentCollectorMaterial( - name="Copper", - density=8.96, - specific_cost=18.1, + name="Copper", + density=8.96, + specific_cost=18.1, color="#B87333" ) - + self.current_collector = PunchedCurrentCollector( material=self.material, width=160, @@ -347,7 +347,7 @@ def test_equality(self): def test_serialization_preserves_property_dependencies(self): """Test that property dependencies work correctly after serialization/deserialization. - + Tests that changing length affects mass and cost both before and after serialization. """ @@ -355,33 +355,33 @@ def test_serialization_preserves_property_dependencies(self): original_length = self.current_collector.length original_mass = self.current_collector.mass original_cost = self.current_collector.cost - + # Double the length new_length = original_length * 2 self.current_collector.length = new_length - + # Verify mass and cost approximately doubled self.assertEqual(self.current_collector.length, new_length) self.assertGreater(self.current_collector.mass, original_mass) self.assertGreater(self.current_collector.cost, original_cost) modified_mass = self.current_collector.mass - + # Reset self.current_collector.length = original_length self.assertEqual(self.current_collector.length, original_length) self.assertAlmostEqual(self.current_collector.mass, original_mass, places=2) - + # Serialize and deserialize serialized = self.current_collector.serialize() deserialized_cc = NotchedCurrentCollector.deserialize(serialized) - + # Verify deserialized has same properties self.assertEqual(deserialized_cc.length, original_length) self.assertAlmostEqual(deserialized_cc.mass, original_mass, places=2) - + # Double length on deserialized object deserialized_cc.length = new_length - + # Verify mass increased on deserialized object self.assertEqual(deserialized_cc.length, new_length) self.assertAlmostEqual(deserialized_cc.mass, modified_mass, places=2) @@ -614,7 +614,7 @@ def test_plots(self): class TestTabWeldedCurrentCollector(unittest.TestCase): - + def setUp(self): """ Set up