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()