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,158 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Formatting (serialization) for Compass .MAK project files.
3
+
4
+ This module provides functions to convert project directive models back to
5
+ the Compass .MAK file format string representation.
6
+ """
7
+
8
+ from collections.abc import Callable
9
+
10
+ from compass_lib.project.models import CommentDirective
11
+ from compass_lib.project.models import CompassMakFile
12
+ from compass_lib.project.models import CompassProjectDirective
13
+ from compass_lib.project.models import DatumDirective
14
+ from compass_lib.project.models import FileDirective
15
+ from compass_lib.project.models import FlagsDirective
16
+ from compass_lib.project.models import LocationDirective
17
+ from compass_lib.project.models import UnknownDirective
18
+ from compass_lib.project.models import UTMConvergenceDirective
19
+ from compass_lib.project.models import UTMZoneDirective
20
+
21
+
22
+ def format_directive(directive: CompassProjectDirective) -> str: # noqa: PLR0911
23
+ """Format a single directive as text.
24
+
25
+ Args:
26
+ directive: Directive to format
27
+
28
+ Returns:
29
+ Formatted directive string with CRLF
30
+ """
31
+ match directive:
32
+ case CommentDirective():
33
+ return f"/{directive.comment}\r\n"
34
+
35
+ case DatumDirective():
36
+ return f"&{directive.datum.value};\r\n"
37
+
38
+ case UTMZoneDirective():
39
+ return f"${directive.utm_zone};\r\n"
40
+
41
+ case UTMConvergenceDirective():
42
+ prefix = "%" if directive.enabled else "*"
43
+ return f"{prefix}{directive.utm_convergence:.3f};\r\n"
44
+
45
+ case FlagsDirective():
46
+ # Use raw_flags if available for roundtrip fidelity
47
+ if directive.raw_flags:
48
+ return f"!{directive.raw_flags};\r\n"
49
+ o = "O" if directive.is_override_lruds else "o"
50
+ t = "T" if directive.is_lruds_at_to_station else "t"
51
+ return f"!{o}{t};\r\n"
52
+
53
+ case LocationDirective():
54
+ parts = [
55
+ f"{directive.easting:.3f}",
56
+ f"{directive.northing:.3f}",
57
+ f"{directive.elevation:.3f}",
58
+ str(directive.utm_zone),
59
+ f"{directive.utm_convergence:.3f}",
60
+ ]
61
+ return f"@{','.join(parts)};\r\n"
62
+
63
+ case FileDirective():
64
+ if not directive.link_stations:
65
+ return f"#{directive.file};\r\n"
66
+
67
+ if len(directive.link_stations) == 1:
68
+ station = directive.link_stations[0]
69
+ station_str = _format_link_station(station)
70
+ return f"#{directive.file},{station_str};\r\n"
71
+
72
+ # Multiple link stations: multiline format
73
+ lines = [f"#{directive.file},\r\n"]
74
+ for i, station in enumerate(directive.link_stations):
75
+ station_str = _format_link_station(station)
76
+ if i < len(directive.link_stations) - 1:
77
+ lines.append(f" {station_str},\r\n")
78
+ else:
79
+ lines.append(f" {station_str};\r\n")
80
+ return "".join(lines)
81
+
82
+ case UnknownDirective():
83
+ return f"{directive.directive_type}{directive.content};\r\n"
84
+
85
+ case _:
86
+ return str(directive) + "\r\n"
87
+
88
+
89
+ def _format_link_station(station) -> str:
90
+ """Format a link station with optional location.
91
+
92
+ Args:
93
+ station: LinkStation object
94
+
95
+ Returns:
96
+ Formatted string
97
+ """
98
+ if station.location is None:
99
+ return station.name
100
+
101
+ loc = station.location
102
+ unit = loc.unit.lower()
103
+ parts = [
104
+ unit.upper(),
105
+ f"{loc.easting:.3f}",
106
+ f"{loc.northing:.3f}",
107
+ f"{loc.elevation:.3f}",
108
+ ]
109
+ return f"{station.name}[{','.join(parts)}]"
110
+
111
+
112
+ def format_mak_file(
113
+ directives: list[CompassProjectDirective],
114
+ *,
115
+ write: Callable[[str], None] | None = None,
116
+ ) -> str | None:
117
+ """Format a complete MAK file from directives.
118
+
119
+ Args:
120
+ directives: List of directives
121
+ write: Optional callback for streaming output. If provided,
122
+ chunks are written via this callback and None is returned.
123
+
124
+ Returns:
125
+ Formatted file content as string (if write is None),
126
+ or None (if write callback is provided)
127
+ """
128
+ if write is not None:
129
+ # Streaming mode
130
+ for directive in directives:
131
+ write(format_directive(directive))
132
+ return None
133
+
134
+ # Return mode
135
+ chunks: list[str] = []
136
+ format_mak_file(directives, write=chunks.append)
137
+ return "".join(chunks)
138
+
139
+
140
+ def format_project(
141
+ project: CompassMakFile,
142
+ *,
143
+ write: Callable[[str], None] | None = None,
144
+ ) -> str | None:
145
+ """Format a complete MAK file from a CompassMakFile.
146
+
147
+ This is a convenience wrapper around format_mak_file that accepts
148
+ a CompassMakFile object directly.
149
+
150
+ Args:
151
+ project: Project to format
152
+ write: Optional callback for streaming output
153
+
154
+ Returns:
155
+ Formatted file content as string (if write is None),
156
+ or None (if write callback is provided)
157
+ """
158
+ return format_mak_file(project.directives, write=write)
@@ -0,0 +1,494 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Project data models for Compass .MAK files.
3
+
4
+ Uses Pydantic discriminated unions for polymorphic directive handling.
5
+ All serialization is handled by Pydantic's built-in methods.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import Enum
11
+ from typing import TYPE_CHECKING
12
+ from typing import Annotated
13
+ from typing import Any
14
+ from typing import ClassVar
15
+ from typing import Literal
16
+
17
+ from pydantic import BaseModel
18
+ from pydantic import ConfigDict
19
+ from pydantic import Discriminator
20
+ from pydantic import Field
21
+ from pydantic import Tag
22
+ from pydantic import field_validator
23
+ from pydantic import model_validator
24
+
25
+ from compass_lib.enums import Datum
26
+ from compass_lib.enums import FormatIdentifier
27
+ from compass_lib.models import NEVLocation # noqa: TC001
28
+ from compass_lib.survey.models import CompassDatFile # noqa: TC001
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import Iterator
32
+
33
+
34
+ # --- Directive Classes ---
35
+ # Each directive has a `type` field that acts as a discriminator
36
+
37
+
38
+ class UnknownDirective(BaseModel):
39
+ """Unknown directive for roundtrip fidelity."""
40
+
41
+ type: Literal["unknown"] = "unknown"
42
+ directive_type: str
43
+ content: str
44
+
45
+ def __str__(self) -> str:
46
+ return f"{self.directive_type}{self.content};"
47
+
48
+
49
+ class FolderStartDirective(BaseModel):
50
+ """Folder start directive (lines starting with [)."""
51
+
52
+ type: Literal["folder_start"] = "folder_start"
53
+ name: str
54
+
55
+ def __str__(self) -> str:
56
+ return f"[{self.name};"
57
+
58
+
59
+ class FolderEndDirective(BaseModel):
60
+ """Folder end directive (];)."""
61
+
62
+ type: Literal["folder_end"] = "folder_end"
63
+
64
+ def __str__(self) -> str:
65
+ return "];"
66
+
67
+
68
+ class CommentDirective(BaseModel):
69
+ """Comment directive (lines starting with /)."""
70
+
71
+ type: Literal["comment"] = "comment"
72
+ comment: str
73
+
74
+ def __str__(self) -> str:
75
+ return f"/ {self.comment}"
76
+
77
+
78
+ class DatumDirective(BaseModel):
79
+ """Datum directive (lines starting with &)."""
80
+
81
+ type: Literal["datum"] = "datum"
82
+ datum: Datum
83
+
84
+ @field_validator("datum", mode="before")
85
+ @classmethod
86
+ def normalize_datum(cls, value: str | Datum) -> Datum:
87
+ """Validate and normalize datum string to Datum enum.
88
+
89
+ Args:
90
+ value: Datum as string or Datum enum
91
+
92
+ Returns:
93
+ Datum enum value
94
+
95
+ Raises:
96
+ ValueError: If datum string is not recognized
97
+ """
98
+ if isinstance(value, Datum):
99
+ return value
100
+ return Datum.normalize(value)
101
+
102
+ def __str__(self) -> str:
103
+ return f"&{self.datum.value};"
104
+
105
+
106
+ class UTMZoneDirective(BaseModel):
107
+ """UTM zone directive (lines starting with $).
108
+
109
+ Positive zones (1-60) indicate northern hemisphere.
110
+ Negative zones (-1 to -60) indicate southern hemisphere.
111
+ """
112
+
113
+ type: Literal["utm_zone"] = "utm_zone"
114
+ utm_zone: int
115
+
116
+ @field_validator("utm_zone")
117
+ @classmethod
118
+ def validate_utm_zone(cls, v: int) -> int:
119
+ if v == 0:
120
+ raise ValueError(
121
+ "UTM zone cannot be 0. Use 1-60 for north, -1 to -60 for south."
122
+ )
123
+ if abs(v) > 60:
124
+ raise ValueError(
125
+ f"UTM zone must be between -60 and 60 (excluding 0), got {v}"
126
+ )
127
+ return v
128
+
129
+ def __str__(self) -> str:
130
+ return f"${self.utm_zone};"
131
+
132
+
133
+ class UTMConvergenceDirective(BaseModel):
134
+ """UTM convergence angle directive (lines starting with % or *).
135
+
136
+ The % prefix indicates file-level convergence is enabled.
137
+ The * prefix indicates file-level convergence is disabled.
138
+ """
139
+
140
+ type: Literal["utm_convergence"] = "utm_convergence"
141
+ utm_convergence: float
142
+ enabled: bool = True # True for %, False for *
143
+
144
+ def __str__(self) -> str:
145
+ prefix = "%" if self.enabled else "*"
146
+ return f"{prefix}{self.utm_convergence:.3f};"
147
+
148
+
149
+ # Flags constants (legacy bitmask - kept for backwards compatibility)
150
+ FLAGS_OVERRIDE_LRUDS: int = 0x1
151
+ FLAGS_LRUDS_AT_TO_STATION: int = 0x2
152
+
153
+
154
+ class DeclinationMode(str, Enum):
155
+ """How declinations are derived and processed."""
156
+
157
+ IGNORE = "I" # Declinations are ignored
158
+ ENTERED = "E" # Use declinations entered in survey book
159
+ AUTO = "A" # Calculate from survey date and geographic location
160
+
161
+
162
+ class FlagsDirective(BaseModel):
163
+ """Project flags directive (lines starting with !).
164
+
165
+ Supports all 10 documented Compass project flags:
166
+ 1. G/g - Global override settings enabled/disabled
167
+ 2. I/E/A - Declination mode (Ignore/Entered/Auto)
168
+ 3. V/v - Apply UTM convergence enabled/disabled
169
+ 4. O/o - Override LRUD associations enabled/disabled
170
+ 5. T/t - LRUDs at To/From station
171
+ 6. S/s - Apply shot flags enabled/disabled
172
+ 7. X/x - Apply total exclusion flags enabled/disabled
173
+ 8. P/p - Apply plotting exclusion flags enabled/disabled
174
+ 9. L/l - Apply length exclusion flags enabled/disabled
175
+ 10. C/c - Apply close exclusion flags enabled/disabled
176
+
177
+ Example: !GAVOTSCXPL;
178
+ """
179
+
180
+ # Class-level constants (must use ClassVar to avoid being treated as fields)
181
+ OVERRIDE_LRUDS: ClassVar[int] = FLAGS_OVERRIDE_LRUDS
182
+ LRUDS_AT_TO_STATION: ClassVar[int] = FLAGS_LRUDS_AT_TO_STATION
183
+
184
+ type: Literal["flags"] = "flags"
185
+
186
+ # Flag 1: G/g - Global override
187
+ global_override: bool = False
188
+
189
+ # Flag 2: I/E/A - Declination mode
190
+ declination_mode: DeclinationMode | None = None
191
+
192
+ # Flag 3: V/v - Apply UTM convergence
193
+ apply_utm_convergence: bool = False
194
+
195
+ # Flag 4: O/o - Override LRUD associations
196
+ override_lruds: bool = False
197
+
198
+ # Flag 5: T/t - LRUDs at To station (vs From station)
199
+ lruds_at_to_station: bool = False
200
+
201
+ # Flag 6: S/s - Apply shot flags
202
+ apply_shot_flags: bool = False
203
+
204
+ # Flag 7: X/x - Apply total exclusion flags
205
+ apply_total_exclusion: bool = False
206
+
207
+ # Flag 8: P/p - Apply plotting exclusion flags
208
+ apply_plotting_exclusion: bool = False
209
+
210
+ # Flag 9: L/l - Apply length exclusion flags
211
+ apply_length_exclusion: bool = False
212
+
213
+ # Flag 10: C/c - Apply close exclusion flags
214
+ apply_close_exclusion: bool = False
215
+
216
+ # Raw string for roundtrip fidelity
217
+ raw_flags: str = ""
218
+
219
+ model_config = ConfigDict(populate_by_name=True)
220
+
221
+ @model_validator(mode="before")
222
+ @classmethod
223
+ def convert_flags_bitmask(cls, data: Any) -> Any:
224
+ """Convert legacy flags bitmask to boolean fields."""
225
+ if isinstance(data, dict) and "flags" in data:
226
+ flags = data.pop("flags")
227
+ if flags:
228
+ data.setdefault("override_lruds", bool(flags & FLAGS_OVERRIDE_LRUDS))
229
+ data.setdefault(
230
+ "lruds_at_to_station", bool(flags & FLAGS_LRUDS_AT_TO_STATION)
231
+ )
232
+ return data
233
+
234
+ @property
235
+ def is_override_lruds(self) -> bool:
236
+ """Check if Override LRUDs flag is set."""
237
+ return self.override_lruds
238
+
239
+ @property
240
+ def is_lruds_at_to_station(self) -> bool:
241
+ """Check if LRUDs at TO station flag is set."""
242
+ return self.lruds_at_to_station
243
+
244
+ @property
245
+ def flags(self) -> int:
246
+ """Get flags as bitmask for backwards compatibility."""
247
+ result = 0
248
+ if self.override_lruds:
249
+ result |= FLAGS_OVERRIDE_LRUDS
250
+ if self.lruds_at_to_station:
251
+ result |= FLAGS_LRUDS_AT_TO_STATION
252
+ return result
253
+
254
+ def __str__(self) -> str:
255
+ if self.raw_flags:
256
+ return f"!{self.raw_flags};"
257
+ # Build flags string from individual flags
258
+ parts = []
259
+ parts.append("G" if self.global_override else "g")
260
+ if self.declination_mode:
261
+ parts.append(self.declination_mode.value)
262
+ parts.append("V" if self.apply_utm_convergence else "v")
263
+ parts.append("O" if self.override_lruds else "o")
264
+ parts.append("T" if self.lruds_at_to_station else "t")
265
+ parts.append("S" if self.apply_shot_flags else "s")
266
+ parts.append("X" if self.apply_total_exclusion else "x")
267
+ parts.append("P" if self.apply_plotting_exclusion else "p")
268
+ parts.append("L" if self.apply_length_exclusion else "l")
269
+ parts.append("C" if self.apply_close_exclusion else "c")
270
+ return f"!{''.join(parts)};"
271
+
272
+
273
+ class LinkStation(BaseModel):
274
+ """A linked/fixed station with optional coordinates."""
275
+
276
+ model_config = ConfigDict(populate_by_name=True)
277
+
278
+ name: str
279
+ location: NEVLocation | None = None
280
+
281
+ def __str__(self) -> str:
282
+ if self.location:
283
+ unit = self.location.unit.lower()
284
+ return (
285
+ f"{self.name}[{unit},{self.location.easting:.3f},"
286
+ f"{self.location.northing:.3f},{self.location.elevation:.3f}]"
287
+ )
288
+ return self.name
289
+
290
+
291
+ class FileDirective(BaseModel):
292
+ """Data file directive (lines starting with #)."""
293
+
294
+ type: Literal["file"] = "file"
295
+ file: str
296
+ link_stations: list[LinkStation] = Field(default_factory=list)
297
+ # Populated when loading project - excluded from serialization by default
298
+ data: CompassDatFile | None = Field(default=None, exclude=True)
299
+
300
+ model_config = ConfigDict(arbitrary_types_allowed=True)
301
+
302
+ def __str__(self) -> str:
303
+ if not self.link_stations:
304
+ return f"#{self.file};"
305
+ result = f"#{self.file}"
306
+ for station in self.link_stations:
307
+ result += f",\r\n {station}"
308
+ result += ";"
309
+ return result
310
+
311
+
312
+ class LocationDirective(BaseModel):
313
+ """Project location directive (lines starting with @).
314
+
315
+ Positive zones (1-60) indicate northern hemisphere.
316
+ Negative zones (-1 to -60) indicate southern hemisphere.
317
+ Zone 0 is allowed to indicate "no location specified".
318
+ """
319
+
320
+ type: Literal["location"] = "location"
321
+ easting: float
322
+ northing: float
323
+ elevation: float
324
+ utm_zone: int
325
+ utm_convergence: float
326
+
327
+ @field_validator("utm_zone")
328
+ @classmethod
329
+ def validate_utm_zone(cls, v: int) -> int:
330
+ if abs(v) > 60:
331
+ raise ValueError(f"UTM zone must be between -60 and 60, got {v}")
332
+ return v
333
+
334
+ @property
335
+ def has_location(self) -> bool:
336
+ """True if this contains a real location (zone != 0)."""
337
+ return self.utm_zone != 0
338
+
339
+ def __str__(self) -> str:
340
+ return (
341
+ f"@{self.easting:.3f},{self.northing:.3f},"
342
+ f"{self.elevation:.3f},{self.utm_zone},{self.utm_convergence:.3f};"
343
+ )
344
+
345
+
346
+ # --- Discriminated Union ---
347
+ # Pydantic automatically deserializes to the correct type based on "type" field
348
+
349
+
350
+ def _get_directive_type(v: Any) -> str:
351
+ """Extract the discriminator value for directive types.
352
+
353
+ Called by Pydantic during validation to determine which directive
354
+ type to instantiate. Handles both dict input (from JSON) and
355
+ already-instantiated model objects.
356
+ """
357
+ if isinstance(v, dict):
358
+ return v.get("type", "unknown")
359
+ # Already a model instance - get the type field
360
+ return getattr(v, "type", "unknown")
361
+
362
+
363
+ Directive = Annotated[
364
+ Annotated[CommentDirective, Tag("comment")]
365
+ | Annotated[DatumDirective, Tag("datum")]
366
+ | Annotated[UTMZoneDirective, Tag("utm_zone")]
367
+ | Annotated[UTMConvergenceDirective, Tag("utm_convergence")]
368
+ | Annotated[FlagsDirective, Tag("flags")]
369
+ | Annotated[LocationDirective, Tag("location")]
370
+ | Annotated[FileDirective, Tag("file")]
371
+ | Annotated[FolderStartDirective, Tag("folder_start")]
372
+ | Annotated[FolderEndDirective, Tag("folder_end")]
373
+ | Annotated[UnknownDirective, Tag("unknown")],
374
+ Discriminator(_get_directive_type),
375
+ ]
376
+
377
+ # Type alias for backwards compatibility and type hints
378
+ CompassProjectDirective = (
379
+ CommentDirective
380
+ | DatumDirective
381
+ | UTMZoneDirective
382
+ | UTMConvergenceDirective
383
+ | FlagsDirective
384
+ | LocationDirective
385
+ | FileDirective
386
+ | FolderStartDirective
387
+ | FolderEndDirective
388
+ | UnknownDirective
389
+ )
390
+
391
+
392
+ # --- Main Project Model ---
393
+
394
+
395
+ class CompassMakFile(BaseModel):
396
+ """A Compass .MAK project file.
397
+
398
+ Serialization is fully automatic via Pydantic:
399
+ json_str = project.model_dump_json(indent=2)
400
+ project = CompassMakFile.model_validate_json(json_str)
401
+ """
402
+
403
+ model_config = ConfigDict(populate_by_name=True)
404
+
405
+ # Wrapper format fields (for JSON compatibility)
406
+ version: str = "1.0"
407
+ format: str = Field(default=FormatIdentifier.COMPASS_MAK.value)
408
+ directives: list[Directive] = Field(default_factory=list)
409
+
410
+ @property
411
+ def file_directives(self) -> list[FileDirective]:
412
+ return [d for d in self.directives if isinstance(d, FileDirective)]
413
+
414
+ @property
415
+ def location(self) -> LocationDirective | None:
416
+ for d in self.directives:
417
+ if isinstance(d, LocationDirective):
418
+ return d
419
+ return None
420
+
421
+ @property
422
+ def datum(self) -> Datum | None:
423
+ for d in self.directives:
424
+ if isinstance(d, DatumDirective):
425
+ return d.datum
426
+ return None
427
+
428
+ @property
429
+ def utm_zone(self) -> int | None:
430
+ # Priority: UTMZoneDirective > LocationDirective (if has_location)
431
+ for d in self.directives:
432
+ if isinstance(d, UTMZoneDirective):
433
+ return d.utm_zone
434
+ loc = self.location
435
+ if loc and loc.has_location:
436
+ return loc.utm_zone
437
+ return None
438
+
439
+ @property
440
+ def flags(self) -> FlagsDirective | None:
441
+ """Get the project flags directive if present."""
442
+ for d in self.directives:
443
+ if isinstance(d, FlagsDirective):
444
+ return d
445
+ return None
446
+
447
+ @property
448
+ def utm_convergence(self) -> float:
449
+ """Get the UTM convergence angle.
450
+
451
+ Returns the convergence value from UTMConvergenceDirective if present,
452
+ or from LocationDirective, defaulting to 0.0.
453
+ """
454
+ for d in self.directives:
455
+ if isinstance(d, UTMConvergenceDirective):
456
+ return d.utm_convergence
457
+ loc = self.location
458
+ if loc:
459
+ return loc.utm_convergence
460
+ return 0.0
461
+
462
+ def iter_files(self) -> Iterator[FileDirective]:
463
+ for d in self.directives:
464
+ if isinstance(d, FileDirective):
465
+ yield d
466
+
467
+ def get_all_stations(self) -> set[str]:
468
+ stations: set[str] = set()
469
+ for fd in self.file_directives:
470
+ if fd.data:
471
+ stations.update(fd.data.get_all_stations())
472
+ return stations
473
+
474
+ def get_all_link_stations(self) -> list[LinkStation]:
475
+ return [ls for fd in self.file_directives for ls in fd.link_stations]
476
+
477
+ def get_fixed_stations(self) -> list[LinkStation]:
478
+ return [
479
+ ls for fd in self.file_directives for ls in fd.link_stations if ls.location
480
+ ]
481
+
482
+ @property
483
+ def total_trips(self) -> int:
484
+ return sum(len(fd.data.trips) if fd.data else 0 for fd in self.file_directives)
485
+
486
+ @property
487
+ def total_shots(self) -> int:
488
+ return sum(fd.data.total_shots if fd.data else 0 for fd in self.file_directives)
489
+
490
+
491
+ # Avoid circular import - import at end
492
+
493
+ # Update forward reference
494
+ FileDirective.model_rebuild()