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.
- compass_lib/__init__.py +113 -3
- compass_lib/commands/__init__.py +2 -0
- compass_lib/commands/convert.py +223 -36
- compass_lib/commands/encrypt.py +33 -7
- compass_lib/commands/geojson.py +118 -0
- compass_lib/commands/main.py +1 -1
- compass_lib/constants.py +84 -36
- compass_lib/enums.py +292 -84
- compass_lib/errors.py +86 -0
- compass_lib/geo_utils.py +47 -0
- compass_lib/geojson.py +1024 -0
- compass_lib/interface.py +332 -0
- compass_lib/io.py +246 -0
- compass_lib/models.py +217 -95
- compass_lib/plot/__init__.py +28 -0
- compass_lib/plot/models.py +265 -0
- compass_lib/plot/parser.py +610 -0
- compass_lib/project/__init__.py +36 -0
- compass_lib/project/format.py +158 -0
- compass_lib/project/models.py +494 -0
- compass_lib/project/parser.py +638 -0
- compass_lib/survey/__init__.py +24 -0
- compass_lib/survey/format.py +284 -0
- compass_lib/survey/models.py +160 -0
- compass_lib/survey/parser.py +842 -0
- compass_lib/validation.py +74 -0
- {compass_lib-0.0.2.dist-info → compass_lib-0.0.3.dist-info}/METADATA +8 -11
- compass_lib-0.0.3.dist-info/RECORD +31 -0
- {compass_lib-0.0.2.dist-info → compass_lib-0.0.3.dist-info}/entry_points.txt +2 -1
- compass_lib/encoding.py +0 -27
- compass_lib/parser.py +0 -435
- compass_lib/utils.py +0 -15
- compass_lib-0.0.2.dist-info/RECORD +0 -16
- {compass_lib-0.0.2.dist-info → compass_lib-0.0.3.dist-info}/WHEEL +0 -0
- {compass_lib-0.0.2.dist-info → compass_lib-0.0.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -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()
|