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
compass_lib/geojson.py
ADDED
|
@@ -0,0 +1,1024 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""GeoJSON export for Compass survey data.
|
|
3
|
+
|
|
4
|
+
This module converts Compass survey data (MAK + DAT files) to GeoJSON format.
|
|
5
|
+
It computes station coordinates by traversing the survey shots from fixed
|
|
6
|
+
reference points (link stations with coordinates or the project location).
|
|
7
|
+
|
|
8
|
+
GeoJSON output uses WGS84 coordinates (longitude, latitude, elevation in meters).
|
|
9
|
+
|
|
10
|
+
Architecture follows openspeleo_lib pattern:
|
|
11
|
+
- Build station graph from shots
|
|
12
|
+
- Propagate coordinates via BFS from anchor points
|
|
13
|
+
- Convert to GeoJSON using the geojson library
|
|
14
|
+
|
|
15
|
+
Declination handling:
|
|
16
|
+
- Declination is ALWAYS calculated from the project anchor location and survey date
|
|
17
|
+
- The datum specified in the MAK file is ignored; WGS84 is always used
|
|
18
|
+
- This provides consistent, accurate magnetic declination values
|
|
19
|
+
|
|
20
|
+
Convergence handling:
|
|
21
|
+
- UTM convergence angle is ALWAYS applied to align survey azimuths with the UTM grid
|
|
22
|
+
- The convergence value comes from the MAK file (% or * directive, or @ location)
|
|
23
|
+
|
|
24
|
+
Exclusion flags:
|
|
25
|
+
- X (total exclusion): Shot excluded from all processing
|
|
26
|
+
- P (plotting exclusion): Shot excluded from GeoJSON output
|
|
27
|
+
- L (length exclusion): Ignored (only affects length statistics)
|
|
28
|
+
- C (close exclusion): Ignored (only affects loop closure)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import datetime
|
|
34
|
+
import logging
|
|
35
|
+
import math
|
|
36
|
+
from collections import deque
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from dataclasses import field
|
|
39
|
+
from typing import TYPE_CHECKING
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
import orjson
|
|
43
|
+
import utm
|
|
44
|
+
from compass_lib.constants import FEET_TO_METERS
|
|
45
|
+
from compass_lib.constants import GEOJSON_COORDINATE_PRECISION
|
|
46
|
+
from compass_lib.constants import JSON_ENCODING
|
|
47
|
+
from compass_lib.enums import Datum
|
|
48
|
+
from compass_lib.geo_utils import GeoLocation
|
|
49
|
+
from compass_lib.geo_utils import get_declination
|
|
50
|
+
from compass_lib.io import load_project
|
|
51
|
+
from geojson import Feature
|
|
52
|
+
from geojson import FeatureCollection
|
|
53
|
+
from geojson import LineString
|
|
54
|
+
from geojson import Point
|
|
55
|
+
from geojson import Polygon
|
|
56
|
+
|
|
57
|
+
if TYPE_CHECKING:
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
|
|
60
|
+
from compass_lib.project.models import CompassMakFile
|
|
61
|
+
from compass_lib.survey.models import CompassShot
|
|
62
|
+
from compass_lib.survey.models import CompassTrip
|
|
63
|
+
|
|
64
|
+
logger = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# -----------------------------------------------------------------------------
|
|
68
|
+
# Exceptions
|
|
69
|
+
# -----------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class NoKnownAnchorError(Exception):
|
|
73
|
+
"""Raised when a survey has no known anchor station."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DisconnectedStationError(Exception):
|
|
77
|
+
"""Raised when a station is disconnected from the graph."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class InvalidCoordinateError(Exception):
|
|
81
|
+
"""Raised when coordinate conversion fails."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# -----------------------------------------------------------------------------
|
|
85
|
+
# Data Classes
|
|
86
|
+
# -----------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class Station:
|
|
91
|
+
"""A computed station with UTM coordinates."""
|
|
92
|
+
|
|
93
|
+
name: str
|
|
94
|
+
easting: float # UTM easting in meters
|
|
95
|
+
northing: float # UTM northing in meters
|
|
96
|
+
elevation: float # Elevation in meters
|
|
97
|
+
file: str = ""
|
|
98
|
+
trip: str = ""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class SurveyLeg:
|
|
103
|
+
"""A survey leg (shot) between two stations."""
|
|
104
|
+
|
|
105
|
+
from_station: Station
|
|
106
|
+
to_station: Station
|
|
107
|
+
distance: float
|
|
108
|
+
azimuth: float | None
|
|
109
|
+
inclination: float | None
|
|
110
|
+
file: str = ""
|
|
111
|
+
trip: str = ""
|
|
112
|
+
left: float | None = None
|
|
113
|
+
right: float | None = None
|
|
114
|
+
up: float | None = None
|
|
115
|
+
down: float | None = None
|
|
116
|
+
lruds_at_to_station: bool = (
|
|
117
|
+
False # Controls which station LRUDs are associated with
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class ComputedSurvey:
|
|
123
|
+
"""Computed survey with station coordinates."""
|
|
124
|
+
|
|
125
|
+
stations: dict[str, Station] = field(default_factory=dict)
|
|
126
|
+
legs: list[SurveyLeg] = field(default_factory=list)
|
|
127
|
+
utm_zone: int | None = None
|
|
128
|
+
utm_northern: bool = True
|
|
129
|
+
datum: Datum | None = None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# -----------------------------------------------------------------------------
|
|
133
|
+
# Coordinate Utilities
|
|
134
|
+
# -----------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def length_to_meters(length_ft: float) -> float:
|
|
138
|
+
"""Convert length from feet to meters."""
|
|
139
|
+
return length_ft * FEET_TO_METERS
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def utm_to_wgs84(
|
|
143
|
+
easting: float,
|
|
144
|
+
northing: float,
|
|
145
|
+
zone: int,
|
|
146
|
+
northern: bool = True,
|
|
147
|
+
) -> tuple[float, float]:
|
|
148
|
+
"""Convert UTM coordinates to WGS84 (longitude, latitude).
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
easting: UTM easting in meters
|
|
152
|
+
northing: UTM northing in meters
|
|
153
|
+
zone: UTM zone number (1-60 north, -1 to -60 south; absolute value used)
|
|
154
|
+
northern: True if northern hemisphere
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Tuple of (longitude, latitude) in degrees (GeoJSON order)
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
InvalidCoordinateError: If conversion fails
|
|
161
|
+
"""
|
|
162
|
+
try:
|
|
163
|
+
# utm.to_latlon requires positive zone number
|
|
164
|
+
lat, lon = utm.to_latlon(easting, northing, abs(zone), northern=northern)
|
|
165
|
+
return (
|
|
166
|
+
round(float(lon), GEOJSON_COORDINATE_PRECISION),
|
|
167
|
+
round(float(lat), GEOJSON_COORDINATE_PRECISION),
|
|
168
|
+
)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
raise InvalidCoordinateError(
|
|
171
|
+
f"Failed to convert UTM ({easting}, {northing}) zone {zone}: {e}"
|
|
172
|
+
) from e
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# -----------------------------------------------------------------------------
|
|
176
|
+
# Declination and Convergence
|
|
177
|
+
# -----------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def get_project_location_wgs84(project: CompassMakFile) -> GeoLocation | None:
|
|
181
|
+
"""Get the project anchor location as WGS84 lat/lon.
|
|
182
|
+
|
|
183
|
+
The anchor location is used for calculating magnetic declination.
|
|
184
|
+
It is found in this priority order:
|
|
185
|
+
1. LocationDirective (@) if it has a valid location (zone != 0)
|
|
186
|
+
2. First fixed station with coordinates from link stations
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
project: The Compass project
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
GeoLocation with lat/lon, or None if no valid location found
|
|
193
|
+
"""
|
|
194
|
+
if not project.utm_zone:
|
|
195
|
+
logger.warning("No UTM zone found in project")
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
# Determine hemisphere from zone sign (positive = north, negative = south)
|
|
199
|
+
northern = project.utm_zone > 0
|
|
200
|
+
utm_zone = abs(project.utm_zone)
|
|
201
|
+
|
|
202
|
+
# Try 1: Use project location (@) if valid
|
|
203
|
+
loc = project.location
|
|
204
|
+
if loc and loc.has_location:
|
|
205
|
+
try:
|
|
206
|
+
lat, lon = utm.to_latlon(
|
|
207
|
+
loc.easting, loc.northing, utm_zone, northern=northern
|
|
208
|
+
)
|
|
209
|
+
return GeoLocation(latitude=lat, longitude=lon)
|
|
210
|
+
except Exception: # noqa: BLE001
|
|
211
|
+
logger.warning(
|
|
212
|
+
"Failed to convert project location to lat/lon: "
|
|
213
|
+
"easting=%.2f, northing=%.2f, zone=%d, northern=%b",
|
|
214
|
+
loc.easting,
|
|
215
|
+
loc.northing,
|
|
216
|
+
utm_zone,
|
|
217
|
+
northern,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Try 2: Use first fixed station with coordinates
|
|
221
|
+
fixed_stations = project.get_fixed_stations()
|
|
222
|
+
if fixed_stations:
|
|
223
|
+
first_fixed = fixed_stations[0]
|
|
224
|
+
fixed_loc = first_fixed.location
|
|
225
|
+
if fixed_loc:
|
|
226
|
+
# Convert feet to meters if needed
|
|
227
|
+
factor = FEET_TO_METERS if fixed_loc.unit.lower() == "f" else 1.0
|
|
228
|
+
|
|
229
|
+
easting = fixed_loc.easting * factor
|
|
230
|
+
northing = fixed_loc.northing * factor
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
lat, lon = utm.to_latlon(easting, northing, utm_zone, northern=northern)
|
|
234
|
+
logger.info(
|
|
235
|
+
"Using fixed station '%s' as anchor location: (%.4f, %.4f)",
|
|
236
|
+
first_fixed.name,
|
|
237
|
+
lat,
|
|
238
|
+
lon,
|
|
239
|
+
)
|
|
240
|
+
return GeoLocation(latitude=lat, longitude=lon)
|
|
241
|
+
except Exception: # noqa: BLE001
|
|
242
|
+
logger.warning(
|
|
243
|
+
"Failed to convert fixed station '%s' to lat/lon: "
|
|
244
|
+
"easting=%.2f, northing=%.2f, zone=%d, northern=%b",
|
|
245
|
+
first_fixed.name,
|
|
246
|
+
easting,
|
|
247
|
+
northing,
|
|
248
|
+
utm_zone,
|
|
249
|
+
northern,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
logger.warning("No valid anchor location found for declination calculation")
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def get_station_location_wgs84(
|
|
257
|
+
station: Station,
|
|
258
|
+
utm_zone: int,
|
|
259
|
+
utm_northern: bool,
|
|
260
|
+
) -> GeoLocation:
|
|
261
|
+
"""Convert a station's UTM coordinates to WGS84 lat/lon for declination calculation.
|
|
262
|
+
|
|
263
|
+
IMPORTANT: Compass stores ALL UTM coordinates in northern hemisphere format,
|
|
264
|
+
regardless of the zone sign. The zone sign only indicates which hemisphere
|
|
265
|
+
the cave is in for grid orientation purposes (convergence corrections).
|
|
266
|
+
|
|
267
|
+
Therefore, we ALWAYS use northern=True when converting coordinates to get
|
|
268
|
+
the actual geographic location for declination calculation.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
station: Station with UTM coordinates
|
|
272
|
+
utm_zone: UTM zone number (can be negative for southern hemisphere)
|
|
273
|
+
utm_northern: Hemisphere flag (parameter kept for API consistency but not used)
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
GeoLocation with actual WGS84 lat/lon coordinates
|
|
277
|
+
|
|
278
|
+
Raises:
|
|
279
|
+
InvalidCoordinateError: If conversion fails
|
|
280
|
+
"""
|
|
281
|
+
# Always use northern=True because Compass stores all coordinates in northern format
|
|
282
|
+
# The zone sign is only used for convergence/grid orientation, not coordinate storage
|
|
283
|
+
lon, lat = utm_to_wgs84(station.easting, station.northing, utm_zone, northern=True)
|
|
284
|
+
return GeoLocation(latitude=lat, longitude=lon)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def calculate_trip_declination(
|
|
288
|
+
station_location: GeoLocation,
|
|
289
|
+
trip_date: datetime.date | None,
|
|
290
|
+
) -> float:
|
|
291
|
+
"""Calculate magnetic declination for a survey trip.
|
|
292
|
+
|
|
293
|
+
Uses a station location from the trip and the survey date to calculate
|
|
294
|
+
the magnetic declination using the IGRF model. The station location should
|
|
295
|
+
be from an anchor station in the trip's file, or from the current station
|
|
296
|
+
being processed.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
station_location: Location of a station from the trip in WGS84
|
|
300
|
+
trip_date: Date of the survey trip
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Declination in degrees (positive = east, negative = west)
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
Exception: If declination calculation fails
|
|
307
|
+
"""
|
|
308
|
+
try:
|
|
309
|
+
if trip_date is None:
|
|
310
|
+
raise ValueError("Impossible to determine the trip date") # noqa: TRY301
|
|
311
|
+
|
|
312
|
+
# Convert date to datetime for the IGRF calculation
|
|
313
|
+
dt = datetime.datetime(trip_date.year, trip_date.month, trip_date.day) # noqa: DTZ001
|
|
314
|
+
return get_declination(station_location, dt)
|
|
315
|
+
|
|
316
|
+
except Exception:
|
|
317
|
+
logger.exception(
|
|
318
|
+
"Failed to calculate declination for date %s at location (%.4f, %.4f)",
|
|
319
|
+
trip_date,
|
|
320
|
+
station_location.latitude,
|
|
321
|
+
station_location.longitude,
|
|
322
|
+
)
|
|
323
|
+
raise
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# -----------------------------------------------------------------------------
|
|
327
|
+
# Graph Building
|
|
328
|
+
# -----------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def build_station_graph(
|
|
332
|
+
project: CompassMakFile,
|
|
333
|
+
) -> tuple[
|
|
334
|
+
dict[str, list[tuple[CompassShot, str, str, CompassTrip, bool]]],
|
|
335
|
+
list[tuple[CompassShot, str, str, CompassTrip]],
|
|
336
|
+
]:
|
|
337
|
+
"""Build adjacency graph from all shots in project.
|
|
338
|
+
|
|
339
|
+
Excludes shots that are marked as:
|
|
340
|
+
- excluded_from_all_processing (X flag)
|
|
341
|
+
- excluded_from_plotting (P flag)
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
Tuple of (adjacency_dict, all_shots_list)
|
|
345
|
+
"""
|
|
346
|
+
all_shots: list[tuple[CompassShot, str, str, CompassTrip]] = []
|
|
347
|
+
adjacency: dict[str, list[tuple[CompassShot, str, str, CompassTrip, bool]]] = {}
|
|
348
|
+
|
|
349
|
+
for file_dir in project.file_directives:
|
|
350
|
+
if not file_dir.data:
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
for trip in file_dir.data.trips:
|
|
354
|
+
trip_name = trip.header.survey_name or "unnamed"
|
|
355
|
+
for shot in trip.shots:
|
|
356
|
+
# Skip shots excluded from processing or plotting
|
|
357
|
+
if shot.excluded_from_all_processing:
|
|
358
|
+
continue
|
|
359
|
+
if shot.excluded_from_plotting:
|
|
360
|
+
continue
|
|
361
|
+
|
|
362
|
+
all_shots.append((shot, file_dir.file, trip_name, trip))
|
|
363
|
+
|
|
364
|
+
from_name = shot.from_station_name
|
|
365
|
+
to_name = shot.to_station_name
|
|
366
|
+
|
|
367
|
+
if from_name not in adjacency:
|
|
368
|
+
adjacency[from_name] = []
|
|
369
|
+
if to_name not in adjacency:
|
|
370
|
+
adjacency[to_name] = []
|
|
371
|
+
|
|
372
|
+
# Add forward and reverse connections
|
|
373
|
+
adjacency[from_name].append(
|
|
374
|
+
(shot, file_dir.file, trip_name, trip, False)
|
|
375
|
+
)
|
|
376
|
+
adjacency[to_name].append((shot, file_dir.file, trip_name, trip, True))
|
|
377
|
+
|
|
378
|
+
return adjacency, all_shots
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def find_anchor_stations(project: CompassMakFile) -> dict[str, Station]:
|
|
382
|
+
"""Find all anchor stations with known coordinates.
|
|
383
|
+
|
|
384
|
+
Anchors come from:
|
|
385
|
+
1. Link stations with fixed coordinates in file directives
|
|
386
|
+
2. Project location (as origin for first station)
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
Dictionary of station name -> Station with coordinates
|
|
390
|
+
"""
|
|
391
|
+
anchors: dict[str, Station] = {}
|
|
392
|
+
|
|
393
|
+
# Get fixed stations from link stations
|
|
394
|
+
for file_dir in project.file_directives:
|
|
395
|
+
for link_station in file_dir.link_stations:
|
|
396
|
+
if link_station.location:
|
|
397
|
+
loc = link_station.location
|
|
398
|
+
factor = FEET_TO_METERS if loc.unit.lower() == "f" else 1.0
|
|
399
|
+
anchors[link_station.name] = Station(
|
|
400
|
+
name=link_station.name,
|
|
401
|
+
easting=loc.easting * factor,
|
|
402
|
+
northing=loc.northing * factor,
|
|
403
|
+
elevation=loc.elevation * factor,
|
|
404
|
+
file=file_dir.file,
|
|
405
|
+
)
|
|
406
|
+
logger.debug(
|
|
407
|
+
"Anchor station: %s at (%.2f, %.2f, %.2f)",
|
|
408
|
+
link_station.name,
|
|
409
|
+
loc.easting * factor,
|
|
410
|
+
loc.northing * factor,
|
|
411
|
+
loc.elevation * factor,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
return anchors
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# -----------------------------------------------------------------------------
|
|
418
|
+
# Coordinate Propagation
|
|
419
|
+
# -----------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def compute_next_station(
|
|
423
|
+
from_station: Station,
|
|
424
|
+
shot: CompassShot,
|
|
425
|
+
to_name: str,
|
|
426
|
+
is_reverse: bool,
|
|
427
|
+
declination: float,
|
|
428
|
+
convergence: float,
|
|
429
|
+
) -> Station:
|
|
430
|
+
"""Compute coordinates of the next station from a shot.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
from_station: Starting station with known coordinates
|
|
434
|
+
shot: The shot data
|
|
435
|
+
to_name: Name of the target station
|
|
436
|
+
is_reverse: Whether traversing in reverse direction
|
|
437
|
+
declination: Magnetic declination to apply (degrees, calculated from location+date)
|
|
438
|
+
convergence: UTM convergence angle to apply (degrees, from project)
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
Station with computed coordinates
|
|
442
|
+
"""
|
|
443
|
+
distance = shot.length or 0.0
|
|
444
|
+
|
|
445
|
+
# Get azimuth/inclination based on direction
|
|
446
|
+
if is_reverse:
|
|
447
|
+
azimuth = shot.backsight_azimuth
|
|
448
|
+
inclination = shot.backsight_inclination
|
|
449
|
+
|
|
450
|
+
if azimuth is not None:
|
|
451
|
+
azimuth = (azimuth + 180.0) % 360.0
|
|
452
|
+
elif shot.frontsight_azimuth is not None:
|
|
453
|
+
azimuth = (shot.frontsight_azimuth + 180.0) % 360.0
|
|
454
|
+
|
|
455
|
+
if inclination is not None:
|
|
456
|
+
inclination = -inclination
|
|
457
|
+
elif shot.frontsight_inclination is not None:
|
|
458
|
+
inclination = -shot.frontsight_inclination
|
|
459
|
+
else:
|
|
460
|
+
azimuth = shot.frontsight_azimuth
|
|
461
|
+
inclination = shot.frontsight_inclination
|
|
462
|
+
|
|
463
|
+
# Default values
|
|
464
|
+
azimuth = azimuth if azimuth is not None else 0.0
|
|
465
|
+
inclination = inclination if inclination is not None else 0.0
|
|
466
|
+
|
|
467
|
+
# Apply declination and convergence
|
|
468
|
+
# Declination: converts magnetic north to true north
|
|
469
|
+
# Convergence: converts true north to UTM grid north
|
|
470
|
+
azimuth_corrected = azimuth + declination - convergence
|
|
471
|
+
|
|
472
|
+
# Convert to radians
|
|
473
|
+
azimuth_rad = math.radians(azimuth_corrected)
|
|
474
|
+
inclination_rad = math.radians(inclination)
|
|
475
|
+
|
|
476
|
+
# Compute deltas (convert feet to meters)
|
|
477
|
+
distance_m = length_to_meters(distance)
|
|
478
|
+
horizontal_distance = distance_m * math.cos(inclination_rad)
|
|
479
|
+
vertical_distance = distance_m * math.sin(inclination_rad)
|
|
480
|
+
|
|
481
|
+
delta_easting = horizontal_distance * math.sin(azimuth_rad)
|
|
482
|
+
delta_northing = horizontal_distance * math.cos(azimuth_rad)
|
|
483
|
+
|
|
484
|
+
return Station(
|
|
485
|
+
name=to_name,
|
|
486
|
+
easting=from_station.easting + delta_easting,
|
|
487
|
+
northing=from_station.northing + delta_northing,
|
|
488
|
+
elevation=from_station.elevation + vertical_distance,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def propagate_coordinates(
|
|
493
|
+
project: CompassMakFile,
|
|
494
|
+
anchors: dict[str, Station],
|
|
495
|
+
adjacency: dict[str, list[tuple[CompassShot, str, str, CompassTrip, bool]]],
|
|
496
|
+
) -> ComputedSurvey:
|
|
497
|
+
"""Propagate coordinates from anchor stations via BFS.
|
|
498
|
+
|
|
499
|
+
Declination is calculated per trip using:
|
|
500
|
+
- Project anchor location (from @ directive)
|
|
501
|
+
- Survey date from trip header
|
|
502
|
+
|
|
503
|
+
UTM convergence is always applied from the project settings.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
project: The Compass project
|
|
507
|
+
anchors: Dictionary of anchor stations with known coordinates
|
|
508
|
+
adjacency: Station adjacency graph
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
ComputedSurvey with all computed stations and legs
|
|
512
|
+
"""
|
|
513
|
+
result = ComputedSurvey()
|
|
514
|
+
|
|
515
|
+
# Set metadata
|
|
516
|
+
result.datum = project.datum
|
|
517
|
+
result.utm_zone = project.utm_zone
|
|
518
|
+
|
|
519
|
+
# Determine hemisphere from zone sign (positive = north, negative = south)
|
|
520
|
+
if result.utm_zone:
|
|
521
|
+
result.utm_northern = result.utm_zone > 0
|
|
522
|
+
|
|
523
|
+
# Get convergence angle from project (always applied)
|
|
524
|
+
convergence = project.utm_convergence
|
|
525
|
+
logger.info("UTM convergence angle: %.3f°", convergence)
|
|
526
|
+
|
|
527
|
+
# Get LRUD flags from project
|
|
528
|
+
flags = project.flags
|
|
529
|
+
lruds_at_to_station = flags.lruds_at_to_station if flags else False
|
|
530
|
+
|
|
531
|
+
# Cache for trip declinations (calculated once per trip)
|
|
532
|
+
trip_declinations: dict[str, float] = {}
|
|
533
|
+
|
|
534
|
+
# Initialize with anchors
|
|
535
|
+
result.stations = dict(anchors)
|
|
536
|
+
|
|
537
|
+
if not result.stations:
|
|
538
|
+
# No anchors - use project location or origin for first station
|
|
539
|
+
loc = project.location
|
|
540
|
+
if loc and loc.has_location:
|
|
541
|
+
origin = Station(
|
|
542
|
+
name="__ORIGIN__",
|
|
543
|
+
easting=loc.easting,
|
|
544
|
+
northing=loc.northing,
|
|
545
|
+
elevation=loc.elevation,
|
|
546
|
+
)
|
|
547
|
+
else:
|
|
548
|
+
origin = Station(
|
|
549
|
+
name="__ORIGIN__",
|
|
550
|
+
easting=0.0,
|
|
551
|
+
northing=0.0,
|
|
552
|
+
elevation=0.0,
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
# Find first station and place at origin
|
|
556
|
+
if adjacency:
|
|
557
|
+
first_station_name = next(iter(adjacency.keys()))
|
|
558
|
+
result.stations[first_station_name] = Station(
|
|
559
|
+
name=first_station_name,
|
|
560
|
+
easting=origin.easting,
|
|
561
|
+
northing=origin.northing,
|
|
562
|
+
elevation=origin.elevation,
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
if not result.stations:
|
|
566
|
+
raise NoKnownAnchorError("No anchor stations found and no shots available")
|
|
567
|
+
|
|
568
|
+
logger.info(
|
|
569
|
+
"Starting coordinate propagation from %d anchor(s)", len(result.stations)
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
# BFS from anchor stations
|
|
573
|
+
queue: deque[str] = deque(result.stations.keys())
|
|
574
|
+
visited_stations: set[str] = set(result.stations.keys())
|
|
575
|
+
visited_shots: set[tuple[str, str]] = set()
|
|
576
|
+
|
|
577
|
+
while queue:
|
|
578
|
+
current_name = queue.popleft()
|
|
579
|
+
current_station = result.stations.get(current_name)
|
|
580
|
+
if not current_station:
|
|
581
|
+
continue
|
|
582
|
+
|
|
583
|
+
for shot, file_name, trip_name, trip, is_reverse in adjacency.get(
|
|
584
|
+
current_name, []
|
|
585
|
+
):
|
|
586
|
+
# Determine direction
|
|
587
|
+
if is_reverse:
|
|
588
|
+
from_name = shot.to_station_name
|
|
589
|
+
to_name = shot.from_station_name
|
|
590
|
+
else:
|
|
591
|
+
from_name = shot.from_station_name
|
|
592
|
+
to_name = shot.to_station_name
|
|
593
|
+
|
|
594
|
+
# Skip already processed shots
|
|
595
|
+
shot_key = (min(from_name, to_name), max(from_name, to_name))
|
|
596
|
+
if shot_key in visited_shots:
|
|
597
|
+
continue
|
|
598
|
+
visited_shots.add(shot_key)
|
|
599
|
+
|
|
600
|
+
# Calculate declination for this trip (cached)
|
|
601
|
+
trip_key = f"{file_name}:{trip_name}"
|
|
602
|
+
if trip_key not in trip_declinations:
|
|
603
|
+
# Find anchor station for this trip's file (priority 1)
|
|
604
|
+
trip_anchor = None
|
|
605
|
+
for anchor_name, anchor_station in anchors.items():
|
|
606
|
+
if anchor_station.file == file_name:
|
|
607
|
+
trip_anchor = anchor_station
|
|
608
|
+
break
|
|
609
|
+
|
|
610
|
+
# Use trip anchor if found, otherwise use current station (priority 2)
|
|
611
|
+
location_station = trip_anchor if trip_anchor else current_station
|
|
612
|
+
|
|
613
|
+
# Convert station to WGS84 for declination calculation
|
|
614
|
+
station_wgs84 = get_station_location_wgs84(
|
|
615
|
+
location_station, result.utm_zone, result.utm_northern
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
logger.debug(
|
|
619
|
+
"Trip %s: using station %s at UTM(%.1f, %.1f) zone=%d northern=%s -> WGS84(%.4f, %.4f)",
|
|
620
|
+
trip_key,
|
|
621
|
+
location_station.name,
|
|
622
|
+
location_station.easting,
|
|
623
|
+
location_station.northing,
|
|
624
|
+
result.utm_zone,
|
|
625
|
+
result.utm_northern,
|
|
626
|
+
station_wgs84.latitude,
|
|
627
|
+
station_wgs84.longitude,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
declination = calculate_trip_declination(
|
|
631
|
+
station_wgs84, trip.header.date
|
|
632
|
+
)
|
|
633
|
+
trip_declinations[trip_key] = declination
|
|
634
|
+
logger.debug(
|
|
635
|
+
"Trip %s declination: %.2f° (date: %s, station: %s)",
|
|
636
|
+
trip_key,
|
|
637
|
+
declination,
|
|
638
|
+
trip.header.date,
|
|
639
|
+
location_station.name,
|
|
640
|
+
)
|
|
641
|
+
else:
|
|
642
|
+
declination = trip_declinations[trip_key]
|
|
643
|
+
|
|
644
|
+
# Compute new station if needed
|
|
645
|
+
if to_name not in result.stations:
|
|
646
|
+
new_station = compute_next_station(
|
|
647
|
+
current_station,
|
|
648
|
+
shot,
|
|
649
|
+
to_name,
|
|
650
|
+
is_reverse,
|
|
651
|
+
declination,
|
|
652
|
+
convergence,
|
|
653
|
+
)
|
|
654
|
+
new_station.file = file_name
|
|
655
|
+
new_station.trip = trip_name
|
|
656
|
+
result.stations[to_name] = new_station
|
|
657
|
+
|
|
658
|
+
if to_name not in visited_stations:
|
|
659
|
+
visited_stations.add(to_name)
|
|
660
|
+
queue.append(to_name)
|
|
661
|
+
|
|
662
|
+
# Create leg
|
|
663
|
+
from_station = result.stations.get(from_name)
|
|
664
|
+
to_station = result.stations.get(to_name)
|
|
665
|
+
|
|
666
|
+
if from_station and to_station:
|
|
667
|
+
# LRUD association based on O/T flags
|
|
668
|
+
# If lruds_at_to_station is True, associate LRUDs with TO station
|
|
669
|
+
# Otherwise (default), associate with FROM station
|
|
670
|
+
leg = SurveyLeg(
|
|
671
|
+
from_station=from_station,
|
|
672
|
+
to_station=to_station,
|
|
673
|
+
distance=shot.length or 0.0,
|
|
674
|
+
azimuth=shot.frontsight_azimuth,
|
|
675
|
+
inclination=shot.frontsight_inclination,
|
|
676
|
+
file=file_name,
|
|
677
|
+
trip=trip_name,
|
|
678
|
+
left=shot.left,
|
|
679
|
+
right=shot.right,
|
|
680
|
+
up=shot.up,
|
|
681
|
+
down=shot.down,
|
|
682
|
+
lruds_at_to_station=lruds_at_to_station,
|
|
683
|
+
)
|
|
684
|
+
result.legs.append(leg)
|
|
685
|
+
|
|
686
|
+
logger.info(
|
|
687
|
+
"Computed %d stations and %d legs",
|
|
688
|
+
len(result.stations),
|
|
689
|
+
len(result.legs),
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
return result
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def compute_survey_coordinates(project: CompassMakFile) -> ComputedSurvey:
|
|
696
|
+
"""Compute station coordinates from survey data.
|
|
697
|
+
|
|
698
|
+
This is the main entry point for coordinate computation.
|
|
699
|
+
|
|
700
|
+
Args:
|
|
701
|
+
project: Loaded CompassMakFile with DAT data
|
|
702
|
+
|
|
703
|
+
Returns:
|
|
704
|
+
ComputedSurvey with station coordinates and legs
|
|
705
|
+
"""
|
|
706
|
+
adjacency, _ = build_station_graph(project)
|
|
707
|
+
anchors = find_anchor_stations(project)
|
|
708
|
+
return propagate_coordinates(project, anchors, adjacency)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
# -----------------------------------------------------------------------------
|
|
712
|
+
# GeoJSON Feature Creation
|
|
713
|
+
# -----------------------------------------------------------------------------
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def station_to_feature(
|
|
717
|
+
station: Station,
|
|
718
|
+
zone: int,
|
|
719
|
+
northern: bool,
|
|
720
|
+
) -> Feature:
|
|
721
|
+
"""Convert a station to a GeoJSON Point Feature.
|
|
722
|
+
|
|
723
|
+
Args:
|
|
724
|
+
station: Station with coordinates
|
|
725
|
+
zone: UTM zone number
|
|
726
|
+
northern: True if northern hemisphere
|
|
727
|
+
|
|
728
|
+
Returns:
|
|
729
|
+
GeoJSON Feature with Point geometry
|
|
730
|
+
"""
|
|
731
|
+
lon, lat = utm_to_wgs84(station.easting, station.northing, zone, northern)
|
|
732
|
+
|
|
733
|
+
return Feature(
|
|
734
|
+
geometry=Point((lon, lat, round(station.elevation, 2))),
|
|
735
|
+
properties={
|
|
736
|
+
"type": "station",
|
|
737
|
+
"name": station.name,
|
|
738
|
+
"file": station.file,
|
|
739
|
+
"trip": station.trip,
|
|
740
|
+
"elevation_m": round(station.elevation, 2),
|
|
741
|
+
},
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def leg_to_feature(
|
|
746
|
+
leg: SurveyLeg,
|
|
747
|
+
zone: int,
|
|
748
|
+
northern: bool,
|
|
749
|
+
) -> Feature:
|
|
750
|
+
"""Convert a survey leg to a GeoJSON LineString Feature.
|
|
751
|
+
|
|
752
|
+
Args:
|
|
753
|
+
leg: Survey leg with from/to stations
|
|
754
|
+
zone: UTM zone number
|
|
755
|
+
northern: True if northern hemisphere
|
|
756
|
+
|
|
757
|
+
Returns:
|
|
758
|
+
GeoJSON Feature with LineString geometry
|
|
759
|
+
"""
|
|
760
|
+
from_lon, from_lat = utm_to_wgs84(
|
|
761
|
+
leg.from_station.easting, leg.from_station.northing, zone, northern
|
|
762
|
+
)
|
|
763
|
+
to_lon, to_lat = utm_to_wgs84(
|
|
764
|
+
leg.to_station.easting, leg.to_station.northing, zone, northern
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
return Feature(
|
|
768
|
+
geometry=LineString(
|
|
769
|
+
[
|
|
770
|
+
(from_lon, from_lat, round(leg.from_station.elevation, 2)),
|
|
771
|
+
(to_lon, to_lat, round(leg.to_station.elevation, 2)),
|
|
772
|
+
]
|
|
773
|
+
),
|
|
774
|
+
properties={
|
|
775
|
+
"type": "leg",
|
|
776
|
+
"from": leg.from_station.name,
|
|
777
|
+
"to": leg.to_station.name,
|
|
778
|
+
"distance_ft": leg.distance,
|
|
779
|
+
"distance_m": round(length_to_meters(leg.distance), 2)
|
|
780
|
+
if leg.distance
|
|
781
|
+
else None,
|
|
782
|
+
"azimuth": leg.azimuth,
|
|
783
|
+
"inclination": leg.inclination,
|
|
784
|
+
"file": leg.file,
|
|
785
|
+
"trip": leg.trip,
|
|
786
|
+
},
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def passage_to_feature(
|
|
791
|
+
leg: SurveyLeg,
|
|
792
|
+
zone: int,
|
|
793
|
+
northern: bool,
|
|
794
|
+
) -> Feature | None:
|
|
795
|
+
"""Convert LRUD data to a passage polygon Feature.
|
|
796
|
+
|
|
797
|
+
The LRUD values are applied at either the FROM or TO station based on
|
|
798
|
+
the lruds_at_to_station flag (controlled by O/T project flags).
|
|
799
|
+
|
|
800
|
+
Args:
|
|
801
|
+
leg: Survey leg with LRUD data
|
|
802
|
+
zone: UTM zone number
|
|
803
|
+
northern: True if northern hemisphere
|
|
804
|
+
|
|
805
|
+
Returns:
|
|
806
|
+
GeoJSON Feature with Polygon geometry, or None if no LRUD data
|
|
807
|
+
"""
|
|
808
|
+
if leg.left is None and leg.right is None:
|
|
809
|
+
return None
|
|
810
|
+
|
|
811
|
+
if leg.azimuth is None:
|
|
812
|
+
return None
|
|
813
|
+
|
|
814
|
+
left_m = length_to_meters(leg.left or 0.0)
|
|
815
|
+
right_m = length_to_meters(leg.right or 0.0)
|
|
816
|
+
|
|
817
|
+
# Perpendicular direction
|
|
818
|
+
azimuth_rad = math.radians(leg.azimuth)
|
|
819
|
+
perp_rad = azimuth_rad + math.pi / 2
|
|
820
|
+
|
|
821
|
+
# Determine which station the LRUDs are associated with
|
|
822
|
+
if leg.lruds_at_to_station:
|
|
823
|
+
# LRUDs are at the TO station - apply full LRUD width at TO,
|
|
824
|
+
# and taper to zero at FROM
|
|
825
|
+
from_left_e = leg.from_station.easting
|
|
826
|
+
from_left_n = leg.from_station.northing
|
|
827
|
+
from_right_e = leg.from_station.easting
|
|
828
|
+
from_right_n = leg.from_station.northing
|
|
829
|
+
|
|
830
|
+
to_left_e = leg.to_station.easting + left_m * math.sin(perp_rad)
|
|
831
|
+
to_left_n = leg.to_station.northing + left_m * math.cos(perp_rad)
|
|
832
|
+
to_right_e = leg.to_station.easting - right_m * math.sin(perp_rad)
|
|
833
|
+
to_right_n = leg.to_station.northing - right_m * math.cos(perp_rad)
|
|
834
|
+
else:
|
|
835
|
+
# LRUDs are at the FROM station (default) - apply full LRUD width at FROM,
|
|
836
|
+
# and taper to zero at TO
|
|
837
|
+
from_left_e = leg.from_station.easting + left_m * math.sin(perp_rad)
|
|
838
|
+
from_left_n = leg.from_station.northing + left_m * math.cos(perp_rad)
|
|
839
|
+
from_right_e = leg.from_station.easting - right_m * math.sin(perp_rad)
|
|
840
|
+
from_right_n = leg.from_station.northing - right_m * math.cos(perp_rad)
|
|
841
|
+
|
|
842
|
+
to_left_e = leg.to_station.easting
|
|
843
|
+
to_left_n = leg.to_station.northing
|
|
844
|
+
to_right_e = leg.to_station.easting
|
|
845
|
+
to_right_n = leg.to_station.northing
|
|
846
|
+
|
|
847
|
+
try:
|
|
848
|
+
from_left = utm_to_wgs84(from_left_e, from_left_n, zone, northern)
|
|
849
|
+
from_right = utm_to_wgs84(from_right_e, from_right_n, zone, northern)
|
|
850
|
+
to_left = utm_to_wgs84(to_left_e, to_left_n, zone, northern)
|
|
851
|
+
to_right = utm_to_wgs84(to_right_e, to_right_n, zone, northern)
|
|
852
|
+
except InvalidCoordinateError:
|
|
853
|
+
return None
|
|
854
|
+
|
|
855
|
+
elev = round((leg.from_station.elevation + leg.to_station.elevation) / 2, 2)
|
|
856
|
+
|
|
857
|
+
return Feature(
|
|
858
|
+
geometry=Polygon(
|
|
859
|
+
[
|
|
860
|
+
[
|
|
861
|
+
(from_left[0], from_left[1], elev),
|
|
862
|
+
(to_left[0], to_left[1], elev),
|
|
863
|
+
(to_right[0], to_right[1], elev),
|
|
864
|
+
(from_right[0], from_right[1], elev),
|
|
865
|
+
(from_left[0], from_left[1], elev), # Close polygon
|
|
866
|
+
]
|
|
867
|
+
]
|
|
868
|
+
),
|
|
869
|
+
properties={
|
|
870
|
+
"type": "passage",
|
|
871
|
+
"from": leg.from_station.name,
|
|
872
|
+
"to": leg.to_station.name,
|
|
873
|
+
"left_ft": leg.left,
|
|
874
|
+
"right_ft": leg.right,
|
|
875
|
+
"up_ft": leg.up,
|
|
876
|
+
"down_ft": leg.down,
|
|
877
|
+
"lruds_at_to_station": leg.lruds_at_to_station,
|
|
878
|
+
},
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
# -----------------------------------------------------------------------------
|
|
883
|
+
# Main Conversion Functions
|
|
884
|
+
# -----------------------------------------------------------------------------
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def survey_to_geojson(
|
|
888
|
+
survey: ComputedSurvey,
|
|
889
|
+
*,
|
|
890
|
+
include_stations: bool = True,
|
|
891
|
+
include_legs: bool = True,
|
|
892
|
+
include_passages: bool = False,
|
|
893
|
+
properties: dict[str, Any] | None = None,
|
|
894
|
+
) -> FeatureCollection:
|
|
895
|
+
"""Convert computed survey to GeoJSON FeatureCollection.
|
|
896
|
+
|
|
897
|
+
Args:
|
|
898
|
+
survey: Computed survey with coordinates
|
|
899
|
+
include_stations: Include Point features for stations
|
|
900
|
+
include_legs: Include LineString features for survey legs
|
|
901
|
+
include_passages: Include Polygon features for passage outlines
|
|
902
|
+
|
|
903
|
+
Returns:
|
|
904
|
+
GeoJSON FeatureCollection
|
|
905
|
+
"""
|
|
906
|
+
if survey.utm_zone is None:
|
|
907
|
+
raise ValueError(
|
|
908
|
+
"Cannot convert to GeoJSON: UTM zone is not known. "
|
|
909
|
+
"The MAK file must have a LocationDirective (@) or UTMZoneDirective ($)."
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
zone = survey.utm_zone
|
|
913
|
+
northern = survey.utm_northern
|
|
914
|
+
features: list[Feature] = []
|
|
915
|
+
|
|
916
|
+
# Add station points
|
|
917
|
+
if include_stations:
|
|
918
|
+
for name, station in survey.stations.items():
|
|
919
|
+
if name.startswith("__"):
|
|
920
|
+
continue # Skip internal markers
|
|
921
|
+
|
|
922
|
+
try:
|
|
923
|
+
features.append(station_to_feature(station, zone, northern))
|
|
924
|
+
except InvalidCoordinateError:
|
|
925
|
+
logger.warning("Skipping station %s: invalid coordinates", name)
|
|
926
|
+
|
|
927
|
+
# Add survey legs
|
|
928
|
+
if include_legs:
|
|
929
|
+
try:
|
|
930
|
+
features.extend(leg_to_feature(leg, zone, northern) for leg in survey.legs)
|
|
931
|
+
except InvalidCoordinateError:
|
|
932
|
+
logger.exception("Invalid leg coordinates")
|
|
933
|
+
raise
|
|
934
|
+
|
|
935
|
+
# Add passage polygons
|
|
936
|
+
if include_passages:
|
|
937
|
+
for leg in survey.legs:
|
|
938
|
+
passage = passage_to_feature(leg, zone, northern)
|
|
939
|
+
if passage:
|
|
940
|
+
features.append(passage)
|
|
941
|
+
|
|
942
|
+
# Build properties
|
|
943
|
+
fc_properties = {}
|
|
944
|
+
if survey.utm_zone:
|
|
945
|
+
fc_properties["source_utm_zone"] = survey.utm_zone
|
|
946
|
+
if survey.datum:
|
|
947
|
+
fc_properties["source_datum"] = (
|
|
948
|
+
survey.datum.value if isinstance(survey.datum, Datum) else survey.datum
|
|
949
|
+
)
|
|
950
|
+
if properties:
|
|
951
|
+
fc_properties.update(properties)
|
|
952
|
+
|
|
953
|
+
return FeatureCollection(
|
|
954
|
+
features, properties=fc_properties if fc_properties else None
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def project_to_geojson(
|
|
959
|
+
project: CompassMakFile,
|
|
960
|
+
*,
|
|
961
|
+
include_stations: bool = True,
|
|
962
|
+
include_legs: bool = True,
|
|
963
|
+
include_passages: bool = False,
|
|
964
|
+
) -> FeatureCollection:
|
|
965
|
+
"""Convert a Compass project to GeoJSON.
|
|
966
|
+
|
|
967
|
+
This is the high-level function that computes coordinates and generates
|
|
968
|
+
GeoJSON in one step.
|
|
969
|
+
|
|
970
|
+
Args:
|
|
971
|
+
project: Loaded CompassMakFile with DAT data
|
|
972
|
+
include_stations: Include Point features for stations
|
|
973
|
+
include_legs: Include LineString features for survey legs
|
|
974
|
+
include_passages: Include Polygon features for passage outlines
|
|
975
|
+
|
|
976
|
+
Returns:
|
|
977
|
+
GeoJSON FeatureCollection
|
|
978
|
+
"""
|
|
979
|
+
survey = compute_survey_coordinates(project)
|
|
980
|
+
return survey_to_geojson(
|
|
981
|
+
survey,
|
|
982
|
+
include_stations=include_stations,
|
|
983
|
+
include_legs=include_legs,
|
|
984
|
+
include_passages=include_passages,
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def convert_mak_to_geojson(
|
|
989
|
+
mak_path: Path,
|
|
990
|
+
output_path: Path | None = None,
|
|
991
|
+
*,
|
|
992
|
+
include_stations: bool = True,
|
|
993
|
+
include_legs: bool = True,
|
|
994
|
+
include_passages: bool = False,
|
|
995
|
+
) -> str:
|
|
996
|
+
"""Convert a MAK file (with DAT files) to GeoJSON.
|
|
997
|
+
|
|
998
|
+
Args:
|
|
999
|
+
mak_path: Path to the MAK file
|
|
1000
|
+
output_path: Optional output path (returns string if None)
|
|
1001
|
+
include_stations: Include Point features for stations
|
|
1002
|
+
include_legs: Include LineString features for survey legs
|
|
1003
|
+
include_passages: Include Polygon features for passage outlines
|
|
1004
|
+
|
|
1005
|
+
Returns:
|
|
1006
|
+
GeoJSON string
|
|
1007
|
+
"""
|
|
1008
|
+
|
|
1009
|
+
project = load_project(mak_path)
|
|
1010
|
+
geojson = project_to_geojson(
|
|
1011
|
+
project,
|
|
1012
|
+
include_stations=include_stations,
|
|
1013
|
+
include_legs=include_legs,
|
|
1014
|
+
include_passages=include_passages,
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
# Use orjson for fast serialization
|
|
1018
|
+
json_bytes = orjson.dumps(geojson, option=orjson.OPT_INDENT_2)
|
|
1019
|
+
json_str = json_bytes.decode(JSON_ENCODING)
|
|
1020
|
+
|
|
1021
|
+
if output_path:
|
|
1022
|
+
output_path.write_text(json_str, encoding=JSON_ENCODING)
|
|
1023
|
+
|
|
1024
|
+
return json_str
|