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/__init__.py +117 -0
- compass_lib/commands/__init__.py +2 -0
- compass_lib/commands/convert.py +251 -0
- compass_lib/commands/encrypt.py +115 -0
- compass_lib/commands/geojson.py +161 -0
- compass_lib/commands/main.py +33 -0
- compass_lib/constants.py +98 -0
- compass_lib/enums.py +394 -0
- compass_lib/errors.py +86 -0
- compass_lib/geo_utils.py +47 -0
- compass_lib/geojson.py +2216 -0
- compass_lib/interface.py +332 -0
- compass_lib/io.py +246 -0
- compass_lib/models.py +251 -0
- compass_lib/plot/__init__.py +28 -0
- compass_lib/plot/models.py +265 -0
- compass_lib/plot/parser.py +610 -0
- compass_lib/project/__init__.py +36 -0
- compass_lib/project/format.py +158 -0
- compass_lib/project/models.py +496 -0
- compass_lib/project/parser.py +639 -0
- compass_lib/solver/__init__.py +43 -0
- compass_lib/solver/ariane.py +488 -0
- compass_lib/solver/base.py +54 -0
- compass_lib/solver/lse.py +304 -0
- compass_lib/solver/models.py +229 -0
- compass_lib/solver/noop.py +28 -0
- compass_lib/solver/proportional.py +201 -0
- compass_lib/solver/sparse.py +249 -0
- compass_lib/survey/__init__.py +24 -0
- compass_lib/survey/format.py +291 -0
- compass_lib/survey/models.py +126 -0
- compass_lib/survey/parser.py +701 -0
- compass_lib/validation.py +241 -0
- compass_lib-0.0.8.dist-info/METADATA +63 -0
- compass_lib-0.0.8.dist-info/RECORD +39 -0
- compass_lib-0.0.8.dist-info/WHEEL +4 -0
- compass_lib-0.0.8.dist-info/entry_points.txt +8 -0
- compass_lib-0.0.8.dist-info/licenses/LICENSE +660 -0
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
|