compass-lib 0.0.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
compass_lib/geojson.py ADDED
@@ -0,0 +1,2216 @@
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 closure statistics)
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import datetime
34
+ import logging
35
+ import math
36
+ import uuid
37
+ from collections import deque
38
+ from dataclasses import dataclass
39
+ from dataclasses import field
40
+ from typing import TYPE_CHECKING
41
+ from typing import Any
42
+
43
+ import orjson
44
+ import utm
45
+ from geojson import Feature
46
+ from geojson import FeatureCollection
47
+ from geojson import LineString
48
+ from geojson import Point
49
+ from geojson import Polygon
50
+ from pyproj import Transformer
51
+ from shapely.geometry import Polygon as ShapelyPolygon
52
+ from shapely.ops import unary_union
53
+
54
+ from compass_lib.constants import FEET_TO_METERS
55
+ from compass_lib.constants import GEOJSON_COORDINATE_PRECISION
56
+ from compass_lib.constants import JSON_ENCODING
57
+ from compass_lib.constants import METERS_TO_FEET
58
+ from compass_lib.enums import Datum
59
+ from compass_lib.geo_utils import GeoLocation
60
+ from compass_lib.geo_utils import get_declination
61
+ from compass_lib.io import load_project
62
+ from compass_lib.solver.models import SurveyNetwork
63
+
64
+ if TYPE_CHECKING:
65
+ from pathlib import Path
66
+
67
+ from compass_lib.project.models import CompassMakFile
68
+ from compass_lib.solver.base import SurveyAdjuster
69
+ from compass_lib.survey.models import CompassShot
70
+ from compass_lib.survey.models import CompassSurvey
71
+
72
+ logger = logging.getLogger(__name__)
73
+
74
+
75
+ # -----------------------------------------------------------------------------
76
+ # Exceptions
77
+ # -----------------------------------------------------------------------------
78
+
79
+
80
+ class NoKnownAnchorError(Exception):
81
+ """Raised when a survey has no known anchor station."""
82
+
83
+
84
+ class DisconnectedStationError(Exception):
85
+ """Raised when a station is disconnected from the graph."""
86
+
87
+
88
+ class InvalidCoordinateError(Exception):
89
+ """Raised when coordinate conversion fails."""
90
+
91
+
92
+ # -----------------------------------------------------------------------------
93
+ # Data Classes
94
+ # -----------------------------------------------------------------------------
95
+
96
+
97
+ @dataclass
98
+ class Station:
99
+ """A computed station with UTM coordinates."""
100
+
101
+ name: str
102
+ easting: float # UTM easting in meters
103
+ northing: float # UTM northing in meters
104
+ elevation: float # Elevation in meters
105
+ file: str = ""
106
+ survey: str = ""
107
+ origin: str = "" # Anchor station this was propagated from (debug)
108
+ path_distance: float = 0.0 # Cumulative survey distance from origin (meters)
109
+
110
+
111
+ @dataclass
112
+ class SurveyLeg:
113
+ """A survey leg (shot) between two stations."""
114
+
115
+ from_station: Station
116
+ to_station: Station
117
+ distance: float
118
+ azimuth: float | None
119
+ inclination: float | None
120
+ file: str = ""
121
+ survey: str = ""
122
+ left: float | None = None
123
+ right: float | None = None
124
+ up: float | None = None
125
+ down: float | None = None
126
+ lruds_at_to_station: bool = (
127
+ False # Controls which station LRUDs are associated with
128
+ )
129
+ excluded_from_plotting: bool = False
130
+ """P-flag: shot participates in coordinate propagation but is
131
+ not rendered in the GeoJSON output (no leg or passage feature).
132
+ """
133
+ measurement_delta: tuple[float, float, float] | None = None
134
+ """Measurement-based displacement (de, dn, dz) in metres.
135
+
136
+ Computed from the shot's distance, bearing, inclination with
137
+ declination and convergence applied. Used by survey adjustment solvers
138
+ to detect misclosure (coordinate differences telescope to zero,
139
+ but measurement deltas do not).
140
+ """
141
+
142
+
143
+ @dataclass
144
+ class MisclosureLeg:
145
+ """A misclosure gap between two BFS propagation fronts.
146
+
147
+ When solver is None (cross-origin), this shows where the BFS from
148
+ one anchor computed a station at position ``ghost`` while the
149
+ station already exists at position ``existing``. The gap between
150
+ them is the raw survey misclosure.
151
+
152
+ When solver is not None (same-origin), this shows where a loop
153
+ within the same BFS front has a closure error. The ``ghost`` is
154
+ the position computed by the arriving (typically longer) path; the
155
+ ``existing`` is the position already set by the first path to
156
+ reach the station. The longest path is considered the "true"
157
+ segment; closure error is ghost - existing.
158
+ """
159
+
160
+ existing: Station # Station already computed by another anchor's BFS
161
+ ghost: Station # Where the current anchor's BFS would place it
162
+ file: str = ""
163
+ survey: str = ""
164
+ same_origin: bool = False # True for loops within the same BFS front
165
+
166
+
167
+ @dataclass
168
+ class ComputedSurvey:
169
+ """Computed survey with station coordinates."""
170
+
171
+ stations: dict[str, Station] = field(default_factory=dict)
172
+ legs: list[SurveyLeg] = field(default_factory=list)
173
+ misclosures: list[MisclosureLeg] = field(default_factory=list)
174
+ anchors: set[str] = field(default_factory=set)
175
+ utm_zone: int | None = None
176
+ utm_northern: bool = True
177
+ datum: Datum | None = None
178
+
179
+
180
+ # -----------------------------------------------------------------------------
181
+ # Origin colour palette (simplestyle spec)
182
+ # -----------------------------------------------------------------------------
183
+
184
+ #: Distinct colours assigned to each BFS origin anchor so that legs and
185
+ #: stations can be visually distinguished by their propagation source.
186
+ #: Viewers that support the simplestyle spec (geojson.io, GitHub, QGIS,
187
+ #: Mapbox, …) will render these automatically.
188
+ ORIGIN_COLORS: list[str] = [
189
+ "#1f77b4", # blue
190
+ "#ff7f0e", # orange
191
+ "#2ca02c", # green
192
+ "#d62728", # red
193
+ "#9467bd", # purple
194
+ "#8c564b", # brown
195
+ "#e377c2", # pink
196
+ "#7f7f7f", # grey
197
+ "#bcbd22", # olive
198
+ "#17becf", # cyan
199
+ ]
200
+
201
+
202
+ # -----------------------------------------------------------------------------
203
+ # Coordinate Utilities
204
+ # -----------------------------------------------------------------------------
205
+
206
+
207
+ def length_to_meters(length_ft: float) -> float:
208
+ """Convert length from feet to meters."""
209
+ return length_ft * FEET_TO_METERS
210
+
211
+
212
+ # Cache for pyproj transformers (source CRS -> transformer)
213
+ _transformer_cache: dict[str, Transformer] = {}
214
+
215
+
216
+ def _get_transformer(zone: int, northern: bool, datum: Datum) -> Transformer:
217
+ """Get or create a cached transformer from UTM to WGS84.
218
+
219
+ Args:
220
+ zone: UTM zone number
221
+ northern: True for northern hemisphere
222
+ datum: The source datum
223
+
224
+ Returns:
225
+ Transformer from the source UTM CRS to WGS84
226
+ """
227
+ source_epsg = datum.get_utm_epsg(zone, northern)
228
+ if source_epsg not in _transformer_cache:
229
+ _transformer_cache[source_epsg] = Transformer.from_crs(
230
+ source_epsg,
231
+ "EPSG:4326", # WGS84
232
+ always_xy=True,
233
+ )
234
+ return _transformer_cache[source_epsg]
235
+
236
+
237
+ def utm_to_wgs84(
238
+ easting: float,
239
+ northing: float,
240
+ zone: int,
241
+ northern: bool,
242
+ datum: Datum,
243
+ ) -> tuple[float, float]:
244
+ """Convert UTM coordinates to WGS84 (longitude, latitude).
245
+
246
+ Uses pyproj for proper datum transformation, which is critical when
247
+ the source data is in NAD27 or NAD83 (common for older Compass files).
248
+
249
+ Args:
250
+ easting: UTM easting in meters
251
+ northing: UTM northing in meters
252
+ zone: UTM zone number (1-60 north, -1 to -60 south; absolute value used)
253
+ northern: True if northern hemisphere
254
+ datum: Source datum (default WGS84, use NAD27 for older data)
255
+
256
+ Returns:
257
+ Tuple of (longitude, latitude) in degrees (GeoJSON order)
258
+
259
+ Raises:
260
+ InvalidCoordinateError: If conversion fails
261
+ """
262
+ try:
263
+ transformer = _get_transformer(abs(zone), northern, datum)
264
+ lon, lat = transformer.transform(easting, northing)
265
+ return (
266
+ round(float(lon), GEOJSON_COORDINATE_PRECISION),
267
+ round(float(lat), GEOJSON_COORDINATE_PRECISION),
268
+ )
269
+ except Exception as e:
270
+ raise InvalidCoordinateError(
271
+ f"Failed to convert UTM ({easting}, {northing}) zone {zone} "
272
+ f"datum {datum}: {e}"
273
+ ) from e
274
+
275
+
276
+ # -----------------------------------------------------------------------------
277
+ # Declination and Convergence
278
+ # -----------------------------------------------------------------------------
279
+
280
+
281
+ def get_project_location_wgs84(project: CompassMakFile) -> GeoLocation | None:
282
+ """Get the project anchor location as WGS84 lat/lon.
283
+
284
+ The anchor location is used for calculating magnetic declination.
285
+ It is found in this priority order:
286
+ 1. LocationDirective (@) if it has a valid location (zone != 0)
287
+ 2. First fixed station with coordinates from link stations
288
+
289
+ Args:
290
+ project: The Compass project
291
+
292
+ Returns:
293
+ GeoLocation with lat/lon, or None if no valid location found
294
+ """
295
+ if not project.utm_zone:
296
+ logger.warning("No UTM zone found in project")
297
+ return None
298
+
299
+ # Determine hemisphere from zone sign (positive = north, negative = south)
300
+ northern = project.utm_zone > 0
301
+ utm_zone = abs(project.utm_zone)
302
+
303
+ # Try 1: Use project location (@) if valid
304
+ loc = project.location
305
+ if loc and loc.has_location:
306
+ try:
307
+ lat, lon = utm.to_latlon(
308
+ loc.easting, loc.northing, utm_zone, northern=northern
309
+ )
310
+ return GeoLocation(latitude=lat, longitude=lon)
311
+ except Exception: # noqa: BLE001
312
+ logger.warning(
313
+ "Failed to convert project location to lat/lon: "
314
+ "easting=%.2f, northing=%.2f, zone=%d, northern=%b",
315
+ loc.easting,
316
+ loc.northing,
317
+ utm_zone,
318
+ northern,
319
+ )
320
+
321
+ # Try 2: Use first fixed station with coordinates
322
+ fixed_stations = project.get_fixed_stations()
323
+ if fixed_stations:
324
+ first_fixed = fixed_stations[0]
325
+ fixed_loc = first_fixed.location
326
+ if fixed_loc:
327
+ # Convert feet to meters if needed
328
+ factor = FEET_TO_METERS if fixed_loc.unit.lower() == "f" else 1.0
329
+
330
+ easting = fixed_loc.easting * factor
331
+ northing = fixed_loc.northing * factor
332
+
333
+ try:
334
+ lat, lon = utm.to_latlon(easting, northing, utm_zone, northern=northern)
335
+ logger.info(
336
+ "Using fixed station '%s' as anchor location: (%.4f, %.4f)",
337
+ first_fixed.name,
338
+ lat,
339
+ lon,
340
+ )
341
+ return GeoLocation(latitude=lat, longitude=lon)
342
+ except Exception: # noqa: BLE001
343
+ logger.warning(
344
+ "Failed to convert fixed station '%s' to lat/lon: "
345
+ "easting=%.2f, northing=%.2f, zone=%d, northern=%b",
346
+ first_fixed.name,
347
+ easting,
348
+ northing,
349
+ utm_zone,
350
+ northern,
351
+ )
352
+
353
+ logger.warning("No valid anchor location found for declination calculation")
354
+ return None
355
+
356
+
357
+ def get_station_location_wgs84(
358
+ station: Station,
359
+ utm_zone: int,
360
+ utm_northern: bool,
361
+ datum: Datum = Datum.WGS_1984,
362
+ ) -> GeoLocation:
363
+ """Convert a station's UTM coordinates to WGS84 lat/lon for declination calculation.
364
+
365
+ IMPORTANT: Compass stores ALL UTM coordinates in northern hemisphere format,
366
+ regardless of the zone sign. The zone sign only indicates which hemisphere
367
+ the cave is in for grid orientation purposes (convergence corrections).
368
+
369
+ Therefore, we ALWAYS use northern=True when converting coordinates to get
370
+ the actual geographic location for declination calculation.
371
+
372
+ The datum parameter is CRITICAL for older Compass projects that use NAD27.
373
+ The shift between NAD27 and WGS84 can be ~200 meters in some regions.
374
+
375
+ Args:
376
+ station: Station with UTM coordinates
377
+ utm_zone: UTM zone number (can be negative for southern hemisphere)
378
+ utm_northern: Hemisphere flag (parameter kept for API consistency but not used)
379
+ datum: Source datum for the UTM coordinates (default WGS84)
380
+
381
+ Returns:
382
+ GeoLocation with actual WGS84 lat/lon coordinates
383
+
384
+ Raises:
385
+ InvalidCoordinateError: If conversion fails
386
+ """
387
+ # Always use northern=True because Compass stores all coordinates in northern format
388
+ # The zone sign is only used for convergence/grid orientation,
389
+ # not coordinate storage
390
+ lon, lat = utm_to_wgs84(
391
+ station.easting, station.northing, utm_zone, northern=True, datum=datum
392
+ )
393
+ return GeoLocation(latitude=lat, longitude=lon)
394
+
395
+
396
+ def calculate_survey_declination(
397
+ station_location: GeoLocation,
398
+ survey_date: datetime.date | None,
399
+ ) -> float:
400
+ """Calculate magnetic declination for a survey survey.
401
+
402
+ Uses a station location from the survey and the survey date to calculate
403
+ the magnetic declination using the IGRF model. The station location should
404
+ be from an anchor station in the survey's file, or from the current station
405
+ being processed.
406
+
407
+ Args:
408
+ station_location: Location of a station from the survey in WGS84
409
+ survey_date: Date of the survey survey
410
+
411
+ Returns:
412
+ Declination in degrees (positive = east, negative = west)
413
+
414
+ Raises:
415
+ Exception: If declination calculation fails
416
+ """
417
+ try:
418
+ if survey_date is None:
419
+ raise ValueError("Impossible to determine the survey date") # noqa: TRY301
420
+
421
+ # Convert date to datetime for the IGRF calculation
422
+ dt = datetime.datetime(survey_date.year, survey_date.month, survey_date.day) # noqa: DTZ001
423
+ return get_declination(station_location, dt)
424
+
425
+ except Exception:
426
+ logger.exception(
427
+ "Failed to calculate declination for date %s at location (%.4f, %.4f)",
428
+ survey_date,
429
+ station_location.latitude,
430
+ station_location.longitude,
431
+ )
432
+ raise
433
+
434
+
435
+ # -----------------------------------------------------------------------------
436
+ # Graph Building
437
+ # -----------------------------------------------------------------------------
438
+
439
+
440
+ # Unit-separator character used to scope station names.
441
+ #
442
+ # Station names that are not listed as link stations in the MAK file are
443
+ # prefixed with ``{scope_id}<US>`` to prevent cross-scope merges. Link station
444
+ # names remain unscoped so that they correctly bridge scopes.
445
+ #
446
+ # Scoping follows the "global until explicit linking" rule:
447
+ #
448
+ # - By default station names are global across all DAT files. Files that do not
449
+ # declare bare (coordinate-free) link stations share the current scope group,
450
+ # so identical names freely merge.
451
+ # - A file that lists at least one link station without coordinates (a bare
452
+ # name) starts a new scope group. Only the declared link station names bridge
453
+ # the two groups; all other names are isolated.
454
+ # - Link stations with coordinates are anchors (fixed reference points) and do
455
+ # not create a scope boundary.
456
+ _FILE_SCOPE_SEP = "\x1f"
457
+
458
+
459
+ def _get_link_names(project: CompassMakFile) -> set[str]:
460
+ """Collect all link station names across every file directive.
461
+
462
+ These names are "global" — they intentionally appear in multiple
463
+ DAT files and must share a single graph node. All other station
464
+ names are scope-grouped to prevent accidental cross-scope merges.
465
+ """
466
+ return {ls.name for fd in project.file_directives for ls in fd.link_stations}
467
+
468
+
469
+ def _compute_file_scopes(project: CompassMakFile) -> dict[str, int]:
470
+ """Assign a scope-group ID to each file directive.
471
+
472
+ Station names are **global until explicit linking**: files that do
473
+ not declare bare link stations share the current scope group (their
474
+ station names freely merge). When a file directive lists at least
475
+ one link station **without** coordinates the scope counter
476
+ increments, isolating that file's non-link station names from the
477
+ previous group.
478
+
479
+ Link stations that carry coordinates are **anchors** (fixed
480
+ reference points) and do **not** create a scope boundary.
481
+
482
+ Returns:
483
+ Mapping of ``file_directive.file`` → scope-group ID.
484
+ """
485
+ file_scopes: dict[str, int] = {}
486
+ current_scope = 0
487
+ for fd in project.file_directives:
488
+ # Explicit linking = at least one link station WITHOUT coordinates.
489
+ # Link stations with coordinates are just anchors (fixed points).
490
+ has_explicit_linking = any(ls.location is None for ls in fd.link_stations)
491
+ if has_explicit_linking:
492
+ current_scope += 1
493
+ file_scopes[fd.file] = current_scope
494
+ return file_scopes
495
+
496
+
497
+ def _scope_name(name: str, scope_id: int, link_names: set[str]) -> str:
498
+ """Return a scope-grouped station name, or the raw name for link stations."""
499
+ if name in link_names:
500
+ return name
501
+ return f"{scope_id}{_FILE_SCOPE_SEP}{name}"
502
+
503
+
504
+ def _display_name(scoped_name: str) -> str:
505
+ """Strip the file-scope prefix to recover the original station name."""
506
+ if _FILE_SCOPE_SEP in scoped_name:
507
+ return scoped_name.split(_FILE_SCOPE_SEP, 1)[1]
508
+ return scoped_name
509
+
510
+
511
+ def build_station_graph(
512
+ project: CompassMakFile,
513
+ ) -> tuple[
514
+ dict[str, list[tuple[CompassShot, str, str, CompassSurvey, bool]]],
515
+ list[tuple[CompassShot, str, str, CompassSurvey]],
516
+ ]:
517
+ """Build adjacency graph from all shots in project.
518
+
519
+ Station names follow the **global-until-explicit-linking** rule:
520
+ files that do not declare link stations share a scope group so
521
+ identical station names freely merge. When a file directive lists
522
+ explicit link stations a new scope group starts, and only the
523
+ declared link station names bridge the two groups. Link station
524
+ names remain global (unscoped) across all scopes.
525
+
526
+ Excludes:
527
+ - Shots marked as excluded_from_all_processing (X flag)
528
+ - Self-loops (from == to station, data errors)
529
+
530
+ Returns:
531
+ Tuple of (adjacency_dict, all_shots_list)
532
+ """
533
+ link_names = _get_link_names(project)
534
+ file_scopes = _compute_file_scopes(project)
535
+
536
+ all_shots: list[tuple[CompassShot, str, str, CompassSurvey]] = []
537
+ adjacency: dict[
538
+ str, list[tuple[CompassShot, str, str, CompassSurvey, bool, str, str]]
539
+ ] = {}
540
+
541
+ for file_dir in project.file_directives:
542
+ if not file_dir.data:
543
+ continue
544
+
545
+ scope_id = file_scopes.get(file_dir.file, 0)
546
+
547
+ for survey in file_dir.data.surveys:
548
+ survey_name = survey.header.survey_name or "unnamed"
549
+ for shot in survey.shots:
550
+ # Skip shots excluded from all processing (X flag).
551
+ # P-flagged shots (excluded from plotting) are kept in
552
+ # the graph for coordinate propagation; the resulting
553
+ # legs are marked so they can be hidden in GeoJSON output.
554
+ if shot.excluded_from_all_processing:
555
+ continue
556
+
557
+ all_shots.append((shot, file_dir.file, survey_name, survey))
558
+
559
+ from_name = _scope_name(shot.from_station_name, scope_id, link_names)
560
+ to_name = _scope_name(shot.to_station_name, scope_id, link_names)
561
+
562
+ # Skip self-loops (from == to). These are data errors
563
+ # that create impossible solver constraints.
564
+ if from_name == to_name:
565
+ logger.warning(
566
+ "Skipping self-loop: %s -> %s in %s:%s",
567
+ shot.from_station_name,
568
+ shot.to_station_name,
569
+ file_dir.file,
570
+ survey_name,
571
+ )
572
+ continue
573
+
574
+ if from_name not in adjacency:
575
+ adjacency[from_name] = []
576
+ if to_name not in adjacency:
577
+ adjacency[to_name] = []
578
+
579
+ # Add forward and reverse connections.
580
+ # Pre-scoped names are stored in the tuple so BFS
581
+ # does not need to re-scope per shot.
582
+ adjacency[from_name].append(
583
+ (
584
+ shot,
585
+ file_dir.file,
586
+ survey_name,
587
+ survey,
588
+ False,
589
+ from_name,
590
+ to_name,
591
+ )
592
+ )
593
+ adjacency[to_name].append(
594
+ (shot, file_dir.file, survey_name, survey, True, from_name, to_name)
595
+ )
596
+
597
+ return adjacency, all_shots
598
+
599
+
600
+ def find_anchor_stations(project: CompassMakFile) -> dict[str, Station]:
601
+ """Find all anchor stations with known coordinates.
602
+
603
+ Anchors come from:
604
+ 1. Link stations with fixed coordinates in file directives
605
+ 2. Project location (as origin for first station)
606
+
607
+ Returns:
608
+ Dictionary of station name -> Station with coordinates
609
+ """
610
+ anchors: dict[str, Station] = {}
611
+
612
+ # Get fixed stations from link stations
613
+ for file_dir in project.file_directives:
614
+ for link_station in file_dir.link_stations:
615
+ if link_station.location:
616
+ loc = link_station.location
617
+ factor = FEET_TO_METERS if loc.unit.lower() == "f" else 1.0
618
+ anchors[link_station.name] = Station(
619
+ name=link_station.name,
620
+ easting=loc.easting * factor,
621
+ northing=loc.northing * factor,
622
+ elevation=loc.elevation * factor,
623
+ file=file_dir.file,
624
+ )
625
+ logger.debug(
626
+ "Anchor station: %s at (%.2f, %.2f, %.2f)",
627
+ link_station.name,
628
+ loc.easting * factor,
629
+ loc.northing * factor,
630
+ loc.elevation * factor,
631
+ )
632
+
633
+ return anchors
634
+
635
+
636
+ # -----------------------------------------------------------------------------
637
+ # Coordinate Propagation
638
+ # -----------------------------------------------------------------------------
639
+
640
+
641
+ def compute_shot_delta(
642
+ shot: CompassShot,
643
+ is_reverse: bool,
644
+ declination: float,
645
+ convergence: float,
646
+ ) -> tuple[float, float, float]:
647
+ """Compute the displacement vector (de, dn, dz) for a shot.
648
+
649
+ This is the measurement-based delta in metres, accounting for
650
+ direction, declination, and convergence. It is independent of
651
+ any station positions.
652
+
653
+ Args:
654
+ shot: The shot data
655
+ is_reverse: Whether traversing in reverse direction
656
+ declination: Magnetic declination (degrees)
657
+ convergence: UTM convergence angle (degrees)
658
+
659
+ Returns:
660
+ Tuple of (delta_easting, delta_northing, delta_elevation) in metres.
661
+ """
662
+ distance = shot.length or 0.0
663
+
664
+ # Get azimuth/inclination based on direction
665
+ if is_reverse:
666
+ if shot.backsight_azimuth is not None:
667
+ azimuth = shot.backsight_azimuth
668
+ elif shot.frontsight_azimuth is not None:
669
+ azimuth = (shot.frontsight_azimuth + 180.0) % 360.0
670
+ else:
671
+ azimuth = None
672
+
673
+ if shot.backsight_inclination is not None:
674
+ inclination = -shot.backsight_inclination
675
+ elif shot.frontsight_inclination is not None:
676
+ inclination = -shot.frontsight_inclination
677
+ else:
678
+ inclination = None
679
+ else:
680
+ azimuth = shot.frontsight_azimuth
681
+ inclination = shot.frontsight_inclination
682
+
683
+ azimuth = azimuth if azimuth is not None else 0.0
684
+ inclination = inclination if inclination is not None else 0.0
685
+
686
+ azimuth_corrected = azimuth + declination - convergence
687
+ azimuth_rad = math.radians(azimuth_corrected)
688
+ inclination_rad = math.radians(inclination)
689
+
690
+ distance_m = length_to_meters(distance)
691
+ horizontal_distance = distance_m * math.cos(inclination_rad)
692
+ vertical_distance = distance_m * math.sin(inclination_rad)
693
+
694
+ delta_easting = horizontal_distance * math.sin(azimuth_rad)
695
+ delta_northing = horizontal_distance * math.cos(azimuth_rad)
696
+
697
+ return (delta_easting, delta_northing, vertical_distance)
698
+
699
+
700
+ def compute_next_station(
701
+ from_station: Station,
702
+ shot: CompassShot,
703
+ to_name: str,
704
+ is_reverse: bool,
705
+ declination: float,
706
+ convergence: float,
707
+ ) -> Station:
708
+ """Compute coordinates of the next station from a shot.
709
+
710
+ Args:
711
+ from_station: Starting station with known coordinates
712
+ shot: The shot data
713
+ to_name: Name of the target station
714
+ is_reverse: Whether traversing in reverse direction
715
+ declination: Magnetic declination to apply (degrees, calculated from location+date)
716
+ convergence: UTM convergence angle to apply (degrees, from project)
717
+
718
+ Returns:
719
+ Station with computed coordinates
720
+ """ # noqa: E501
721
+ de, dn, dz = compute_shot_delta(shot, is_reverse, declination, convergence)
722
+
723
+ return Station(
724
+ name=to_name,
725
+ easting=from_station.easting + de,
726
+ northing=from_station.northing + dn,
727
+ elevation=from_station.elevation + dz,
728
+ )
729
+
730
+
731
+ def propagate_coordinates(
732
+ project: CompassMakFile,
733
+ anchors: dict[str, Station],
734
+ adjacency: dict[str, list[tuple[CompassShot, str, str, CompassSurvey, bool]]],
735
+ *,
736
+ detect_same_origin_loops: bool = False,
737
+ ) -> ComputedSurvey:
738
+ """Propagate coordinates from anchor stations via BFS.
739
+
740
+ Declination is calculated per survey using:
741
+ - Project anchor location (from @ directive)
742
+ - Survey date from survey header
743
+
744
+ UTM convergence is always applied from the project settings.
745
+
746
+ Args:
747
+ project: The Compass project
748
+ anchors: Dictionary of anchor stations with known coordinates
749
+ adjacency: Station adjacency graph
750
+ detect_same_origin_loops: When True, record :class:`MisclosureLeg`
751
+ entries for loops that close within the same BFS front (same
752
+ origin). The longest path from the origin to the closing
753
+ station is the "true" segment; the gap between its computed
754
+ position and the existing position is the raw closure error.
755
+ Enable this when a solver will be applied.
756
+
757
+ Returns:
758
+ ComputedSurvey with all computed stations and legs
759
+ """
760
+ result = ComputedSurvey()
761
+
762
+ # Set metadata
763
+ result.datum = project.datum
764
+ result.utm_zone = project.utm_zone
765
+
766
+ # Determine hemisphere from zone sign (positive = north, negative = south)
767
+ if result.utm_zone:
768
+ result.utm_northern = result.utm_zone > 0
769
+
770
+ # Get convergence angle from project (always applied)
771
+ convergence = project.utm_convergence
772
+ logger.info("UTM convergence angle: %.3f°", convergence)
773
+
774
+ # Get LRUD flags from project
775
+ flags = project.flags
776
+ lruds_at_to_station = flags.lruds_at_to_station if flags else False
777
+
778
+ # Cache for survey declinations (calculated once per survey)
779
+ survey_declinations: dict[str, float] = {}
780
+
781
+ # Anchor-by-file index for O(1) declination lookup (avoids linear
782
+ # scan over all anchors per survey).
783
+ anchor_by_file: dict[str, Station] = {}
784
+ for _a in anchors.values():
785
+ if _a.file and _a.file not in anchor_by_file:
786
+ anchor_by_file[_a.file] = _a
787
+
788
+ # When a solver is active, use *single-origin* BFS: seed from one
789
+ # anchor and traverse the full network (forward + reverse edges).
790
+ # This propagates the entire traverse from the same starting point
791
+ # so that closures appear at the far anchor, not at arbitrary BFS
792
+ # meeting points. The longest path is the "true" segment.
793
+ _secondary_anchors: dict[str, Station] = {}
794
+
795
+ if detect_same_origin_loops and len(anchors) > 1:
796
+ # Pick first connected anchor as primary
797
+ primary_name = next(
798
+ (n for n in anchors if n in adjacency),
799
+ next(iter(anchors.keys())),
800
+ )
801
+ primary_station = anchors[primary_name]
802
+ primary_station.origin = primary_name
803
+ result.stations = {primary_name: primary_station}
804
+ result.anchors = {primary_name}
805
+ _secondary_anchors = {k: v for k, v in anchors.items() if k != primary_name}
806
+ else:
807
+ # Multi-origin BFS: seed from all anchors
808
+ result.stations = dict(anchors)
809
+ result.anchors = set(anchors.keys())
810
+ for anchor_name, anchor_station in result.stations.items():
811
+ anchor_station.origin = anchor_name
812
+
813
+ # Validate seeded stations against the shot graph — a station
814
+ # whose name does not appear in any shot is disconnected and
815
+ # cannot seed BFS.
816
+ disconnected = {name for name in result.stations if name not in adjacency}
817
+ if disconnected:
818
+ for name in sorted(disconnected):
819
+ logger.warning(
820
+ "Anchor '%s' (file: %s) has no shots in the survey data "
821
+ "-- check that the link station name matches a station "
822
+ "in the DAT file",
823
+ name,
824
+ result.stations[name].file,
825
+ )
826
+ # Remove disconnected anchors so they don't pollute the BFS
827
+ for name in disconnected:
828
+ del result.stations[name]
829
+ result.anchors.discard(name)
830
+
831
+ if not result.stations:
832
+ raise NoKnownAnchorError("No anchor stations found and no shots available")
833
+
834
+ logger.info(
835
+ "Starting coordinate propagation from %d anchor(s)", len(result.stations)
836
+ )
837
+
838
+ # BFS from anchor stations.
839
+ # Multi-origin mode: FORWARD ONLY (reverse edges skipped).
840
+ # Single-origin mode (solver active): forward + reverse edges
841
+ # so the full traverse is computed from the same starting point
842
+ # and closures appear at the far anchor, not at arbitrary BFS
843
+ # meeting points.
844
+ queue: deque[str] = deque(result.stations.keys())
845
+ visited_stations: set[str] = set(result.stations.keys())
846
+ visited_shots: set[tuple[str, str]] = set()
847
+
848
+ while queue:
849
+ current_name = queue.popleft()
850
+ current_station = result.stations.get(current_name)
851
+ if not current_station:
852
+ continue
853
+
854
+ for (
855
+ shot,
856
+ file_name,
857
+ survey_name,
858
+ survey,
859
+ is_reverse,
860
+ from_name,
861
+ to_name,
862
+ ) in adjacency.get(current_name, []):
863
+ # Multi-origin: forward only. Single-origin: both.
864
+ if is_reverse and not detect_same_origin_loops:
865
+ continue
866
+
867
+ # Target station depends on traversal direction
868
+ target_name = from_name if is_reverse else to_name
869
+
870
+ # Skip already processed shots
871
+ shot_key = (min(from_name, to_name), max(from_name, to_name))
872
+ if shot_key in visited_shots:
873
+ continue
874
+ visited_shots.add(shot_key)
875
+
876
+ # Calculate declination for this survey (cached)
877
+ survey_key = f"{file_name}:{survey_name}"
878
+ if survey_key not in survey_declinations:
879
+ survey_anchor = anchor_by_file.get(file_name)
880
+ location_station = survey_anchor if survey_anchor else current_station
881
+
882
+ station_wgs84 = get_station_location_wgs84(
883
+ location_station,
884
+ result.utm_zone,
885
+ result.utm_northern,
886
+ result.datum or Datum.WGS_1984,
887
+ )
888
+
889
+ logger.debug(
890
+ "Survey %s: using station %s at UTM(%.1f, %.1f) zone=%d northern=%s -> WGS84(%.4f, %.4f)", # noqa: E501
891
+ survey_key,
892
+ location_station.name,
893
+ location_station.easting,
894
+ location_station.northing,
895
+ result.utm_zone,
896
+ result.utm_northern,
897
+ station_wgs84.latitude,
898
+ station_wgs84.longitude,
899
+ )
900
+
901
+ declination = calculate_survey_declination(
902
+ station_wgs84, survey.header.date
903
+ )
904
+ survey_declinations[survey_key] = declination
905
+ logger.debug(
906
+ "Survey %s declination: %.2f° (date: %s, station: %s)",
907
+ survey_key,
908
+ declination,
909
+ survey.header.date,
910
+ location_station.name,
911
+ )
912
+ else:
913
+ declination = survey_declinations[survey_key]
914
+
915
+ # Direction-dependent delta. For forward edges this uses
916
+ # the frontsight; for reverse edges it uses the backsight
917
+ # (or frontsight + 180° when no backsight is available).
918
+ shot_delta = compute_shot_delta(
919
+ shot,
920
+ is_reverse,
921
+ declination,
922
+ convergence,
923
+ )
924
+ de, dn, dz = shot_delta
925
+
926
+ # Compute target station position.
927
+ ghost_e = current_station.easting + de
928
+ ghost_n = current_station.northing + dn
929
+ ghost_z = current_station.elevation + dz
930
+
931
+ if target_name not in result.stations:
932
+ # New station: place it at the computed position.
933
+ new_station = Station(
934
+ name=_display_name(target_name),
935
+ easting=ghost_e,
936
+ northing=ghost_n,
937
+ elevation=ghost_z,
938
+ )
939
+ new_station.file = file_name
940
+ new_station.survey = survey_name
941
+ new_station.origin = current_station.origin
942
+ new_station.path_distance = (
943
+ current_station.path_distance + length_to_meters(shot.length or 0.0)
944
+ )
945
+ result.stations[target_name] = new_station
946
+
947
+ if target_name not in visited_stations:
948
+ visited_stations.add(target_name)
949
+ queue.append(target_name)
950
+
951
+ # Create the leg.
952
+ leg = SurveyLeg(
953
+ from_station=current_station,
954
+ to_station=new_station,
955
+ distance=shot.length or 0.0,
956
+ azimuth=shot.frontsight_azimuth,
957
+ inclination=shot.frontsight_inclination,
958
+ file=file_name,
959
+ survey=survey_name,
960
+ left=shot.left,
961
+ right=shot.right,
962
+ up=shot.up,
963
+ down=shot.down,
964
+ lruds_at_to_station=lruds_at_to_station,
965
+ excluded_from_plotting=shot.excluded_from_plotting,
966
+ measurement_delta=shot_delta,
967
+ )
968
+ result.legs.append(leg)
969
+
970
+ else:
971
+ # Target station already exists. If it was computed by
972
+ # a different origin, record a misclosure gap.
973
+ existing_station = result.stations[target_name]
974
+
975
+ if existing_station.origin != current_station.origin:
976
+ # Ghost = where THIS front would place the station.
977
+ ghost_station = Station(
978
+ name=_display_name(target_name) + "'",
979
+ easting=ghost_e,
980
+ northing=ghost_n,
981
+ elevation=ghost_z,
982
+ )
983
+ ghost_station.file = file_name
984
+ ghost_station.survey = survey_name
985
+ ghost_station.origin = current_station.origin
986
+ ghost_station.path_distance = (
987
+ current_station.path_distance
988
+ + length_to_meters(shot.length or 0.0)
989
+ )
990
+
991
+ # Leg from current → ghost (shows where BFS goes).
992
+ leg = SurveyLeg(
993
+ from_station=current_station,
994
+ to_station=ghost_station,
995
+ distance=shot.length or 0.0,
996
+ azimuth=shot.frontsight_azimuth,
997
+ inclination=shot.frontsight_inclination,
998
+ file=file_name,
999
+ survey=survey_name,
1000
+ left=shot.left,
1001
+ right=shot.right,
1002
+ up=shot.up,
1003
+ down=shot.down,
1004
+ lruds_at_to_station=lruds_at_to_station,
1005
+ excluded_from_plotting=shot.excluded_from_plotting,
1006
+ measurement_delta=shot_delta,
1007
+ )
1008
+ result.legs.append(leg)
1009
+
1010
+ # Misclosure line from ghost → existing.
1011
+ result.misclosures.append(
1012
+ MisclosureLeg(
1013
+ existing=existing_station,
1014
+ ghost=ghost_station,
1015
+ file=file_name,
1016
+ survey=survey_name,
1017
+ ),
1018
+ )
1019
+ else:
1020
+ # Same origin: create a normal leg to the
1021
+ # existing station (loop within same BFS front).
1022
+ leg = SurveyLeg(
1023
+ from_station=current_station,
1024
+ to_station=existing_station,
1025
+ distance=shot.length or 0.0,
1026
+ azimuth=shot.frontsight_azimuth,
1027
+ inclination=shot.frontsight_inclination,
1028
+ file=file_name,
1029
+ survey=survey_name,
1030
+ left=shot.left,
1031
+ right=shot.right,
1032
+ up=shot.up,
1033
+ down=shot.down,
1034
+ lruds_at_to_station=lruds_at_to_station,
1035
+ excluded_from_plotting=shot.excluded_from_plotting,
1036
+ measurement_delta=shot_delta,
1037
+ )
1038
+ result.legs.append(leg)
1039
+
1040
+ # When a solver will be applied, record same-origin
1041
+ # loop closures. The whole path is propagated from
1042
+ # the same origin; the longest path to the closing
1043
+ # station is the "true" segment.
1044
+ if detect_same_origin_loops:
1045
+ ghost_station = Station(
1046
+ name=_display_name(target_name) + "'",
1047
+ easting=ghost_e,
1048
+ northing=ghost_n,
1049
+ elevation=ghost_z,
1050
+ file=file_name,
1051
+ survey=survey_name,
1052
+ origin=current_station.origin,
1053
+ path_distance=(
1054
+ current_station.path_distance
1055
+ + length_to_meters(shot.length or 0.0)
1056
+ ),
1057
+ )
1058
+ result.misclosures.append(
1059
+ MisclosureLeg(
1060
+ existing=existing_station,
1061
+ ghost=ghost_station,
1062
+ file=file_name,
1063
+ survey=survey_name,
1064
+ same_origin=True,
1065
+ ),
1066
+ )
1067
+
1068
+ # ── Secondary anchor closure (single-origin BFS) ────────────
1069
+ # In single-origin mode, secondary anchors were placed by BFS
1070
+ # with propagated positions. Compare with their known (fixed)
1071
+ # positions to get the full-traverse closure, then snap them
1072
+ # to the known position so the solver treats them as anchors.
1073
+ #
1074
+ # Secondary anchors that were NOT reached by BFS belong to
1075
+ # disconnected components (separate caves). They are placed
1076
+ # at their known position and queued for additional BFS so
1077
+ # their connected stations are also computed.
1078
+ _disconnected_secondaries: list[str] = []
1079
+
1080
+ if _secondary_anchors:
1081
+ for sec_name, sec_known in _secondary_anchors.items():
1082
+ bfs_station = result.stations.get(sec_name)
1083
+ if bfs_station is not None:
1084
+ gap = (
1085
+ (bfs_station.easting - sec_known.easting) ** 2
1086
+ + (bfs_station.northing - sec_known.northing) ** 2
1087
+ + (bfs_station.elevation - sec_known.elevation) ** 2
1088
+ ) ** 0.5
1089
+ if gap > 1e-6:
1090
+ ghost = Station(
1091
+ name=sec_known.name + "'",
1092
+ easting=bfs_station.easting,
1093
+ northing=bfs_station.northing,
1094
+ elevation=bfs_station.elevation,
1095
+ file=bfs_station.file,
1096
+ survey=bfs_station.survey,
1097
+ origin=bfs_station.origin,
1098
+ path_distance=bfs_station.path_distance,
1099
+ )
1100
+ result.misclosures.append(
1101
+ MisclosureLeg(
1102
+ existing=Station(
1103
+ name=sec_known.name,
1104
+ easting=sec_known.easting,
1105
+ northing=sec_known.northing,
1106
+ elevation=sec_known.elevation,
1107
+ file=sec_known.file,
1108
+ ),
1109
+ ghost=ghost,
1110
+ file=bfs_station.file,
1111
+ survey=bfs_station.survey,
1112
+ ),
1113
+ )
1114
+ logger.info(
1115
+ "Closure at anchor '%s': %.3f m (path distance: %.1f m)",
1116
+ sec_known.name,
1117
+ gap,
1118
+ bfs_station.path_distance,
1119
+ )
1120
+ # Snap to known anchor position for the solver
1121
+ bfs_station.easting = sec_known.easting
1122
+ bfs_station.northing = sec_known.northing
1123
+ bfs_station.elevation = sec_known.elevation
1124
+ bfs_station.origin = sec_name
1125
+ else:
1126
+ # Not reached by BFS — disconnected component.
1127
+ # Place at known position and queue for BFS.
1128
+ sec_station = Station(
1129
+ name=sec_known.name,
1130
+ easting=sec_known.easting,
1131
+ northing=sec_known.northing,
1132
+ elevation=sec_known.elevation,
1133
+ file=sec_known.file,
1134
+ origin=sec_name,
1135
+ )
1136
+ result.stations[sec_name] = sec_station
1137
+ _disconnected_secondaries.append(sec_name)
1138
+ result.anchors.add(sec_name)
1139
+
1140
+ # ── BFS from disconnected secondary anchors ────────────────
1141
+ # Each disconnected secondary anchor seeds an independent BFS
1142
+ # for its connected component. These are separate caves that
1143
+ # don't share any stations with the primary anchor's component.
1144
+ if _disconnected_secondaries:
1145
+ logger.info(
1146
+ "Running BFS from %d disconnected anchor(s): %s",
1147
+ len(_disconnected_secondaries),
1148
+ ", ".join(_disconnected_secondaries),
1149
+ )
1150
+ for sec_name in _disconnected_secondaries:
1151
+ sec_queue: deque[str] = deque([sec_name])
1152
+ visited_stations.add(sec_name)
1153
+
1154
+ while sec_queue:
1155
+ current_name = sec_queue.popleft()
1156
+ current_station = result.stations.get(current_name)
1157
+ if not current_station:
1158
+ continue
1159
+
1160
+ for (
1161
+ shot,
1162
+ file_name,
1163
+ survey_name,
1164
+ survey,
1165
+ is_reverse,
1166
+ from_name,
1167
+ to_name,
1168
+ ) in adjacency.get(current_name, []):
1169
+ # Same traversal rules as main BFS
1170
+ target_name = from_name if is_reverse else to_name
1171
+
1172
+ shot_key = (min(from_name, to_name), max(from_name, to_name))
1173
+ if shot_key in visited_shots:
1174
+ continue
1175
+ visited_shots.add(shot_key)
1176
+
1177
+ survey_key = f"{file_name}:{survey_name}"
1178
+ if survey_key not in survey_declinations:
1179
+ survey_anchor = anchor_by_file.get(file_name)
1180
+ location_station = (
1181
+ survey_anchor if survey_anchor else current_station
1182
+ )
1183
+ station_wgs84 = get_station_location_wgs84(
1184
+ location_station,
1185
+ result.utm_zone,
1186
+ result.utm_northern,
1187
+ result.datum or Datum.WGS_1984,
1188
+ )
1189
+ declination = calculate_survey_declination(
1190
+ station_wgs84,
1191
+ survey.header.date,
1192
+ )
1193
+ survey_declinations[survey_key] = declination
1194
+ logger.debug(
1195
+ "Survey %s declination: %.2f° (date: %s)",
1196
+ survey_key,
1197
+ declination,
1198
+ survey.header.date,
1199
+ )
1200
+ else:
1201
+ declination = survey_declinations[survey_key]
1202
+
1203
+ shot_delta = compute_shot_delta(
1204
+ shot,
1205
+ is_reverse,
1206
+ declination,
1207
+ convergence,
1208
+ )
1209
+ de, dn, dz = shot_delta
1210
+
1211
+ ghost_e = current_station.easting + de
1212
+ ghost_n = current_station.northing + dn
1213
+ ghost_z = current_station.elevation + dz
1214
+
1215
+ if target_name not in result.stations:
1216
+ new_station = Station(
1217
+ name=_display_name(target_name),
1218
+ easting=ghost_e,
1219
+ northing=ghost_n,
1220
+ elevation=ghost_z,
1221
+ file=file_name,
1222
+ survey=survey_name,
1223
+ origin=sec_name,
1224
+ path_distance=(
1225
+ current_station.path_distance
1226
+ + length_to_meters(shot.length or 0.0)
1227
+ ),
1228
+ )
1229
+ result.stations[target_name] = new_station
1230
+
1231
+ if target_name not in visited_stations:
1232
+ visited_stations.add(target_name)
1233
+ sec_queue.append(target_name)
1234
+
1235
+ leg = SurveyLeg(
1236
+ from_station=current_station,
1237
+ to_station=new_station,
1238
+ distance=shot.length or 0.0,
1239
+ azimuth=shot.frontsight_azimuth,
1240
+ inclination=shot.frontsight_inclination,
1241
+ file=file_name,
1242
+ survey=survey_name,
1243
+ left=shot.left,
1244
+ right=shot.right,
1245
+ up=shot.up,
1246
+ down=shot.down,
1247
+ lruds_at_to_station=lruds_at_to_station,
1248
+ excluded_from_plotting=shot.excluded_from_plotting,
1249
+ measurement_delta=shot_delta,
1250
+ )
1251
+ result.legs.append(leg)
1252
+ else:
1253
+ existing_station = result.stations[target_name]
1254
+ leg = SurveyLeg(
1255
+ from_station=current_station,
1256
+ to_station=existing_station,
1257
+ distance=shot.length or 0.0,
1258
+ azimuth=shot.frontsight_azimuth,
1259
+ inclination=shot.frontsight_inclination,
1260
+ file=file_name,
1261
+ survey=survey_name,
1262
+ left=shot.left,
1263
+ right=shot.right,
1264
+ up=shot.up,
1265
+ down=shot.down,
1266
+ lruds_at_to_station=lruds_at_to_station,
1267
+ excluded_from_plotting=shot.excluded_from_plotting,
1268
+ measurement_delta=shot_delta,
1269
+ )
1270
+ result.legs.append(leg)
1271
+
1272
+ if detect_same_origin_loops:
1273
+ ghost_station = Station(
1274
+ name=_display_name(target_name) + "'",
1275
+ easting=ghost_e,
1276
+ northing=ghost_n,
1277
+ elevation=ghost_z,
1278
+ file=file_name,
1279
+ survey=survey_name,
1280
+ origin=sec_name,
1281
+ path_distance=(
1282
+ current_station.path_distance
1283
+ + length_to_meters(shot.length or 0.0)
1284
+ ),
1285
+ )
1286
+ result.misclosures.append(
1287
+ MisclosureLeg(
1288
+ existing=existing_station,
1289
+ ghost=ghost_station,
1290
+ file=file_name,
1291
+ survey=survey_name,
1292
+ same_origin=True,
1293
+ ),
1294
+ )
1295
+
1296
+ # Fallback: stations unreachable by forward-only BFS.
1297
+ # Some surveys have the link station as the TO station, so
1298
+ # the only way to reach their FROM stations is via reverse.
1299
+ # Do a second pass using reverse edges for these orphans only.
1300
+ all_graph_stations = set(adjacency.keys())
1301
+ orphaned = all_graph_stations - visited_stations
1302
+
1303
+ if orphaned:
1304
+ # Seed the queue with all already-computed stations that have
1305
+ # reverse edges to orphaned stations.
1306
+ fallback_queue: deque[str] = deque()
1307
+ for name in list(visited_stations):
1308
+ for (
1309
+ _,
1310
+ _,
1311
+ _,
1312
+ _,
1313
+ is_reverse,
1314
+ _from_scoped,
1315
+ _,
1316
+ ) in adjacency.get(name, []):
1317
+ if not is_reverse:
1318
+ continue
1319
+ if _from_scoped in orphaned:
1320
+ fallback_queue.append(name)
1321
+ break
1322
+
1323
+ while fallback_queue:
1324
+ current_name = fallback_queue.popleft()
1325
+ current_station = result.stations.get(current_name)
1326
+ if not current_station:
1327
+ continue
1328
+
1329
+ for (
1330
+ shot,
1331
+ file_name,
1332
+ survey_name,
1333
+ survey,
1334
+ is_reverse,
1335
+ from_name,
1336
+ to_name,
1337
+ ) in adjacency.get(current_name, []):
1338
+ shot_key = (min(from_name, to_name), max(from_name, to_name))
1339
+ if shot_key in visited_shots:
1340
+ continue
1341
+
1342
+ # Determine target and direction.
1343
+ target_name = from_name if is_reverse else to_name
1344
+
1345
+ if target_name in result.stations:
1346
+ continue # Already computed
1347
+
1348
+ visited_shots.add(shot_key)
1349
+
1350
+ survey_key = f"{file_name}:{survey_name}"
1351
+ if survey_key not in survey_declinations:
1352
+ survey_anchor = anchor_by_file.get(file_name)
1353
+ location_station = (
1354
+ survey_anchor if survey_anchor else current_station
1355
+ )
1356
+ station_wgs84 = get_station_location_wgs84(
1357
+ location_station,
1358
+ result.utm_zone,
1359
+ result.utm_northern,
1360
+ result.datum or Datum.WGS_1984,
1361
+ )
1362
+ declination = calculate_survey_declination(
1363
+ station_wgs84,
1364
+ survey.header.date,
1365
+ )
1366
+ survey_declinations[survey_key] = declination
1367
+ else:
1368
+ declination = survey_declinations[survey_key]
1369
+
1370
+ forward_delta = compute_shot_delta(
1371
+ shot,
1372
+ is_reverse=False,
1373
+ declination=declination,
1374
+ convergence=convergence,
1375
+ )
1376
+ de, dn, dz = forward_delta
1377
+
1378
+ if is_reverse:
1379
+ # Computing from_station from to_station.
1380
+ new_station = Station(
1381
+ name=_display_name(target_name),
1382
+ easting=current_station.easting - de,
1383
+ northing=current_station.northing - dn,
1384
+ elevation=current_station.elevation - dz,
1385
+ )
1386
+ leg_from = new_station
1387
+ leg_to = current_station
1388
+ else:
1389
+ # Computing to_station from from_station.
1390
+ new_station = Station(
1391
+ name=_display_name(target_name),
1392
+ easting=current_station.easting + de,
1393
+ northing=current_station.northing + dn,
1394
+ elevation=current_station.elevation + dz,
1395
+ )
1396
+ leg_from = current_station
1397
+ leg_to = new_station
1398
+
1399
+ new_station.file = file_name
1400
+ new_station.survey = survey_name
1401
+ new_station.origin = current_station.origin
1402
+ new_station.path_distance = (
1403
+ current_station.path_distance + length_to_meters(shot.length or 0.0)
1404
+ )
1405
+ result.stations[target_name] = new_station
1406
+ visited_stations.add(target_name)
1407
+ fallback_queue.append(target_name)
1408
+
1409
+ # Leg always in forward direction.
1410
+ leg = SurveyLeg(
1411
+ from_station=leg_from,
1412
+ to_station=leg_to,
1413
+ distance=shot.length or 0.0,
1414
+ azimuth=shot.frontsight_azimuth,
1415
+ inclination=shot.frontsight_inclination,
1416
+ file=file_name,
1417
+ survey=survey_name,
1418
+ left=shot.left,
1419
+ right=shot.right,
1420
+ up=shot.up,
1421
+ down=shot.down,
1422
+ lruds_at_to_station=lruds_at_to_station,
1423
+ excluded_from_plotting=shot.excluded_from_plotting,
1424
+ measurement_delta=forward_delta,
1425
+ )
1426
+ result.legs.append(leg)
1427
+
1428
+ # Re-check for any still-orphaned stations.
1429
+ orphaned = all_graph_stations - visited_stations
1430
+
1431
+ if orphaned:
1432
+ display = sorted(_display_name(n) for n in orphaned)
1433
+ logger.warning(
1434
+ "%d station(s) unreachable from any anchor: %s",
1435
+ len(orphaned),
1436
+ ", ".join(display[:20]) + (" ..." if len(display) > 20 else ""),
1437
+ )
1438
+
1439
+ logger.info(
1440
+ "Computed %d stations and %d legs",
1441
+ len(result.stations),
1442
+ len(result.legs),
1443
+ )
1444
+
1445
+ return result
1446
+
1447
+
1448
+ def compute_survey_coordinates(
1449
+ project: CompassMakFile,
1450
+ solver: SurveyAdjuster | None = None,
1451
+ ) -> ComputedSurvey:
1452
+ """Compute station coordinates from survey data.
1453
+
1454
+ This is the main entry point for coordinate computation.
1455
+
1456
+ Args:
1457
+ project: Loaded CompassMakFile with DAT data
1458
+ solver: Optional survey adjuster. When provided, traverse
1459
+ adjustment is applied after BFS propagation. Pass
1460
+ ``ProportionalSolver()`` for proportional correction.
1461
+
1462
+ Returns:
1463
+ ComputedSurvey with station coordinates and legs
1464
+ """
1465
+ adjacency, _ = build_station_graph(project)
1466
+ anchors = find_anchor_stations(project)
1467
+ result = propagate_coordinates(
1468
+ project,
1469
+ anchors,
1470
+ adjacency,
1471
+ detect_same_origin_loops=(solver is not None),
1472
+ )
1473
+
1474
+ if solver is not None:
1475
+ logger.info("Applying survey adjustment: %s", solver.name)
1476
+
1477
+ # Solver handles all closures — clear misclosure lines
1478
+ # so they do not appear in the GeoJSON output.
1479
+ result.misclosures.clear()
1480
+
1481
+ # Reconnect ghost-station leg endpoints to real stations.
1482
+ _name_to_station: dict[str, Station] = {}
1483
+ for _key, _st in result.stations.items():
1484
+ _name_to_station[_st.name] = _st
1485
+ _dname = _display_name(_key)
1486
+ if _dname != _st.name:
1487
+ _name_to_station[_dname] = _st
1488
+
1489
+ for leg in result.legs:
1490
+ if leg.to_station.name.endswith("'"):
1491
+ real = _name_to_station.get(leg.to_station.name[:-1])
1492
+ if real is not None:
1493
+ leg.to_station = real
1494
+ if leg.from_station.name.endswith("'"):
1495
+ real = _name_to_station.get(leg.from_station.name[:-1])
1496
+ if real is not None:
1497
+ leg.from_station = real
1498
+
1499
+ # Run solver
1500
+ network = SurveyNetwork.from_computed_survey(result, anchors)
1501
+ adjusted = solver.adjust(network)
1502
+ for name, coords in adjusted.items():
1503
+ station = result.stations.get(name)
1504
+ if station is not None:
1505
+ station.easting = coords.x
1506
+ station.northing = coords.y
1507
+ station.elevation = coords.z
1508
+
1509
+ return result
1510
+
1511
+
1512
+ # -----------------------------------------------------------------------------
1513
+ # GeoJSON Feature Creation
1514
+ # -----------------------------------------------------------------------------
1515
+
1516
+
1517
+ def _cached_utm_to_wgs84(
1518
+ easting: float,
1519
+ northing: float,
1520
+ zone: int,
1521
+ northern: bool,
1522
+ datum: Datum,
1523
+ cache: dict[tuple[float, float], tuple[float, float]] | None = None,
1524
+ ) -> tuple[float, float]:
1525
+ """UTM-to-WGS84 with optional coordinate cache.
1526
+
1527
+ When *cache* is provided, results are memoised by ``(easting,
1528
+ northing)`` so that the same station is only converted once
1529
+ through pyproj regardless of how many legs reference it.
1530
+ """
1531
+ if cache is not None:
1532
+ key = (easting, northing)
1533
+ hit = cache.get(key)
1534
+ if hit is not None:
1535
+ return hit
1536
+ result = utm_to_wgs84(easting, northing, zone, northern, datum)
1537
+ cache[key] = result
1538
+ return result
1539
+ return utm_to_wgs84(easting, northing, zone, northern, datum)
1540
+
1541
+
1542
+ def station_to_feature(
1543
+ station: Station,
1544
+ zone: int,
1545
+ northern: bool,
1546
+ datum: Datum = Datum.WGS_1984,
1547
+ wgs84_cache: dict[tuple[float, float], tuple[float, float]] | None = None,
1548
+ ) -> Feature:
1549
+ """Convert a station to a GeoJSON Point Feature.
1550
+
1551
+ Args:
1552
+ station: Station with coordinates
1553
+ zone: UTM zone number
1554
+ northern: True if northern hemisphere
1555
+ datum: Source datum for UTM coordinates (critical for NAD27 data)
1556
+ wgs84_cache: Optional coordinate cache for deduplication
1557
+
1558
+ Returns:
1559
+ GeoJSON Feature with Point geometry
1560
+ """
1561
+ lon, lat = _cached_utm_to_wgs84(
1562
+ station.easting,
1563
+ station.northing,
1564
+ zone,
1565
+ northern,
1566
+ datum,
1567
+ wgs84_cache,
1568
+ )
1569
+
1570
+ properties: dict[str, Any] = {
1571
+ "type": "station",
1572
+ "name": station.name,
1573
+ "file": station.file,
1574
+ "survey": station.survey,
1575
+ "elevation_m": round(station.elevation, 2),
1576
+ }
1577
+ if station.origin:
1578
+ properties["origin"] = station.origin
1579
+
1580
+ return Feature(
1581
+ geometry=Point((lon, lat, round(station.elevation, 2))),
1582
+ properties=properties,
1583
+ )
1584
+
1585
+
1586
+ def anchor_to_feature(
1587
+ station: Station,
1588
+ zone: int,
1589
+ northern: bool,
1590
+ datum: Datum = Datum.WGS_1984,
1591
+ wgs84_cache: dict[tuple[float, float], tuple[float, float]] | None = None,
1592
+ ) -> Feature:
1593
+ """Convert an anchor station to a GeoJSON Point Feature.
1594
+
1595
+ Anchor stations are fixed reference points with known coordinates
1596
+ (from link stations in the MAK file).
1597
+
1598
+ Args:
1599
+ station: Anchor station with coordinates
1600
+ zone: UTM zone number
1601
+ northern: True if northern hemisphere
1602
+ datum: Source datum for UTM coordinates (critical for NAD27 data)
1603
+ wgs84_cache: Optional coordinate cache for deduplication
1604
+
1605
+ Returns:
1606
+ GeoJSON Feature with Point geometry and type "anchor"
1607
+ """
1608
+ lon, lat = _cached_utm_to_wgs84(
1609
+ station.easting,
1610
+ station.northing,
1611
+ zone,
1612
+ northern,
1613
+ datum,
1614
+ wgs84_cache,
1615
+ )
1616
+
1617
+ depth_ft = round(station.elevation * METERS_TO_FEET, 2)
1618
+
1619
+ return Feature(
1620
+ geometry=Point((lon, lat, round(station.elevation, 2))),
1621
+ properties={
1622
+ "id": station.name,
1623
+ "depth": abs(depth_ft),
1624
+ "name": station.survey,
1625
+ },
1626
+ )
1627
+
1628
+
1629
+ def leg_to_feature(
1630
+ leg: SurveyLeg,
1631
+ zone: int,
1632
+ northern: bool,
1633
+ datum: Datum = Datum.WGS_1984,
1634
+ wgs84_cache: dict[tuple[float, float], tuple[float, float]] | None = None,
1635
+ ) -> Feature:
1636
+ """Convert a survey leg to a GeoJSON LineString Feature.
1637
+
1638
+ Args:
1639
+ leg: Survey leg with from/to stations
1640
+ zone: UTM zone number
1641
+ northern: True if northern hemisphere
1642
+ datum: Source datum for UTM coordinates (critical for NAD27 data)
1643
+ wgs84_cache: Optional coordinate cache for deduplication
1644
+
1645
+ Returns:
1646
+ GeoJSON Feature with LineString geometry
1647
+ """
1648
+ from_lon, from_lat = _cached_utm_to_wgs84(
1649
+ leg.from_station.easting,
1650
+ leg.from_station.northing,
1651
+ zone,
1652
+ northern,
1653
+ datum,
1654
+ wgs84_cache,
1655
+ )
1656
+ to_lon, to_lat = _cached_utm_to_wgs84(
1657
+ leg.to_station.easting,
1658
+ leg.to_station.northing,
1659
+ zone,
1660
+ northern,
1661
+ datum,
1662
+ wgs84_cache,
1663
+ )
1664
+
1665
+ depth_m = leg.to_station.elevation
1666
+ depth_ft = round(depth_m * METERS_TO_FEET, 2)
1667
+
1668
+ return Feature(
1669
+ geometry=LineString(
1670
+ [
1671
+ (from_lon, from_lat, round(leg.from_station.elevation, 2)),
1672
+ (to_lon, to_lat, round(leg.to_station.elevation, 2)),
1673
+ ]
1674
+ ),
1675
+ properties={
1676
+ "id": str(uuid.uuid5(uuid.NAMESPACE_OID, leg.to_station.name)),
1677
+ "depth": abs(depth_ft), # Compass depth is < 0
1678
+ "name": leg.survey,
1679
+ },
1680
+ )
1681
+
1682
+
1683
+ def _station_left_right_utm(
1684
+ easting: float,
1685
+ northing: float,
1686
+ azimuth_rad: float,
1687
+ left_m: float,
1688
+ right_m: float,
1689
+ ) -> tuple[tuple[float, float], tuple[float, float]]:
1690
+ """Compute left and right offset positions in UTM from a station and shot direction.
1691
+
1692
+ Returns:
1693
+ ((left_e, left_n), (right_e, right_n))
1694
+ """
1695
+ perp_rad = azimuth_rad + math.pi / 2
1696
+ left_e = easting + left_m * math.sin(perp_rad)
1697
+ left_n = northing + left_m * math.cos(perp_rad)
1698
+ right_e = easting - right_m * math.sin(perp_rad)
1699
+ right_n = northing - right_m * math.cos(perp_rad)
1700
+ return (left_e, left_n), (right_e, right_n)
1701
+
1702
+
1703
+ def _shapely_to_passage_features(
1704
+ geom: Any,
1705
+ elev: float,
1706
+ properties: dict[str, Any],
1707
+ ) -> list[Feature]:
1708
+ """Convert Shapely Polygon or MultiPolygon to GeoJSON Feature(s) with elevation."""
1709
+ features: list[Feature] = []
1710
+ if geom.is_empty or not geom.is_valid:
1711
+ return features
1712
+ if geom.geom_type not in ("Polygon", "MultiPolygon"):
1713
+ return features
1714
+ polys = list(geom.geoms) if geom.geom_type == "MultiPolygon" else [geom]
1715
+ for poly in polys:
1716
+ if poly.is_empty or not poly.is_valid:
1717
+ continue
1718
+ ext = list(poly.exterior.coords)
1719
+ ring = [
1720
+ [
1721
+ round(x, GEOJSON_COORDINATE_PRECISION),
1722
+ round(y, GEOJSON_COORDINATE_PRECISION),
1723
+ elev,
1724
+ ]
1725
+ for x, y in ext
1726
+ ]
1727
+ holes = []
1728
+ for interior in poly.interiors:
1729
+ hole = [
1730
+ [
1731
+ round(x, GEOJSON_COORDINATE_PRECISION),
1732
+ round(y, GEOJSON_COORDINATE_PRECISION),
1733
+ elev,
1734
+ ]
1735
+ for x, y in interior.coords
1736
+ ]
1737
+ holes.append(hole)
1738
+ coords = [ring, *holes]
1739
+ features.append(Feature(geometry=Polygon(coords), properties=properties))
1740
+ return features
1741
+
1742
+
1743
+ def _build_station_lrud_map(
1744
+ survey: ComputedSurvey,
1745
+ ) -> dict[str, tuple[tuple[float, float], tuple[float, float]]]:
1746
+ """Build a map of station name -> (left_utm, right_utm) from legs that define LRUD
1747
+ at that station.
1748
+
1749
+ For each leg, the station that "owns" the LRUD (FROM if lruds_at_to_station
1750
+ is False, TO if True) gets its left/right positions from that leg's azimuth and
1751
+ left/right values. This allows tunnel segments to share edges: the left edge of
1752
+ one leg connects to the left edge of the adjacent leg at the shared station.
1753
+ """
1754
+ result: dict[str, tuple[tuple[float, float], tuple[float, float]]] = {}
1755
+ for leg in survey.legs:
1756
+ if (leg.left is None and leg.right is None) or leg.azimuth is None:
1757
+ continue
1758
+
1759
+ left_m = length_to_meters(leg.left or 0.0)
1760
+ right_m = length_to_meters(leg.right or 0.0)
1761
+ azimuth_rad = math.radians(leg.azimuth)
1762
+ station = leg.to_station if leg.lruds_at_to_station else leg.from_station
1763
+ result[station.name] = _station_left_right_utm(
1764
+ station.easting, station.northing, azimuth_rad, left_m, right_m
1765
+ )
1766
+ return result
1767
+
1768
+
1769
+ def passage_to_feature(
1770
+ leg: SurveyLeg,
1771
+ zone: int,
1772
+ northern: bool,
1773
+ datum: Datum = Datum.WGS_1984,
1774
+ station_lrud: dict[str, tuple[tuple[float, float], tuple[float, float]]]
1775
+ | None = None,
1776
+ wgs84_cache: dict[tuple[float, float], tuple[float, float]] | None = None,
1777
+ ) -> Feature | None:
1778
+ """Convert LRUD data to a passage polygon Feature (one tunnel segment).
1779
+
1780
+ Each leg produces a quadrilateral: the "base" is the left/right at FROM and TO.
1781
+ When station_lrud is provided, the left/right at each station come from the leg
1782
+ that defines LRUD at that station, so segments connect and form a continuous tunnel.
1783
+ If a station is missing from the map, this leg's LRUD and azimuth are used for
1784
+ that end.
1785
+
1786
+ Args:
1787
+ leg: Survey leg with LRUD data
1788
+ zone: UTM zone number
1789
+ northern: True if northern hemisphere
1790
+ datum: Source datum for UTM coordinates (critical for NAD27 data)
1791
+ station_lrud: Optional precomputed map station name -> ((left_e, left_n), (right_e, right_n))
1792
+ wgs84_cache: Optional coordinate cache for deduplication
1793
+
1794
+ Returns:
1795
+ GeoJSON Feature with Polygon geometry, or None if no LRUD data
1796
+ """ # noqa: E501
1797
+ if leg.left is None and leg.right is None:
1798
+ return None
1799
+
1800
+ if leg.azimuth is None:
1801
+ return None
1802
+
1803
+ left_m = length_to_meters(leg.left or 0.0)
1804
+ right_m = length_to_meters(leg.right or 0.0)
1805
+ azimuth_rad = math.radians(leg.azimuth)
1806
+
1807
+ # FROM end: use station_lrud if available, else this leg at FROM
1808
+ if station_lrud and leg.from_station.name in station_lrud:
1809
+ (from_left_e, from_left_n), (from_right_e, from_right_n) = station_lrud[
1810
+ leg.from_station.name
1811
+ ]
1812
+ else:
1813
+ (from_left_e, from_left_n), (from_right_e, from_right_n) = (
1814
+ _station_left_right_utm(
1815
+ leg.from_station.easting,
1816
+ leg.from_station.northing,
1817
+ azimuth_rad,
1818
+ left_m,
1819
+ right_m,
1820
+ )
1821
+ )
1822
+
1823
+ # TO end: use station_lrud if available, else this leg at TO
1824
+ if station_lrud and leg.to_station.name in station_lrud:
1825
+ (to_left_e, to_left_n), (to_right_e, to_right_n) = station_lrud[
1826
+ leg.to_station.name
1827
+ ]
1828
+ else:
1829
+ (to_left_e, to_left_n), (to_right_e, to_right_n) = _station_left_right_utm(
1830
+ leg.to_station.easting,
1831
+ leg.to_station.northing,
1832
+ azimuth_rad,
1833
+ left_m,
1834
+ right_m,
1835
+ )
1836
+
1837
+ try:
1838
+ from_left = _cached_utm_to_wgs84(
1839
+ from_left_e,
1840
+ from_left_n,
1841
+ zone,
1842
+ northern,
1843
+ datum,
1844
+ wgs84_cache,
1845
+ )
1846
+ from_right = _cached_utm_to_wgs84(
1847
+ from_right_e,
1848
+ from_right_n,
1849
+ zone,
1850
+ northern,
1851
+ datum,
1852
+ wgs84_cache,
1853
+ )
1854
+ to_left = _cached_utm_to_wgs84(
1855
+ to_left_e,
1856
+ to_left_n,
1857
+ zone,
1858
+ northern,
1859
+ datum,
1860
+ wgs84_cache,
1861
+ )
1862
+ to_right = _cached_utm_to_wgs84(
1863
+ to_right_e,
1864
+ to_right_n,
1865
+ zone,
1866
+ northern,
1867
+ datum,
1868
+ wgs84_cache,
1869
+ )
1870
+ except InvalidCoordinateError:
1871
+ return None
1872
+
1873
+ elev = round((leg.from_station.elevation + leg.to_station.elevation) / 2, 2)
1874
+
1875
+ return Feature(
1876
+ geometry=Polygon(
1877
+ [
1878
+ [
1879
+ (from_left[0], from_left[1], elev),
1880
+ (to_left[0], to_left[1], elev),
1881
+ (to_right[0], to_right[1], elev),
1882
+ (from_right[0], from_right[1], elev),
1883
+ (from_left[0], from_left[1], elev), # Close polygon
1884
+ ]
1885
+ ]
1886
+ ),
1887
+ properties={
1888
+ "type": "passage",
1889
+ "from": leg.from_station.name,
1890
+ "to": leg.to_station.name,
1891
+ "left_ft": leg.left,
1892
+ "right_ft": leg.right,
1893
+ "up_ft": leg.up,
1894
+ "down_ft": leg.down,
1895
+ "lruds_at_to_station": leg.lruds_at_to_station,
1896
+ },
1897
+ )
1898
+
1899
+
1900
+ # -----------------------------------------------------------------------------
1901
+ # Main Conversion Functions
1902
+ # -----------------------------------------------------------------------------
1903
+
1904
+
1905
+ def survey_to_geojson(
1906
+ survey: ComputedSurvey,
1907
+ *,
1908
+ include_stations: bool = True,
1909
+ include_legs: bool = True,
1910
+ include_passages: bool = False,
1911
+ include_anchors: bool = False,
1912
+ color_by_origin: bool = True,
1913
+ properties: dict[str, Any] | None = None,
1914
+ ) -> FeatureCollection:
1915
+ """Convert computed survey to GeoJSON FeatureCollection.
1916
+
1917
+ Args:
1918
+ survey: Computed survey with coordinates
1919
+ include_stations: Include Point features for stations
1920
+ include_legs: Include LineString features for survey legs
1921
+ include_passages: Include Polygon features for passage outlines
1922
+ include_anchors: Include Point features for anchor stations
1923
+ color_by_origin: Color stations and legs by their propagation
1924
+ origin anchor (default True)
1925
+
1926
+ Returns:
1927
+ GeoJSON FeatureCollection
1928
+ """
1929
+ if survey.utm_zone is None:
1930
+ raise ValueError(
1931
+ "Cannot convert to GeoJSON: UTM zone is not known. "
1932
+ "The MAK file must have a LocationDirective (@) or UTMZoneDirective ($)."
1933
+ )
1934
+
1935
+ zone = survey.utm_zone
1936
+ northern = survey.utm_northern
1937
+ datum = survey.datum or Datum.WGS_1984
1938
+ features: list[Feature] = []
1939
+
1940
+ # Coordinate cache: avoids redundant pyproj calls for the same
1941
+ # (easting, northing) pair. Each station appears once as a Point,
1942
+ # then again as the endpoint of every leg it touches.
1943
+ wgs84_cache: dict[tuple[float, float], tuple[float, float]] = {}
1944
+
1945
+ # Build origin -> colour mapping (stable: sorted anchor names)
1946
+ origin_color_map: dict[str, str] = {}
1947
+ if color_by_origin:
1948
+ origin_names = sorted({s.origin for s in survey.stations.values() if s.origin})
1949
+ origin_color_map = {
1950
+ name: ORIGIN_COLORS[i % len(ORIGIN_COLORS)]
1951
+ for i, name in enumerate(origin_names)
1952
+ }
1953
+
1954
+ # Add station points
1955
+ if include_stations:
1956
+ for name, station in survey.stations.items():
1957
+ try:
1958
+ feat = station_to_feature(station, zone, northern, datum, wgs84_cache)
1959
+ if color_by_origin:
1960
+ color = origin_color_map.get(station.origin)
1961
+ if color:
1962
+ feat["properties"]["marker-color"] = color
1963
+ features.append(feat)
1964
+ except InvalidCoordinateError:
1965
+ logger.warning("Skipping station %s: invalid coordinates", name)
1966
+
1967
+ # Add anchor points
1968
+ if include_anchors:
1969
+ for name in survey.anchors:
1970
+ station = survey.stations.get(name)
1971
+ if station is None:
1972
+ continue
1973
+ try:
1974
+ feat = anchor_to_feature(station, zone, northern, datum, wgs84_cache)
1975
+ if color_by_origin:
1976
+ color = origin_color_map.get(station.origin)
1977
+ if color:
1978
+ feat["properties"]["marker-color"] = color
1979
+ features.append(feat)
1980
+ except InvalidCoordinateError:
1981
+ logger.warning("Skipping anchor %s: invalid coordinates", name)
1982
+
1983
+ # Add survey legs (skip P-excluded legs — they were used for
1984
+ # coordinate propagation but are not rendered).
1985
+ if include_legs:
1986
+ try:
1987
+ for leg in survey.legs:
1988
+ if leg.excluded_from_plotting:
1989
+ continue
1990
+ feat = leg_to_feature(leg, zone, northern, datum, wgs84_cache)
1991
+ if color_by_origin:
1992
+ color = origin_color_map.get(leg.from_station.origin)
1993
+ if color:
1994
+ feat["properties"]["stroke"] = color
1995
+ feat["properties"]["stroke-width"] = 2
1996
+ feat["properties"]["stroke-opacity"] = 1
1997
+ features.append(feat)
1998
+ except InvalidCoordinateError:
1999
+ logger.exception("Invalid leg coordinates")
2000
+ raise
2001
+
2002
+ # Add misclosure gap lines.
2003
+ # Cross-origin misclosures (same_origin=False) are present only
2004
+ # when solver is None — raw BFS shows where propagation fronts
2005
+ # disagree.
2006
+ # Same-origin misclosures (same_origin=True) are present when a
2007
+ # solver was applied — they show the raw loop closure error
2008
+ # computed with both paths propagated from the same origin,
2009
+ # using the longest path as the "true" segment.
2010
+ if include_legs and survey.misclosures:
2011
+ for misc in survey.misclosures:
2012
+ try:
2013
+ ex_lon, ex_lat = utm_to_wgs84(
2014
+ misc.existing.easting,
2015
+ misc.existing.northing,
2016
+ zone,
2017
+ northern,
2018
+ datum,
2019
+ )
2020
+ gh_lon, gh_lat = utm_to_wgs84(
2021
+ misc.ghost.easting,
2022
+ misc.ghost.northing,
2023
+ zone,
2024
+ northern,
2025
+ datum,
2026
+ )
2027
+
2028
+ gap_dist = (
2029
+ (misc.existing.easting - misc.ghost.easting) ** 2
2030
+ + (misc.existing.northing - misc.ghost.northing) ** 2
2031
+ + (misc.existing.elevation - misc.ghost.elevation) ** 2
2032
+ ) ** 0.5
2033
+
2034
+ props: dict[str, Any] = {
2035
+ "type": "misclosure",
2036
+ "from": misc.ghost.name,
2037
+ "to": misc.existing.name,
2038
+ "gap_m": round(gap_dist, 2),
2039
+ "file": misc.file,
2040
+ "survey": misc.survey,
2041
+ "stroke": "#ff0000",
2042
+ "stroke-width": 3,
2043
+ "stroke-opacity": 1,
2044
+ }
2045
+
2046
+ if misc.same_origin:
2047
+ props["same_origin"] = True
2048
+ # Report the true (longest) and closing (shortest)
2049
+ # segment distances so users can assess the loop.
2050
+ true_dist = max(
2051
+ misc.ghost.path_distance,
2052
+ misc.existing.path_distance,
2053
+ )
2054
+ closing_dist = min(
2055
+ misc.ghost.path_distance,
2056
+ misc.existing.path_distance,
2057
+ )
2058
+ props["true_segment_distance_m"] = round(true_dist, 2)
2059
+ props["closing_segment_distance_m"] = round(closing_dist, 2)
2060
+
2061
+ line_feat = Feature(
2062
+ geometry=LineString(
2063
+ [
2064
+ (gh_lon, gh_lat, round(misc.ghost.elevation, 2)),
2065
+ (ex_lon, ex_lat, round(misc.existing.elevation, 2)),
2066
+ ]
2067
+ ),
2068
+ properties=props,
2069
+ )
2070
+ features.append(line_feat)
2071
+
2072
+ except InvalidCoordinateError:
2073
+ pass
2074
+
2075
+ # Add passage polygons (tunnel segments); clip each new segment to the "outside"
2076
+ # of the tunnel so far so overlapping layers are not drawn (collision detection).
2077
+ if include_passages:
2078
+ station_lrud = _build_station_lrud_map(survey)
2079
+ existing_tunnel: ShapelyPolygon | None = None
2080
+ for leg in survey.legs:
2081
+ if leg.excluded_from_plotting:
2082
+ continue
2083
+ passage = passage_to_feature(
2084
+ leg,
2085
+ zone,
2086
+ northern,
2087
+ datum,
2088
+ station_lrud,
2089
+ wgs84_cache,
2090
+ )
2091
+ if not passage:
2092
+ continue
2093
+ ring = passage["geometry"]["coordinates"][0]
2094
+ ring_2d = [(c[0], c[1]) for c in ring]
2095
+ segment = ShapelyPolygon(ring_2d)
2096
+ if not segment.is_valid or segment.is_empty:
2097
+ continue
2098
+ if existing_tunnel is not None and existing_tunnel.intersects(segment):
2099
+ clipped = segment.difference(existing_tunnel)
2100
+ if not clipped.is_empty and clipped.is_valid:
2101
+ elev = ring[0][2]
2102
+ props = dict(passage["properties"])
2103
+ features.extend(_shapely_to_passage_features(clipped, elev, props))
2104
+ # Merge full segment into tunnel so next segment is clipped against it
2105
+ existing_tunnel = unary_union([existing_tunnel, segment])
2106
+ else:
2107
+ features.append(passage)
2108
+ existing_tunnel = (
2109
+ segment
2110
+ if existing_tunnel is None
2111
+ else unary_union([existing_tunnel, segment])
2112
+ )
2113
+
2114
+ # Build properties
2115
+ fc_properties = {}
2116
+ if survey.utm_zone:
2117
+ fc_properties["source_utm_zone"] = survey.utm_zone
2118
+ if survey.datum:
2119
+ fc_properties["source_datum"] = (
2120
+ survey.datum.value if isinstance(survey.datum, Datum) else survey.datum
2121
+ )
2122
+ if properties:
2123
+ fc_properties.update(properties)
2124
+
2125
+ return FeatureCollection(
2126
+ features, properties=fc_properties if fc_properties else None
2127
+ )
2128
+
2129
+
2130
+ def project_to_geojson(
2131
+ project: CompassMakFile,
2132
+ *,
2133
+ include_stations: bool = True,
2134
+ include_legs: bool = True,
2135
+ include_passages: bool = False,
2136
+ include_anchors: bool = False,
2137
+ color_by_origin: bool = True,
2138
+ solver: SurveyAdjuster | None = None,
2139
+ ) -> FeatureCollection:
2140
+ """Convert a Compass project to GeoJSON.
2141
+
2142
+ This is the high-level function that computes coordinates and generates
2143
+ GeoJSON in one step.
2144
+
2145
+ Args:
2146
+ project: Loaded CompassMakFile with DAT data
2147
+ include_stations: Include Point features for stations
2148
+ include_legs: Include LineString features for survey legs
2149
+ include_passages: Include Polygon features for passage outlines
2150
+ include_anchors: Include Point features for anchor stations
2151
+ color_by_origin: Color stations and legs by propagation origin
2152
+ solver: Optional survey adjuster
2153
+
2154
+ Returns:
2155
+ GeoJSON FeatureCollection
2156
+ """
2157
+ survey = compute_survey_coordinates(project, solver=solver)
2158
+ return survey_to_geojson(
2159
+ survey,
2160
+ include_stations=include_stations,
2161
+ include_legs=include_legs,
2162
+ include_passages=include_passages,
2163
+ include_anchors=include_anchors,
2164
+ color_by_origin=color_by_origin,
2165
+ )
2166
+
2167
+
2168
+ def convert_mak_to_geojson(
2169
+ mak_path: Path,
2170
+ output_path: Path | None = None,
2171
+ *,
2172
+ include_stations: bool = True,
2173
+ include_legs: bool = True,
2174
+ include_passages: bool = False,
2175
+ include_anchors: bool = False,
2176
+ color_by_origin: bool = True,
2177
+ solver: SurveyAdjuster | None = None,
2178
+ minify: bool = False,
2179
+ ) -> str:
2180
+ """Convert a MAK file (with DAT files) to GeoJSON.
2181
+
2182
+ Args:
2183
+ mak_path: Path to the MAK file
2184
+ output_path: Optional output path (returns string if None)
2185
+ include_stations: Include Point features for stations
2186
+ include_legs: Include LineString features for survey legs
2187
+ include_passages: Include Polygon features for passage outlines
2188
+ include_anchors: Include Point features for anchor stations
2189
+ color_by_origin: Color stations and legs by propagation origin
2190
+ solver: Optional survey adjuster
2191
+ minify: Omit indentation for compact output
2192
+
2193
+ Returns:
2194
+ GeoJSON string
2195
+ """
2196
+
2197
+ project = load_project(mak_path)
2198
+ geojson = project_to_geojson(
2199
+ project,
2200
+ include_stations=include_stations,
2201
+ include_legs=include_legs,
2202
+ include_passages=include_passages,
2203
+ include_anchors=include_anchors,
2204
+ color_by_origin=color_by_origin,
2205
+ solver=solver,
2206
+ )
2207
+
2208
+ # Use orjson for fast serialization
2209
+ opts = 0 if minify else orjson.OPT_INDENT_2
2210
+ json_bytes = orjson.dumps(geojson, option=opts)
2211
+ json_str = json_bytes.decode(JSON_ENCODING)
2212
+
2213
+ if output_path:
2214
+ output_path.write_text(json_str, encoding=JSON_ENCODING)
2215
+
2216
+ return json_str