From a77cf39e936a5d18617951f8659f557887d9bc08 Mon Sep 17 00:00:00 2001 From: Christoph Renzing Date: Fri, 9 Jan 2026 15:02:05 +0100 Subject: [PATCH 1/3] first implementation, missing tests --- pyroll/core/__init__.py | 3 +- pyroll/core/transport/__init__.py | 3 +- pyroll/core/transport/hookimpls/__init__.py | 1 + pyroll/core/transport/hookimpls/spooler.py | 90 +++++++++++++++++++ pyroll/core/transport/spooler.py | 90 +++++++++++++++++++ .../test_roll_pass_profile_location.py | 1 - 6 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 pyroll/core/transport/hookimpls/spooler.py create mode 100644 pyroll/core/transport/spooler.py diff --git a/pyroll/core/__init__.py b/pyroll/core/__init__.py index 5384ddaf..72163a14 100644 --- a/pyroll/core/__init__.py +++ b/pyroll/core/__init__.py @@ -26,7 +26,7 @@ EquivalentRibbedGroove, create_groove_by_type_name, ) -from .transport import Transport, CoolingPipe +from .transport import Transport, CoolingPipe, Spooler from .roll_pass import BaseRollPass, DeformationUnit, ThreeRollPass, SymmetricRollPass, TwoRollPass from .roll_pass import TwoRollPass as RollPass from .unit import Unit @@ -84,6 +84,7 @@ # transport "Transport", "CoolingPipe", + "Spooler", # roll_pass "RollPass", "BaseRollPass", diff --git a/pyroll/core/transport/__init__.py b/pyroll/core/transport/__init__.py index 42a634db..d2d981ab 100644 --- a/pyroll/core/transport/__init__.py +++ b/pyroll/core/transport/__init__.py @@ -1,6 +1,7 @@ from .transport import Transport from .cooling_pipe import CoolingPipe +from .spooler import Spooler from . import hookimpls # noqa: F401 -__all__ = ["Transport", "CoolingPipe"] +__all__ = ["Transport", "CoolingPipe", "Spooler"] diff --git a/pyroll/core/transport/hookimpls/__init__.py b/pyroll/core/transport/hookimpls/__init__.py index e61dbc03..b3629f63 100644 --- a/pyroll/core/transport/hookimpls/__init__.py +++ b/pyroll/core/transport/hookimpls/__init__.py @@ -1,2 +1,3 @@ from . import transport from . import cooling_pipe +from . import spooler diff --git a/pyroll/core/transport/hookimpls/spooler.py b/pyroll/core/transport/hookimpls/spooler.py new file mode 100644 index 00000000..1041bfae --- /dev/null +++ b/pyroll/core/transport/hookimpls/spooler.py @@ -0,0 +1,90 @@ +import numpy as np + +from ..spooler import Spooler + + +@Spooler.duration( + trylast=True # do not override getting from in_profile +) +def duration(self: Spooler): + if self.has_set_or_cached("finished_coil_weight"): + length = self.finished_coil_weight / self.in_profile.density + return length / self.velocity + else: + return self.in_profile.length / self.velocity + + +@Spooler.velocity( + trylast=True # do not override getting from in_profile +) +def velocity(self: Spooler): + return self.prev.velocity + + +@Spooler.windings_per_layer +def windings_per_layer(self: Spooler): + equivalent_diameter = self.in_profile.equivalent_radius * 2 + return self.mandrel_width / equivalent_diameter + + +@Spooler.finished_coil_radius +def finished_coil_radius(self: Spooler): + return self.coil_layer_radii[-1] + + +@Spooler.coil_layer_bending_stresses +def coil_layer_bending_stresses(self: Spooler): + equivalent_diameter = self.in_profile.equivalent_radius * 2 + bending_stresses = [] + + for layer_radius in self.coil_layer_radii: + stress = self.in_profile.elastic_modulus * equivalent_diameter / (2 * layer_radius) + bending_stresses.append(stress) + + return np.array(bending_stresses) + + +@Spooler.coil_layer_bending_torques +def coil_layer_bending_torques(self: Spooler): + equivalent_diameter = self.in_profile.equivalent_radius * 2 + + section_modulus = np.pi * equivalent_diameter**3 / 32 + + bending_torques = [] + + for bending_stress in self.coil_layer_bending_stresses: + torque = bending_stress * section_modulus + bending_torques.append(torque) + + return np.array(bending_torques) + + +@Spooler.coil_layer_radii +def coil_layer_radii(self: Spooler): + layer_radii = [] + + layer_number = 1 + cumulative_weight = 0 + equivalent_diameter = self.in_profile.equivalent_radius * 2 + while cumulative_weight < self.finished_coil_weight: + outer_radius_current_layer = self.mandrel_radius + layer_number * equivalent_diameter + layer_radii.append(outer_radius_current_layer) + + mean_radius_current_layer = self.mandrel_radius + layer_number * equivalent_diameter + wire_length_current_layer = self.windings_per_layer * 2 * np.pi * mean_radius_current_layer + + wire_volume_current_layer = wire_length_current_layer * self.in_profile.cross_sectional_area + weight_current_layer = wire_volume_current_layer * self.in_profile.density + + if cumulative_weight + weight_current_layer <= self.finished_coil_weight: + cumulative_weight += weight_current_layer + layer_number += 1 + else: + remaining_weight = self.finished_coil_weight - cumulative_weight + partial_layer_fraction = remaining_weight / weight_current_layer + + final_radius = self.mandrel_radius + (layer_number - 1 + partial_layer_fraction) * equivalent_diameter + layer_radii[-1] = final_radius + break + + return np.array(layer_radii) diff --git a/pyroll/core/transport/spooler.py b/pyroll/core/transport/spooler.py new file mode 100644 index 00000000..6d93059d --- /dev/null +++ b/pyroll/core/transport/spooler.py @@ -0,0 +1,90 @@ +import numpy as np +from typing import List, cast + +from ..hooks import Hook +from .transport import Transport + +__all__ = ["Spooler"] + + +class Spooler(Transport): + """Represents a spooler for spooled bar in coil.""" + + mandrel_radius = Hook[float]() + """Radius of the mandrel.""" + + mandrel_width = Hook[float]() + """Width of the mandrel.""" + + overspeed = Hook[float]() + """ Percentage of overspeed of the spooler mandrel for hooking.""" + + maximum_bending_stress = Hook[float]() + """Maximum stress to bend a wire.""" + + maximum_bending_torque = Hook[float]() + """Maximum torque to bend a wire.""" + + windings_per_layer = Hook[float]() + """Number of windings per layer.""" + + coil_layer_bending_stresses = Hook[np.ndarray]() + """Bending stress of each layer as a array.""" + + coil_layer_bending_torques = Hook[np.ndarray]() + """Bending torques of each layer as a array.""" + + coil_layer_radii = Hook[np.ndarray]() + """Radii of each layer as a array.""" + + finished_coil_weight = Hook[float]() + """Finished weight of the resulting coil.""" + + finished_coil_radius = Hook[float]() + """Finished radius of the resulting coil.""" + + @property + def disk_elements(self) -> List["Spooler.DiskElement"]: + """A list of disk elements used to subdivide this unit.""" + return list(self._subunits) + + class Profile(Transport.Profile): + """Represents a profile in context of a transport unit.""" + + @property + def spooler(self) -> "Spooler": + """Reference to the spooler. Alias for ``self.unit``.""" + return cast(Spooler, self.unit) + + class InProfile(Profile, Transport.InProfile): + """Represents an incoming profile of a spooler unit.""" + + class OutProfile(Profile, Transport.OutProfile): + """Represents an outgoing profile of a transport unit.""" + + class DiskElement(Transport.DiskElement): + """Represents a disk element in a roll pass.""" + + @property + def spooler(self) -> "Spooler": + """Reference to the transport. Alias for ``self.parent``.""" + return cast(Spooler, self.parent) + + class Profile(Transport.DiskElement.Profile): + """Represents a profile in context of a disk element unit.""" + + @property + def disk_element(self) -> "Spooler.DiskElement": + """Reference to the disk element. Alias for ``self.unit``""" + return cast(Spooler.DiskElement, self.unit) + + @property + def spooler(self) -> "Spooler": + """Reference to the transport. Alias for ``self.unit.parent``""" + return cast(Spooler, self.unit.parent) + + class InProfile(Profile, Transport.DiskElement.InProfile): + """Represents an incoming profile of a disk element unit.""" + + class OutProfile(Profile, Transport.DiskElement.OutProfile): + """Represents an outgoing profile of a disk element unit.""" diff --git a/tests/roll_pass/test_roll_pass_profile_location.py b/tests/roll_pass/test_roll_pass_profile_location.py index 447b152a..0cf58dd4 100644 --- a/tests/roll_pass/test_roll_pass_profile_location.py +++ b/tests/roll_pass/test_roll_pass_profile_location.py @@ -6,7 +6,6 @@ from pyroll.core import Profile, Roll, RollPass, CircularOvalGroove -# noinspection DuplicatedCode def test_cartesian_positions(tmp_path: Path, caplog): caplog.set_level(logging.DEBUG, logger="pyroll") From 6bc10336dbea577b1840cc42dedd45c85b7da566 Mon Sep 17 00:00:00 2001 From: Christoph Renzing Date: Mon, 12 Jan 2026 15:57:24 +0100 Subject: [PATCH 2/3] added test for spooler --- pyroll/core/transport/hookimpls/spooler.py | 36 +++++++++++----------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/pyroll/core/transport/hookimpls/spooler.py b/pyroll/core/transport/hookimpls/spooler.py index 1041bfae..8cee0a32 100644 --- a/pyroll/core/transport/hookimpls/spooler.py +++ b/pyroll/core/transport/hookimpls/spooler.py @@ -3,9 +3,7 @@ from ..spooler import Spooler -@Spooler.duration( - trylast=True # do not override getting from in_profile -) +@Spooler.duration def duration(self: Spooler): if self.has_set_or_cached("finished_coil_weight"): length = self.finished_coil_weight / self.in_profile.density @@ -14,17 +12,10 @@ def duration(self: Spooler): return self.in_profile.length / self.velocity -@Spooler.velocity( - trylast=True # do not override getting from in_profile -) -def velocity(self: Spooler): - return self.prev.velocity - - @Spooler.windings_per_layer def windings_per_layer(self: Spooler): equivalent_diameter = self.in_profile.equivalent_radius * 2 - return self.mandrel_width / equivalent_diameter + return np.round(self.mandrel_width / equivalent_diameter) @Spooler.finished_coil_radius @@ -62,26 +53,35 @@ def coil_layer_bending_torques(self: Spooler): @Spooler.coil_layer_radii def coil_layer_radii(self: Spooler): layer_radii = [] - layer_number = 1 cumulative_weight = 0 + cumulative_length = 0 equivalent_diameter = self.in_profile.equivalent_radius * 2 - while cumulative_weight < self.finished_coil_weight: + + while cumulative_weight < self.finished_coil_weight and cumulative_length < self.in_profile.length: outer_radius_current_layer = self.mandrel_radius + layer_number * equivalent_diameter layer_radii.append(outer_radius_current_layer) - mean_radius_current_layer = self.mandrel_radius + layer_number * equivalent_diameter + mean_radius_current_layer = self.mandrel_radius + (layer_number - 0.5) * equivalent_diameter wire_length_current_layer = self.windings_per_layer * 2 * np.pi * mean_radius_current_layer - wire_volume_current_layer = wire_length_current_layer * self.in_profile.cross_sectional_area + wire_volume_current_layer = wire_length_current_layer * self.in_profile.cross_section.area weight_current_layer = wire_volume_current_layer * self.in_profile.density - if cumulative_weight + weight_current_layer <= self.finished_coil_weight: + weight_ok = cumulative_weight + weight_current_layer <= self.finished_coil_weight + length_ok = cumulative_length + wire_length_current_layer <= self.in_profile.length + + if weight_ok and length_ok: cumulative_weight += weight_current_layer + cumulative_length += wire_length_current_layer layer_number += 1 else: - remaining_weight = self.finished_coil_weight - cumulative_weight - partial_layer_fraction = remaining_weight / weight_current_layer + if not weight_ok: + remaining_weight = self.finished_coil_weight - cumulative_weight + partial_layer_fraction = remaining_weight / weight_current_layer + else: + remaining_length = self.in_profile.length - cumulative_length + partial_layer_fraction = remaining_length / wire_length_current_layer final_radius = self.mandrel_radius + (layer_number - 1 + partial_layer_fraction) * equivalent_diameter layer_radii[-1] = final_radius From ccb843c2c7ef7b9759f55e179c080d1e9c57b210 Mon Sep 17 00:00:00 2001 From: Christoph Renzing Date: Tue, 13 Jan 2026 10:11:41 +0100 Subject: [PATCH 3/3] added test and cumulative bending torques --- pyroll/core/transport/hookimpls/spooler.py | 11 +++ pyroll/core/transport/spooler.py | 6 ++ tests/spooler/test_spooler_values.py | 102 +++++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 tests/spooler/test_spooler_values.py diff --git a/pyroll/core/transport/hookimpls/spooler.py b/pyroll/core/transport/hookimpls/spooler.py index 8cee0a32..007d9ca6 100644 --- a/pyroll/core/transport/hookimpls/spooler.py +++ b/pyroll/core/transport/hookimpls/spooler.py @@ -50,6 +50,17 @@ def coil_layer_bending_torques(self: Spooler): return np.array(bending_torques) +@Spooler.coil_layer_torque +def coil_layer_torque(self: Spooler): + torques_single_bending = self.coil_layer_bending_torques + return torques_single_bending * self.windings_per_layer + + +@Spooler.coil_cumulative_torque +def coil_cumulative_torque(self: Spooler): + return np.cumsum(self.coil_layer_torque) + + @Spooler.coil_layer_radii def coil_layer_radii(self: Spooler): layer_radii = [] diff --git a/pyroll/core/transport/spooler.py b/pyroll/core/transport/spooler.py index 6d93059d..bc3a9de4 100644 --- a/pyroll/core/transport/spooler.py +++ b/pyroll/core/transport/spooler.py @@ -37,6 +37,12 @@ class Spooler(Transport): coil_layer_radii = Hook[np.ndarray]() """Radii of each layer as a array.""" + coil_layer_torque = Hook[float]() + """Torque per layer""" + + coil_cumulative_torque = Hook[float]() + """Total required Torque for all layers""" + finished_coil_weight = Hook[float]() """Finished weight of the resulting coil.""" diff --git a/tests/spooler/test_spooler_values.py b/tests/spooler/test_spooler_values.py new file mode 100644 index 00000000..afbb5f2c --- /dev/null +++ b/tests/spooler/test_spooler_values.py @@ -0,0 +1,102 @@ +import numpy as np +from matplotlib import pyplot as plt + +from pyroll.core import Spooler, Profile + +in_profile = Profile.round( + diameter=20e-3, flow_stress=1, velocity=100, length=5100, density=7500, elastic_modulus=8.3e10 +) + + +def test_spooler_coil_geometry(): + in_profile_geometry = Profile.round( + diameter=10e-3, flow_stress=1, velocity=100, length=5200, density=7500, elastic_modulus=8.3e10 + ) + + sp = Spooler( + label="Demo Spooler", + mandrel_radius=300e-3, + mandrel_width=800e-3, + finished_coil_weight=3000, + velocity=35, + ) + + sp.solve(in_profile_geometry) + + assert np.isclose(sp.windings_per_layer, 80) + assert np.isclose(sp.finished_coil_radius, 0.5411044, rtol=1e-5) + + +def test_spooler_bending_stress_visual(): + sp = Spooler( + label="Demo Spooler", + mandrel_radius=300e-3, + mandrel_width=800e-3, + finished_coil_weight=3200, + velocity=35, + ) + + sp.solve(in_profile) + + fig, ax = plt.subplots(figsize=(10, 6)) + + layer_numbers = range(1, len(sp.coil_layer_bending_stresses) + 1) + ax.plot(layer_numbers, sp.coil_layer_bending_stresses) + + ax.set_xlabel("Layer Number") + ax.set_ylabel("Bending Stress") + ax.set_title("Bending Stress per Layer") + ax.grid(True, alpha=0.3) + + plt.tight_layout() + plt.show() + + +def test_spooler_bending_torque_visual(): + sp = Spooler( + label="Demo Spooler", + mandrel_radius=300e-3, + mandrel_width=800e-3, + finished_coil_weight=3200, + velocity=35, + ) + + sp.solve(in_profile) + + fig, ax = plt.subplots(figsize=(10, 6)) + + layer_numbers = range(1, len(sp.coil_layer_bending_torques) + 1) + ax.plot(layer_numbers, sp.coil_layer_bending_torques) + + ax.set_xlabel("Layer Number") + ax.set_ylabel("Bending Torque") + ax.set_title("Bending Torque per Layer") + ax.grid(True, alpha=0.3) + + plt.tight_layout() + plt.show() + + +def test_spooler_cumulative_torque_visual(): + sp = Spooler( + label="Demo Spooler", + mandrel_radius=300e-3, + mandrel_width=800e-3, + finished_coil_weight=3200, + velocity=35, + ) + + sp.solve(in_profile) + + fig, ax = plt.subplots(figsize=(10, 6)) + + layer_numbers = range(1, len(sp.coil_cumulative_torque) + 1) + ax.plot(layer_numbers, sp.coil_cumulative_torque) + + ax.set_xlabel("Layer Number") + ax.set_ylabel("Torque") + ax.set_title("Cumulative Bending Torque per Layer") + ax.grid(True, alpha=0.3) + + plt.tight_layout() + plt.show()