compass-lib 0.0.1__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.
Files changed (38) hide show
  1. compass_lib/__init__.py +115 -3
  2. compass_lib/commands/__init__.py +2 -1
  3. compass_lib/commands/convert.py +225 -32
  4. compass_lib/commands/encrypt.py +115 -0
  5. compass_lib/commands/geojson.py +118 -0
  6. compass_lib/commands/main.py +4 -2
  7. compass_lib/constants.py +84 -0
  8. compass_lib/enums.py +309 -65
  9. compass_lib/errors.py +86 -0
  10. compass_lib/geo_utils.py +47 -0
  11. compass_lib/geojson.py +1024 -0
  12. compass_lib/interface.py +332 -0
  13. compass_lib/io.py +246 -0
  14. compass_lib/models.py +251 -0
  15. compass_lib/plot/__init__.py +28 -0
  16. compass_lib/plot/models.py +265 -0
  17. compass_lib/plot/parser.py +610 -0
  18. compass_lib/project/__init__.py +36 -0
  19. compass_lib/project/format.py +158 -0
  20. compass_lib/project/models.py +494 -0
  21. compass_lib/project/parser.py +638 -0
  22. compass_lib/survey/__init__.py +24 -0
  23. compass_lib/survey/format.py +284 -0
  24. compass_lib/survey/models.py +160 -0
  25. compass_lib/survey/parser.py +842 -0
  26. compass_lib/validation.py +74 -0
  27. compass_lib-0.0.3.dist-info/METADATA +60 -0
  28. compass_lib-0.0.3.dist-info/RECORD +31 -0
  29. {compass_lib-0.0.1.dist-info → compass_lib-0.0.3.dist-info}/WHEEL +1 -3
  30. compass_lib-0.0.3.dist-info/entry_points.txt +8 -0
  31. compass_lib/parser.py +0 -282
  32. compass_lib/section.py +0 -18
  33. compass_lib/shot.py +0 -21
  34. compass_lib-0.0.1.dist-info/METADATA +0 -268
  35. compass_lib-0.0.1.dist-info/RECORD +0 -14
  36. compass_lib-0.0.1.dist-info/entry_points.txt +0 -5
  37. compass_lib-0.0.1.dist-info/top_level.txt +0 -1
  38. {compass_lib-0.0.1.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