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,284 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Formatting (serialization) for Compass .DAT survey files.
3
+
4
+ This module provides functions to convert survey data models back to
5
+ the Compass .DAT file format string representation.
6
+ """
7
+
8
+ from collections.abc import Callable
9
+
10
+ from compass_lib.constants import FLAG_CHARS
11
+ from compass_lib.constants import MISSING_VALUE_STRING
12
+ from compass_lib.constants import NUMBER_WIDTH
13
+ from compass_lib.constants import STATION_NAME_WIDTH
14
+ from compass_lib.enums import LrudAssociation
15
+ from compass_lib.survey.models import CompassShot
16
+ from compass_lib.survey.models import CompassTrip
17
+ from compass_lib.survey.models import CompassTripHeader
18
+ from compass_lib.validation import validate_station_name
19
+
20
+
21
+ def _cell(value: str, width: int) -> str:
22
+ """Format a value as a right-aligned cell.
23
+
24
+ Args:
25
+ value: String value
26
+ width: Minimum width (actual width may be larger if value is longer)
27
+
28
+ Returns:
29
+ Right-aligned, space-padded string (never truncated to preserve data)
30
+ """
31
+ # Never truncate - the parser is whitespace-delimited so longer values work fine
32
+ # Always ensure at least one leading space to separate from previous column
33
+ if len(value) >= width:
34
+ return " " + value
35
+ return value.rjust(width)
36
+
37
+
38
+ def _format_number(value: float | None, width: int = NUMBER_WIDTH) -> str:
39
+ """Format a number for output.
40
+
41
+ Args:
42
+ value: Numeric value (None for missing)
43
+ width: Column width
44
+
45
+ Returns:
46
+ Formatted string representation
47
+ """
48
+ if value is None:
49
+ return _cell(MISSING_VALUE_STRING, width)
50
+ # Check if value has more than 2 decimal places of precision
51
+ # by comparing rounded vs original
52
+ rounded_2 = round(value, 2)
53
+ if abs(value - rounded_2) > 1e-9:
54
+ # Value has more precision, use 3 decimal places
55
+ return _cell(f"{value:.3f}", width)
56
+ return _cell(f"{value:.2f}", width)
57
+
58
+
59
+ def format_shot(shot: CompassShot, header: CompassTripHeader) -> str:
60
+ """Format a single shot as a line of text.
61
+
62
+ Args:
63
+ shot: Shot data
64
+ header: Trip header (for format settings)
65
+
66
+ Returns:
67
+ Formatted shot line with CRLF
68
+ """
69
+ # Validate station names
70
+ validate_station_name(shot.from_station_name)
71
+ validate_station_name(shot.to_station_name)
72
+
73
+ # Handle depth gauge inclination unit
74
+ # (would need Length unit instead of Angle, but we store as float)
75
+
76
+ columns = [
77
+ _cell(shot.from_station_name, STATION_NAME_WIDTH),
78
+ _cell(shot.to_station_name, STATION_NAME_WIDTH),
79
+ _format_number(shot.length),
80
+ _format_number(shot.frontsight_azimuth),
81
+ _format_number(shot.frontsight_inclination),
82
+ _format_number(shot.left),
83
+ _format_number(shot.up),
84
+ _format_number(shot.down),
85
+ _format_number(shot.right),
86
+ ]
87
+
88
+ # Add backsights if present
89
+ if header.has_backsights:
90
+ columns.append(_format_number(shot.backsight_azimuth))
91
+ columns.append(_format_number(shot.backsight_inclination))
92
+
93
+ # Build flags
94
+ flags = ""
95
+ if shot.excluded_from_length:
96
+ flags += FLAG_CHARS["exclude_distance"]
97
+ if shot.excluded_from_plotting:
98
+ flags += FLAG_CHARS["exclude_from_plotting"]
99
+ if shot.excluded_from_all_processing:
100
+ flags += FLAG_CHARS["exclude_from_all_processing"]
101
+ if shot.do_not_adjust:
102
+ flags += FLAG_CHARS["do_not_adjust"]
103
+
104
+ if flags:
105
+ columns.append(f" #|{flags}#")
106
+
107
+ # Add comment
108
+ if shot.comment:
109
+ # Clean comment: replace newlines with space (no truncation to preserve data)
110
+ clean_comment = shot.comment.replace("\r\n", " ").replace("\n", " ")
111
+ clean_comment = clean_comment.strip()
112
+ columns.append(" " + clean_comment)
113
+
114
+ columns.append("\r\n")
115
+ return "".join(columns)
116
+
117
+
118
+ def format_trip_header(
119
+ header: CompassTripHeader,
120
+ *,
121
+ include_column_headers: bool = True,
122
+ ) -> str:
123
+ """Format a trip header as text.
124
+
125
+ Args:
126
+ header: Trip header data
127
+ include_column_headers: Whether to include column header line
128
+
129
+ Returns:
130
+ Formatted header text with CRLF
131
+ """
132
+ lines = []
133
+
134
+ # Cave name (no truncation to preserve data)
135
+ lines.append(header.cave_name or "")
136
+
137
+ # Survey name (preserve full name for roundtrip, Compass may use longer names)
138
+ survey_name = header.survey_name or ""
139
+ lines.append(f"SURVEY NAME: {survey_name}")
140
+
141
+ # Survey date and comment
142
+ if header.date:
143
+ date_str = f"{header.date.month} {header.date.day} {header.date.year}"
144
+ else:
145
+ date_str = "1 1 1"
146
+
147
+ date_line = f"SURVEY DATE: {date_str}"
148
+ if header.comment:
149
+ date_line += f" COMMENT:{header.comment}"
150
+ lines.append(date_line)
151
+
152
+ # Team (no truncation to preserve data)
153
+ lines.append("SURVEY TEAM:")
154
+ lines.append(header.team or "")
155
+
156
+ # Declination and format
157
+ declination = header.declination
158
+ format_items = [
159
+ header.azimuth_unit.value,
160
+ header.length_unit.value,
161
+ header.lrud_unit.value,
162
+ header.inclination_unit.value,
163
+ ]
164
+ format_items.extend(item.value for item in header.lrud_order)
165
+ format_items.extend(item.value for item in header.shot_measurement_order)
166
+
167
+ # Backsight indicator
168
+ if header.has_backsights or header.lrud_association:
169
+ format_items.append("B" if header.has_backsights else "N")
170
+ assoc = header.lrud_association or LrudAssociation.FROM
171
+ format_items.append(assoc.value)
172
+
173
+ format_str = "".join(format_items)
174
+ decl_line = f"DECLINATION: {declination:.2f} FORMAT: {format_str}"
175
+
176
+ # Corrections
177
+ has_corrections = any(
178
+ [
179
+ header.length_correction,
180
+ header.frontsight_azimuth_correction,
181
+ header.frontsight_inclination_correction,
182
+ ]
183
+ )
184
+
185
+ if has_corrections:
186
+ corr_values = [
187
+ header.length_correction or 0.0,
188
+ header.frontsight_azimuth_correction or 0.0,
189
+ header.frontsight_inclination_correction or 0.0,
190
+ ]
191
+ corr_str = " ".join(f"{v:.2f}" for v in corr_values)
192
+ decl_line += f" CORRECTIONS: {corr_str}"
193
+
194
+ # Backsight corrections
195
+ has_bs_corrections = any(
196
+ [
197
+ header.backsight_azimuth_correction,
198
+ header.backsight_inclination_correction,
199
+ ]
200
+ )
201
+ if has_bs_corrections:
202
+ bs_values = [
203
+ header.backsight_azimuth_correction or 0.0,
204
+ header.backsight_inclination_correction or 0.0,
205
+ ]
206
+ bs_str = " ".join(f"{v:.2f}" for v in bs_values)
207
+ decl_line += f" CORRECTIONS2: {bs_str}"
208
+
209
+ lines.append(decl_line)
210
+
211
+ # Column headers
212
+ if include_column_headers:
213
+ lines.append("") # Blank line
214
+
215
+ col_headers = [
216
+ "FROM ",
217
+ "TO ",
218
+ "LEN ",
219
+ "BEAR ",
220
+ "INC ",
221
+ "LEFT ",
222
+ "UP ",
223
+ "DOWN ",
224
+ "RIGHT ",
225
+ ]
226
+ if header.has_backsights:
227
+ col_headers.append("AZM2 ")
228
+ col_headers.append("INC2 ")
229
+ col_headers.append("FLAGS ")
230
+ col_headers.append("COMMENTS")
231
+
232
+ lines.append("".join(col_headers))
233
+ lines.append("") # Blank line before data
234
+
235
+ # Join with CRLF
236
+ return "\r\n".join(lines) + "\r\n"
237
+
238
+
239
+ def format_trip(trip: CompassTrip, *, include_column_headers: bool = True) -> str:
240
+ """Format a complete trip (header + shots).
241
+
242
+ Args:
243
+ trip: Trip data
244
+ include_column_headers: Whether to include column headers
245
+
246
+ Returns:
247
+ Formatted trip text
248
+ """
249
+ parts = [
250
+ format_trip_header(trip.header, include_column_headers=include_column_headers)
251
+ ]
252
+
253
+ parts.extend(format_shot(shot, trip.header) for shot in trip.shots)
254
+
255
+ return "".join(parts)
256
+
257
+
258
+ def format_dat_file(
259
+ trips: list[CompassTrip],
260
+ *,
261
+ write: Callable[[str], None] | None = None,
262
+ ) -> str | None:
263
+ """Format a complete DAT file from trips.
264
+
265
+ Args:
266
+ trips: List of trips
267
+ write: Optional callback for streaming output. If provided,
268
+ chunks are written via this callback and None is returned.
269
+
270
+ Returns:
271
+ Formatted file content as string (if write is None),
272
+ or None (if write callback is provided)
273
+ """
274
+ if write is not None:
275
+ # Streaming mode
276
+ for trip in trips:
277
+ write(format_trip(trip))
278
+ write("\f\r\n")
279
+ return None
280
+
281
+ # Return mode
282
+ chunks: list[str] = []
283
+ format_dat_file(trips, write=chunks.append)
284
+ return "".join(chunks)
@@ -0,0 +1,160 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Survey data models for Compass .DAT files.
3
+
4
+ This module contains Pydantic models for representing survey data:
5
+ - CompassShot: A single shot between two stations
6
+ - CompassTripHeader: Metadata and settings for a survey trip
7
+ - CompassTrip: A complete trip with header and shots
8
+ - CompassDatFile: A DAT file containing one or more trips
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import datetime # noqa: TC003
14
+
15
+ from pydantic import BaseModel
16
+ from pydantic import ConfigDict
17
+ from pydantic import Field
18
+ from pydantic import field_serializer
19
+ from pydantic import field_validator
20
+
21
+ from compass_lib.enums import AzimuthUnit
22
+ from compass_lib.enums import InclinationUnit
23
+ from compass_lib.enums import LengthUnit
24
+ from compass_lib.enums import LrudAssociation
25
+ from compass_lib.enums import LrudItem
26
+ from compass_lib.enums import ShotItem
27
+
28
+
29
+ class CompassShot(BaseModel):
30
+ """A single survey shot between two stations.
31
+
32
+ All measurements are stored in internal units (feet, degrees).
33
+ """
34
+
35
+ model_config = ConfigDict(
36
+ populate_by_name=True,
37
+ extra="ignore",
38
+ )
39
+
40
+ from_station_name: str = Field(alias="from_station")
41
+ to_station_name: str = Field(alias="to_station")
42
+ length: float | None = Field(default=None, alias="distance")
43
+ frontsight_azimuth: float | None = None
44
+ frontsight_inclination: float | None = None
45
+ backsight_azimuth: float | None = None
46
+ backsight_inclination: float | None = None
47
+ left: float | None = None
48
+ right: float | None = None
49
+ up: float | None = None
50
+ down: float | None = None
51
+ comment: str | None = None
52
+ excluded_from_length: bool = Field(default=False, alias="exclude_distance")
53
+ excluded_from_plotting: bool = False
54
+ excluded_from_all_processing: bool = False
55
+ do_not_adjust: bool = False
56
+
57
+ # NOTE: Validation is relaxed to allow real-world data with out-of-range values.
58
+ # The parser tracks these issues in its errors list.
59
+ # Strict validation can be performed separately if needed.
60
+
61
+
62
+ class CompassTripHeader(BaseModel):
63
+ """Metadata and settings for a survey trip."""
64
+
65
+ model_config = ConfigDict(populate_by_name=True)
66
+
67
+ cave_name: str | None = None
68
+ survey_name: str | None = None
69
+ date: datetime.date | None = None
70
+ comment: str | None = None
71
+ team: str | None = None
72
+ declination: float = 0.0
73
+ length_unit: LengthUnit = LengthUnit.DECIMAL_FEET
74
+ lrud_unit: LengthUnit = LengthUnit.DECIMAL_FEET
75
+ azimuth_unit: AzimuthUnit = AzimuthUnit.DEGREES
76
+ inclination_unit: InclinationUnit = InclinationUnit.DEGREES
77
+ lrud_order: list[LrudItem] = Field(
78
+ default_factory=lambda: [
79
+ LrudItem.LEFT,
80
+ LrudItem.RIGHT,
81
+ LrudItem.UP,
82
+ LrudItem.DOWN,
83
+ ]
84
+ )
85
+ shot_measurement_order: list[ShotItem] = Field(
86
+ default_factory=lambda: [
87
+ ShotItem.LENGTH,
88
+ ShotItem.FRONTSIGHT_AZIMUTH,
89
+ ShotItem.FRONTSIGHT_INCLINATION,
90
+ ]
91
+ )
92
+ has_backsights: bool = True
93
+ lrud_association: LrudAssociation = LrudAssociation.FROM
94
+ length_correction: float = 0.0
95
+ frontsight_azimuth_correction: float = 0.0
96
+ frontsight_inclination_correction: float = 0.0
97
+ backsight_azimuth_correction: float = 0.0
98
+ backsight_inclination_correction: float = 0.0
99
+
100
+ @field_serializer("comment", "team")
101
+ @classmethod
102
+ def serialize_empty_as_none(cls, v: str | None) -> str | None:
103
+ return None if v == "" else v
104
+
105
+ @field_serializer("lrud_order")
106
+ @classmethod
107
+ def serialize_lrud_order(cls, v: list[LrudItem]) -> list[str]:
108
+ return [item.value for item in v]
109
+
110
+ @field_serializer("shot_measurement_order")
111
+ @classmethod
112
+ def serialize_shot_order(cls, v: list[ShotItem]) -> list[str]:
113
+ return [item.value for item in v]
114
+
115
+ @field_validator("lrud_order", mode="before")
116
+ @classmethod
117
+ def parse_lrud_order(cls, v: list) -> list[LrudItem]:
118
+ if v and isinstance(v[0], str):
119
+ return [LrudItem(item) for item in v]
120
+ return v
121
+
122
+ @field_validator("shot_measurement_order", mode="before")
123
+ @classmethod
124
+ def parse_shot_order(cls, v: list) -> list[ShotItem]:
125
+ if v and isinstance(v[0], str):
126
+ return [ShotItem(item) for item in v]
127
+ return v
128
+
129
+
130
+ class CompassTrip(BaseModel):
131
+ """A complete survey trip with header and shots."""
132
+
133
+ model_config = ConfigDict(populate_by_name=True)
134
+
135
+ header: CompassTripHeader
136
+ shots: list[CompassShot] = Field(default_factory=list)
137
+
138
+
139
+ class CompassDatFile(BaseModel):
140
+ """A Compass .DAT file containing one or more survey trips."""
141
+
142
+ model_config = ConfigDict(populate_by_name=True)
143
+
144
+ trips: list[CompassTrip] = Field(default_factory=list)
145
+
146
+ @property
147
+ def total_shots(self) -> int:
148
+ return sum(len(trip.shots) for trip in self.trips)
149
+
150
+ @property
151
+ def trip_names(self) -> list[str]:
152
+ return [trip.header.survey_name or "<unnamed>" for trip in self.trips]
153
+
154
+ def get_all_stations(self) -> set[str]:
155
+ stations: set[str] = set()
156
+ for trip in self.trips:
157
+ for shot in trip.shots:
158
+ stations.add(shot.from_station_name)
159
+ stations.add(shot.to_station_name)
160
+ return stations