nc-gcode-interpreter 0.2.0__cp314-cp314-win_amd64.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.
- nc_gcode_interpreter/__init__.py +530 -0
- nc_gcode_interpreter/_internal.cp314-win_amd64.pyd +0 -0
- nc_gcode_interpreter/_internal.pyi +40 -0
- nc_gcode_interpreter/cli.py +187 -0
- nc_gcode_interpreter/ggroups.json +2922 -0
- nc_gcode_interpreter/py.typed +0 -0
- nc_gcode_interpreter/viz.py +343 -0
- nc_gcode_interpreter-0.2.0.dist-info/METADATA +27 -0
- nc_gcode_interpreter-0.2.0.dist-info/RECORD +13 -0
- nc_gcode_interpreter-0.2.0.dist-info/WHEEL +4 -0
- nc_gcode_interpreter-0.2.0.dist-info/entry_points.txt +2 -0
- nc_gcode_interpreter-0.2.0.dist-info/licenses/LICENSE +21 -0
- nc_gcode_interpreter-0.2.0.dist-info/sboms/nc-gcode-interpreter.cyclonedx.json +3006 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
from typing import Protocol, Iterator
|
|
2
|
+
import os
|
|
3
|
+
import polars as pl
|
|
4
|
+
from ._internal import nc_to_rows as _nc_to_rows
|
|
5
|
+
from ._internal import nc_to_batches as _nc_to_batches
|
|
6
|
+
from ._internal import __doc__ # noqa: F401
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TypedDict, TypeVar, Any, Generic
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Define TextFileLike Protocol
|
|
14
|
+
class TextFileLike(Protocol):
|
|
15
|
+
def read(self) -> str: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"nc_to_dataframe",
|
|
20
|
+
"nc_to_rows",
|
|
21
|
+
"nc_to_batches",
|
|
22
|
+
"sanitize_dataframe",
|
|
23
|
+
"dataframe_to_nc",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# Batch size nc_to_dataframe uses internally when draining the batch stream it
|
|
27
|
+
# concatenates. A 1.1 GB / 22M-row sweep put the optimum at 250k-1M (~33 s, a
|
|
28
|
+
# flat minimum); it degrades outside that - 100k pays per-batch overhead, and
|
|
29
|
+
# batches >=2M starve the pipeline (the worker blocks building a huge batch
|
|
30
|
+
# before the consumer sees one) and add first-output latency. 500k measured
|
|
31
|
+
# best and matches the nc_to_batches default, so both paths share one size.
|
|
32
|
+
_COLLECT_BATCH_SIZE = 500_000
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalize_input(input: "TextFileLike | str | os.PathLike") -> tuple[str, bool]:
|
|
36
|
+
"""Normalize the ``input`` argument into ``(text_or_path, is_path)``.
|
|
37
|
+
|
|
38
|
+
Backwards compatible with the historical contract: a ``str`` is the
|
|
39
|
+
program text and a file-like object is read in Python. A ``pathlib.Path``
|
|
40
|
+
(or any :class:`os.PathLike`) is passed to Rust as a *path* so the file is
|
|
41
|
+
read there once, avoiding a Python-side read and the str->String copy over
|
|
42
|
+
the PyO3 boundary for large programs.
|
|
43
|
+
"""
|
|
44
|
+
if input is None:
|
|
45
|
+
raise ValueError("input cannot be None")
|
|
46
|
+
if isinstance(input, str):
|
|
47
|
+
return input, False
|
|
48
|
+
if isinstance(input, os.PathLike):
|
|
49
|
+
return os.fspath(input), True
|
|
50
|
+
return input.read(), False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def nc_to_dataframe(
|
|
54
|
+
input: "TextFileLike | str | os.PathLike",
|
|
55
|
+
initial_state: TextFileLike | str | None = None,
|
|
56
|
+
axis_identifiers: list[str] | None = None,
|
|
57
|
+
extra_axes: list[str] | None = None,
|
|
58
|
+
iteration_limit: int = 10000,
|
|
59
|
+
disable_forward_fill: bool = False,
|
|
60
|
+
axis_index_map: dict[str, int] | None = None,
|
|
61
|
+
allow_undefined_variables: bool = False,
|
|
62
|
+
flatten_tolerance: float | None = None,
|
|
63
|
+
) -> tuple[pl.DataFrame, dict]:
|
|
64
|
+
"""
|
|
65
|
+
Parses Sinumerik-flavored NC G-code and converts it into a Polars DataFrame along with the final state.
|
|
66
|
+
|
|
67
|
+
This function processes the provided G-code input, interprets it according to Sinumerik specifications,
|
|
68
|
+
and outputs the movement commands and other relevant data in a structured DataFrame format. It also
|
|
69
|
+
returns a dictionary representing the final state after code execution, which can be useful for further analysis or inspection.
|
|
70
|
+
|
|
71
|
+
Parameters:
|
|
72
|
+
-----------
|
|
73
|
+
input: TextFileLike | str
|
|
74
|
+
The G-code input as a string or a file-like object.
|
|
75
|
+
initial_state: TextFileLike | str | None, optional
|
|
76
|
+
An optional initial state string or a file-like object to initialize the interpreter's state.
|
|
77
|
+
axis_identifiers: list[str] | None, optional
|
|
78
|
+
A list of axis identifiers to override the default axes (e.g., ['X', 'Y', 'Z']).
|
|
79
|
+
extra_axes: list[str] | None, optional
|
|
80
|
+
A list of extra axis identifiers to include in addition to the default ones (e.g., ['A', 'B', 'C']).
|
|
81
|
+
iteration_limit: int, optional
|
|
82
|
+
The maximum number of iterations to process, to prevent infinite loops in the G-code [default: 10000].
|
|
83
|
+
disable_forward_fill: bool, optional
|
|
84
|
+
If True, disables forward-filling of null values in axes columns in the resulting DataFrame.
|
|
85
|
+
axis_index_map: dict[str, int] | None, optional
|
|
86
|
+
A mapping from axis identifiers (e.g., 'E') to numeric indices (e.g., 4) for array assignments like FL[E]=10.
|
|
87
|
+
This allows user-configurable mapping of axis names to indices. Example: {'E': 4, 'X': 0}.
|
|
88
|
+
allow_undefined_variables: bool, optional
|
|
89
|
+
If True, allows undefined variables to be used in expressions with a value of 0 [default: False].
|
|
90
|
+
flatten_tolerance: float | None, optional
|
|
91
|
+
When set, flatten curved motions (G2/G3 arcs and ASPLINE/BSPLINE/CSPLINE
|
|
92
|
+
splines) into runs of G1 moves whose polyline stays within this maximum
|
|
93
|
+
deviation (in path units, i.e. mm) of the true curve. The interpolation
|
|
94
|
+
parameters (I/J/K/CR, PW/SD/PL) are consumed and do not appear in the
|
|
95
|
+
output. Default None (curves pass through untouched).
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
--------
|
|
99
|
+
tuple[pl.DataFrame, dict]
|
|
100
|
+
A tuple containing:
|
|
101
|
+
- A Polars DataFrame representing the parsed G-code.
|
|
102
|
+
- A nested dictionary representing the final state after execution.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
-------
|
|
106
|
+
ValueError
|
|
107
|
+
If the input is None or invalid.
|
|
108
|
+
|
|
109
|
+
Example:
|
|
110
|
+
--------
|
|
111
|
+
>>> df, state = nc_to_dataframe('G1 X10 Y20 Z30')
|
|
112
|
+
>>> print(df)
|
|
113
|
+
shape: (1, 4)
|
|
114
|
+
┌─────────────┬──────┬──────┬──────┐
|
|
115
|
+
│ gg01_motion ┆ X ┆ Y ┆ Z │
|
|
116
|
+
│ --- ┆ --- ┆ --- ┆ --- │
|
|
117
|
+
│ str ┆ f64 ┆ f64 ┆ f64 │
|
|
118
|
+
╞═════════════╪══════╪══════╪══════╡
|
|
119
|
+
│ G1 ┆ 10.0 ┆ 20.0 ┆ 30.0 │
|
|
120
|
+
└─────────────┴──────┴──────┴──────┘
|
|
121
|
+
"""
|
|
122
|
+
program, input_is_path = _normalize_input(input)
|
|
123
|
+
if initial_state is not None and not isinstance(initial_state, str):
|
|
124
|
+
initial_state = initial_state.read()
|
|
125
|
+
|
|
126
|
+
# The whole table is the concatenation of the batch stream: the interpreter
|
|
127
|
+
# runs on a worker thread, building columnar Arrow batches (each handed over
|
|
128
|
+
# zero-copy through the Arrow PyCapsule interface, no Python-list
|
|
129
|
+
# materialization) that this thread wraps with pl.DataFrame and concatenates.
|
|
130
|
+
# Streaming the batches keeps only one batch of intermediate rows alive at a
|
|
131
|
+
# time - far less peak memory than collecting all rows up front - and
|
|
132
|
+
# overlaps interpretation with DataFrame assembly.
|
|
133
|
+
it = _nc_to_batches(
|
|
134
|
+
program,
|
|
135
|
+
_COLLECT_BATCH_SIZE,
|
|
136
|
+
initial_state,
|
|
137
|
+
axis_identifiers,
|
|
138
|
+
extra_axes,
|
|
139
|
+
iteration_limit,
|
|
140
|
+
disable_forward_fill,
|
|
141
|
+
axis_index_map,
|
|
142
|
+
allow_undefined_variables,
|
|
143
|
+
input_is_path,
|
|
144
|
+
flatten_tolerance,
|
|
145
|
+
)
|
|
146
|
+
# pl.DataFrame wraps each Arrow record batch via __arrow_c_array__ (polars
|
|
147
|
+
# >= 1.3), no pyarrow needed. Exhaust the iterator before reading .state.
|
|
148
|
+
frames: list[pl.DataFrame] = [pl.DataFrame(batch) for batch in it]
|
|
149
|
+
state = it.state
|
|
150
|
+
if not frames:
|
|
151
|
+
# A program with no output rows (e.g. only variable assignments) yields
|
|
152
|
+
# no batches; return an empty frame with the final state, as before.
|
|
153
|
+
return pl.DataFrame(), state
|
|
154
|
+
if len(frames) == 1:
|
|
155
|
+
return frames[0], state
|
|
156
|
+
# Later batches may introduce a column a header line did not (e.g. `M`
|
|
157
|
+
# first appears mid-program): a diagonal concat unions them (absent -> null),
|
|
158
|
+
# and the last batch's columns are the canonical order (the batch builder
|
|
159
|
+
# applies canonical_order cumulatively, so the final batch has every column
|
|
160
|
+
# in the same order the whole-table path produced).
|
|
161
|
+
return pl.concat(frames, how="diagonal").select(frames[-1].columns), state
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
_T = TypeVar("_T")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _classproperty(Generic[_T]):
|
|
168
|
+
def __init__(self, fget: Callable[[Any], _T]) -> None:
|
|
169
|
+
self.fget = fget
|
|
170
|
+
|
|
171
|
+
def __get__(self, instance: Any, owner: type[Any]) -> _T:
|
|
172
|
+
return self.fget(owner)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class _GGroupEntry(TypedDict):
|
|
176
|
+
id: str
|
|
177
|
+
nr: int
|
|
178
|
+
description: str
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class _GGroup(TypedDict):
|
|
182
|
+
nr: int
|
|
183
|
+
title: str
|
|
184
|
+
effectiveness: str
|
|
185
|
+
short_name: str
|
|
186
|
+
entries: list[_GGroupEntry]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class GGroups:
|
|
190
|
+
_g_groups: list[_GGroup] | None = None
|
|
191
|
+
_g_group_short_names: set[str] | None = None
|
|
192
|
+
_g_groups_by_short_name: dict[str, _GGroup] | None = None
|
|
193
|
+
|
|
194
|
+
@_classproperty
|
|
195
|
+
def g_groups(cls) -> list[_GGroup]:
|
|
196
|
+
if cls._g_groups is None:
|
|
197
|
+
cls._load_data()
|
|
198
|
+
assert cls._g_groups is not None
|
|
199
|
+
return cls._g_groups
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def _load_data(cls) -> None:
|
|
203
|
+
json_file = Path(__file__).parent / "ggroups.json"
|
|
204
|
+
with open(json_file) as file:
|
|
205
|
+
g_groups = json.load(file)
|
|
206
|
+
cls._g_groups = g_groups
|
|
207
|
+
cls._g_group_short_names = {group["short_name"] for group in g_groups}
|
|
208
|
+
cls._g_groups_by_short_name = {
|
|
209
|
+
group["short_name"]: group for group in g_groups
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
@classmethod
|
|
213
|
+
def is_g_group(cls, name: str) -> bool:
|
|
214
|
+
if cls._g_group_short_names is None:
|
|
215
|
+
cls._load_data()
|
|
216
|
+
assert cls._g_group_short_names is not None
|
|
217
|
+
return name in cls._g_group_short_names
|
|
218
|
+
|
|
219
|
+
@classmethod
|
|
220
|
+
def is_modal_g_group(cls, name: str) -> bool:
|
|
221
|
+
if cls._g_groups_by_short_name is None:
|
|
222
|
+
cls._load_data()
|
|
223
|
+
assert cls._g_groups_by_short_name is not None
|
|
224
|
+
return cls._g_groups_by_short_name[name]["effectiveness"] == "modal"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def sanitize_dataframe(
|
|
228
|
+
df: pl.DataFrame, disable_forward_fill: bool = False
|
|
229
|
+
) -> pl.DataFrame:
|
|
230
|
+
"""
|
|
231
|
+
Cleans and preprocesses the DataFrame resulting from G-code interpretation.
|
|
232
|
+
|
|
233
|
+
This function performs necessary sanitization steps on the DataFrame, such as handling null values,
|
|
234
|
+
forward-filling axis positions if enabled, and preparing the DataFrame for further processing or conversion.
|
|
235
|
+
|
|
236
|
+
Parameters:
|
|
237
|
+
-----------
|
|
238
|
+
df: pl.DataFrame
|
|
239
|
+
The DataFrame to sanitize.
|
|
240
|
+
disable_forward_fill: bool, optional
|
|
241
|
+
If True, disables forward-filling of null values in axes columns.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
--------
|
|
245
|
+
pl.DataFrame
|
|
246
|
+
The sanitized DataFrame ready for analysis or conversion back to G-code.
|
|
247
|
+
"""
|
|
248
|
+
modal = [g["short_name"] for g in GGroups.g_groups if g["effectiveness"] == "modal"]
|
|
249
|
+
non_modal = [g["short_name"] for g in GGroups.g_groups if g["effectiveness"] != "modal"]
|
|
250
|
+
known_axes = [
|
|
251
|
+
"X", "Y", "Z", "A", "B", "C", "D", "E", "F", "S", "U", "V",
|
|
252
|
+
"RA1", "RA2", "RA3", "RA4", "RA5", "RA6",
|
|
253
|
+
]
|
|
254
|
+
string_columns = {*modal, *non_modal, "T", "non_returning_function_call", "comment"}
|
|
255
|
+
# Block addresses: value columns, but never forward-filled. These are the
|
|
256
|
+
# circular/helical interpolation parameters (I/J/K arc-centre offsets and
|
|
257
|
+
# the CR radius form) and the spline programming addresses (PW/SD/PL).
|
|
258
|
+
# Each value belongs only to the block that programs it, so it must not be
|
|
259
|
+
# carried forward. Keep this list identical (and in the same order) to the
|
|
260
|
+
# Rust BLOCK_ADDRESSES constant so both output layers agree on columns.
|
|
261
|
+
block_addresses = ["I", "J", "K", "CR", "PW", "SD", "PL"]
|
|
262
|
+
|
|
263
|
+
# Value columns: anything that is not a known string/list column.
|
|
264
|
+
value_columns = [
|
|
265
|
+
c for c in df.columns if c not in string_columns and c not in ("M", "N")
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
# Cast to the canonical dtypes.
|
|
269
|
+
casts = []
|
|
270
|
+
if "N" in df.columns:
|
|
271
|
+
casts.append(pl.col("N").cast(pl.Int64))
|
|
272
|
+
casts += [pl.col(c).cast(pl.String) for c in df.columns if c in string_columns]
|
|
273
|
+
casts += [pl.col(c).cast(pl.Float64) for c in value_columns]
|
|
274
|
+
if "M" in df.columns:
|
|
275
|
+
casts.append(pl.col("M").cast(pl.List(pl.String)))
|
|
276
|
+
df = df.with_columns(casts)
|
|
277
|
+
|
|
278
|
+
# Canonical column order.
|
|
279
|
+
order: list[str] = []
|
|
280
|
+
for name in (
|
|
281
|
+
["N"] + modal + non_modal + known_axes
|
|
282
|
+
+ sorted(c for c in value_columns if c not in known_axes and c not in block_addresses)
|
|
283
|
+
+ block_addresses
|
|
284
|
+
+ ["T", "M", "non_returning_function_call", "comment"]
|
|
285
|
+
):
|
|
286
|
+
if name in df.columns and name not in order:
|
|
287
|
+
order.append(name)
|
|
288
|
+
df = df.select(order)
|
|
289
|
+
|
|
290
|
+
# Forward-fill value columns and modal G-group columns.
|
|
291
|
+
if not disable_forward_fill:
|
|
292
|
+
fill = [
|
|
293
|
+
c
|
|
294
|
+
for c in df.columns
|
|
295
|
+
if (c in value_columns and c not in block_addresses) or c == "N" or c in modal
|
|
296
|
+
]
|
|
297
|
+
df = df.with_columns([pl.col(c).fill_null(strategy="forward") for c in fill])
|
|
298
|
+
return df
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def dataframe_to_nc(df: pl.DataFrame, file_path: str | Path):
|
|
302
|
+
"""
|
|
303
|
+
Converts a Polars DataFrame back into NC G-code and writes it to a file.
|
|
304
|
+
|
|
305
|
+
This function takes a DataFrame representing G-code commands (as produced by `nc_to_dataframe`)
|
|
306
|
+
and reconstructs the G-code, writing the output to the specified file path.
|
|
307
|
+
|
|
308
|
+
Parameters:
|
|
309
|
+
-----------
|
|
310
|
+
df: pl.DataFrame
|
|
311
|
+
The DataFrame containing G-code data to be converted back into NC code.
|
|
312
|
+
file_path: str or Path
|
|
313
|
+
The file path where the generated G-code should be written.
|
|
314
|
+
|
|
315
|
+
Notes:
|
|
316
|
+
------
|
|
317
|
+
- Currently, this function is implemented in Python. Future versions may implement this in Rust for performance.
|
|
318
|
+
- The function ensures that consecutive duplicate values are appropriately handled to generate clean G-code.
|
|
319
|
+
|
|
320
|
+
Example:
|
|
321
|
+
--------
|
|
322
|
+
>>> import polars as pl
|
|
323
|
+
>>> from nc_gcode_interpreter import dataframe_to_nc
|
|
324
|
+
>>> df = pl.DataFrame({'X': [10, 20], 'Y': [0, 0], 'Z': [0, 0]})
|
|
325
|
+
>>> dataframe_to_nc(df, 'output.MPF')
|
|
326
|
+
"""
|
|
327
|
+
df = sanitize_dataframe(df)
|
|
328
|
+
# Python prototype of df to nc conversion code
|
|
329
|
+
float_cols = [col for col in df.columns if df[col].dtype == pl.Float64]
|
|
330
|
+
int_cols = [col for col in df.columns if df[col].dtype == pl.Int64]
|
|
331
|
+
g_group_cols = [col for col in df.columns if GGroups.is_g_group(col)]
|
|
332
|
+
list_of_str_cols = [
|
|
333
|
+
col for col in df.columns if df[col].dtype == pl.List(pl.String)
|
|
334
|
+
]
|
|
335
|
+
string_axes_cols = [col for col in df.columns if col in ["T"]]
|
|
336
|
+
|
|
337
|
+
# Replace consecutive duplicates with null values
|
|
338
|
+
df = df.with_columns(
|
|
339
|
+
[
|
|
340
|
+
pl.when(pl.col(c) == pl.col(c).shift(1))
|
|
341
|
+
.then(None)
|
|
342
|
+
.otherwise(
|
|
343
|
+
pl.lit(f"{c}{'=' if len(c) > 1 else ''}")
|
|
344
|
+
+ pl.col(c).round(3).cast(pl.String)
|
|
345
|
+
)
|
|
346
|
+
.alias(c)
|
|
347
|
+
for c in float_cols
|
|
348
|
+
]
|
|
349
|
+
+ [
|
|
350
|
+
pl.when(pl.col(c) == pl.col(c).shift(1))
|
|
351
|
+
.then(None)
|
|
352
|
+
.otherwise(
|
|
353
|
+
pl.lit(f"{c}{'=' if len(c) > 1 else ''}") + pl.col(c).cast(pl.String)
|
|
354
|
+
)
|
|
355
|
+
.alias(c)
|
|
356
|
+
for c in int_cols
|
|
357
|
+
]
|
|
358
|
+
+ [
|
|
359
|
+
(pl.lit(f'{c}="') + pl.col(c) + pl.lit('"')).alias(c)
|
|
360
|
+
for c in string_axes_cols
|
|
361
|
+
]
|
|
362
|
+
+ [
|
|
363
|
+
pl.when(pl.col(c) == pl.col(c).shift(1))
|
|
364
|
+
.then(None)
|
|
365
|
+
.otherwise(pl.col(c))
|
|
366
|
+
.alias(c)
|
|
367
|
+
for c in g_group_cols
|
|
368
|
+
]
|
|
369
|
+
+ [pl.col(c).list.join(separator=" ").alias(c) for c in list_of_str_cols]
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# Define the columns you want to include in the output
|
|
373
|
+
columns_of_interest = df.columns
|
|
374
|
+
df_line = df.with_columns(
|
|
375
|
+
pl.concat_str(
|
|
376
|
+
[pl.col(c) for c in columns_of_interest], ignore_nulls=True, separator=" "
|
|
377
|
+
).alias("line")
|
|
378
|
+
).select("line")
|
|
379
|
+
df_line.write_csv(file_path, include_header=False, quote_style="never")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def nc_to_rows(
|
|
383
|
+
input: "TextFileLike | str | os.PathLike",
|
|
384
|
+
initial_state: TextFileLike | str | None = None,
|
|
385
|
+
axis_identifiers: list[str] | None = None,
|
|
386
|
+
extra_axes: list[str] | None = None,
|
|
387
|
+
iteration_limit: int = 10000,
|
|
388
|
+
forward_fill: bool = True,
|
|
389
|
+
include_variables: bool = False,
|
|
390
|
+
axis_index_map: dict[str, int] | None = None,
|
|
391
|
+
allow_undefined_variables: bool = False,
|
|
392
|
+
flatten_tolerance: float | None = None,
|
|
393
|
+
):
|
|
394
|
+
"""Interpret an NC program lazily, yielding one row at a time.
|
|
395
|
+
|
|
396
|
+
Returns an iterator of ``(line_no, row)`` tuples: the 1-based source
|
|
397
|
+
line the block came from (loops and jumps repeat / reorder line
|
|
398
|
+
numbers), and a dict of column values typed like the batch DataFrame
|
|
399
|
+
(``N``: int, ``M``: list[str], G-group/T/comment columns: str, axes and
|
|
400
|
+
other value columns: float). Rows are forward-filled like
|
|
401
|
+
:func:`nc_to_dataframe` unless ``forward_fill`` is False.
|
|
402
|
+
|
|
403
|
+
With ``include_variables=True`` the iterator yields ``(line_no, row,
|
|
404
|
+
variables)`` instead, where ``variables`` maps each variable the block
|
|
405
|
+
assigned (``R1=R1+1``, ``DEF REAL Q=5``, FOR counters) to its new float
|
|
406
|
+
value. Blocks that only assign variables — invisible in the batch
|
|
407
|
+
DataFrame and in the default stream — are then also yielded, with an
|
|
408
|
+
empty (never forward-filled) ``row``. Accumulating the ``variables``
|
|
409
|
+
dicts with ``dict.update`` reconstructs the full variable state at any
|
|
410
|
+
point of the stream.
|
|
411
|
+
|
|
412
|
+
The interpreter runs on a background thread behind a bounded channel:
|
|
413
|
+
rows arrive as they are produced, memory use is constant, and dropping
|
|
414
|
+
the iterator aborts interpretation. Breaking out of a ``for`` loop over
|
|
415
|
+
an anonymous iterator drops it; a stored iterator keeps the worker
|
|
416
|
+
alive (parked on the bounded channel) until it is deleted or
|
|
417
|
+
garbage-collected.
|
|
418
|
+
Errors raise ``ValueError`` from ``next()`` when reached. After the
|
|
419
|
+
iterator is exhausted, its ``state`` attribute holds the final
|
|
420
|
+
interpreter state (axes, symbol_table, translation).
|
|
421
|
+
|
|
422
|
+
Example:
|
|
423
|
+
--------
|
|
424
|
+
>>> for line_no, row in nc_to_rows("G1 X10\nX20 Y5"):
|
|
425
|
+
... print(line_no, row["X"])
|
|
426
|
+
1 10.0
|
|
427
|
+
2 20.0
|
|
428
|
+
>>> for line_no, row, variables in nc_to_rows("R1=5\nX=R1", include_variables=True):
|
|
429
|
+
... print(line_no, row.get("X"), variables)
|
|
430
|
+
1 None {'R1': 5.0}
|
|
431
|
+
2 5.0 {}
|
|
432
|
+
"""
|
|
433
|
+
program, input_is_path = _normalize_input(input)
|
|
434
|
+
if initial_state is not None and not isinstance(initial_state, str):
|
|
435
|
+
initial_state = initial_state.read()
|
|
436
|
+
return _nc_to_rows(
|
|
437
|
+
program,
|
|
438
|
+
initial_state,
|
|
439
|
+
axis_identifiers,
|
|
440
|
+
extra_axes,
|
|
441
|
+
iteration_limit,
|
|
442
|
+
forward_fill,
|
|
443
|
+
include_variables,
|
|
444
|
+
axis_index_map,
|
|
445
|
+
allow_undefined_variables,
|
|
446
|
+
input_is_path,
|
|
447
|
+
flatten_tolerance,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
class _BatchIterator:
|
|
452
|
+
"""Iterator of polars DataFrames returned by :func:`nc_to_batches`.
|
|
453
|
+
|
|
454
|
+
Wraps the Rust batch iterator, which yields each batch as an Arrow record
|
|
455
|
+
batch built column-wise in Rust and handed over the Arrow PyCapsule
|
|
456
|
+
interface (``__arrow_c_array__``, via pyo3-arrow) - a zero-copy transfer
|
|
457
|
+
with no Python list of primitives materialized. ``pl.DataFrame`` wraps that
|
|
458
|
+
capsule directly (polars >= 1.3), so no ``pyarrow`` is involved. After
|
|
459
|
+
exhaustion its ``state`` attribute holds the final interpreter state (axes,
|
|
460
|
+
symbol_table, translation), like the iterator returned by :func:`nc_to_rows`.
|
|
461
|
+
"""
|
|
462
|
+
|
|
463
|
+
def __init__(self, inner: Any) -> None:
|
|
464
|
+
self._inner = inner
|
|
465
|
+
|
|
466
|
+
def __iter__(self) -> "Iterator[pl.DataFrame]":
|
|
467
|
+
return self
|
|
468
|
+
|
|
469
|
+
def __next__(self) -> pl.DataFrame:
|
|
470
|
+
return pl.DataFrame(next(self._inner))
|
|
471
|
+
|
|
472
|
+
@property
|
|
473
|
+
def state(self) -> dict | None:
|
|
474
|
+
return self._inner.state
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def nc_to_batches(
|
|
478
|
+
input: "TextFileLike | str | os.PathLike",
|
|
479
|
+
batch_size: int = 500_000,
|
|
480
|
+
initial_state: TextFileLike | str | None = None,
|
|
481
|
+
axis_identifiers: list[str] | None = None,
|
|
482
|
+
extra_axes: list[str] | None = None,
|
|
483
|
+
iteration_limit: int = 10000,
|
|
484
|
+
disable_forward_fill: bool = False,
|
|
485
|
+
axis_index_map: dict[str, int] | None = None,
|
|
486
|
+
allow_undefined_variables: bool = False,
|
|
487
|
+
flatten_tolerance: float | None = None,
|
|
488
|
+
) -> _BatchIterator:
|
|
489
|
+
"""Interpret an NC program into a stream of columnar polars DataFrames.
|
|
490
|
+
|
|
491
|
+
Yields one :class:`polars.DataFrame` per batch of at most ``batch_size``
|
|
492
|
+
output rows. Each batch is built column-wise in Rust (the same machinery
|
|
493
|
+
:func:`nc_to_dataframe` uses), so memory stays bounded by the batch size
|
|
494
|
+
rather than materializing the whole program at once - the way to process a
|
|
495
|
+
program too large to fit in one DataFrame.
|
|
496
|
+
|
|
497
|
+
Forward-filling (axes, value columns and modal G-groups) carries across
|
|
498
|
+
batch boundaries, and the canonical column set grows as new columns appear,
|
|
499
|
+
so concatenating the batches reconstructs :func:`nc_to_dataframe` exactly.
|
|
500
|
+
When every column already appears in the first batch (the usual case) the
|
|
501
|
+
batches share one schema and ``pl.concat`` suffices; otherwise concatenate
|
|
502
|
+
with ``how="diagonal"`` and select the final column order.
|
|
503
|
+
|
|
504
|
+
The returned iterator exposes a ``state`` attribute holding the final
|
|
505
|
+
interpreter state once it is exhausted, like :func:`nc_to_rows`.
|
|
506
|
+
|
|
507
|
+
Example:
|
|
508
|
+
--------
|
|
509
|
+
>>> batches = nc_to_batches("G1 X10\\nX20 Y5", batch_size=1)
|
|
510
|
+
>>> frames = list(batches)
|
|
511
|
+
>>> pl.concat(frames, how="diagonal").height
|
|
512
|
+
2
|
|
513
|
+
"""
|
|
514
|
+
program, input_is_path = _normalize_input(input)
|
|
515
|
+
if initial_state is not None and not isinstance(initial_state, str):
|
|
516
|
+
initial_state = initial_state.read()
|
|
517
|
+
inner = _nc_to_batches(
|
|
518
|
+
program,
|
|
519
|
+
batch_size,
|
|
520
|
+
initial_state,
|
|
521
|
+
axis_identifiers,
|
|
522
|
+
extra_axes,
|
|
523
|
+
iteration_limit,
|
|
524
|
+
disable_forward_fill,
|
|
525
|
+
axis_index_map,
|
|
526
|
+
allow_undefined_variables,
|
|
527
|
+
input_is_path,
|
|
528
|
+
flatten_tolerance,
|
|
529
|
+
)
|
|
530
|
+
return _BatchIterator(inner)
|
|
Binary file
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from typing import Any, Iterator, Optional, List, Dict, Tuple
|
|
2
|
+
|
|
3
|
+
# Type stubs for the compiled Rust extension `nc_gcode_interpreter._internal`.
|
|
4
|
+
# mypy cannot introspect the pyo3 module, so the public entry points the Python
|
|
5
|
+
# wrapper imports are declared here. Signatures mirror the `#[pyo3(signature)]`
|
|
6
|
+
# defaults in src/lib.rs.
|
|
7
|
+
|
|
8
|
+
def nc_to_rows(
|
|
9
|
+
input: str,
|
|
10
|
+
initial_state: Optional[str] = None,
|
|
11
|
+
axis_identifiers: Optional[List[str]] = None,
|
|
12
|
+
extra_axes: Optional[List[str]] = None,
|
|
13
|
+
iteration_limit: int = 10000,
|
|
14
|
+
forward_fill: bool = True,
|
|
15
|
+
include_variables: bool = False,
|
|
16
|
+
axis_index_map: Optional[Dict[str, int]] = None,
|
|
17
|
+
allow_undefined_variables: bool = False,
|
|
18
|
+
input_is_path: bool = False,
|
|
19
|
+
flatten_tolerance: Optional[float] = None,
|
|
20
|
+
) -> Iterator[Tuple[Any, ...]]:
|
|
21
|
+
"""Interpret an NC program lazily into ``(line_no, row[, variables])`` tuples."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
def nc_to_batches(
|
|
25
|
+
input: str,
|
|
26
|
+
batch_size: int = 500_000,
|
|
27
|
+
initial_state: Optional[str] = None,
|
|
28
|
+
axis_identifiers: Optional[List[str]] = None,
|
|
29
|
+
extra_axes: Optional[List[str]] = None,
|
|
30
|
+
iteration_limit: int = 10000,
|
|
31
|
+
disable_forward_fill: bool = False,
|
|
32
|
+
axis_index_map: Optional[Dict[str, int]] = None,
|
|
33
|
+
allow_undefined_variables: bool = False,
|
|
34
|
+
input_is_path: bool = False,
|
|
35
|
+
flatten_tolerance: Optional[float] = None,
|
|
36
|
+
) -> Any:
|
|
37
|
+
"""Interpret an NC program into an iterator of columnar polars DataFrames."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
__all__ = ["nc_to_rows", "nc_to_batches"]
|