diff --git a/pyroll/core/__init__.py b/pyroll/core/__init__.py index 168f786..15e5644 100644 --- a/pyroll/core/__init__.py +++ b/pyroll/core/__init__.py @@ -2,7 +2,7 @@ from .grooves import * from .transport import Transport, CoolingPipe -from .roll_pass import BaseRollPass, RollPass, DeformationUnit, ThreeRollPass +from .roll_pass import BaseRollPass, RollPass, DeformationUnit, ThreeRollPass, AsymmetricTwoRollPass from .unit import Unit from .roll import Roll from .profile import * @@ -16,6 +16,8 @@ root_hooks.extend( [ + AsymmetricTwoRollPass.InProfile.pass_line, + AsymmetricTwoRollPass.InProfile.cross_section, BaseRollPass.roll_force, BaseRollPass.Roll.roll_torque, BaseRollPass.elongation_efficiency, diff --git a/pyroll/core/roll_pass/__init__.py b/pyroll/core/roll_pass/__init__.py index 36025e3..735a05d 100644 --- a/pyroll/core/roll_pass/__init__.py +++ b/pyroll/core/roll_pass/__init__.py @@ -1,5 +1,6 @@ from .base import BaseRollPass from .two_roll_pass import TwoRollPass +from .asymmetric_two_roll_pass import AsymmetricTwoRollPass from .three_roll_pass import ThreeRollPass from .deformation_unit import DeformationUnit diff --git a/pyroll/core/roll_pass/asymmetric_two_roll_pass.py b/pyroll/core/roll_pass/asymmetric_two_roll_pass.py new file mode 100644 index 0000000..1c69061 --- /dev/null +++ b/pyroll/core/roll_pass/asymmetric_two_roll_pass.py @@ -0,0 +1,114 @@ +from typing import List, cast + +import numpy as np +from shapely.affinity import translate, scale +from shapely.geometry import LineString + +from .base import BaseRollPass +from ..hooks import Hook +from ..roll import Roll as BaseRoll + + +class AsymmetricTwoRollPass(BaseRollPass): + """Represents a symmetric roll pass with equal upper and lower working roll.""" + + def __init__( + self, + upper_roll: BaseRoll, + lower_roll: BaseRoll, + label: str = "", + **kwargs + ): + super().__init__(label, **kwargs) + + self.upper_roll = self.Roll(upper_roll, self) + """The upper working roll of this pass.""" + + self.lower_roll = self.Roll(lower_roll, self) + """The upper working roll of this pass.""" + + @property + def contour_lines(self) -> List[LineString]: + if self._contour_lines: + return self._contour_lines + + upper = translate(self.upper_roll.contour_line, yoff=self.gap / 2) + lower = scale( + translate(self.lower_roll.contour_line.reverse(), yoff=self.gap / 2), + xfact=1, yfact=-1, origin=(0, 0) + ) + + self._contour_lines = [upper, lower] + return self._contour_lines + + @property + def classifiers(self): + """A tuple of keywords to specify the shape type classifiers of this roll pass. + Shortcut to ``self.groove.classifiers``.""" + return set(self.upper_roll.groove.classifiers) | set(self.lower_roll.groove.classifiers) | {"asymmetric"} + + @property + def disk_elements(self) -> List['AsymmetricTwoRollPass.DiskElement']: + """A list of disk elements used to subdivide this unit.""" + return list(self._subunits) + + def get_root_hook_results(self): + super_results = super().get_root_hook_results() + upper_roll_results = self.upper_roll.evaluate_and_set_hooks() + lower_roll_results = self.lower_roll.evaluate_and_set_hooks() + return np.concatenate([super_results, upper_roll_results, lower_roll_results], axis=0) + + def reevaluate_cache(self): + super().reevaluate_cache() + self.upper_roll.reevaluate_cache() + self.lower_roll.reevaluate_cache() + self._contour_lines = None + + class Profile(BaseRollPass.Profile): + """Represents a profile in context of a roll pass.""" + + @property + def roll_pass(self) -> 'AsymmetricTwoRollPass': + """Reference to the roll pass. Alias for ``self.unit``.""" + return cast(AsymmetricTwoRollPass, self.unit) + + class InProfile(Profile, BaseRollPass.InProfile): + """Represents an incoming profile of a roll pass.""" + + pass_line = Hook[tuple[float, float, float]]() + """Point (x, y, z) where the incoming profile centroid enters the roll pass.""" + + class OutProfile(Profile, BaseRollPass.OutProfile): + """Represents an outgoing profile of a roll pass.""" + + filling_ratio = Hook[float]() + + class Roll(BaseRollPass.Roll): + """Represents a roll applied in a :py:class:`RollPass`.""" + + @property + def roll_pass(self) -> 'AsymmetricTwoRollPass': + """Reference to the roll pass.""" + return cast(AsymmetricTwoRollPass, self._roll_pass()) + + class DiskElement(BaseRollPass.DiskElement): + """Represents a disk element in a roll pass.""" + + @property + def roll_pass(self) -> 'AsymmetricTwoRollPass': + """Reference to the roll pass. Alias for ``self.parent``.""" + return cast(AsymmetricTwoRollPass, self.parent) + + class Profile(BaseRollPass.DiskElement.Profile): + """Represents a profile in context of a disk element unit.""" + + @property + def disk_element(self) -> 'AsymmetricTwoRollPass.DiskElement': + """Reference to the disk element. Alias for ``self.unit``""" + return cast(AsymmetricTwoRollPass.DiskElement, self.unit) + + class InProfile(Profile, BaseRollPass.DiskElement.InProfile): + """Represents an incoming profile of a disk element unit.""" + + class OutProfile(Profile, BaseRollPass.DiskElement.OutProfile): + """Represents an outgoing profile of a disk element unit.""" diff --git a/pyroll/core/roll_pass/base.py b/pyroll/core/roll_pass/base.py index 823068c..7b94682 100644 --- a/pyroll/core/roll_pass/base.py +++ b/pyroll/core/roll_pass/base.py @@ -109,6 +109,12 @@ def __init__( self._contour_lines = None + self.given_in_profile: BaseProfile | None = None + """The incoming profile as was given to the ``solve`` method.""" + + self.rotated_in_profile: BaseProfile | None = None + """The incoming profile after rotation.""" + @property @abstractmethod def contour_lines(self): @@ -128,6 +134,8 @@ def disk_elements(self) -> List['BaseRollPass.DiskElement']: return list(self._subunits) def init_solve(self, in_profile: BaseProfile): + self.given_in_profile = in_profile + if self.rotation: rotator = Rotator( # make True determining from hook functions @@ -136,14 +144,15 @@ def init_solve(self, in_profile: BaseProfile): duration=0, length=0, parent=self ) rotator.solve(in_profile) - in_profile = rotator.out_profile + self.rotated_in_profile = rotator.out_profile + else: + self.rotated_in_profile = in_profile - super().init_solve(in_profile) + super().init_solve(self.rotated_in_profile) self.out_profile.cross_section = self.usable_cross_section def reevaluate_cache(self): super().reevaluate_cache() - self.roll.reevaluate_cache() self._contour_lines = None class Profile(DiskElementUnit.Profile, DeformationUnit.Profile): diff --git a/pyroll/core/roll_pass/hookimpls/__init__.py b/pyroll/core/roll_pass/hookimpls/__init__.py index f2217e5..0e65d1d 100644 --- a/pyroll/core/roll_pass/hookimpls/__init__.py +++ b/pyroll/core/roll_pass/hookimpls/__init__.py @@ -1,6 +1,7 @@ from . import base_roll_pass from . import symmetric_roll_pass from . import two_roll_pass +from . import asymmetric_two_roll_pass from . import three_roll_pass from . import profile from . import roll diff --git a/pyroll/core/roll_pass/hookimpls/asymmetric_two_roll_pass.py b/pyroll/core/roll_pass/hookimpls/asymmetric_two_roll_pass.py new file mode 100644 index 0000000..26d1d19 --- /dev/null +++ b/pyroll/core/roll_pass/hookimpls/asymmetric_two_roll_pass.py @@ -0,0 +1,140 @@ +import numpy as np +import scipy.optimize +import shapely.affinity +from shapely import Polygon + +from . import helpers +from ..asymmetric_two_roll_pass import AsymmetricTwoRollPass +from ...grooves import GenericElongationGroove + + +@AsymmetricTwoRollPass.usable_width +def usable_width(self: AsymmetricTwoRollPass): + return min(self.upper_roll.groove.usable_width, self.lower_roll.groove.usable_width) + + +@AsymmetricTwoRollPass.tip_width +def tip_width(self: AsymmetricTwoRollPass): + if isinstance(self.upper_roll.groove, GenericElongationGroove) and isinstance( + self.lower_roll.groove, GenericElongationGroove + ): + return min( + self.upper_roll.groove.usable_width + self.gap / 2 / np.tan(self.upper_roll.groove.flank_angle), + self.lower_roll.groove.usable_width + self.gap / 2 / np.tan(self.lower_roll.groove.flank_angle), + ) + + +@AsymmetricTwoRollPass.usable_cross_section +def usable_cross_section(self: AsymmetricTwoRollPass) -> Polygon: + return helpers.out_cross_section(self, self.usable_width) + + +@AsymmetricTwoRollPass.tip_cross_section +def tip_cross_section(self: AsymmetricTwoRollPass) -> Polygon: + return helpers.out_cross_section(self, self.tip_width) + + +@AsymmetricTwoRollPass.gap +def gap(self: AsymmetricTwoRollPass): + if self.has_set_or_cached("height"): + return self.height - self.upper_roll.groove.depth - self.lower_roll.groove.depth + + +@AsymmetricTwoRollPass.height +def height(self: AsymmetricTwoRollPass): + if self.has_set_or_cached("gap"): + return self.gap + self.upper_roll.groove.depth + self.lower_roll.groove.depth + + +@AsymmetricTwoRollPass.contact_area +def contact_area(self: AsymmetricTwoRollPass): + return self.upper_roll.contact_area + self.lower_roll.contact_area + + +@AsymmetricTwoRollPass.target_cross_section_area +def target_cross_section_area_from_target_width(self: AsymmetricTwoRollPass): + if self.has_value("target_width"): + target_cross_section = helpers.out_cross_section(self, self.target_width) + return target_cross_section.area + + +@AsymmetricTwoRollPass.power +def roll_power(self: AsymmetricTwoRollPass): + return self.upper_roll.roll_power + self.lower_roll.roll_power + + +@AsymmetricTwoRollPass.velocity +def velocity(self: AsymmetricTwoRollPass): + if self.upper_roll.has_value("neutral_angle") and self.lower_roll.has_value("neutral_angle"): + return ( + self.upper_roll.working_velocity * np.cos(self.upper_roll.neutral_angle) + + self.lower_roll.working_velocity * np.cos(self.lower_roll.neutral_angle) + ) / 2 + else: + return (self.upper_roll.working_velocity + self.lower_roll.working_velocity) / 2 + + +@AsymmetricTwoRollPass.roll_force +def roll_force(self: AsymmetricTwoRollPass): + return ( + (self.in_profile.flow_stress + 2 * self.out_profile.flow_stress) + / 3 + * (self.upper_roll.contact_area + self.lower_roll.contact_area) + / 2 + ) + + +@AsymmetricTwoRollPass.InProfile.pass_line +def pass_line(self: AsymmetricTwoRollPass.InProfile) -> tuple[float, float, float]: + rp = self.roll_pass + + if not self.has_set("pass_line"): + height_change = self.height - rp.height + x_guess = -( + np.sqrt(height_change) + * np.sqrt( + (2 * rp.upper_roll.min_radius - height_change) + * (2 * rp.lower_roll.min_radius - height_change) + * (2 * rp.upper_roll.min_radius + 2 * rp.lower_roll.min_radius - height_change) + ) + ) / (2 * (rp.upper_roll.min_radius + rp.lower_roll.min_radius - height_change)) + y_guess = 0 + else: + x_guess, y_guess, _ = self.pass_line + + def contact_objective(xy): + shifted_cross_section = shapely.affinity.translate(rp.rotated_in_profile.cross_section, yoff=xy[1]) + + upper_contour = shapely.geometry.LineString(np.stack([ + rp.upper_roll.surface_z, + rp.upper_roll.surface_interpolation(xy[0], rp.upper_roll.surface_z).squeeze(axis=1) + ], axis=1)) + upper_contour = shapely.affinity.translate(upper_contour,yoff=self.roll_pass.gap / 2) + lower_contour = shapely.geometry.LineString(np.stack([ + rp.lower_roll.surface_z, + rp.lower_roll.surface_interpolation(xy[0], rp.lower_roll.surface_z).squeeze(axis=1) + ], axis=1)) + lower_contour = shapely.affinity.scale(shapely.affinity.translate(lower_contour, yoff=self.roll_pass.gap / 2), xfact=1, yfact=-1, origin=(0,0)) + + upper_intersection = shapely.intersection(upper_contour, shifted_cross_section) + lower_intersection = shapely.intersection(lower_contour, shifted_cross_section) + + upper_value = upper_intersection.length if not upper_intersection.is_empty else shapely.shortest_line(upper_contour, shifted_cross_section).length + lower_value = lower_intersection.length if not lower_intersection.is_empty else shapely.shortest_line(lower_contour, shifted_cross_section).length + + return upper_value ** 2 + lower_value ** 2 + + sol = scipy.optimize.minimize(contact_objective, (x_guess, y_guess), method="BFGS", options=dict(xrtol=1e-2)) + + return sol.x[0], sol.x[1], 0 + + +@AsymmetricTwoRollPass.InProfile.cross_section +def in_cross_section(self:AsymmetricTwoRollPass.InProfile): + return shapely.affinity.translate(self.roll_pass.rotated_in_profile.cross_section, xoff=self.pass_line[2], yoff=self.pass_line[1]) + + +@AsymmetricTwoRollPass.entry_point +def entry_point(self: AsymmetricTwoRollPass): + return self.in_profile.pass_line[0] + diff --git a/pyroll/core/roll_pass/hookimpls/helpers.py b/pyroll/core/roll_pass/hookimpls/helpers.py index 96436d5..b355622 100644 --- a/pyroll/core/roll_pass/hookimpls/helpers.py +++ b/pyroll/core/roll_pass/hookimpls/helpers.py @@ -7,10 +7,11 @@ from ..two_roll_pass import TwoRollPass from ..three_roll_pass import ThreeRollPass +from ..asymmetric_two_roll_pass import AsymmetricTwoRollPass from ...profile.profile import refine_cross_section -def out_cross_section(rp: TwoRollPass, width: float) -> Polygon: +def out_cross_section(rp: TwoRollPass | AsymmetricTwoRollPass, width: float) -> Polygon: poly = Polygon(np.concatenate([cl.coords for cl in rp.contour_lines])) poly = clip_by_rect(poly, -width / 2, -math.inf, width / 2, math.inf) return refine_cross_section(poly) diff --git a/tests/test_solve_asymmetric.py b/tests/test_solve_asymmetric.py new file mode 100644 index 0000000..a03409a --- /dev/null +++ b/tests/test_solve_asymmetric.py @@ -0,0 +1,71 @@ +import logging +import webbrowser +from pathlib import Path + +import numpy as np + +from pyroll.core import Profile, Roll, AsymmetricTwoRollPass, CircularOvalGroove, PassSequence + + +def flow_stress(self: AsymmetricTwoRollPass.Profile): + return 50e6 * (1 + self.strain) ** 0.2 * self.roll_pass.strain_rate ** 0.1 + + +# noinspection DuplicatedCode +def test_solve_asymmetric(tmp_path: Path, caplog): + caplog.set_level(logging.INFO, logger="pyroll") + + with AsymmetricTwoRollPass.Profile.flow_stress(flow_stress): + + in_profile = Profile.round( + diameter=30e-3, + temperature=1200 + 273.15, + material=["C45", "steel"], + length=1, + ) + + sequence = PassSequence([ + AsymmetricTwoRollPass( + label="Oval I", + upper_roll=Roll( + groove=CircularOvalGroove( + depth=14e-3, + r1=6e-3, + usable_width=50e-3, + ), + nominal_radius=160e-3, + rotational_frequency=1, + neutral_point=-20e-3 + ), + lower_roll=Roll( + groove=CircularOvalGroove( + depth=4e-3, + r1=6e-3, + usable_width=50e-3, + ), + nominal_radius=160e-3, + rotational_frequency=1, + neutral_point=-20e-3 + ), + gap=2e-3, + ), + ]) + + try: + sequence.solve(in_profile) + finally: + print("\nLog:") + print(caplog.text) + + try: + import pyroll.report + + report = pyroll.report.report(sequence) + + report_file = tmp_path / "report.html" + report_file.write_text(report, encoding="utf-8") + print(report_file) + webbrowser.open(report_file.as_uri()) + + except ImportError: + pass