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.
Files changed (49) hide show
  1. fiberpath/__init__.py +10 -0
  2. fiberpath/config/__init__.py +22 -0
  3. fiberpath/config/schemas.py +72 -0
  4. fiberpath/config/validator.py +31 -0
  5. fiberpath/execution/__init__.py +7 -0
  6. fiberpath/execution/marlin.py +311 -0
  7. fiberpath/gcode/__init__.py +6 -0
  8. fiberpath/gcode/dialects.py +47 -0
  9. fiberpath/gcode/generator.py +31 -0
  10. fiberpath/geometry/__init__.py +13 -0
  11. fiberpath/geometry/curves.py +16 -0
  12. fiberpath/geometry/intersections.py +23 -0
  13. fiberpath/geometry/surfaces.py +17 -0
  14. fiberpath/math_utils.py +20 -0
  15. fiberpath/planning/__init__.py +13 -0
  16. fiberpath/planning/calculations.py +50 -0
  17. fiberpath/planning/exceptions.py +19 -0
  18. fiberpath/planning/helpers.py +55 -0
  19. fiberpath/planning/layer_strategies.py +194 -0
  20. fiberpath/planning/machine.py +151 -0
  21. fiberpath/planning/planner.py +133 -0
  22. fiberpath/planning/validators.py +51 -0
  23. fiberpath/simulation/__init__.py +5 -0
  24. fiberpath/simulation/simulator.py +186 -0
  25. fiberpath/visualization/__init__.py +13 -0
  26. fiberpath/visualization/export_json.py +14 -0
  27. fiberpath/visualization/plotter.py +261 -0
  28. fiberpath-0.3.0.dist-info/METADATA +827 -0
  29. fiberpath-0.3.0.dist-info/RECORD +49 -0
  30. fiberpath-0.3.0.dist-info/WHEEL +4 -0
  31. fiberpath-0.3.0.dist-info/entry_points.txt +2 -0
  32. fiberpath-0.3.0.dist-info/licenses/LICENSE +661 -0
  33. fiberpath_api/__init__.py +5 -0
  34. fiberpath_api/main.py +19 -0
  35. fiberpath_api/routes/__init__.py +1 -0
  36. fiberpath_api/routes/plan.py +41 -0
  37. fiberpath_api/routes/simulate.py +29 -0
  38. fiberpath_api/routes/stream.py +49 -0
  39. fiberpath_api/routes/validate.py +21 -0
  40. fiberpath_api/schemas.py +50 -0
  41. fiberpath_cli/__init__.py +5 -0
  42. fiberpath_cli/__main__.py +6 -0
  43. fiberpath_cli/main.py +27 -0
  44. fiberpath_cli/output.py +14 -0
  45. fiberpath_cli/plan.py +100 -0
  46. fiberpath_cli/plot.py +37 -0
  47. fiberpath_cli/simulate.py +36 -0
  48. fiberpath_cli/stream.py +123 -0
  49. 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)
@@ -0,0 +1,5 @@
1
+ """Simulation entry points."""
2
+
3
+ from .simulator import SimulationError, SimulationResult, simulate_program
4
+
5
+ __all__ = ["SimulationResult", "SimulationError", "simulate_program"]