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/models.py CHANGED
@@ -1,129 +1,251 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Core data models for Compass file formats.
3
+
4
+ This module contains the base Pydantic models used across
5
+ survey, project, and plot parsing.
6
+ """
7
+
1
8
  from __future__ import annotations
2
9
 
3
- import datetime # noqa: TC003
4
- import json
5
- from pathlib import Path
6
10
  from typing import Annotated
7
- from typing import Any
8
11
 
9
12
  from pydantic import BaseModel
10
- from pydantic import ConfigDict
11
13
  from pydantic import Field
12
14
  from pydantic import field_validator
15
+ from pyproj import CRS
16
+ from pyproj import Transformer
17
+
18
+ from compass_lib.enums import Datum
19
+
20
+
21
+ class NEVLocation(BaseModel):
22
+ """3D location with Northing, Easting, and Vertical (elevation) components.
23
+
24
+ All values are stored with their associated unit. The unit for all three
25
+ components must be the same.
26
+
27
+ Attributes:
28
+ easting: East-West coordinate
29
+ northing: North-South coordinate
30
+ elevation: Vertical elevation
31
+ unit: The length unit for all coordinates ('f' for feet, 'm' for meters)
32
+ """
33
+
34
+ easting: float
35
+ northing: float
36
+ elevation: float
37
+ unit: Annotated[str, Field(default="f", pattern="^[fmFM]$")]
38
+
39
+ def __str__(self) -> str:
40
+ """Format as human-readable string."""
41
+ return (
42
+ f"NEVLocation(easting={self.easting}, "
43
+ f"northing={self.northing}, "
44
+ f"elevation={self.elevation}, "
45
+ f"unit={self.unit})"
46
+ )
47
+
48
+
49
+ class UTMLocation(BaseModel):
50
+ """Represents a UTM-based coordinate for fixed stations."""
51
+
52
+ easting: Annotated[
53
+ float,
54
+ Field(
55
+ ge=166_000,
56
+ le=834_000,
57
+ description="Easting coordinate in meters (valid UTM range)",
58
+ ),
59
+ ]
60
+
61
+ northing: Annotated[
62
+ float,
63
+ Field(ge=0, le=10_000_000, description="Northing coordinate in meters"),
64
+ ]
65
+
66
+ elevation: Annotated[
67
+ float,
68
+ Field(
69
+ ge=-435.0, # Dead Sea (Israel/Jordan/West Bank)
70
+ le=8850, # Everest
71
+ description="Elevation in meters",
72
+ ),
73
+ ]
74
+
75
+ zone: Annotated[
76
+ int,
77
+ Field(
78
+ ge=-60,
79
+ le=60,
80
+ description="UTM zone number (1-60 north, -1 to -60 south, 0 not allowed)",
81
+ ),
82
+ ]
83
+
84
+ convergence: Annotated[
85
+ float,
86
+ Field(default=0.0, ge=-5.0, le=5.0, description="Convergence angle in degrees"),
87
+ ]
88
+
89
+ datum: Annotated[
90
+ Datum | None,
91
+ Field(
92
+ default=None,
93
+ description="Datum (e.g., Datum.NORTH_AMERICAN_1927, Datum.WGS_1984)",
94
+ ),
95
+ ]
96
+
97
+ # -----------------------------
98
+ # Validators
99
+ # -----------------------------
100
+
101
+ @field_validator("zone")
102
+ @classmethod
103
+ def validate_zone(cls, v: int) -> int:
104
+ """Validate UTM zone number.
105
+
106
+ Positive values (1-60) indicate northern hemisphere.
107
+ Negative values (-1 to -60) indicate southern hemisphere.
108
+ Zero is not allowed.
109
+
110
+ Args:
111
+ v: Zone number
112
+
113
+ Returns:
114
+ Validated zone number
115
+
116
+ Raises:
117
+ ValueError: If zone is 0 or abs(zone) > 60
118
+ """
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
+ @field_validator("datum", mode="before")
130
+ @classmethod
131
+ def normalize_datum(cls, value: str | Datum | None) -> Datum | None:
132
+ """Validate and normalize datum string to Datum enum.
13
133
 
14
- from compass_lib.encoding import EnhancedJSONEncoder
15
-
16
- # from compass_lib.errors import DuplicateValueError
17
-
18
-
19
- class SurveyShot(BaseModel):
20
- from_id: str
21
- to_id: str
22
-
23
- azimuth: Annotated[float, Field(ge=0, lt=360)]
134
+ Accepts either a string (which will be normalized) or a Datum enum value.
24
135
 
25
- inclination: Annotated[float, Field(ge=-90, le=90)]
26
- length: Annotated[float, Field(ge=0)]
136
+ Args:
137
+ value: Datum as string or Datum enum, or None
138
+
139
+ Returns:
140
+ Datum enum value or None
27
141
 
28
- # Optional Values
29
- comment: str | None = None
30
- flags: Any | None = None
142
+ Raises:
143
+ ValueError: If datum string is not recognized
144
+ """
145
+ if value is None or isinstance(value, Datum):
146
+ return value
31
147
 
32
- azimuth2: Annotated[float, Field(ge=0, lt=360)] | None = None
33
- inclination2: Annotated[float, Field(ge=-90, le=90)] | None = None
148
+ # Normalize string to Datum enum
149
+ return Datum.normalize(value)
34
150
 
35
- # LRUD
36
- left: Annotated[float, Field(ge=0)] = 0.0
37
- right: Annotated[float, Field(ge=0)] = 0.0
38
- up: Annotated[float, Field(ge=0)] = 0.0
39
- down: Annotated[float, Field(ge=0)] = 0.0
151
+ # -----------------------------
152
+ # Properties
153
+ # -----------------------------
40
154
 
41
- model_config = ConfigDict(extra="forbid")
155
+ @property
156
+ def is_northern_hemisphere(self) -> bool:
157
+ """Check if this location is in the northern hemisphere.
42
158
 
43
- @field_validator("left", "right", "up", "down", mode="before")
44
- @classmethod
45
- def validate_lrud(cls, value: float) -> float:
46
- return value if value > 0 else 0.0
159
+ Returns:
160
+ True if northern hemisphere (zone > 0), False if southern (zone < 0)
161
+ """
162
+ return self.zone > 0
47
163
 
48
- @field_validator("azimuth", "azimuth2", mode="before")
49
- @classmethod
50
- def validate_azimuth(cls, value: float) -> float:
51
- return value if value > 0 else 0.0
164
+ @property
165
+ def zone_number(self) -> int:
166
+ """Get the absolute zone number (1-60).
52
167
 
53
- @field_validator("inclination2", mode="before")
54
- @classmethod
55
- def validate_inclination2(cls, value: float) -> float:
56
- return value if -90 <= value <= 90 else 0.0
168
+ Returns:
169
+ Absolute value of the zone number
170
+ """
171
+ return abs(self.zone)
57
172
 
58
- # ======================== VALIDATOR UTILS ======================== #
173
+ # -----------------------------
174
+ # Methods
175
+ # -----------------------------
59
176
 
60
- # @classmethod
61
- # def validate_unique(cls, field: str, values: list) -> list:
62
- # vals2check = [getattr(val, field) for val in values]
63
- # dupl_vals = list(duplicates(vals2check))
64
- # if dupl_vals:
65
- # raise DuplicateValueError(
66
- # f"[{cls.__name__}] Duplicate value found for `{field}`: "
67
- # f"{dupl_vals}"
68
- # )
69
- # return values
177
+ def to_latlon(self) -> tuple[float, float]:
178
+ """
179
+ Convert this UTM location to GPS coordinates (latitude, longitude).
70
180
 
71
- # @field_validator("to_id", mode="before")
72
- # @classmethod
73
- # def validate_unique_to_id(cls, value: str | None) -> str:
74
- # """Note: Validators are only ran with custom fed values.
75
- # Not autogenerated ones. Hence we need to register the name."""
181
+ NOTE: This method takes the decision to exclusively use DATUM WGS 1984 for uniformity
182
+ and ignore the datum from the MAK project file.
183
+ This allows for a consistent and predictable conversion of UTM coordinates to GPS coordinates.
184
+ And inter-operability with other software that uses WGS 1984 for GPS coordinates.
76
185
 
77
- # if value is None or value == "":
78
- # return cls.to_id.default_factory()
186
+ The hemisphere is determined by the sign of the zone:
187
+ - Positive zone (1-60): Northern hemisphere
188
+ - Negative zone (-1 to -60): Southern hemisphere
79
189
 
80
- # # 1. Verify the name is only composed of valid chars.
81
- # for char in value:
82
- # if char.upper() not in [
83
- # *UniqueNameGenerator.VOCAB,
84
- # *list("_-~:!?.'()[]{}@*&#%|$")
85
- # ]:
86
- # raise ValueError(f"The character `{char}` is not allowed as `name`.")
190
+ Args:
191
+ None
87
192
 
88
- # if len(value) > COMPASS_MAX_NAME_LENGTH:
89
- # raise ValueError(f"Name {value} is too long, maximum allowed: "
90
- # f"{COMPASS_MAX_NAME_LENGTH}")
193
+ Returns:
194
+ (lat, lon) in decimal degrees
195
+ """ # noqa: E501
91
196
 
92
- # UniqueNameGenerator.register(value=value)
93
- # return value
197
+ WGS_1984_EPSG = 4326
198
+ geographic_crs = CRS.from_epsg(WGS_1984_EPSG)
94
199
 
200
+ # ---- Build UTM CRS ----
201
+ # Note: pyproj expects "WGS84" (no space) in proj4 strings, not "WGS 1984"
202
+ # Note: pyproj requires positive zone number and hemisphere specified separately
203
+ hemisphere = "+north" if self.is_northern_hemisphere else "+south"
204
+ utm_crs = CRS.from_proj4(
205
+ f"+proj=utm +zone={self.zone_number} {hemisphere} +datum=WGS84 +units=m +no_defs" # noqa: E501
206
+ )
95
207
 
96
- class SurveySection(BaseModel):
97
- name: str
98
- comment: str
99
- correction: list[float]
100
- correction2: list[float]
101
- survey_date: datetime.date | None = None
102
- discovery_date: datetime.date | None = None
103
- declination: float
104
- format: str = "DDDDUDLRLADN"
105
- shots: list[SurveyShot]
106
- surveyors: str | None = None
208
+ transformer = Transformer.from_crs(
209
+ utm_crs,
210
+ geographic_crs,
211
+ always_xy=True,
212
+ )
107
213
 
108
- model_config = ConfigDict(extra="forbid")
214
+ lon, lat = transformer.transform(self.easting, self.northing)
215
+ return lat, lon
109
216
 
110
217
 
111
- class Survey(BaseModel):
112
- cave_name: str
113
- description: str = ""
218
+ class Location(BaseModel):
219
+ """3D plot location with northing, easting, and vertical components.
114
220
 
115
- sections: list[SurveySection] = []
221
+ Used for plot file commands. Values are in feet.
116
222
 
117
- model_config = ConfigDict(extra="forbid")
223
+ Attributes:
224
+ northing: North-South coordinate (feet)
225
+ easting: East-West coordinate (feet)
226
+ vertical: Vertical coordinate (feet)
227
+ """
118
228
 
119
- def to_json(self, filepath: str | Path | None = None) -> str:
120
- filepath = Path(filepath) if filepath else None
121
- data = self.model_dump()
229
+ northing: float | None = None
230
+ easting: float | None = None
231
+ vertical: float | None = None
232
+
233
+ def __str__(self) -> str:
234
+ """Format as human-readable string."""
235
+ return (
236
+ f"Location(northing={self.northing}, "
237
+ f"easting={self.easting}, "
238
+ f"vertical={self.vertical})"
239
+ )
122
240
 
123
- json_str = json.dumps(data, indent=4, sort_keys=True, cls=EnhancedJSONEncoder)
124
241
 
125
- if filepath is not None:
126
- with filepath.open(mode="w") as file:
127
- file.write(json_str)
242
+ class Bounds(BaseModel):
243
+ """Bounding box with lower and upper bounds.
244
+
245
+ Attributes:
246
+ lower: Lower bound location
247
+ upper: Upper bound location
248
+ """
128
249
 
129
- return json_str
250
+ lower: Location = Field(default_factory=Location)
251
+ upper: Location = Field(default_factory=Location)
@@ -0,0 +1,28 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Plot module for parsing Compass .PLT files."""
3
+
4
+ from compass_lib.plot.models import BeginFeatureCommand
5
+ from compass_lib.plot.models import BeginSectionCommand
6
+ from compass_lib.plot.models import BeginSurveyCommand
7
+ from compass_lib.plot.models import CaveBoundsCommand
8
+ from compass_lib.plot.models import CompassPlotCommand
9
+ from compass_lib.plot.models import DatumCommand
10
+ from compass_lib.plot.models import DrawSurveyCommand
11
+ from compass_lib.plot.models import FeatureCommand
12
+ from compass_lib.plot.models import SurveyBoundsCommand
13
+ from compass_lib.plot.models import UtmZoneCommand
14
+ from compass_lib.plot.parser import CompassPlotParser
15
+
16
+ __all__ = [
17
+ "BeginFeatureCommand",
18
+ "BeginSectionCommand",
19
+ "BeginSurveyCommand",
20
+ "CaveBoundsCommand",
21
+ "CompassPlotCommand",
22
+ "CompassPlotParser",
23
+ "DatumCommand",
24
+ "DrawSurveyCommand",
25
+ "FeatureCommand",
26
+ "SurveyBoundsCommand",
27
+ "UtmZoneCommand",
28
+ ]
@@ -0,0 +1,265 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Plot data models for Compass .PLT files.
3
+
4
+ This module contains Pydantic models for representing plot commands
5
+ found in Compass .PLT plot files.
6
+ """
7
+
8
+ import datetime
9
+ from decimal import Decimal
10
+
11
+ from pydantic import BaseModel
12
+ from pydantic import Field
13
+ from pydantic import field_validator
14
+
15
+ from compass_lib.enums import Datum
16
+ from compass_lib.enums import DrawOperation
17
+ from compass_lib.models import Bounds
18
+ from compass_lib.models import Location
19
+
20
+
21
+ class CompassPlotCommand(BaseModel):
22
+ """Base class for all plot commands."""
23
+
24
+
25
+ class BeginSurveyCommand(CompassPlotCommand):
26
+ """Begin survey command (N).
27
+
28
+ Marks the start of a new survey section.
29
+
30
+ Attributes:
31
+ survey_name: Survey identifier (max 12 chars)
32
+ date: Survey date
33
+ comment: Optional comment (max 80 chars)
34
+ """
35
+
36
+ survey_name: str
37
+ date: datetime.date | None = None
38
+ comment: str | None = None
39
+
40
+ def __str__(self) -> str:
41
+ """Format as PLT file syntax."""
42
+ result = f"N{self.survey_name[:12]}"
43
+ if self.date:
44
+ result += f"\tD {self.date.month} {self.date.day} {self.date.year}"
45
+ else:
46
+ result += "\tD 1 1 1"
47
+ if self.comment:
48
+ result += f"\tC{self.comment[:80]}"
49
+ return result
50
+
51
+
52
+ class BeginSectionCommand(CompassPlotCommand):
53
+ """Begin section command (S).
54
+
55
+ Marks the start of a new section.
56
+
57
+ Attributes:
58
+ section_name: Section name (max 20 chars)
59
+ """
60
+
61
+ section_name: str
62
+
63
+ def __str__(self) -> str:
64
+ """Format as PLT file syntax."""
65
+ return f"S{self.section_name[:20]}"
66
+
67
+
68
+ class BeginFeatureCommand(CompassPlotCommand):
69
+ """Begin feature command (F).
70
+
71
+ Marks the start of a feature definition with optional range.
72
+
73
+ Attributes:
74
+ feature_name: Feature name (max 12 chars)
75
+ min_value: Optional minimum value for range
76
+ max_value: Optional maximum value for range
77
+ """
78
+
79
+ feature_name: str
80
+ min_value: Decimal | None = None
81
+ max_value: Decimal | None = None
82
+
83
+ def __str__(self) -> str:
84
+ """Format as PLT file syntax."""
85
+ result = f"F{self.feature_name[:12]}"
86
+ if self.min_value is not None and self.max_value is not None:
87
+ result += f"\tR\t{float(self.min_value)}\t{float(self.max_value)}"
88
+ return result
89
+
90
+
91
+ class DrawSurveyCommand(CompassPlotCommand):
92
+ """Draw survey command (M or D).
93
+
94
+ Represents a move-to or draw-to operation with station data.
95
+
96
+ Attributes:
97
+ operation: MOVE_TO (M) or LINE_TO (D)
98
+ location: 3D coordinates
99
+ station_name: Station identifier
100
+ left: Distance to left wall (feet, None if missing)
101
+ right: Distance to right wall (feet, None if missing)
102
+ up: Distance to ceiling (feet, None if missing)
103
+ down: Distance to floor (feet, None if missing)
104
+ distance_from_entrance: Distance from cave entrance (feet)
105
+ """
106
+
107
+ operation: DrawOperation
108
+ location: Location = Field(default_factory=Location)
109
+ station_name: str | None = None
110
+ left: float | None = None
111
+ right: float | None = None
112
+ up: float | None = None
113
+ down: float | None = None
114
+ distance_from_entrance: float = 0.0
115
+
116
+ def __str__(self) -> str:
117
+ """Format as PLT file syntax."""
118
+ cmd = "M" if self.operation == DrawOperation.MOVE_TO else "D"
119
+ result = f"{cmd}\t{self.location.northing}\t{self.location.easting}"
120
+ result += f"\t{self.location.vertical}"
121
+ if self.station_name:
122
+ result += f"\tS{self.station_name[:12]}"
123
+ result += "\tP"
124
+ result += f"\t{self.left if self.left is not None else -9.0}"
125
+ result += f"\t{self.up if self.up is not None else -9.0}"
126
+ result += f"\t{self.down if self.down is not None else -9.0}"
127
+ result += f"\t{self.right if self.right is not None else -9.0}"
128
+ result += f"\tI\t{self.distance_from_entrance}"
129
+ return result
130
+
131
+
132
+ class FeatureCommand(CompassPlotCommand):
133
+ """Feature point command (L).
134
+
135
+ Represents a feature location with optional value.
136
+
137
+ Attributes:
138
+ location: 3D coordinates
139
+ station_name: Station identifier
140
+ left: Distance to left wall (feet, None if missing)
141
+ right: Distance to right wall (feet, None if missing)
142
+ up: Distance to ceiling (feet, None if missing)
143
+ down: Distance to floor (feet, None if missing)
144
+ value: Feature value
145
+ """
146
+
147
+ location: Location = Field(default_factory=Location)
148
+ station_name: str | None = None
149
+ left: float | None = None
150
+ right: float | None = None
151
+ up: float | None = None
152
+ down: float | None = None
153
+ value: Decimal | None = None
154
+
155
+ def __str__(self) -> str:
156
+ """Format as PLT file syntax."""
157
+ result = f"L\t{self.location.northing}\t{self.location.easting}"
158
+ result += f"\t{self.location.vertical}"
159
+ if self.station_name:
160
+ result += f"\tS{self.station_name[:12]}"
161
+ result += "\tP"
162
+ result += f"\t{self.left if self.left is not None else -9.0}"
163
+ result += f"\t{self.right if self.right is not None else -9.0}"
164
+ result += f"\t{self.up if self.up is not None else -9.0}"
165
+ result += f"\t{self.down if self.down is not None else -9.0}"
166
+ if self.value is not None:
167
+ result += f"\tV\t{self.value}"
168
+ return result
169
+
170
+
171
+ class SurveyBoundsCommand(CompassPlotCommand):
172
+ """Survey bounds command (X).
173
+
174
+ Defines the bounding box for the current survey.
175
+
176
+ Attributes:
177
+ bounds: Lower and upper bound locations
178
+ """
179
+
180
+ bounds: Bounds = Field(default_factory=Bounds)
181
+
182
+ def __str__(self) -> str:
183
+ """Format as PLT file syntax."""
184
+ lb = self.bounds.lower
185
+ ub = self.bounds.upper
186
+ return (
187
+ f"X\t{lb.northing}\t{ub.northing}\t{lb.easting}"
188
+ f"\t{ub.easting}\t{lb.vertical}\t{ub.vertical}"
189
+ )
190
+
191
+
192
+ class CaveBoundsCommand(CompassPlotCommand):
193
+ """Cave bounds command (Z).
194
+
195
+ Defines the overall bounding box for the cave.
196
+
197
+ Attributes:
198
+ bounds: Lower and upper bound locations
199
+ distance_to_farthest_station: Distance to farthest station from entrance
200
+ """
201
+
202
+ bounds: Bounds = Field(default_factory=Bounds)
203
+ distance_to_farthest_station: float | None = None
204
+
205
+ def __str__(self) -> str:
206
+ """Format as PLT file syntax."""
207
+ lb = self.bounds.lower
208
+ ub = self.bounds.upper
209
+ result = (
210
+ f"Z\t{lb.northing}\t{ub.northing}\t{lb.easting}"
211
+ f"\t{ub.easting}\t{lb.vertical}\t{ub.vertical}"
212
+ )
213
+ if self.distance_to_farthest_station is not None:
214
+ result += f"\tI\t{self.distance_to_farthest_station}"
215
+ return result
216
+
217
+
218
+ class DatumCommand(CompassPlotCommand):
219
+ """Datum command (O).
220
+
221
+ Specifies the geodetic datum.
222
+
223
+ Attributes:
224
+ datum: Datum identifier
225
+ """
226
+
227
+ datum: Datum
228
+
229
+ @field_validator("datum", mode="before")
230
+ @classmethod
231
+ def normalize_datum(cls, value: str | Datum) -> Datum:
232
+ """Validate and normalize datum string to Datum enum.
233
+
234
+ Args:
235
+ value: Datum as string or Datum enum
236
+
237
+ Returns:
238
+ Datum enum value
239
+
240
+ Raises:
241
+ ValueError: If datum string is not recognized
242
+ """
243
+ if isinstance(value, Datum):
244
+ return value
245
+ return Datum.normalize(value)
246
+
247
+ def __str__(self) -> str:
248
+ """Format as PLT file syntax."""
249
+ return f"O{self.datum.value}"
250
+
251
+
252
+ class UtmZoneCommand(CompassPlotCommand):
253
+ """UTM zone command (G).
254
+
255
+ Specifies the UTM zone.
256
+
257
+ Attributes:
258
+ utm_zone: UTM zone identifier (stored as string)
259
+ """
260
+
261
+ utm_zone: str
262
+
263
+ def __str__(self) -> str:
264
+ """Format as PLT file syntax."""
265
+ return f"G{self.utm_zone}"