compass-lib 0.0.2__py3-none-any.whl → 0.0.3__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.
@@ -0,0 +1,610 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Parser for Compass .PLT plot files.
3
+
4
+ This module implements the parser for reading Compass plot files,
5
+ which contain computed 3D coordinates for cave visualization.
6
+ """
7
+
8
+ import re
9
+ from datetime import date
10
+ from decimal import Decimal
11
+ from pathlib import Path
12
+
13
+ from compass_lib.constants import ASCII_ENCODING
14
+ from compass_lib.constants import NULL_LRUD_VALUES
15
+ from compass_lib.enums import DrawOperation
16
+ from compass_lib.enums import Severity
17
+ from compass_lib.errors import CompassParseError
18
+ from compass_lib.errors import SourceLocation
19
+ from compass_lib.models import Bounds
20
+ from compass_lib.models import Location
21
+ from compass_lib.plot.models import BeginFeatureCommand
22
+ from compass_lib.plot.models import BeginSectionCommand
23
+ from compass_lib.plot.models import BeginSurveyCommand
24
+ from compass_lib.plot.models import CaveBoundsCommand
25
+ from compass_lib.plot.models import CompassPlotCommand
26
+ from compass_lib.plot.models import DatumCommand
27
+ from compass_lib.plot.models import DrawSurveyCommand
28
+ from compass_lib.plot.models import FeatureCommand
29
+ from compass_lib.plot.models import SurveyBoundsCommand
30
+ from compass_lib.plot.models import UtmZoneCommand
31
+
32
+
33
+ class CompassPlotParser:
34
+ """Parser for Compass .PLT plot files.
35
+
36
+ This parser reads Compass plot files and returns a list of
37
+ CompassPlotCommand objects. Errors are collected rather than thrown,
38
+ allowing partial parsing of malformed files.
39
+
40
+ Attributes:
41
+ errors: List of parsing errors and warnings encountered
42
+ commands: List of successfully parsed commands
43
+ """
44
+
45
+ # Regex patterns
46
+ UINT_PATTERN = re.compile(r"[1-9]\d*")
47
+ NUMBER_PATTERN = re.compile(r"[-+]?\d+\.?\d*(?:[eE][-+]?\d+)?")
48
+ NON_WHITESPACE = re.compile(r"\S+")
49
+
50
+ def __init__(self) -> None:
51
+ """Initialize the parser."""
52
+ self.errors: list[CompassParseError] = []
53
+ self.commands: list[CompassPlotCommand] = []
54
+ self._source: str = "<string>"
55
+
56
+ def _add_error(
57
+ self,
58
+ message: str,
59
+ text: str = "",
60
+ line: int = 0,
61
+ column: int = 0,
62
+ ) -> None:
63
+ """Add an error to the error list."""
64
+ self.errors.append(
65
+ CompassParseError(
66
+ severity=Severity.ERROR,
67
+ message=message,
68
+ location=SourceLocation(
69
+ source=self._source,
70
+ line=line,
71
+ column=column,
72
+ text=text,
73
+ ),
74
+ )
75
+ )
76
+
77
+ def _add_warning(
78
+ self,
79
+ message: str,
80
+ text: str = "",
81
+ line: int = 0,
82
+ column: int = 0,
83
+ ) -> None:
84
+ """Add a warning to the error list."""
85
+ self.errors.append(
86
+ CompassParseError(
87
+ severity=Severity.WARNING,
88
+ message=message,
89
+ location=SourceLocation(
90
+ source=self._source,
91
+ line=line,
92
+ column=column,
93
+ text=text,
94
+ ),
95
+ )
96
+ )
97
+
98
+ def parse_file(self, path: Path) -> list[CompassPlotCommand]:
99
+ """Parse a plot file.
100
+
101
+ Args:
102
+ path: Path to the .PLT file
103
+
104
+ Returns:
105
+ List of parsed commands
106
+ """
107
+ self._source = str(path)
108
+ with path.open(mode="r", encoding=ASCII_ENCODING, errors="replace") as f:
109
+ return self.parse_lines(f, str(path))
110
+
111
+ def parse_string(
112
+ self,
113
+ data: str,
114
+ source: str = "<string>",
115
+ ) -> list[CompassPlotCommand]:
116
+ """Parse plot data from a string.
117
+
118
+ Args:
119
+ data: Plot file content
120
+ source: Source identifier for error messages
121
+
122
+ Returns:
123
+ List of parsed commands
124
+ """
125
+ self._source = source
126
+ lines = data.split("\n")
127
+ return self._parse_lines_list(lines, source)
128
+
129
+ def parse_lines(
130
+ self,
131
+ file_obj,
132
+ source: str = "<string>",
133
+ ) -> list[CompassPlotCommand]:
134
+ """Parse plot data from a file object.
135
+
136
+ Args:
137
+ file_obj: File-like object to read from
138
+ source: Source identifier for error messages
139
+
140
+ Returns:
141
+ List of parsed commands
142
+ """
143
+ self._source = source
144
+ commands: list[CompassPlotCommand] = []
145
+
146
+ for line_num, line in enumerate(file_obj):
147
+ _line = line.strip()
148
+ if not _line:
149
+ continue
150
+
151
+ try:
152
+ command = self._parse_command(_line, line_num)
153
+ if command:
154
+ commands.append(command)
155
+ except Exception as e: # noqa: BLE001
156
+ self._add_error(str(e), _line, line_num)
157
+
158
+ self.commands.extend(commands)
159
+ return commands
160
+
161
+ def _parse_lines_list(
162
+ self,
163
+ lines: list[str],
164
+ source: str,
165
+ ) -> list[CompassPlotCommand]:
166
+ """Parse lines from a list."""
167
+ self._source = source
168
+ commands: list[CompassPlotCommand] = []
169
+
170
+ for line_num, line in enumerate(lines):
171
+ _line = line.strip()
172
+ if not _line:
173
+ continue
174
+
175
+ try:
176
+ command = self._parse_command(_line, line_num)
177
+ if command:
178
+ commands.append(command)
179
+ except Exception as e: # noqa: BLE001
180
+ self._add_error(str(e), _line, line_num)
181
+
182
+ self.commands.extend(commands)
183
+ return commands
184
+
185
+ def _parse_command( # noqa: PLR0911
186
+ self,
187
+ line: str,
188
+ line_num: int,
189
+ ) -> CompassPlotCommand | None:
190
+ """Parse a single command line.
191
+
192
+ Args:
193
+ line: Command line text
194
+ line_num: Line number for error reporting
195
+
196
+ Returns:
197
+ Parsed command or None if unknown/invalid
198
+ """
199
+ if not line:
200
+ return None
201
+
202
+ cmd = line[0]
203
+ rest = line[1:]
204
+
205
+ if cmd in ("M", "D"):
206
+ return self._parse_draw_command(cmd, rest, line_num)
207
+ if cmd == "N":
208
+ return self._parse_begin_survey(rest, line_num)
209
+ if cmd == "F":
210
+ return self._parse_begin_feature(rest, line_num)
211
+ if cmd == "S":
212
+ return self._parse_begin_section(rest, line_num)
213
+ if cmd == "L":
214
+ return self._parse_feature_command(rest, line_num)
215
+ if cmd == "X":
216
+ return self._parse_survey_bounds(rest, line_num)
217
+ if cmd == "Z":
218
+ return self._parse_cave_bounds(rest, line_num)
219
+ if cmd == "O":
220
+ return self._parse_datum(rest, line_num)
221
+ if cmd == "G":
222
+ return self._parse_utm_zone(rest, line_num)
223
+
224
+ # Unknown command - ignore silently (many undocumented commands exist)
225
+ return None
226
+
227
+ def _parse_number(
228
+ self,
229
+ text: str,
230
+ line_num: int,
231
+ field_name: str,
232
+ ) -> float | None:
233
+ """Parse a number, returning None on failure."""
234
+ text = text.strip()
235
+ try:
236
+ return float(text)
237
+ except ValueError:
238
+ self._add_error(f"invalid {field_name}: {text}", text, line_num)
239
+ return None
240
+
241
+ def _parse_lrud(
242
+ self,
243
+ text: str,
244
+ line_num: int,
245
+ field_name: str,
246
+ ) -> float | None:
247
+ """Parse LRUD measurement.
248
+
249
+ Returns None for missing data indicators (negative or 999/999.9).
250
+ """
251
+ value = self._parse_number(text, line_num, field_name)
252
+ if value is None:
253
+ return None
254
+
255
+ # Null indicators
256
+ if value < 0 or value in NULL_LRUD_VALUES:
257
+ return None
258
+
259
+ return value
260
+
261
+ def _parse_date(
262
+ self,
263
+ parts: list[str],
264
+ start_idx: int,
265
+ line_num: int,
266
+ ) -> tuple[date | None, int]:
267
+ """Parse date from parts (month day year).
268
+
269
+ Returns (date, new_index) tuple.
270
+ """
271
+ if start_idx + 2 >= len(parts):
272
+ self._add_error("incomplete date", "", line_num)
273
+ return None, start_idx
274
+
275
+ try:
276
+ month = int(parts[start_idx])
277
+ day = int(parts[start_idx + 1])
278
+ year = int(parts[start_idx + 2])
279
+
280
+ if month < 1 or month > 12:
281
+ self._add_error(
282
+ f"month must be between 1 and 12: {month}", "", line_num
283
+ )
284
+ return None, start_idx + 3
285
+
286
+ if day < 1 or day > 31:
287
+ self._add_error(f"day must be between 1 and 31: {day}", "", line_num)
288
+ return None, start_idx + 3
289
+
290
+ return date(year, month, day), start_idx + 3
291
+ except ValueError as e:
292
+ self._add_error(f"invalid date: {e}", "", line_num)
293
+ return None, start_idx + 3
294
+
295
+ def _parse_draw_command(
296
+ self,
297
+ cmd: str,
298
+ rest: str,
299
+ line_num: int,
300
+ ) -> DrawSurveyCommand | None:
301
+ """Parse M (move) or D (draw) command."""
302
+ operation = DrawOperation.MOVE_TO if cmd == "M" else DrawOperation.LINE_TO
303
+ parts = rest.split()
304
+
305
+ if len(parts) < 3:
306
+ self._add_error(
307
+ "draw command requires at least 3 coordinates",
308
+ rest,
309
+ line_num,
310
+ )
311
+ return None
312
+
313
+ northing = self._parse_number(parts[0], line_num, "northing")
314
+ easting = self._parse_number(parts[1], line_num, "easting")
315
+ vertical = self._parse_number(parts[2], line_num, "vertical")
316
+
317
+ command = DrawSurveyCommand(
318
+ operation=operation,
319
+ location=Location(
320
+ northing=northing,
321
+ easting=easting,
322
+ vertical=vertical,
323
+ ),
324
+ )
325
+
326
+ # Parse subcommands
327
+ idx = 3
328
+ while idx < len(parts):
329
+ subcmd = parts[idx]
330
+ idx += 1
331
+
332
+ if subcmd.startswith("S"):
333
+ # Station name
334
+ command.station_name = subcmd[1:] if len(subcmd) > 1 else None
335
+ if not command.station_name and idx < len(parts):
336
+ command.station_name = parts[idx]
337
+ idx += 1
338
+
339
+ elif subcmd == "P":
340
+ # LRUD: left, up, down, right (in this order for draw commands)
341
+ if idx + 3 < len(parts):
342
+ command.left = self._parse_lrud(parts[idx], line_num, "left")
343
+ idx += 1
344
+ command.up = self._parse_lrud(parts[idx], line_num, "up")
345
+ idx += 1
346
+ command.down = self._parse_lrud(parts[idx], line_num, "down")
347
+ idx += 1
348
+ command.right = self._parse_lrud(parts[idx], line_num, "right")
349
+ idx += 1
350
+
351
+ elif subcmd == "I":
352
+ # Distance from entrance
353
+ if idx < len(parts):
354
+ dist = self._parse_number(
355
+ parts[idx],
356
+ line_num,
357
+ "distance from entrance",
358
+ )
359
+ if dist is not None:
360
+ if dist < 0:
361
+ self._add_warning(
362
+ "distance from entrance is negative",
363
+ parts[idx],
364
+ line_num,
365
+ )
366
+ command.distance_from_entrance = dist
367
+ idx += 1
368
+ # Stop parsing after I (undocumented commands may follow)
369
+ break
370
+
371
+ return command
372
+
373
+ def _parse_feature_command(
374
+ self,
375
+ rest: str,
376
+ line_num: int,
377
+ ) -> FeatureCommand | None:
378
+ """Parse L (feature) command."""
379
+ parts = rest.split()
380
+
381
+ if len(parts) < 3:
382
+ self._add_error(
383
+ "feature command requires at least 3 coordinates",
384
+ rest,
385
+ line_num,
386
+ )
387
+ return None
388
+
389
+ northing = self._parse_number(parts[0], line_num, "northing")
390
+ easting = self._parse_number(parts[1], line_num, "easting")
391
+ vertical = self._parse_number(parts[2], line_num, "vertical")
392
+
393
+ command = FeatureCommand(
394
+ location=Location(
395
+ northing=northing,
396
+ easting=easting,
397
+ vertical=vertical,
398
+ ),
399
+ )
400
+
401
+ # Parse subcommands
402
+ idx = 3
403
+ while idx < len(parts):
404
+ subcmd = parts[idx]
405
+ idx += 1
406
+
407
+ if subcmd.startswith("S"):
408
+ # Station name
409
+ command.station_name = subcmd[1:] if len(subcmd) > 1 else None
410
+ if not command.station_name and idx < len(parts):
411
+ command.station_name = parts[idx]
412
+ idx += 1
413
+
414
+ elif subcmd == "P":
415
+ # LRUD: left, right, up, down (different order than draw!)
416
+ if idx + 3 < len(parts):
417
+ command.left = self._parse_lrud(parts[idx], line_num, "left")
418
+ idx += 1
419
+ command.right = self._parse_lrud(parts[idx], line_num, "right")
420
+ idx += 1
421
+ command.up = self._parse_lrud(parts[idx], line_num, "up")
422
+ idx += 1
423
+ command.down = self._parse_lrud(parts[idx], line_num, "down")
424
+ idx += 1
425
+
426
+ elif subcmd == "V":
427
+ # Feature value
428
+ if idx < len(parts):
429
+ try:
430
+ command.value = Decimal(parts[idx])
431
+ except Exception: # noqa: BLE001
432
+ self._add_error(
433
+ f"invalid value: {parts[idx]}",
434
+ parts[idx],
435
+ line_num,
436
+ )
437
+ idx += 1
438
+
439
+ return command
440
+
441
+ def _parse_begin_survey(
442
+ self,
443
+ rest: str,
444
+ line_num: int,
445
+ ) -> BeginSurveyCommand:
446
+ """Parse N (begin survey) command."""
447
+ parts = rest.split()
448
+
449
+ if not parts:
450
+ self._add_error("missing survey name", rest, line_num)
451
+ return BeginSurveyCommand(survey_name="")
452
+
453
+ survey_name = parts[0]
454
+ command = BeginSurveyCommand(survey_name=survey_name)
455
+
456
+ # Parse subcommands
457
+ idx = 1
458
+ while idx < len(parts):
459
+ subcmd = parts[idx]
460
+ idx += 1
461
+
462
+ if subcmd == "D":
463
+ # Date
464
+ parsed_date, idx = self._parse_date(parts, idx, line_num)
465
+ command.date = parsed_date
466
+
467
+ elif subcmd == "C" or subcmd.startswith("C"):
468
+ # Comment (rest of line)
469
+ if subcmd == "C":
470
+ command.comment = " ".join(parts[idx:]).strip()
471
+ else:
472
+ command.comment = (subcmd[1:] + " " + " ".join(parts[idx:])).strip()
473
+ break
474
+
475
+ return command
476
+
477
+ def _parse_begin_section(
478
+ self,
479
+ rest: str,
480
+ line_num: int,
481
+ ) -> BeginSectionCommand:
482
+ """Parse S (begin section) command."""
483
+ # Section name is the rest of the line
484
+ return BeginSectionCommand(section_name=rest.strip())
485
+
486
+ def _parse_begin_feature(
487
+ self,
488
+ rest: str,
489
+ line_num: int,
490
+ ) -> BeginFeatureCommand:
491
+ """Parse F (begin feature) command."""
492
+ parts = rest.split()
493
+
494
+ if not parts:
495
+ self._add_error("missing feature name", rest, line_num)
496
+ return BeginFeatureCommand(feature_name="")
497
+
498
+ feature_name = parts[0]
499
+ command = BeginFeatureCommand(feature_name=feature_name)
500
+
501
+ # Look for R min max
502
+ idx = 1
503
+ while idx < len(parts):
504
+ if parts[idx] == "R" and idx + 2 < len(parts):
505
+ idx += 1
506
+ try:
507
+ command.min_value = Decimal(parts[idx])
508
+ idx += 1
509
+ command.max_value = Decimal(parts[idx])
510
+ idx += 1
511
+ except Exception: # noqa: BLE001
512
+ self._add_error("invalid feature range", rest, line_num)
513
+ else:
514
+ idx += 1
515
+
516
+ return command
517
+
518
+ def _parse_bounds(
519
+ self,
520
+ parts: list[str],
521
+ start_idx: int,
522
+ line_num: int,
523
+ ) -> tuple[Bounds, int]:
524
+ """Parse bounds (minN maxN minE maxE minV maxV).
525
+
526
+ Returns (Bounds, new_index) tuple.
527
+ """
528
+ bounds = Bounds()
529
+ idx = start_idx
530
+
531
+ if idx + 5 < len(parts):
532
+ min_n = self._parse_number(parts[idx], line_num, "min northing")
533
+ idx += 1
534
+ max_n = self._parse_number(parts[idx], line_num, "max northing")
535
+ idx += 1
536
+ min_e = self._parse_number(parts[idx], line_num, "min easting")
537
+ idx += 1
538
+ max_e = self._parse_number(parts[idx], line_num, "max easting")
539
+ idx += 1
540
+ min_v = self._parse_number(parts[idx], line_num, "min vertical")
541
+ idx += 1
542
+ max_v = self._parse_number(parts[idx], line_num, "max vertical")
543
+ idx += 1
544
+
545
+ bounds.lower = Location(northing=min_n, easting=min_e, vertical=min_v)
546
+ bounds.upper = Location(northing=max_n, easting=max_e, vertical=max_v)
547
+
548
+ return bounds, idx
549
+
550
+ def _parse_survey_bounds(
551
+ self,
552
+ rest: str,
553
+ line_num: int,
554
+ ) -> SurveyBoundsCommand:
555
+ """Parse X (survey bounds) command."""
556
+ parts = rest.split()
557
+ bounds, _ = self._parse_bounds(parts, 0, line_num)
558
+ return SurveyBoundsCommand(bounds=bounds)
559
+
560
+ def _parse_cave_bounds(
561
+ self,
562
+ rest: str,
563
+ line_num: int,
564
+ ) -> CaveBoundsCommand:
565
+ """Parse Z (cave bounds) command."""
566
+ parts = rest.split()
567
+ bounds, idx = self._parse_bounds(parts, 0, line_num)
568
+
569
+ command = CaveBoundsCommand(bounds=bounds)
570
+
571
+ # Look for I (distance to farthest station)
572
+ while idx < len(parts):
573
+ if parts[idx] == "I" and idx + 1 < len(parts):
574
+ idx += 1
575
+ dist = self._parse_number(
576
+ parts[idx],
577
+ line_num,
578
+ "distance to farthest station",
579
+ )
580
+ if dist is not None:
581
+ if dist < 0:
582
+ self._add_warning(
583
+ "distance to farthest station is negative",
584
+ parts[idx],
585
+ line_num,
586
+ )
587
+ command.distance_to_farthest_station = dist
588
+ idx += 1
589
+ else:
590
+ idx += 1
591
+
592
+ return command
593
+
594
+ def _parse_datum(
595
+ self,
596
+ rest: str,
597
+ line_num: int,
598
+ ) -> DatumCommand:
599
+ """Parse O (datum) command."""
600
+ datum = rest.split(maxsplit=1)[0] if rest.split() else rest.strip()
601
+ return DatumCommand(datum=datum)
602
+
603
+ def _parse_utm_zone(
604
+ self,
605
+ rest: str,
606
+ line_num: int,
607
+ ) -> UtmZoneCommand:
608
+ """Parse G (UTM zone) command."""
609
+ utm_zone = rest.split(maxsplit=1)[0] if rest.split() else rest.strip()
610
+ return UtmZoneCommand(utm_zone=utm_zone)
@@ -0,0 +1,36 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Project module for parsing and formatting Compass .MAK files."""
3
+
4
+ from compass_lib.project.format import format_directive
5
+ from compass_lib.project.format import format_mak_file
6
+ from compass_lib.project.format import format_project
7
+ from compass_lib.project.models import CommentDirective
8
+ from compass_lib.project.models import CompassMakFile
9
+ from compass_lib.project.models import CompassProjectDirective
10
+ from compass_lib.project.models import DatumDirective
11
+ from compass_lib.project.models import FileDirective
12
+ from compass_lib.project.models import FlagsDirective
13
+ from compass_lib.project.models import LinkStation
14
+ from compass_lib.project.models import LocationDirective
15
+ from compass_lib.project.models import UnknownDirective
16
+ from compass_lib.project.models import UTMConvergenceDirective
17
+ from compass_lib.project.models import UTMZoneDirective
18
+ from compass_lib.project.parser import CompassProjectParser
19
+
20
+ __all__ = [
21
+ "CommentDirective",
22
+ "CompassMakFile",
23
+ "CompassProjectDirective",
24
+ "CompassProjectParser",
25
+ "DatumDirective",
26
+ "FileDirective",
27
+ "FlagsDirective",
28
+ "LinkStation",
29
+ "LocationDirective",
30
+ "UTMConvergenceDirective",
31
+ "UTMZoneDirective",
32
+ "UnknownDirective",
33
+ "format_directive",
34
+ "format_mak_file",
35
+ "format_project",
36
+ ]