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,98 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Constants used throughout the compass_lib library.
3
+
4
+ This module centralizes all constant values to ensure consistency
5
+ and avoid magic numbers/strings scattered across the codebase.
6
+ """
7
+
8
+ # -----------------------------------------------------------------------------
9
+ # File Encodings
10
+ # -----------------------------------------------------------------------------
11
+
12
+ #: Default encoding for Compass files (Windows-1252 / CP1252)
13
+ COMPASS_ENCODING = "cp1252"
14
+
15
+ #: Encoding used for JSON files
16
+ JSON_ENCODING = "utf-8"
17
+
18
+ #: Encoding used when reading raw Compass files (ASCII with replacements)
19
+ ASCII_ENCODING = "ascii"
20
+
21
+ # -----------------------------------------------------------------------------
22
+ # Unit Conversions
23
+ # -----------------------------------------------------------------------------
24
+
25
+ #: Conversion factor from feet to meters
26
+ FEET_TO_METERS: float = 0.3048
27
+
28
+ #: Conversion factor from meters to feet
29
+ METERS_TO_FEET: float = 1.0 / FEET_TO_METERS
30
+
31
+ # -----------------------------------------------------------------------------
32
+ # Missing Data Indicators
33
+ # -----------------------------------------------------------------------------
34
+
35
+ #: Values >= this threshold indicate missing data for distances/measurements
36
+ MISSING_VALUE_THRESHOLD: float = 990.0
37
+
38
+ #: Values <= this threshold indicate missing data for angles
39
+ MISSING_ANGLE_THRESHOLD: float = -900.0
40
+
41
+ #: String representation of missing value in formatted output
42
+ MISSING_VALUE_STRING: str = "-999.00"
43
+
44
+ #: Null LRUD values used in PLT files (either 999.0 or 999.9 indicates missing)
45
+ NULL_LRUD_VALUES: tuple[float, float] = (999.0, 999.9)
46
+
47
+ # -----------------------------------------------------------------------------
48
+ # Formatting Constants
49
+ # -----------------------------------------------------------------------------
50
+
51
+ #: Width of station name column in DAT files
52
+ STATION_NAME_WIDTH: int = 13
53
+
54
+ #: Width of numeric columns in DAT files
55
+ NUMBER_WIDTH: int = 8
56
+
57
+ #: Decimal precision for GeoJSON coordinates (WGS84)
58
+ GEOJSON_COORDINATE_PRECISION: int = 7
59
+
60
+ #: Decimal precision for elevation values in GeoJSON
61
+ GEOJSON_ELEVATION_PRECISION: int = 2
62
+
63
+ # -----------------------------------------------------------------------------
64
+ # Shot Flag Characters
65
+ # -----------------------------------------------------------------------------
66
+
67
+ #: Mapping of shot flags to their character representations
68
+ FLAG_CHARS: dict[str, str] = {
69
+ "exclude_distance": "L",
70
+ "exclude_from_plotting": "P",
71
+ "exclude_from_all_processing": "X",
72
+ "do_not_adjust": "C",
73
+ }
74
+
75
+ # -----------------------------------------------------------------------------
76
+ # UTM Constants
77
+ # -----------------------------------------------------------------------------
78
+
79
+ #: Southern hemisphere UTM northing offset (10 million meters)
80
+ #: Note: This constant represents the false northing added to southern hemisphere
81
+ #: UTM coordinates. However, in Compass, hemisphere is determined by the ZONE SIGN
82
+ #: (positive = north, negative = south), NOT by comparing northing to this threshold.
83
+ #: This constant is kept for reference and potential future use.
84
+ UTM_SOUTHERN_HEMISPHERE_OFFSET: float = 10_000_000.0
85
+
86
+ # -----------------------------------------------------------------------------
87
+ # Solver Constraints
88
+ # -----------------------------------------------------------------------------
89
+
90
+ #: Maximum relative change to a shot's tape length during traverse adjustment
91
+ #: (0.05 = 5 %). The solver will never stretch or shrink a shot by more than
92
+ #: this fraction of the original survey measurement.
93
+ SOLVER_MAX_LENGTH_CHANGE: float = 0.15
94
+
95
+ #: Maximum relative change to a shot's compass heading during traverse
96
+ #: adjustment (0.15 = 15 %). The solver will never rotate a shot's bearing
97
+ #: or inclination by more than this fraction of the original survey reading.
98
+ SOLVER_MAX_HEADING_CHANGE: float = 0.15
compass_lib/enums.py ADDED
@@ -0,0 +1,394 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Enumerations for Compass file formats.
3
+
4
+ This module contains all enumerations used in Compass survey data formats,
5
+ including units for measurements, file types, and various classification types.
6
+ """
7
+
8
+ from enum import StrEnum
9
+ from math import radians
10
+ from math import tan
11
+
12
+ from compass_lib.constants import FEET_TO_METERS
13
+
14
+
15
+ class FileFormat(StrEnum):
16
+ """File format types for conversion operations.
17
+
18
+ Attributes:
19
+ COMPASS: Native Compass binary/text format (.dat, .mak, .plt)
20
+ JSON: JSON serialization format
21
+ GEOJSON: GeoJSON geographic format
22
+ """
23
+
24
+ COMPASS = "compass"
25
+ JSON = "json"
26
+ GEOJSON = "geojson"
27
+
28
+
29
+ class CompassFileType(StrEnum):
30
+ """Types of Compass files.
31
+
32
+ Attributes:
33
+ DAT: Survey data file containing shots
34
+ MAK: Project/make file linking DAT files
35
+ PLT: Plot file with processed survey data
36
+ """
37
+
38
+ DAT = "dat"
39
+ MAK = "mak"
40
+ PLT = "plt"
41
+
42
+ @property
43
+ def extension(self) -> str:
44
+ """Get the file extension for this type (with dot)."""
45
+ return {
46
+ CompassFileType.DAT: FileExtension.DAT.value,
47
+ CompassFileType.MAK: FileExtension.MAK.value,
48
+ CompassFileType.PLT: FileExtension.PLT.value,
49
+ }[self]
50
+
51
+ @classmethod
52
+ def from_extension(cls, ext: str) -> "CompassFileType | None":
53
+ """Get file type from extension.
54
+
55
+ Args:
56
+ ext: File extension (with or without dot, case-insensitive)
57
+
58
+ Returns:
59
+ CompassFileType or None if not recognized
60
+ """
61
+ ext_lower = ext.lower().lstrip(".")
62
+ mapping = {
63
+ "dat": cls.DAT,
64
+ "mak": cls.MAK,
65
+ "plt": cls.PLT,
66
+ }
67
+ return mapping.get(ext_lower)
68
+
69
+
70
+ class FileExtension(StrEnum):
71
+ """File extensions for various file formats (with dot).
72
+
73
+ Attributes:
74
+ DAT: Compass survey data file extension
75
+ MAK: Compass project/make file extension
76
+ PLT: Compass plot file extension
77
+ JSON: JSON file extension
78
+ GEOJSON: GeoJSON file extension
79
+ """
80
+
81
+ DAT = ".dat"
82
+ MAK = ".mak"
83
+ PLT = ".plt"
84
+ JSON = ".json"
85
+ GEOJSON = ".geojson"
86
+
87
+
88
+ class FormatIdentifier(StrEnum):
89
+ """Format identifiers used in JSON files.
90
+
91
+ Attributes:
92
+ COMPASS_DAT: Format identifier for DAT files in JSON
93
+ COMPASS_MAK: Format identifier for MAK/project files in JSON
94
+ """
95
+
96
+ COMPASS_DAT = "compass_dat"
97
+ COMPASS_MAK = "compass_mak"
98
+
99
+
100
+ class AzimuthUnit(StrEnum):
101
+ """Unit for compass azimuth (bearing) measurements.
102
+
103
+ Attributes:
104
+ DEGREES: Standard degrees (0-360)
105
+ QUADS: Quadrant notation
106
+ GRADS: Gradians (400 per circle)
107
+ """
108
+
109
+ DEGREES = "D"
110
+ QUADS = "Q"
111
+ GRADS = "R"
112
+
113
+ @staticmethod
114
+ def convert(degrees: float | None, to_unit: "AzimuthUnit") -> float | None:
115
+ """Convert degrees to the target unit.
116
+
117
+ Args:
118
+ degrees: Value in degrees (or None)
119
+ to_unit: Target unit to convert to
120
+
121
+ Returns:
122
+ Converted value or None if input is None
123
+ """
124
+ if degrees is None:
125
+ return None
126
+ if to_unit == AzimuthUnit.GRADS:
127
+ return degrees * 400 / 360
128
+ return degrees
129
+
130
+
131
+ class InclinationUnit(StrEnum):
132
+ """Unit for vertical angle (inclination) measurements.
133
+
134
+ Attributes:
135
+ DEGREES: Standard degrees (-90 to +90)
136
+ PERCENT_GRADE: Percentage gradient (tan(angle) * 100)
137
+ DEGREES_AND_MINUTES: Degrees with minutes notation
138
+ GRADS: Gradians
139
+ DEPTH_GAUGE: Delta depth (From_depth - To_depth) for underwater surveys.
140
+ Negative = descending (going deeper), positive = ascending.
141
+ Uses the same units as length measurements.
142
+ """
143
+
144
+ DEGREES = "D"
145
+ PERCENT_GRADE = "G"
146
+ DEGREES_AND_MINUTES = "M"
147
+ GRADS = "R"
148
+ DEPTH_GAUGE = "W"
149
+
150
+ @staticmethod
151
+ def convert(value: float | None, to_unit: "InclinationUnit") -> float | None:
152
+ """Convert degrees to the target unit.
153
+
154
+ Args:
155
+ value: Value in degrees (or None)
156
+ to_unit: Target unit to convert to
157
+
158
+ Returns:
159
+ Converted value or None if input is None
160
+ """
161
+ if value is None:
162
+ return None
163
+ if to_unit == InclinationUnit.PERCENT_GRADE:
164
+ return tan(radians(value)) * 100
165
+ if to_unit == InclinationUnit.GRADS:
166
+ return value * 200 / 180
167
+ return value
168
+
169
+
170
+ class LengthUnit(StrEnum):
171
+ """Unit for distance measurements.
172
+
173
+ Attributes:
174
+ DECIMAL_FEET: Standard feet with decimals
175
+ FEET_AND_INCHES: Feet and inches notation
176
+ METERS: Metric meters
177
+ """
178
+
179
+ DECIMAL_FEET = "D"
180
+ FEET_AND_INCHES = "I"
181
+ METERS = "M"
182
+
183
+ @staticmethod
184
+ def convert(feet: float | None, to_unit: "LengthUnit") -> float | None:
185
+ """Convert feet to the target unit.
186
+
187
+ Args:
188
+ feet: Value in feet (or None)
189
+ to_unit: Target unit to convert to
190
+
191
+ Returns:
192
+ Converted value or None if input is None
193
+ """
194
+ if feet is None:
195
+ return None
196
+ if to_unit == LengthUnit.METERS:
197
+ return feet * FEET_TO_METERS
198
+ return feet
199
+
200
+
201
+ class LrudAssociation(StrEnum):
202
+ """Indicates which station LRUD measurements are associated with.
203
+
204
+ Attributes:
205
+ FROM: LRUD measured at the FROM station
206
+ TO: LRUD measured at the TO station
207
+ """
208
+
209
+ FROM = "F"
210
+ TO = "T"
211
+
212
+
213
+ class LrudItem(StrEnum):
214
+ """Individual LRUD dimension identifiers.
215
+
216
+ Attributes:
217
+ LEFT: Distance to left wall
218
+ RIGHT: Distance to right wall
219
+ UP: Distance to ceiling
220
+ DOWN: Distance to floor
221
+ """
222
+
223
+ LEFT = "L"
224
+ RIGHT = "R"
225
+ UP = "U"
226
+ DOWN = "D"
227
+
228
+
229
+ class ShotItem(StrEnum):
230
+ """Components of a shot measurement for format ordering.
231
+
232
+ Attributes:
233
+ LENGTH: Distance between stations
234
+ FRONTSIGHT_AZIMUTH: Compass bearing forward
235
+ FRONTSIGHT_INCLINATION: Vertical angle forward
236
+ BACKSIGHT_AZIMUTH: Compass bearing backward
237
+ BACKSIGHT_INCLINATION: Vertical angle backward
238
+ """
239
+
240
+ LENGTH = "L"
241
+ FRONTSIGHT_AZIMUTH = "A"
242
+ FRONTSIGHT_INCLINATION = "D"
243
+ BACKSIGHT_AZIMUTH = "a"
244
+ BACKSIGHT_INCLINATION = "d"
245
+
246
+
247
+ class Severity(StrEnum):
248
+ """Severity level for parse errors.
249
+
250
+ Attributes:
251
+ ERROR: Critical parsing error
252
+ WARNING: Non-fatal warning
253
+ """
254
+
255
+ ERROR = "error"
256
+ WARNING = "warning"
257
+
258
+
259
+ class DrawOperation(StrEnum):
260
+ """Drawing operations for plot commands.
261
+
262
+ Attributes:
263
+ MOVE_TO: Move to location without drawing
264
+ LINE_TO: Draw line to location
265
+ """
266
+
267
+ MOVE_TO = "M"
268
+ LINE_TO = "D"
269
+
270
+
271
+ class Datum(StrEnum):
272
+ """Geodetic datum values supported by Compass.
273
+
274
+ These are the standard datum values that can be used in MAK project files.
275
+ The enum values match the exact strings used in Compass MAK files.
276
+
277
+ Attributes:
278
+ ADINDAN: Adindan datum
279
+ ARC_1950: Arc 1950 datum
280
+ ARC_1960: Arc 1960 datum
281
+ AUSTRALIAN_1966: Australian 1966 datum
282
+ AUSTRALIAN_1984: Australian 1984 datum
283
+ CAMP_AREA_ASTRO: Camp Area Astro datum
284
+ CAPE: Cape datum
285
+ EUROPEAN_1950: European 1950 datum
286
+ EUROPEAN_1979: European 1979 datum
287
+ GEODETIC_1949: Geodetic 1949 datum
288
+ HONG_KONG_1963: Hong Kong 1963 datum
289
+ HU_TZU_SHAN: Hu Tzu Shan datum
290
+ INDIAN: Indian datum
291
+ NORTH_AMERICAN_1927: North American 1927 datum (NAD27)
292
+ NORTH_AMERICAN_1983: North American 1983 datum (NAD83)
293
+ OMAN: Oman datum
294
+ ORDNANCE_SURVEY_1936: Ordnance Survey 1936 datum
295
+ PULKOVO_1942: Pulkovo 1942 datum
296
+ SOUTH_AMERICAN_1956: South American 1956 datum
297
+ SOUTH_AMERICAN_1969: South American 1969 datum
298
+ TOKYO: Tokyo datum
299
+ WGS_1972: WGS 1972 datum
300
+ WGS_1984: WGS 1984 datum
301
+ """
302
+
303
+ ADINDAN = "Adindan"
304
+ ARC_1950 = "Arc 1950"
305
+ ARC_1960 = "Arc 1960"
306
+ AUSTRALIAN_1966 = "Australian 1966"
307
+ AUSTRALIAN_1984 = "Australian 1984"
308
+ CAMP_AREA_ASTRO = "Camp Area Astro"
309
+ CAPE = "Cape"
310
+ EUROPEAN_1950 = "European 1950"
311
+ EUROPEAN_1979 = "European 1979"
312
+ GEODETIC_1949 = "Geodetic 1949"
313
+ HONG_KONG_1963 = "Hong Kong 1963"
314
+ HU_TZU_SHAN = "Hu Tzu Shan"
315
+ INDIAN = "Indian"
316
+ NORTH_AMERICAN_1927 = "North American 1927"
317
+ NORTH_AMERICAN_1983 = "North American 1983"
318
+ OMAN = "Oman"
319
+ ORDNANCE_SURVEY_1936 = "Ordnance Survey 1936"
320
+ PULKOVO_1942 = "Pulkovo 1942"
321
+ SOUTH_AMERICAN_1956 = "South American 1956"
322
+ SOUTH_AMERICAN_1969 = "South American 1969"
323
+ TOKYO = "Tokyo"
324
+ WGS_1972 = "WGS 1972"
325
+ WGS_1984 = "WGS 1984"
326
+
327
+ def get_utm_epsg(self, zone: int, northern: bool) -> str:
328
+ """Get the EPSG code for a UTM zone with this datum.
329
+
330
+ Args:
331
+ zone: UTM zone number (1-60)
332
+ northern: True for northern hemisphere
333
+
334
+ Returns:
335
+ EPSG code string (e.g., "EPSG:26717" for NAD27 UTM Zone 17N)
336
+ """
337
+ zone = abs(zone)
338
+
339
+ match self:
340
+ case Datum.NORTH_AMERICAN_1927:
341
+ # NAD27 / UTM: EPSG:26701-26722 (zones 1-22 north)
342
+ return f"EPSG:{26700 + zone}"
343
+
344
+ case Datum.NORTH_AMERICAN_1983:
345
+ # NAD83 / UTM: EPSG:26901-26923 (zones 1-23 north)
346
+ return f"EPSG:{26900 + zone}"
347
+
348
+ case Datum.WGS_1972:
349
+ # WGS72 / UTM: EPSG:32201-32260 (north), EPSG:32301-32360 (south)
350
+ if northern:
351
+ return f"EPSG:{32200 + zone}"
352
+ return f"EPSG:{32300 + zone}"
353
+
354
+ case Datum.WGS_1984:
355
+ # WGS84 / UTM: EPSG:32601-32660 (north), EPSG:32701-32760 (south)
356
+ if northern:
357
+ return f"EPSG:{32600 + zone}"
358
+ return f"EPSG:{32700 + zone}"
359
+
360
+ case _:
361
+ raise NotImplementedError(f"This DATUM: `{self}` is not supported")
362
+
363
+ @classmethod
364
+ def normalize(cls, value: str | None) -> "Datum | None":
365
+ """Normalize and validate a datum string to a Datum enum value.
366
+
367
+ Performs case-insensitive matching with whitespace normalization.
368
+
369
+ Args:
370
+ value: The datum string to normalize (case-insensitive)
371
+
372
+ Returns:
373
+ The corresponding Datum enum value, or None if value is None
374
+
375
+ Raises:
376
+ ValueError: If the datum string is not recognized
377
+ """
378
+ if value is None:
379
+ return None
380
+
381
+ # Normalize: lowercase, strip whitespace, collapse multiple spaces
382
+ normalized = " ".join(value.strip().lower().split())
383
+
384
+ # Match against enum values (case-insensitive)
385
+ for datum in cls:
386
+ if datum.value.lower() == normalized:
387
+ return datum
388
+
389
+ raise ValueError(f"Unknown datum: {value!r}")
390
+
391
+ @classmethod
392
+ def from_string(cls, value: str | None) -> "Datum | None":
393
+ """Alias for normalize() for backwards compatibility."""
394
+ return cls.normalize(value)
compass_lib/errors.py ADDED
@@ -0,0 +1,86 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Error handling for Compass file parsing.
3
+
4
+ This module provides error classes for tracking parsing errors with
5
+ source location information for helpful error messages.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+
10
+ from compass_lib.enums import Severity
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class SourceLocation:
15
+ """Tracks the source location of text for error reporting.
16
+
17
+ Attributes:
18
+ source: The source file name or identifier
19
+ line: Line number (0-based)
20
+ column: Column number (0-based)
21
+ text: The text at this location
22
+ """
23
+
24
+ source: str
25
+ line: int
26
+ column: int
27
+ text: str
28
+
29
+ def __str__(self) -> str:
30
+ """Format as human-readable location string."""
31
+ return f"(in {self.source}, line {self.line + 1}, column {self.column + 1})"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class CompassParseError:
36
+ """Represents a parsing error or warning with source location.
37
+
38
+ This is a data record for storing error information, not an exception.
39
+ Use CompassParseException for raising errors.
40
+
41
+ Attributes:
42
+ severity: ERROR or WARNING
43
+ message: Human-readable error message
44
+ location: Source location where error occurred (optional)
45
+ """
46
+
47
+ severity: Severity
48
+ message: str
49
+ location: SourceLocation | None = None
50
+
51
+ def __str__(self) -> str:
52
+ """Format as human-readable error string."""
53
+ base = f"{self.severity.value}: {self.message}"
54
+ if self.location:
55
+ base += f" {self.location}"
56
+ if self.location.text:
57
+ base += f"\n {self.location.text}"
58
+ return base
59
+
60
+
61
+ class CompassParseException(Exception): # noqa: N818
62
+ """Exception raised for critical parsing errors.
63
+
64
+ Attributes:
65
+ message: Error message
66
+ location: Source location where error occurred
67
+ """
68
+
69
+ def __init__(self, message: str, location: SourceLocation | None = None):
70
+ self.message = message
71
+ self.location = location
72
+ super().__init__(str(self))
73
+
74
+ def __str__(self) -> str:
75
+ """Format as human-readable exception string."""
76
+ if self.location:
77
+ return f"{self.message} {self.location}"
78
+ return self.message
79
+
80
+ def to_error(self) -> CompassParseError:
81
+ """Convert exception to CompassParseError record."""
82
+ return CompassParseError(
83
+ severity=Severity.ERROR,
84
+ message=self.message,
85
+ location=self.location,
86
+ )
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+
5
+ import pyIGRF14 as pyIGRF
6
+ from pydantic import BaseModel
7
+ from pydantic_extra_types.coordinate import Latitude # noqa: TC002
8
+ from pydantic_extra_types.coordinate import Longitude # noqa: TC002
9
+
10
+ from compass_lib.constants import GEOJSON_COORDINATE_PRECISION
11
+
12
+
13
+ class GeoLocation(BaseModel):
14
+ latitude: Latitude
15
+ longitude: Longitude
16
+
17
+ def as_tuple(self) -> tuple[float, float]:
18
+ """Return the latitude and longitude as a tuple.
19
+ # RFC 7946: (longitude, latitude)
20
+ """
21
+ return (
22
+ round(self.longitude, GEOJSON_COORDINATE_PRECISION),
23
+ round(self.latitude, GEOJSON_COORDINATE_PRECISION),
24
+ )
25
+
26
+
27
+ def decimal_year(dt: datetime.datetime) -> float:
28
+ dt_start = datetime.datetime( # noqa: DTZ001
29
+ year=dt.year, month=1, day=1, hour=0, minute=0, second=0
30
+ )
31
+ dt_end = datetime.datetime( # noqa: DTZ001
32
+ year=dt.year + 1, month=1, day=1, hour=0, minute=0, second=0
33
+ )
34
+ return round(
35
+ dt.year + (dt - dt_start).total_seconds() / (dt_end - dt_start).total_seconds(),
36
+ ndigits=2,
37
+ )
38
+
39
+
40
+ def get_declination(location: GeoLocation, dt: datetime.datetime) -> float:
41
+ declination, _, _, _, _, _, _ = pyIGRF.igrf_value(
42
+ location.latitude,
43
+ location.longitude,
44
+ alt=0.0,
45
+ year=decimal_year(dt),
46
+ )
47
+ return round(declination, 2)