diff --git a/README.md b/README.md index aecbc7c..35b9a7f 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_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:** @@ -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, center spacings, and gaps by electrode | **`PunchedStack` / `ZFoldStack` — additional settable:** @@ -944,6 +947,12 @@ Shared by all current collector types. | `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:** | Property | Unit | Description | @@ -965,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 @@ -1061,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 eb53547..e4f83e4 100644 --- a/steer_opencell_design/Components/CurrentCollectors/Notched.py +++ b/steer_opencell_design/Components/CurrentCollectors/Notched.py @@ -12,6 +12,7 @@ # import materials from steer_opencell_design.Materials.Other import CurrentCollectorMaterial +from collections.abc import Iterable from typing import Tuple, Optional import numpy as np @@ -82,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} Ω") @@ -118,6 +119,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 +140,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 +157,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 +177,7 @@ def __init__( ) self.tab_spacing = tab_spacing + self.tab_center_positions = tab_center_positions self._calculate_all_properties() self._update_properties = True @@ -241,19 +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. - """ + """Calculate tab positions for the configured spacing mode.""" + explicit_centers = getattr(self, "_tab_center_positions", None) + if explicit_centers is not None: + 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: @@ -272,6 +309,28 @@ def _calculate_tab_positions(self) -> None: self._tab_positions = np.column_stack((tab_starts, tab_ends)) + 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.") + 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() @@ -461,6 +520,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_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) + 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 n_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 @@ -514,6 +611,10 @@ def tab_spacing(self, tab_spacing: float) -> None: 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 +640,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_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 ed27e3a..92735b4 100644 --- a/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py +++ b/steer_opencell_design/Constructions/ElectrodeAssemblies/JellyRolls.py @@ -36,7 +36,8 @@ from steer_opencell_design.Components.CurrentCollectors.Tabbed import ( TabWeldedCurrentCollector, ) - +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 @@ -3472,6 +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_position: Optional[float] = None, + anode_notch_alignment_position: Optional[float] = None, ) -> None: """Initialize flat wound jelly roll electrode assembly. @@ -3481,6 +3484,14 @@ def __init__( The layup structure to be wound mandrel : FlatMandrel Flat mandrel for racetrack winding + 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 ------ @@ -3490,6 +3501,24 @@ def __init__( if not isinstance(mandrel, FlatMandrel): raise TypeError(f"mandrel must be FlatMandrel, got {type(mandrel)}") + self._validate_notch_alignment_position( + cathode_notch_alignment_position, "cathode_notch_alignment_position" + ) + 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 = [] + super().__init__( laminate=laminate, mandrel=mandrel, @@ -3526,9 +3555,18 @@ def _calculate_roll( self, laminate_x_spacing=0.004, initial_rotation_angle: Optional[float] = None, + 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. @@ -3537,6 +3575,221 @@ 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 + ) -> None: + """Validate an optional notch-stack position in millimeters.""" + if value is None: + return + try: + 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(position): + raise ValueError(f"{name} must be finite.") + 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." + ) + + 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 + ) + + def _apply_thickness_aware_notches(self) -> None: + """Apply configured same-position notch centers to notched collectors. + + 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_position, + "anode": self._anode_notch_alignment_position, + } + + for electrode_name, alignment_position in configurations.items(): + electrode = getattr(self._layup, f"_{electrode_name}") + collector = electrode._current_collector + + if alignment_position 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_position requires a " + "NotchedCurrentCollector." + ) + + spiral = self._component_spirals[f"{electrode_name}_current_collector"] + 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, + 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 + centers_local = centers_global - leading_edge + collector._validate_explicit_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 _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_position, + "anode": self._anode_notch_alignment_position, + } + 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. @@ -3732,7 +3985,7 @@ def _calculate_thickness_width_range( straight_length = self._pressed_straight_length # get the thickness minimum bound - small_layup = deepcopy(self._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() @@ -3759,7 +4012,7 @@ def _calculate_thickness_width_range( ) # get the thickness maximum bound - big_layup = deepcopy(self._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() @@ -3956,6 +4209,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) ) @@ -4025,10 +4285,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]: @@ -4127,6 +4406,177 @@ 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_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_position.setter + def cathode_notch_alignment_position(self, value: Optional[float]) -> None: + self._set_notch_alignment_position( + "_cathode_notch_alignment_position", + value, + "cathode_notch_alignment_position", + ) + + @property + 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_position.setter + def anode_notch_alignment_position(self, value: Optional[float]) -> None: + 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.""" + result: Dict[str, Dict[str, Any]] = {} + for electrode_name in ("cathode", "anode"): + 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_position": position * M_TO_MM, + "centers": collector.tab_center_positions, + "center_spacings": collector.tab_center_spacings, + "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 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"): + position = getattr(self, f"_{electrode_name}_notch_alignment_position") + if position 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], + ) + 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, + 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), marker_turns) + ), + hovertemplate=( + "Turn %{customdata[1]:.0f}
" + "Unwrapped center: %{customdata[0]:.2f} mm" + ), + ) + ) + + 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.""" @@ -4186,7 +4636,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) + 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} @@ -4202,7 +4652,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 @@ -4254,7 +4705,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) + 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} @@ -4270,7 +4721,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 2dc2c25..b03e274 100644 --- a/steer_opencell_design/Constructions/ElectrodeAssemblies/SpiralUtils.py +++ b/steer_opencell_design/Constructions/ElectrodeAssemblies/SpiralUtils.py @@ -1391,6 +1391,129 @@ 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_at_x( + spiral: np.ndarray, + target_x: float, + tab_width: float = 0.0, + minimum_gap: float = 0.0, + straight_x_bounds: Optional[tuple[float, float]] = None, + ) -> np.ndarray: + """Return one unwrapped center per turn at a fixed x-coordinate. + + 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] <= TURNS_COL: + raise ValueError( + "spiral must be a two-dimensional array containing unwrapped " + "length, x-coordinate, and turn columns." + ) + 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." + ) + + x_unwrapped = spiral[:, X_UNWRAPPED_COL] + x_coordinate = spiral[:, X_COORD_COL] + turns = spiral[:, TURNS_COL] + if len(spiral) < 2: + return np.empty(0, dtype=float) + + 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) + + 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) + + centers = np.sort(np.asarray(list(centers_by_turn.values()), dtype=float)) + + half_width = tab_width / 2 + 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 + # 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 + ): + 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." + ) + + return centers + @staticmethod def calculate_variable_thickness_spiral( laminate: Laminate, diff --git a/test/test_assembly.py b/test/test_assembly.py index b108e7c..5af8ea2 100644 --- a/test/test_assembly.py +++ b/test/test_assembly.py @@ -6,6 +6,7 @@ import pandas as pd import plotly.graph_objects as go from copy import deepcopy +import numpy as np from steer_opencell_design import ( CathodeFormulation, @@ -37,6 +38,15 @@ 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, + TURNS_COL, + X_COORD_COL, + X_UNWRAPPED_COL, + Z_COORD_COL, +) class TestRoundJellyRoll(unittest.TestCase): @@ -628,6 +638,277 @@ 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_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) + 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[:, 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 + ) + 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) + + 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.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 = { + 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 + + 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 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_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_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): + 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 + ) / MM_TO_M + self.my_jellyroll.cathode_notch_alignment_position = minimum_position + self.assertAlmostEqual( + 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 + 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_current_collectors.py b/test/test_current_collectors.py index 436c3ee..487e6e1 100644 --- a/test/test_current_collectors.py +++ b/test/test_current_collectors.py @@ -61,6 +61,66 @@ 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_spacings_and_gaps(self): + self.assertEqual(self.collector.tab_center_positions, [50, 155, 270, 395]) + 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) + + 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 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): + 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.n_tabs, 4) + + class TestPunchedCurrentCollector(unittest.TestCase): def setUp(self): """ @@ -668,4 +728,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 ebb37a4..4557dd8 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,5 +360,77 @@ def test_interior_point_is_linear_interpolation(self): self.assertAlmostEqual(value, x, places=10, msg=f"x={x}") +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, + ) + + 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) + + 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, + 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): + tab_width = 0.1 + centers = SpiralCalculator.aligned_positions_at_x( + self.spiral, target_x=0.0, tab_width=tab_width + ) + + 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), 4) + + def test_rejects_non_finite_target_x(self): + spiral = np.zeros((2, 6)) + with self.assertRaises(ValueError): + SpiralCalculator.aligned_positions_at_x(spiral, np.nan) + + if __name__ == "__main__": unittest.main()