fiberpath 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fiberpath/__init__.py +10 -0
- fiberpath/config/__init__.py +22 -0
- fiberpath/config/schemas.py +72 -0
- fiberpath/config/validator.py +31 -0
- fiberpath/execution/__init__.py +7 -0
- fiberpath/execution/marlin.py +311 -0
- fiberpath/gcode/__init__.py +6 -0
- fiberpath/gcode/dialects.py +47 -0
- fiberpath/gcode/generator.py +31 -0
- fiberpath/geometry/__init__.py +13 -0
- fiberpath/geometry/curves.py +16 -0
- fiberpath/geometry/intersections.py +23 -0
- fiberpath/geometry/surfaces.py +17 -0
- fiberpath/math_utils.py +20 -0
- fiberpath/planning/__init__.py +13 -0
- fiberpath/planning/calculations.py +50 -0
- fiberpath/planning/exceptions.py +19 -0
- fiberpath/planning/helpers.py +55 -0
- fiberpath/planning/layer_strategies.py +194 -0
- fiberpath/planning/machine.py +151 -0
- fiberpath/planning/planner.py +133 -0
- fiberpath/planning/validators.py +51 -0
- fiberpath/simulation/__init__.py +5 -0
- fiberpath/simulation/simulator.py +186 -0
- fiberpath/visualization/__init__.py +13 -0
- fiberpath/visualization/export_json.py +14 -0
- fiberpath/visualization/plotter.py +261 -0
- fiberpath-0.3.0.dist-info/METADATA +827 -0
- fiberpath-0.3.0.dist-info/RECORD +49 -0
- fiberpath-0.3.0.dist-info/WHEEL +4 -0
- fiberpath-0.3.0.dist-info/entry_points.txt +2 -0
- fiberpath-0.3.0.dist-info/licenses/LICENSE +661 -0
- fiberpath_api/__init__.py +5 -0
- fiberpath_api/main.py +19 -0
- fiberpath_api/routes/__init__.py +1 -0
- fiberpath_api/routes/plan.py +41 -0
- fiberpath_api/routes/simulate.py +29 -0
- fiberpath_api/routes/stream.py +49 -0
- fiberpath_api/routes/validate.py +21 -0
- fiberpath_api/schemas.py +50 -0
- fiberpath_cli/__init__.py +5 -0
- fiberpath_cli/__main__.py +6 -0
- fiberpath_cli/main.py +27 -0
- fiberpath_cli/output.py +14 -0
- fiberpath_cli/plan.py +100 -0
- fiberpath_cli/plot.py +37 -0
- fiberpath_cli/simulate.py +36 -0
- fiberpath_cli/stream.py +123 -0
- fiberpath_cli/validate.py +26 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Reusable numeric helpers for planner kinematics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from fiberpath.config.schemas import HelicalLayer, MandrelParameters, TowParameters
|
|
9
|
+
from fiberpath.math_utils import deg_to_rad
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class HelicalKinematics:
|
|
14
|
+
mandrel_circumference: float
|
|
15
|
+
tow_arc_length: float
|
|
16
|
+
num_circuits: int
|
|
17
|
+
pattern_step_degrees: float
|
|
18
|
+
pass_rotation_mm: float
|
|
19
|
+
pass_rotation_degrees: float
|
|
20
|
+
pass_degrees_per_mm: float
|
|
21
|
+
lead_in_degrees: float
|
|
22
|
+
main_pass_degrees: float
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def compute_helical_kinematics(
|
|
26
|
+
layer: HelicalLayer,
|
|
27
|
+
mandrel_parameters: MandrelParameters,
|
|
28
|
+
tow_parameters: TowParameters,
|
|
29
|
+
) -> HelicalKinematics:
|
|
30
|
+
mandrel_circumference = math.pi * mandrel_parameters.diameter
|
|
31
|
+
tow_arc_length = tow_parameters.width / math.cos(deg_to_rad(layer.wind_angle))
|
|
32
|
+
num_circuits = math.ceil(mandrel_circumference / tow_arc_length)
|
|
33
|
+
pattern_step_degrees = 360.0 * (1 / num_circuits)
|
|
34
|
+
pass_rotation_mm = mandrel_parameters.wind_length * math.tan(deg_to_rad(layer.wind_angle))
|
|
35
|
+
pass_rotation_degrees = 360.0 * (pass_rotation_mm / mandrel_circumference)
|
|
36
|
+
pass_degrees_per_mm = pass_rotation_degrees / mandrel_parameters.wind_length
|
|
37
|
+
lead_in_degrees = pass_degrees_per_mm * layer.lead_in_mm
|
|
38
|
+
main_pass_degrees = pass_degrees_per_mm * (mandrel_parameters.wind_length - layer.lead_in_mm)
|
|
39
|
+
|
|
40
|
+
return HelicalKinematics(
|
|
41
|
+
mandrel_circumference=mandrel_circumference,
|
|
42
|
+
tow_arc_length=tow_arc_length,
|
|
43
|
+
num_circuits=num_circuits,
|
|
44
|
+
pattern_step_degrees=pattern_step_degrees,
|
|
45
|
+
pass_rotation_mm=pass_rotation_mm,
|
|
46
|
+
pass_rotation_degrees=pass_rotation_degrees,
|
|
47
|
+
pass_degrees_per_mm=pass_degrees_per_mm,
|
|
48
|
+
lead_in_degrees=lead_in_degrees,
|
|
49
|
+
main_pass_degrees=main_pass_degrees,
|
|
50
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Custom exceptions raised during planning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PlanningError(RuntimeError):
|
|
7
|
+
"""Raised when the planner encounters a recoverable validation error."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str) -> None:
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.message = message
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LayerValidationError(PlanningError):
|
|
15
|
+
"""Raised when layer-specific validation fails."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, layer_index: int, message: str) -> None:
|
|
18
|
+
super().__init__(f"Layer {layer_index}: {message}")
|
|
19
|
+
self.layer_index = layer_index
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Utility helpers shared by planner components."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from fiberpath.math_utils import strip_precision
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from fiberpath.gcode.dialects import AxisMapping
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Axis(Enum):
|
|
15
|
+
CARRIAGE = "carriage"
|
|
16
|
+
MANDREL = "mandrel"
|
|
17
|
+
DELIVERY_HEAD = "delivery_head"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
AXIS_LOOKUP: dict[Axis, str] = {
|
|
21
|
+
Axis.CARRIAGE: "X",
|
|
22
|
+
Axis.MANDREL: "Y",
|
|
23
|
+
Axis.DELIVERY_HEAD: "Z",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
Coordinate = dict[Axis, float]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_axis_letter(axis: Axis, mapping: AxisMapping) -> str:
|
|
30
|
+
"""Get G-code axis letter for logical axis based on dialect mapping."""
|
|
31
|
+
return {
|
|
32
|
+
Axis.CARRIAGE: mapping.carriage,
|
|
33
|
+
Axis.MANDREL: mapping.mandrel,
|
|
34
|
+
Axis.DELIVERY_HEAD: mapping.delivery_head,
|
|
35
|
+
}[axis]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def serialize_coordinate(coordinate: Coordinate) -> str:
|
|
39
|
+
serialized = " ".join(
|
|
40
|
+
f"{axis.value}:{strip_precision(value)}" for axis, value in coordinate.items()
|
|
41
|
+
)
|
|
42
|
+
return "{" + serialized + "}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def interpolate_coordinates(start: Coordinate, end: Coordinate, steps: int) -> list[Coordinate]:
|
|
46
|
+
if steps <= 0:
|
|
47
|
+
raise ValueError("Steps cannot be less than 1")
|
|
48
|
+
if steps == 1:
|
|
49
|
+
return [end]
|
|
50
|
+
|
|
51
|
+
coordinates: list[Coordinate] = []
|
|
52
|
+
delta = {axis: (end[axis] - start[axis]) / (steps - 1) for axis in Axis}
|
|
53
|
+
for step in range(steps):
|
|
54
|
+
coordinates.append({axis: start[axis] + step * delta[axis] for axis in Axis})
|
|
55
|
+
return coordinates
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Layer-specific planning helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
from fiberpath.config.schemas import (
|
|
9
|
+
HelicalLayer,
|
|
10
|
+
HoopLayer,
|
|
11
|
+
LayerModel,
|
|
12
|
+
MandrelParameters,
|
|
13
|
+
SkipLayer,
|
|
14
|
+
TowParameters,
|
|
15
|
+
)
|
|
16
|
+
from fiberpath.math_utils import rad_to_deg
|
|
17
|
+
|
|
18
|
+
from .calculations import HelicalKinematics, compute_helical_kinematics
|
|
19
|
+
from .helpers import Axis
|
|
20
|
+
from .machine import WinderMachine
|
|
21
|
+
|
|
22
|
+
LOGGER = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_layer_summary(index: int, total: int, layer: LayerModel) -> str:
|
|
26
|
+
base = f"Layer {index} of {total}: {layer.wind_type}"
|
|
27
|
+
if isinstance(layer, HelicalLayer):
|
|
28
|
+
return base
|
|
29
|
+
return base
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def dispatch_layer(
|
|
33
|
+
machine: WinderMachine,
|
|
34
|
+
layer: LayerModel,
|
|
35
|
+
mandrel_parameters: MandrelParameters,
|
|
36
|
+
tow_parameters: TowParameters,
|
|
37
|
+
*,
|
|
38
|
+
helical_kinematics: HelicalKinematics | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
if isinstance(layer, HoopLayer):
|
|
41
|
+
plan_hoop_layer(machine, layer, mandrel_parameters, tow_parameters)
|
|
42
|
+
return
|
|
43
|
+
if isinstance(layer, HelicalLayer):
|
|
44
|
+
plan_helical_layer(
|
|
45
|
+
machine,
|
|
46
|
+
layer,
|
|
47
|
+
mandrel_parameters,
|
|
48
|
+
tow_parameters,
|
|
49
|
+
helical_kinematics=helical_kinematics,
|
|
50
|
+
)
|
|
51
|
+
return
|
|
52
|
+
if isinstance(layer, SkipLayer):
|
|
53
|
+
plan_skip_layer(machine, layer)
|
|
54
|
+
return
|
|
55
|
+
raise TypeError(f"Unsupported layer type: {layer}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def plan_hoop_layer(
|
|
59
|
+
machine: WinderMachine,
|
|
60
|
+
layer: HoopLayer,
|
|
61
|
+
mandrel_parameters: MandrelParameters,
|
|
62
|
+
tow_parameters: TowParameters,
|
|
63
|
+
) -> None:
|
|
64
|
+
lock_degrees = 180.0
|
|
65
|
+
wind_angle = 90.0 - rad_to_deg(math.atan(mandrel_parameters.diameter / tow_parameters.width))
|
|
66
|
+
mandrel_rotations = mandrel_parameters.wind_length / tow_parameters.width
|
|
67
|
+
far_mandrel = lock_degrees + mandrel_rotations * 360.0
|
|
68
|
+
far_lock = far_mandrel + lock_degrees
|
|
69
|
+
near_mandrel = far_lock + mandrel_rotations * 360.0
|
|
70
|
+
near_lock = near_mandrel + lock_degrees
|
|
71
|
+
|
|
72
|
+
machine.move({Axis.CARRIAGE: 0.0, Axis.MANDREL: lock_degrees, Axis.DELIVERY_HEAD: 0.0})
|
|
73
|
+
machine.move({Axis.DELIVERY_HEAD: -wind_angle})
|
|
74
|
+
machine.move({Axis.CARRIAGE: mandrel_parameters.wind_length, Axis.MANDREL: far_mandrel})
|
|
75
|
+
machine.move({Axis.MANDREL: far_lock, Axis.DELIVERY_HEAD: 0.0})
|
|
76
|
+
|
|
77
|
+
if layer.terminal:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
machine.move({Axis.DELIVERY_HEAD: wind_angle})
|
|
81
|
+
machine.move({Axis.CARRIAGE: 0.0, Axis.MANDREL: near_mandrel})
|
|
82
|
+
machine.move({Axis.MANDREL: near_lock, Axis.DELIVERY_HEAD: 0.0})
|
|
83
|
+
machine.zero_axes(near_lock)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def plan_helical_layer(
|
|
87
|
+
machine: WinderMachine,
|
|
88
|
+
layer: HelicalLayer,
|
|
89
|
+
mandrel_parameters: MandrelParameters,
|
|
90
|
+
tow_parameters: TowParameters,
|
|
91
|
+
*,
|
|
92
|
+
helical_kinematics: HelicalKinematics | None = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
delivery_head_pass_start_angle = -10.0
|
|
95
|
+
lead_out_degrees = layer.lead_out_degrees
|
|
96
|
+
wind_lead_in_mm = layer.lead_in_mm
|
|
97
|
+
lock_degrees = layer.lock_degrees
|
|
98
|
+
delivery_head_angle = -1.0 * (90.0 - layer.wind_angle)
|
|
99
|
+
pattern_number = layer.pattern_number
|
|
100
|
+
|
|
101
|
+
kinematics = helical_kinematics or compute_helical_kinematics(
|
|
102
|
+
layer, mandrel_parameters, tow_parameters
|
|
103
|
+
)
|
|
104
|
+
num_circuits = kinematics.num_circuits
|
|
105
|
+
pattern_step_degrees = kinematics.pattern_step_degrees
|
|
106
|
+
pass_rotation_degrees = kinematics.pass_rotation_degrees
|
|
107
|
+
lead_in_degrees = kinematics.lead_in_degrees
|
|
108
|
+
main_pass_degrees = kinematics.main_pass_degrees
|
|
109
|
+
number_of_patterns = num_circuits / pattern_number
|
|
110
|
+
start_position_increment = 360.0 / pattern_number
|
|
111
|
+
pass_parameters = [
|
|
112
|
+
{
|
|
113
|
+
"delivery_head_sign": 1,
|
|
114
|
+
"lead_in_end_mm": wind_lead_in_mm,
|
|
115
|
+
"full_pass_end_mm": mandrel_parameters.wind_length,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"delivery_head_sign": -1,
|
|
119
|
+
"lead_in_end_mm": mandrel_parameters.wind_length - wind_lead_in_mm,
|
|
120
|
+
"full_pass_end_mm": 0.0,
|
|
121
|
+
},
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
LOGGER.debug("Helical wind with %s circuits", num_circuits)
|
|
125
|
+
|
|
126
|
+
if num_circuits % pattern_number != 0:
|
|
127
|
+
LOGGER.warning(
|
|
128
|
+
"Skipping helical layer: %s circuits not divisible by pattern %s",
|
|
129
|
+
num_circuits,
|
|
130
|
+
pattern_number,
|
|
131
|
+
)
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
if not layer.skip_initial_near_lock:
|
|
135
|
+
machine.move({Axis.CARRIAGE: 0.0, Axis.MANDREL: lock_degrees, Axis.DELIVERY_HEAD: 0.0})
|
|
136
|
+
machine.set_position({Axis.MANDREL: 0.0})
|
|
137
|
+
|
|
138
|
+
mandrel_position = 0.0
|
|
139
|
+
patterns = int(number_of_patterns)
|
|
140
|
+
for pattern_index in range(patterns):
|
|
141
|
+
for in_pattern_index in range(pattern_number):
|
|
142
|
+
machine.insert_comment(
|
|
143
|
+
f"\tPattern: {pattern_index + 1}/{patterns} "
|
|
144
|
+
f"Circuit: {in_pattern_index + 1}/{pattern_number}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
for pass_params in pass_parameters:
|
|
148
|
+
sign = pass_params["delivery_head_sign"]
|
|
149
|
+
machine.move({Axis.MANDREL: mandrel_position, Axis.DELIVERY_HEAD: 0.0})
|
|
150
|
+
machine.move({Axis.DELIVERY_HEAD: sign * delivery_head_pass_start_angle})
|
|
151
|
+
|
|
152
|
+
mandrel_position += lead_in_degrees
|
|
153
|
+
machine.move(
|
|
154
|
+
{
|
|
155
|
+
Axis.CARRIAGE: pass_params["lead_in_end_mm"],
|
|
156
|
+
Axis.MANDREL: mandrel_position,
|
|
157
|
+
Axis.DELIVERY_HEAD: sign * delivery_head_angle,
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
mandrel_position += main_pass_degrees
|
|
162
|
+
machine.move(
|
|
163
|
+
{
|
|
164
|
+
Axis.CARRIAGE: pass_params["full_pass_end_mm"],
|
|
165
|
+
Axis.MANDREL: mandrel_position,
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
mandrel_position += lead_out_degrees
|
|
170
|
+
machine.move(
|
|
171
|
+
{
|
|
172
|
+
Axis.MANDREL: mandrel_position,
|
|
173
|
+
Axis.DELIVERY_HEAD: sign * delivery_head_pass_start_angle,
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
mandrel_position += (
|
|
178
|
+
lock_degrees - lead_out_degrees - (pass_rotation_degrees % 360.0)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
mandrel_position += start_position_increment
|
|
182
|
+
|
|
183
|
+
mandrel_position += pattern_step_degrees
|
|
184
|
+
|
|
185
|
+
mandrel_position += lock_degrees
|
|
186
|
+
machine.move({Axis.MANDREL: mandrel_position, Axis.DELIVERY_HEAD: 0.0})
|
|
187
|
+
machine.zero_axes(mandrel_position)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def plan_skip_layer(machine: WinderMachine, layer: SkipLayer) -> None:
|
|
191
|
+
machine.move(
|
|
192
|
+
{Axis.CARRIAGE: 0.0, Axis.MANDREL: layer.mandrel_rotation, Axis.DELIVERY_HEAD: 0.0}
|
|
193
|
+
)
|
|
194
|
+
machine.set_position({Axis.MANDREL: 0.0})
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""G-code aware machine abstraction used for planning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from fiberpath.math_utils import strip_precision
|
|
10
|
+
|
|
11
|
+
from .helpers import Axis, get_axis_letter, interpolate_coordinates, serialize_coordinate
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from fiberpath.gcode.dialects import MarlinDialect
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WinderMachine:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
mandrel_diameter: float,
|
|
21
|
+
verbose_output: bool = False,
|
|
22
|
+
dialect: MarlinDialect | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self._verbose = verbose_output
|
|
25
|
+
self._gcode: list[str] = []
|
|
26
|
+
self._feed_rate_mmpm = 0.0
|
|
27
|
+
self._total_time_s = 0.0
|
|
28
|
+
self._total_tow_length_mm = 0.0
|
|
29
|
+
self._last_position: dict[Axis, float] = {
|
|
30
|
+
Axis.CARRIAGE: 0.0,
|
|
31
|
+
Axis.MANDREL: 0.0,
|
|
32
|
+
Axis.DELIVERY_HEAD: 0.0,
|
|
33
|
+
}
|
|
34
|
+
self._mandrel_diameter = mandrel_diameter
|
|
35
|
+
|
|
36
|
+
# Import here to avoid circular dependency
|
|
37
|
+
if dialect is None:
|
|
38
|
+
from fiberpath.gcode.dialects import MARLIN_XYZ_LEGACY
|
|
39
|
+
|
|
40
|
+
dialect = MARLIN_XYZ_LEGACY
|
|
41
|
+
self._dialect = dialect
|
|
42
|
+
|
|
43
|
+
def get_gcode(self) -> list[str]:
|
|
44
|
+
return self._gcode.copy()
|
|
45
|
+
|
|
46
|
+
def add_raw_gcode(self, command: str) -> None:
|
|
47
|
+
self._gcode.append(command)
|
|
48
|
+
|
|
49
|
+
def insert_comment(self, text: str) -> None:
|
|
50
|
+
self._gcode.append(f"; {text}")
|
|
51
|
+
|
|
52
|
+
def set_feed_rate(self, feed_rate_mmpm: float) -> None:
|
|
53
|
+
self._feed_rate_mmpm = feed_rate_mmpm
|
|
54
|
+
self._gcode.append(f"G0 F{strip_precision(feed_rate_mmpm)}")
|
|
55
|
+
|
|
56
|
+
def move(self, position: Mapping[Axis, float]) -> None:
|
|
57
|
+
complete_end = self._last_position.copy()
|
|
58
|
+
complete_end.update(position)
|
|
59
|
+
do_segment_move = not math.isclose(
|
|
60
|
+
self._last_position[Axis.CARRIAGE], complete_end[Axis.CARRIAGE], abs_tol=1e-6
|
|
61
|
+
)
|
|
62
|
+
if not do_segment_move:
|
|
63
|
+
if self._verbose:
|
|
64
|
+
self.insert_comment(
|
|
65
|
+
"Move "
|
|
66
|
+
f"{serialize_coordinate(self._last_position)} -> "
|
|
67
|
+
f"{serialize_coordinate(complete_end)}"
|
|
68
|
+
)
|
|
69
|
+
# Pass complete_end so all axes are included in the G-code command
|
|
70
|
+
self._move_segment(complete_end)
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
carriage_delta = abs(self._last_position[Axis.CARRIAGE] - complete_end[Axis.CARRIAGE])
|
|
74
|
+
num_segments = int(round(carriage_delta)) + 1
|
|
75
|
+
if self._verbose:
|
|
76
|
+
self.insert_comment(
|
|
77
|
+
"Segmented move "
|
|
78
|
+
f"{serialize_coordinate(self._last_position)} -> "
|
|
79
|
+
f"{serialize_coordinate(complete_end)} in {num_segments} steps"
|
|
80
|
+
)
|
|
81
|
+
for intermediate in interpolate_coordinates(
|
|
82
|
+
self._last_position, complete_end, num_segments
|
|
83
|
+
):
|
|
84
|
+
self._move_segment(intermediate)
|
|
85
|
+
|
|
86
|
+
def set_position(self, position: Mapping[Axis, float]) -> None:
|
|
87
|
+
command_parts = ["G92"]
|
|
88
|
+
for axis, value in position.items():
|
|
89
|
+
axis_letter = get_axis_letter(axis, self._dialect.axis_mapping)
|
|
90
|
+
command_parts.append(f"{axis_letter}{strip_precision(value)}")
|
|
91
|
+
self._last_position[axis] = value
|
|
92
|
+
self._gcode.append(" ".join(command_parts))
|
|
93
|
+
|
|
94
|
+
def zero_axes(self, current_angle_degrees: float) -> None:
|
|
95
|
+
self.set_position(
|
|
96
|
+
{
|
|
97
|
+
Axis.CARRIAGE: 0.0,
|
|
98
|
+
Axis.MANDREL: current_angle_degrees % 360.0,
|
|
99
|
+
Axis.DELIVERY_HEAD: 0.0,
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
self.move({Axis.MANDREL: 360.0})
|
|
103
|
+
self.set_position({Axis.MANDREL: 0.0})
|
|
104
|
+
|
|
105
|
+
def get_gcode_time_s(self) -> float:
|
|
106
|
+
return self._total_time_s
|
|
107
|
+
|
|
108
|
+
def get_tow_length_m(self) -> float:
|
|
109
|
+
return self._total_tow_length_mm / 1000.0
|
|
110
|
+
|
|
111
|
+
def set_mandrel_diameter(self, mandrel_diameter: float) -> None:
|
|
112
|
+
self._mandrel_diameter = mandrel_diameter
|
|
113
|
+
|
|
114
|
+
def get_mandrel_diameter(self) -> float:
|
|
115
|
+
return self._mandrel_diameter
|
|
116
|
+
|
|
117
|
+
def _move_segment(self, position: Mapping[Axis, float]) -> None:
|
|
118
|
+
command_parts = ["G0"]
|
|
119
|
+
total_distance_sq = 0.0
|
|
120
|
+
tow_length_sq = 0.0
|
|
121
|
+
for axis, value in position.items():
|
|
122
|
+
axis_letter = get_axis_letter(axis, self._dialect.axis_mapping)
|
|
123
|
+
command_parts.append(f"{axis_letter}{strip_precision(value)}")
|
|
124
|
+
move_component = value - self._last_position[axis]
|
|
125
|
+
|
|
126
|
+
# Distance calculation depends on whether axis is truly rotational in Marlin
|
|
127
|
+
if axis == Axis.MANDREL:
|
|
128
|
+
# For XYZ legacy: Y/Z configured as linear in Marlin
|
|
129
|
+
# For XAB standard: A/B are rotational in Marlin
|
|
130
|
+
# In both cases, use degree value directly for distance calculation
|
|
131
|
+
total_distance_sq += move_component**2
|
|
132
|
+
# For tow length, always convert to arc length
|
|
133
|
+
arc_length = move_component / 360.0 * self._mandrel_diameter * math.pi
|
|
134
|
+
tow_length_sq += arc_length**2
|
|
135
|
+
elif axis == Axis.CARRIAGE:
|
|
136
|
+
# Carriage: always linear in mm
|
|
137
|
+
total_distance_sq += move_component**2
|
|
138
|
+
tow_length_sq += move_component**2
|
|
139
|
+
elif axis == Axis.DELIVERY_HEAD:
|
|
140
|
+
# Delivery head: use value directly for distance
|
|
141
|
+
total_distance_sq += move_component**2
|
|
142
|
+
# Delivery head rotation doesn't contribute to tow length
|
|
143
|
+
|
|
144
|
+
self._last_position[axis] = value
|
|
145
|
+
|
|
146
|
+
if self._feed_rate_mmpm <= 0:
|
|
147
|
+
raise RuntimeError("Feed rate must be set before moving the machine")
|
|
148
|
+
|
|
149
|
+
self._total_time_s += math.sqrt(total_distance_sq) / self._feed_rate_mmpm * 60.0
|
|
150
|
+
self._total_tow_length_mm += math.sqrt(tow_length_sq)
|
|
151
|
+
self._gcode.append(" ".join(command_parts))
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""High-level wind planning orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from fiberpath.config import WindDefinition
|
|
9
|
+
from fiberpath.config.schemas import HelicalLayer, MandrelParameters
|
|
10
|
+
from fiberpath.gcode.generator import sanitize_program
|
|
11
|
+
|
|
12
|
+
from .calculations import HelicalKinematics
|
|
13
|
+
from .layer_strategies import build_layer_summary, dispatch_layer
|
|
14
|
+
from .machine import WinderMachine
|
|
15
|
+
from .validators import (
|
|
16
|
+
validate_helical_layer,
|
|
17
|
+
validate_layer_numeric_bounds,
|
|
18
|
+
validate_layer_sequence,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from fiberpath.gcode.dialects import MarlinDialect
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class PlanOptions:
|
|
27
|
+
verbose: bool = False
|
|
28
|
+
dialect: MarlinDialect = field(default_factory=lambda: _get_default_dialect()) # noqa: E731
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_default_dialect() -> MarlinDialect:
|
|
32
|
+
"""Import default dialect lazily to avoid circular imports."""
|
|
33
|
+
from fiberpath.gcode.dialects import MARLIN_XAB_STANDARD
|
|
34
|
+
|
|
35
|
+
return MARLIN_XAB_STANDARD
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class LayerMetrics:
|
|
40
|
+
index: int
|
|
41
|
+
wind_type: str
|
|
42
|
+
commands: int
|
|
43
|
+
time_s: float
|
|
44
|
+
cumulative_time_s: float
|
|
45
|
+
tow_m: float
|
|
46
|
+
cumulative_tow_m: float
|
|
47
|
+
terminal: bool
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(slots=True)
|
|
51
|
+
class PlanResult:
|
|
52
|
+
commands: list[str]
|
|
53
|
+
total_time_s: float
|
|
54
|
+
total_tow_m: float
|
|
55
|
+
layers: list[LayerMetrics]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def plan_wind(definition: WindDefinition, options: PlanOptions | None = None) -> PlanResult:
|
|
59
|
+
options = options or PlanOptions()
|
|
60
|
+
machine = WinderMachine(
|
|
61
|
+
mandrel_diameter=definition.mandrel_parameters.diameter,
|
|
62
|
+
verbose_output=options.verbose,
|
|
63
|
+
dialect=options.dialect,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Generate initial position command using correct axis letters
|
|
67
|
+
mapping = options.dialect.axis_mapping
|
|
68
|
+
init_cmd = f"G0 {mapping.carriage}0 {mapping.mandrel}0 {mapping.delivery_head}0"
|
|
69
|
+
program: list[str] = [definition.dump_header(), init_cmd]
|
|
70
|
+
|
|
71
|
+
machine.set_feed_rate(definition.default_feed_rate)
|
|
72
|
+
layer_metrics: list[LayerMetrics] = []
|
|
73
|
+
encountered_terminal = False
|
|
74
|
+
mandrel_diameter = definition.mandrel_parameters.diameter
|
|
75
|
+
|
|
76
|
+
for index, layer in enumerate(definition.layers, start=1):
|
|
77
|
+
validate_layer_sequence(index, encountered_terminal)
|
|
78
|
+
validate_layer_numeric_bounds(index, layer)
|
|
79
|
+
|
|
80
|
+
current_mandrel = MandrelParameters(
|
|
81
|
+
diameter=mandrel_diameter,
|
|
82
|
+
windLength=definition.mandrel_parameters.wind_length,
|
|
83
|
+
)
|
|
84
|
+
machine.set_mandrel_diameter(current_mandrel.diameter)
|
|
85
|
+
|
|
86
|
+
helical_kinematics: HelicalKinematics | None = None
|
|
87
|
+
if isinstance(layer, HelicalLayer):
|
|
88
|
+
helical_kinematics = validate_helical_layer(
|
|
89
|
+
index, layer, current_mandrel, definition.tow_parameters
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
summary = build_layer_summary(index, len(definition.layers), layer)
|
|
93
|
+
machine.insert_comment(summary)
|
|
94
|
+
|
|
95
|
+
pre_commands = len(machine.get_gcode())
|
|
96
|
+
pre_time = machine.get_gcode_time_s()
|
|
97
|
+
pre_tow = machine.get_tow_length_m()
|
|
98
|
+
|
|
99
|
+
dispatch_layer(
|
|
100
|
+
machine,
|
|
101
|
+
layer,
|
|
102
|
+
current_mandrel,
|
|
103
|
+
definition.tow_parameters,
|
|
104
|
+
helical_kinematics=helical_kinematics,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
layer_metrics.append(
|
|
108
|
+
LayerMetrics(
|
|
109
|
+
index=index,
|
|
110
|
+
wind_type=layer.wind_type,
|
|
111
|
+
commands=len(machine.get_gcode()) - pre_commands,
|
|
112
|
+
time_s=machine.get_gcode_time_s() - pre_time,
|
|
113
|
+
cumulative_time_s=machine.get_gcode_time_s(),
|
|
114
|
+
tow_m=machine.get_tow_length_m() - pre_tow,
|
|
115
|
+
cumulative_tow_m=machine.get_tow_length_m(),
|
|
116
|
+
terminal=bool(getattr(layer, "terminal", False)),
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if getattr(layer, "terminal", False):
|
|
121
|
+
encountered_terminal = True
|
|
122
|
+
program.extend(machine.get_gcode())
|
|
123
|
+
|
|
124
|
+
if options.verbose:
|
|
125
|
+
program.insert(0, "; Verbose output enabled")
|
|
126
|
+
|
|
127
|
+
sanitized_commands = sanitize_program(program)
|
|
128
|
+
return PlanResult(
|
|
129
|
+
commands=sanitized_commands,
|
|
130
|
+
total_time_s=machine.get_gcode_time_s(),
|
|
131
|
+
total_tow_m=machine.get_tow_length_m(),
|
|
132
|
+
layers=layer_metrics,
|
|
133
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Validation helpers for planner inputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from math import gcd
|
|
6
|
+
|
|
7
|
+
from fiberpath.config.schemas import HelicalLayer, LayerModel, MandrelParameters, TowParameters
|
|
8
|
+
|
|
9
|
+
from .calculations import HelicalKinematics, compute_helical_kinematics
|
|
10
|
+
from .exceptions import LayerValidationError
|
|
11
|
+
|
|
12
|
+
MIN_WIND_ANGLE = 1.0
|
|
13
|
+
MAX_WIND_ANGLE = 89.0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_layer_sequence(layer_index: int, encountered_terminal: bool) -> None:
|
|
17
|
+
if encountered_terminal:
|
|
18
|
+
raise LayerValidationError(
|
|
19
|
+
layer_index,
|
|
20
|
+
"terminal layer must be the final entry in the definition",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_layer_numeric_bounds(layer_index: int, layer: LayerModel) -> None:
|
|
25
|
+
wind_angle = getattr(layer, "wind_angle", None)
|
|
26
|
+
if wind_angle is not None and not (MIN_WIND_ANGLE <= wind_angle <= MAX_WIND_ANGLE):
|
|
27
|
+
raise LayerValidationError(
|
|
28
|
+
layer_index,
|
|
29
|
+
f"wind angle {wind_angle}° must be between {MIN_WIND_ANGLE}° and {MAX_WIND_ANGLE}°",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def validate_helical_layer(
|
|
34
|
+
layer_index: int,
|
|
35
|
+
layer: HelicalLayer,
|
|
36
|
+
mandrel: MandrelParameters,
|
|
37
|
+
tow: TowParameters,
|
|
38
|
+
) -> HelicalKinematics:
|
|
39
|
+
if layer.skip_index >= layer.pattern_number:
|
|
40
|
+
raise LayerValidationError(
|
|
41
|
+
layer_index,
|
|
42
|
+
"skipIndex must be less than patternNumber",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if gcd(layer.skip_index, layer.pattern_number) != 1:
|
|
46
|
+
raise LayerValidationError(
|
|
47
|
+
layer_index,
|
|
48
|
+
"skipIndex and patternNumber must be coprime for full coverage",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return compute_helical_kinematics(layer, mandrel, tow)
|