compass-lib 0.0.8__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,639 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Parser for Compass .MAK project files.
3
+
4
+ This module implements the parser for reading Compass project files,
5
+ which define project structure, geographic settings, and data file references.
6
+
7
+ Architecture: The parser produces dictionaries (like loading JSON) which are
8
+ then fed to Pydantic models via a single `model_validate()` call. This keeps
9
+ parsing logic separate from model construction.
10
+ """
11
+
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from compass_lib.constants import ASCII_ENCODING
17
+ from compass_lib.enums import FormatIdentifier
18
+ from compass_lib.errors import CompassParseException
19
+ from compass_lib.errors import SourceLocation
20
+ from compass_lib.project.models import CompassMakFile
21
+ from compass_lib.project.models import CompassProjectDirective
22
+
23
+
24
+ class CompassProjectParser:
25
+ """Parser for Compass .MAK project files.
26
+
27
+ This parser reads Compass project files and produces dictionaries
28
+ (like loading JSON from disk). The dictionaries can then be fed to
29
+ Pydantic models via a single `model_validate()` call.
30
+
31
+ The parser is strict - it raises CompassParseException on errors.
32
+ """
33
+
34
+ # Regex patterns
35
+ EOL_PATTERN = re.compile(r"\r\n?|\n")
36
+ FILE_NAME_PATTERN = re.compile(r"[^,;/]+")
37
+ DATUM_PATTERN = re.compile(r"[^;/]+")
38
+ LINK_STATION_PATTERN = re.compile(r"[^,;/\[]+")
39
+ NUMBER_PATTERN = re.compile(r"[-+]?\d+(\.\d*)?|\.\d+")
40
+
41
+ def __init__(self) -> None:
42
+ """Initialize the parser."""
43
+ self._data: str = ""
44
+ self._pos: int = 0
45
+ self._source: str = "<string>"
46
+ self._line: int = 0
47
+
48
+ # -------------------------------------------------------------------------
49
+ # Dictionary-returning methods (primary API)
50
+ # -------------------------------------------------------------------------
51
+
52
+ def parse_file_to_dict(self, path: Path) -> dict[str, Any]:
53
+ """Parse a project file to dictionary.
54
+
55
+ This is the primary parsing method. It returns a dictionary that
56
+ can be directly fed to `CompassMakFile.model_validate()`.
57
+
58
+ Args:
59
+ path: Path to the .MAK file
60
+
61
+ Returns:
62
+ Dictionary with directives list
63
+
64
+ Raises:
65
+ CompassParseException: On parse errors
66
+ """
67
+ with path.open(mode="r", encoding=ASCII_ENCODING, errors="replace") as f:
68
+ data = f.read()
69
+ return self.parse_string_to_dict(data, str(path))
70
+
71
+ def parse_string_to_dict(
72
+ self,
73
+ data: str,
74
+ source: str = "<string>",
75
+ ) -> dict[str, Any]:
76
+ """Parse project data from a string to dictionary.
77
+
78
+ Args:
79
+ data: Project file content
80
+ source: Source identifier for error messages
81
+
82
+ Returns:
83
+ Dictionary with directives list
84
+
85
+ Raises:
86
+ CompassParseException: On parse errors
87
+ """
88
+ self._data = data
89
+ self._pos = 0
90
+ self._source = source
91
+ self._line = 0
92
+
93
+ directives: list[dict[str, Any]] = []
94
+
95
+ while self._pos < len(self._data):
96
+ self._skip_whitespace()
97
+ if self._pos >= len(self._data):
98
+ break
99
+
100
+ char = self._data[self._pos]
101
+ self._pos += 1
102
+
103
+ if char == "#":
104
+ directives.append(self._parse_survey_file_to_dict())
105
+ elif char == "@":
106
+ directives.append(self._parse_location_to_dict())
107
+ elif char == "&":
108
+ directives.append(self._parse_datum_to_dict())
109
+ elif char == "%":
110
+ directives.append(self._parse_utm_convergence_to_dict(enabled=True))
111
+ elif char == "*":
112
+ directives.append(self._parse_utm_convergence_to_dict(enabled=False))
113
+ elif char == "$":
114
+ directives.append(self._parse_utm_zone_to_dict())
115
+ elif char == "!":
116
+ directives.append(self._parse_flags_to_dict())
117
+ elif char == "[":
118
+ directives.append(self._parse_folder_start_to_dict())
119
+ elif char == "]":
120
+ directives.append(self._parse_folder_end_to_dict())
121
+ elif char == "/":
122
+ directives.append(self._parse_comment_to_dict())
123
+ elif ord(char) >= 0x20:
124
+ # Unknown directive - parse to semicolon for roundtrip fidelity
125
+ directives.append(self._parse_unknown_directive_to_dict(char))
126
+
127
+ return {
128
+ "version": "1.0",
129
+ "format": FormatIdentifier.COMPASS_MAK.value,
130
+ "directives": directives,
131
+ }
132
+
133
+ # -------------------------------------------------------------------------
134
+ # Legacy model-returning methods (thin wrappers for backwards compat)
135
+ # -------------------------------------------------------------------------
136
+
137
+ def parse_file(self, path: Path) -> list[CompassProjectDirective]:
138
+ """Parse a project file.
139
+
140
+ DEPRECATED: Use parse_file_to_dict() for new code.
141
+
142
+ Args:
143
+ path: Path to the .MAK file
144
+
145
+ Returns:
146
+ List of parsed directives
147
+
148
+ Raises:
149
+ CompassParseException: On parse errors
150
+ """
151
+
152
+ data = self.parse_file_to_dict(path)
153
+ mak_file = CompassMakFile.model_validate(data)
154
+ return mak_file.directives
155
+
156
+ def parse_string(
157
+ self,
158
+ data: str,
159
+ source: str = "<string>",
160
+ ) -> list[CompassProjectDirective]:
161
+ """Parse project data from a string.
162
+
163
+ DEPRECATED: Use parse_string_to_dict() for new code.
164
+
165
+ Args:
166
+ data: Project file content
167
+ source: Source identifier for error messages
168
+
169
+ Returns:
170
+ List of parsed directives
171
+
172
+ Raises:
173
+ CompassParseException: On parse errors
174
+ """
175
+
176
+ parsed = self.parse_string_to_dict(data, source)
177
+ mak_file = CompassMakFile.model_validate(parsed)
178
+ return mak_file.directives
179
+
180
+ def _location(self, text: str = "") -> SourceLocation:
181
+ """Create a SourceLocation at current position."""
182
+ return SourceLocation(
183
+ source=self._source,
184
+ line=self._line,
185
+ column=self._pos - 1,
186
+ text=text,
187
+ )
188
+
189
+ def _skip_whitespace(self) -> None:
190
+ """Skip whitespace characters, tracking line numbers."""
191
+ while self._pos < len(self._data):
192
+ char = self._data[self._pos]
193
+ if char == "\n":
194
+ self._line += 1
195
+ self._pos += 1
196
+ elif char == "\r":
197
+ self._line += 1
198
+ self._pos += 1
199
+ if self._pos < len(self._data) and self._data[self._pos] == "\n":
200
+ self._pos += 1
201
+ elif char.isspace():
202
+ self._pos += 1
203
+ else:
204
+ break
205
+
206
+ def _skip_whitespace_and_comments(self) -> None:
207
+ """Skip whitespace and inline comments."""
208
+ self._skip_whitespace()
209
+ while self._pos < len(self._data) and self._data[self._pos] == "/":
210
+ self._pos += 1
211
+ self._skip_to_end_of_line()
212
+ self._skip_whitespace()
213
+
214
+ def _skip_to_end_of_line(self) -> None:
215
+ """Skip to end of current line."""
216
+ while self._pos < len(self._data):
217
+ char = self._data[self._pos]
218
+ if char == "\n":
219
+ self._line += 1
220
+ self._pos += 1
221
+ break
222
+ if char == "\r":
223
+ self._line += 1
224
+ self._pos += 1
225
+ if self._pos < len(self._data) and self._data[self._pos] == "\n":
226
+ self._pos += 1
227
+ break
228
+ self._pos += 1
229
+
230
+ def _expect(self, char: str) -> None:
231
+ """Expect a specific character.
232
+
233
+ Raises:
234
+ CompassParseError: If character doesn't match
235
+ """
236
+ if self._pos >= len(self._data):
237
+ raise CompassParseException(
238
+ f"expected {char}, got end of file",
239
+ self._location(),
240
+ )
241
+ if self._data[self._pos] != char:
242
+ raise CompassParseException(
243
+ f"expected {char}, got {self._data[self._pos]}",
244
+ self._location(self._data[self._pos]),
245
+ )
246
+ self._pos += 1
247
+
248
+ def _expect_match(
249
+ self,
250
+ pattern: re.Pattern[str],
251
+ error_msg: str,
252
+ ) -> str:
253
+ """Match a pattern at current position.
254
+
255
+ Args:
256
+ pattern: Regex pattern to match
257
+ error_msg: Error message if no match
258
+
259
+ Returns:
260
+ Matched text
261
+
262
+ Raises:
263
+ CompassParseError: If no match
264
+ """
265
+ match = pattern.match(self._data, self._pos)
266
+ if not match or match.start() != self._pos:
267
+ raise CompassParseException(error_msg, self._location())
268
+ self._pos = match.end()
269
+ return match.group()
270
+
271
+ def _expect_number(self, error_msg: str) -> float:
272
+ """Parse a number at current position.
273
+
274
+ Args:
275
+ error_msg: Error message if no number
276
+
277
+ Returns:
278
+ Parsed number
279
+
280
+ Raises:
281
+ CompassParseError: If no valid number
282
+ """
283
+ text = self._expect_match(self.NUMBER_PATTERN, error_msg)
284
+ return float(text)
285
+
286
+ def _expect_utm_zone(self, *, allow_zero: bool = False) -> int:
287
+ """Parse UTM zone (integer 1-60 or -1 to -60, or 0 if allow_zero).
288
+
289
+ Positive zones indicate northern hemisphere.
290
+ Negative zones indicate southern hemisphere.
291
+
292
+ Args:
293
+ allow_zero: If True, allows zone 0 (meaning "not specified")
294
+
295
+ Returns:
296
+ UTM zone number
297
+
298
+ Raises:
299
+ CompassParseError: If invalid zone
300
+ """
301
+ text = self._expect_match(self.NUMBER_PATTERN, "missing UTM zone")
302
+
303
+ if "." in text:
304
+ raise CompassParseException(
305
+ "invalid UTM zone (not an integer)", self._location(text)
306
+ )
307
+
308
+ zone = int(text)
309
+
310
+ # Check if zone is valid
311
+ if zone == 0 and not allow_zero:
312
+ raise CompassParseException(
313
+ "UTM zone cannot be 0. Use 1-60 for north, -1 to -60 for south.",
314
+ self._location(text),
315
+ )
316
+
317
+ if abs(zone) > 60:
318
+ raise CompassParseException(
319
+ f"UTM zone must be between -60 and 60, got {zone}",
320
+ self._location(text),
321
+ )
322
+
323
+ return zone
324
+
325
+ def _parse_length_unit(self) -> str:
326
+ """Parse length unit (f or m).
327
+
328
+ Returns:
329
+ 'f' for feet, 'm' for meters
330
+
331
+ Raises:
332
+ CompassParseError: If invalid unit
333
+ """
334
+ if self._pos >= len(self._data):
335
+ raise CompassParseException("missing length unit", self._location())
336
+
337
+ char = self._data[self._pos].lower()
338
+ if char not in ("f", "m"):
339
+ raise CompassParseException(
340
+ f"invalid length unit: {char}",
341
+ self._location(char),
342
+ )
343
+ self._pos += 1
344
+ return char
345
+
346
+ def _parse_survey_file_to_dict(self) -> dict[str, Any]:
347
+ """Parse #filename,station[location],...; to dictionary."""
348
+ file_name = self._expect_match(
349
+ self.FILE_NAME_PATTERN,
350
+ "missing file name",
351
+ ).strip()
352
+
353
+ link_stations: list[dict[str, Any]] = []
354
+
355
+ while True:
356
+ self._skip_whitespace_and_comments()
357
+
358
+ if self._pos >= len(self._data):
359
+ raise CompassParseException(
360
+ "missing ; at end of file line",
361
+ self._location(),
362
+ )
363
+
364
+ char = self._data[self._pos]
365
+ self._pos += 1
366
+
367
+ if char == ";":
368
+ return {
369
+ "type": "file",
370
+ "file": file_name,
371
+ "link_stations": link_stations,
372
+ }
373
+
374
+ if char == ",":
375
+ self._skip_whitespace_and_comments()
376
+ station_name = self._expect_match(
377
+ self.LINK_STATION_PATTERN,
378
+ "missing station name",
379
+ ).strip()
380
+
381
+ location = None
382
+ self._skip_whitespace_and_comments()
383
+
384
+ if self._pos < len(self._data) and self._data[self._pos] == "[":
385
+ self._pos += 1
386
+ self._skip_whitespace_and_comments()
387
+
388
+ unit = self._parse_length_unit()
389
+ self._skip_whitespace_and_comments()
390
+ self._expect(",")
391
+ self._skip_whitespace_and_comments()
392
+
393
+ easting = self._expect_number("missing easting")
394
+ self._skip_whitespace_and_comments()
395
+ self._expect(",")
396
+ self._skip_whitespace_and_comments()
397
+
398
+ northing = self._expect_number("missing northing")
399
+ self._skip_whitespace_and_comments()
400
+ self._expect(",")
401
+ self._skip_whitespace_and_comments()
402
+
403
+ elevation = self._expect_number("missing elevation")
404
+ self._skip_whitespace_and_comments()
405
+ self._expect("]")
406
+
407
+ location = {
408
+ "easting": easting,
409
+ "northing": northing,
410
+ "elevation": elevation,
411
+ "unit": unit,
412
+ }
413
+
414
+ link_stations.append({"name": station_name, "location": location})
415
+ else:
416
+ raise CompassParseException(
417
+ f"unexpected character: {char}",
418
+ self._location(char),
419
+ )
420
+
421
+ def _parse_location_to_dict(self) -> dict[str, Any]:
422
+ """Parse @easting,northing,elevation,zone,convergence; to dictionary.
423
+
424
+ Note: UTM zone of 0 is allowed here, meaning "no location specified"
425
+ (commonly used when only declination information is needed).
426
+ """
427
+ self._skip_whitespace()
428
+ easting = self._expect_number("missing easting")
429
+ self._skip_whitespace()
430
+ self._expect(",")
431
+ self._skip_whitespace()
432
+ northing = self._expect_number("missing northing")
433
+ self._skip_whitespace()
434
+ self._expect(",")
435
+ self._skip_whitespace()
436
+ elevation = self._expect_number("missing elevation")
437
+ self._skip_whitespace()
438
+ self._expect(",")
439
+ self._skip_whitespace()
440
+ utm_zone = self._expect_utm_zone(allow_zero=True)
441
+ self._skip_whitespace()
442
+ self._expect(",")
443
+ self._skip_whitespace()
444
+ convergence = self._expect_number("missing UTM convergence")
445
+ self._expect(";")
446
+
447
+ return {
448
+ "type": "location",
449
+ "easting": easting,
450
+ "northing": northing,
451
+ "elevation": elevation,
452
+ "utm_zone": utm_zone,
453
+ "utm_convergence": convergence,
454
+ }
455
+
456
+ def _parse_datum_to_dict(self) -> dict[str, Any]:
457
+ """Parse &datum_name; to dictionary."""
458
+ datum = self._expect_match(self.DATUM_PATTERN, "missing datum").strip()
459
+ self._expect(";")
460
+ return {"type": "datum", "datum": datum}
461
+
462
+ def _parse_utm_convergence_to_dict(self, *, enabled: bool) -> dict[str, Any]:
463
+ """Parse %convergence; or *convergence; to dictionary.
464
+
465
+ Args:
466
+ enabled: True if parsed from %, False if parsed from *
467
+ """
468
+ self._skip_whitespace()
469
+ convergence = self._expect_number("missing UTM convergence")
470
+ self._expect(";")
471
+ return {
472
+ "type": "utm_convergence",
473
+ "utm_convergence": convergence,
474
+ "enabled": enabled,
475
+ }
476
+
477
+ def _parse_utm_zone_to_dict(self) -> dict[str, Any]:
478
+ """Parse $zone; to dictionary."""
479
+ self._skip_whitespace()
480
+ zone = self._expect_utm_zone()
481
+ self._expect(";")
482
+ return {"type": "utm_zone", "utm_zone": zone}
483
+
484
+ def _parse_flags_to_dict(self) -> dict[str, Any]:
485
+ """Parse !flags; to dictionary.
486
+
487
+ Parses all 10 documented Compass project flags:
488
+ 1. G/g - Global override settings enabled/disabled
489
+ 2. I/E/A - Declination mode (Ignore/Entered/Auto)
490
+ 3. V/v - Apply UTM convergence enabled/disabled
491
+ 4. O/o - Override LRUD associations enabled/disabled
492
+ 5. T/t - LRUDs at To/From station
493
+ 6. S/s - Apply shot flags enabled/disabled
494
+ 7. X/x - Apply total exclusion flags enabled/disabled
495
+ 8. P/p - Apply plotting exclusion flags enabled/disabled
496
+ 9. L/l - Apply length exclusion flags enabled/disabled
497
+ 10. C/c - Apply close exclusion flags enabled/disabled
498
+ """
499
+ start_pos = self._pos
500
+
501
+ # Initialize all flags
502
+ result: dict[str, Any] = {
503
+ "type": "flags",
504
+ "global_override": False,
505
+ "declination_mode": None,
506
+ "apply_utm_convergence": False,
507
+ "override_lruds": False,
508
+ "lruds_at_to_station": False,
509
+ "apply_shot_flags": False,
510
+ "apply_total_exclusion": False,
511
+ "apply_plotting_exclusion": False,
512
+ "apply_length_exclusion": False,
513
+ "apply_close_exclusion": False,
514
+ }
515
+
516
+ while self._pos < len(self._data):
517
+ char = self._data[self._pos]
518
+ self._pos += 1
519
+
520
+ if char == ";":
521
+ result["raw_flags"] = self._data[start_pos : self._pos - 1]
522
+ return result
523
+
524
+ # Flag 1: G/g - Global override
525
+ if char == "G":
526
+ result["global_override"] = True
527
+ elif char == "g":
528
+ result["global_override"] = False
529
+
530
+ # Flag 2: I/E/A - Declination mode
531
+ elif char == "I":
532
+ result["declination_mode"] = "I"
533
+ elif char == "E":
534
+ result["declination_mode"] = "E"
535
+ elif char == "A":
536
+ result["declination_mode"] = "A"
537
+
538
+ # Flag 3: V/v - Apply UTM convergence
539
+ elif char == "V":
540
+ result["apply_utm_convergence"] = True
541
+ elif char == "v":
542
+ result["apply_utm_convergence"] = False
543
+
544
+ # Flag 4: O/o - Override LRUD associations
545
+ elif char == "O":
546
+ result["override_lruds"] = True
547
+ elif char == "o":
548
+ result["override_lruds"] = False
549
+
550
+ # Flag 5: T/t - LRUDs at To station
551
+ elif char == "T":
552
+ result["lruds_at_to_station"] = True
553
+ elif char == "t":
554
+ result["lruds_at_to_station"] = False
555
+
556
+ # Flag 6: S/s - Apply shot flags
557
+ elif char == "S":
558
+ result["apply_shot_flags"] = True
559
+ elif char == "s":
560
+ result["apply_shot_flags"] = False
561
+
562
+ # Flag 7: X/x - Apply total exclusion flags
563
+ elif char == "X":
564
+ result["apply_total_exclusion"] = True
565
+ elif char == "x":
566
+ result["apply_total_exclusion"] = False
567
+
568
+ # Flag 8: P/p - Apply plotting exclusion flags
569
+ elif char == "P":
570
+ result["apply_plotting_exclusion"] = True
571
+ elif char == "p":
572
+ result["apply_plotting_exclusion"] = False
573
+
574
+ # Flag 9: L/l - Apply length exclusion flags
575
+ elif char == "L":
576
+ result["apply_length_exclusion"] = True
577
+ elif char == "l":
578
+ result["apply_length_exclusion"] = False
579
+
580
+ # Flag 10: C/c - Apply close exclusion flags
581
+ elif char == "C":
582
+ result["apply_close_exclusion"] = True
583
+ elif char == "c":
584
+ result["apply_close_exclusion"] = False
585
+
586
+ # Silently skip unknown characters for lenient parsing
587
+
588
+ raise CompassParseException(
589
+ "missing or incomplete flags",
590
+ self._location(),
591
+ )
592
+
593
+ def _parse_folder_start_to_dict(self) -> dict[str, Any]:
594
+ """Parse [FolderName; to dictionary."""
595
+ self._skip_whitespace()
596
+ # Read until semicolon
597
+ start = self._pos
598
+ while self._pos < len(self._data) and self._data[self._pos] != ";":
599
+ self._pos += 1
600
+ name = self._data[start : self._pos].strip()
601
+ self._expect(";")
602
+ return {"type": "folder_start", "name": name}
603
+
604
+ def _parse_folder_end_to_dict(self) -> dict[str, Any]:
605
+ """Parse ]; to dictionary."""
606
+ self._skip_whitespace()
607
+ self._expect(";")
608
+ return {"type": "folder_end"}
609
+
610
+ def _parse_comment_to_dict(self) -> dict[str, Any]:
611
+ """Parse / comment (to end of line) to dictionary."""
612
+ start = self._pos
613
+ self._skip_to_end_of_line()
614
+ comment = self._data[start : self._pos].strip()
615
+ return {"type": "comment", "comment": comment}
616
+
617
+ def _parse_unknown_directive_to_dict(self, directive_type: str) -> dict[str, Any]:
618
+ """Parse unknown directive to semicolon for roundtrip fidelity."""
619
+ start = self._pos
620
+
621
+ while self._pos < len(self._data):
622
+ char = self._data[self._pos]
623
+ if char == ";":
624
+ content = self._data[start : self._pos]
625
+ self._pos += 1 # Skip the semicolon
626
+ return {
627
+ "type": "unknown",
628
+ "directive_type": directive_type,
629
+ "content": content,
630
+ }
631
+ self._pos += 1
632
+
633
+ # No semicolon found - use rest of data
634
+ content = self._data[start : self._pos]
635
+ return {
636
+ "type": "unknown",
637
+ "directive_type": directive_type,
638
+ "content": content,
639
+ }
@@ -0,0 +1,43 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Survey adjustment solvers for cave survey networks.
3
+
4
+ Usage::
5
+
6
+ from compass_lib.solver import ProportionalSolver
7
+ from compass_lib.geojson import convert_mak_to_geojson
8
+
9
+ geojson = convert_mak_to_geojson(path, solver=ProportionalSolver())
10
+
11
+ Available solvers:
12
+
13
+ - :class:`NoopSolver` -- no adjustment (raw BFS, the default)
14
+ - :class:`ProportionalSolver` -- proportional traverse error
15
+ distribution by graph distance (fast, suitable for most networks)
16
+
17
+ To create a custom solver, subclass :class:`SurveyAdjuster` and
18
+ implement the :meth:`~SurveyAdjuster.adjust` method.
19
+ """
20
+
21
+ from compass_lib.solver.ariane import ArianeSolver
22
+ from compass_lib.solver.base import SurveyAdjuster
23
+ from compass_lib.solver.lse import LSESolver
24
+ from compass_lib.solver.models import NetworkShot
25
+ from compass_lib.solver.models import SurveyNetwork
26
+ from compass_lib.solver.models import Traverse
27
+ from compass_lib.solver.models import Vector3D
28
+ from compass_lib.solver.noop import NoopSolver
29
+ from compass_lib.solver.proportional import ProportionalSolver
30
+ from compass_lib.solver.sparse import SparseSolver
31
+
32
+ __all__ = [
33
+ "ArianeSolver",
34
+ "LSESolver",
35
+ "NetworkShot",
36
+ "NoopSolver",
37
+ "ProportionalSolver",
38
+ "SparseSolver",
39
+ "SurveyAdjuster",
40
+ "SurveyNetwork",
41
+ "Traverse",
42
+ "Vector3D",
43
+ ]