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