10.3. Coordinate Injection#

Loading Hardware and EDI Data ended with every K2 station still sitting at 0.0, 0.0, 0.0 – the WinGLink placeholder from Stratagem Concepts. The real positions live in a separate GPS deliverable, almost always in a projected CRS rather than WGS84 directly: metre-scale easting and northing on some local zone, not longitude and latitude. CoordinateInjector is what turns that table into real LAT/LONG/ELEV values in each EDI’s >HEAD section, converting through project_point_utm2ll(). Getting there for a real delivery takes two separate problems solved in order: figuring out which columns of the table actually hold the coordinates, and making sure the table has exactly one row per station before injection can run at all. K2’s own GPS table fails both, in ways worth walking through directly rather than assuming a clean input.

10.3.1. Coordinate Column Detection#

K2’s raw coordinate export, k2-gps-ref.csv, is a Gauss-Kruger table with the columns literally named lon and lat – and neither one is a longitude or a latitude:

>>> import pandas as pd

>>> gps_ref = pd.read_csv("data/stratagem/K2/k2-gps-ref.csv", encoding="utf-8-sig")
>>> gps_ref.columns = [c.strip() for c in gps_ref.columns]
>>> gps_ref[["lon", "lat", "elev", "step"]].head(3)
           lon          lat      elev     step
0  2850828.525  362607.5696  257.9511   0.0000
1  2850821.854  362626.9799  257.4241  19.7991
2  2850815.405  362646.2191  257.4001  40.3187

Those are metre-scale Gauss-Kruger values, not decimal degrees, and the lon column – 2.85 million – is actually the northing; lat – 362 thousand – is the easting. This is a real, common habit in Chinese field deliveries, not a K2-specific mistake, which is why CoordinateInjector never trusts a column name: its private _detect_coord_cols helper compares the median magnitude of the numeric candidate columns instead, on the reasoning that northing (distance from the equator) is reliably larger than easting for any standard UTM/Gauss-Kruger zone in the northern hemisphere:

>>> from pycsamt.stratagem.gis_correct import _detect_coord_cols

>>> e_col, n_col = _detect_coord_cols(gps_ref, None, None, exclude={"elev", "step"})
>>> e_col, n_col
('lat', 'lon')

The detector correctly picks lat as the easting column and lon as the northing column – the exact reverse of what the names claim – because it never looks at the names at all. That heuristic only has a sound basis when exactly two numeric candidates remain once non-coordinate columns are excluded. The reconciled coordinate table built later in this page, k2-gps-aligned.csv, has five numeric columns rather than two, and detection refuses to guess among them:

>>> aligned_raw = pd.read_csv("data/stratagem/K2/k2-gps-aligned.csv")
>>> aligned_raw.select_dtypes(include="number").columns.tolist()
['station_num', 'chainage_design_m', 'northing', 'easting', 'elev']
>>> _detect_coord_cols(aligned_raw, None, None, exclude={"elev"})
Traceback (most recent call last):
    ...
pycsamt.exceptions.ValidationError: Cannot auto-detect easting/northing unambiguously: 4 numeric candidate columns remain after exclusion: ['station_num', 'chainage_design_m', 'northing', 'easting'].  Picking the min/max-median pair among more than two candidates is a guess (e.g. a station index or chainage column could be mistaken for a coordinate). Pass explicit easting_col and northing_col to CoordinateInjector.fit(), or add the extra columns to the exclusion set.

station_num and chainage_design_m are both numeric and both plausible-looking by magnitude, so guessing between them and the real easting/northing pair would be exactly the silent misidentification the module docstring warns about. Passing easting_col="easting", northing_col="northing" explicitly – shown later in this page – sidesteps the ambiguity entirely rather than trying to make the heuristic smarter.

10.3.2. Station-Count Reconciliation#

CoordinateInjector requires exactly one coordinate row per EDI station – and k2-gps-ref.csv has 80 rows against 87 K2 stations. Cross-referencing the field report against the raw table (done once by data/stratagem/K2/build_gps_aligned.py, reproducible with python data/stratagem/K2/build_gps_aligned.py) turns up four distinct real-world reasons for that gap, not one:

  • Station 1 is a calibration/”parallel test” shot at chainage 1 m, off the real profile – a file exists, but there is no corresponding survey position to inject.

  • Stations 43, 69, 75, and 87 are “check point” repeats: QC measurements taken again at the same physical location as the station immediately before them, so they share a chainage with an existing GPS row rather than needing a new one.

  • One physical point at chainage 560 m was GPS-shot but the instrument never recorded a file there (too much road traffic, per the field report) – an orphan GPS row with nothing to attach to.

  • Three further chainages (~1080 m, ~1260 m, ~1300 m) were simply never GPS-shot.

The raw table hides a second problem underneath the first: its station column is shifted one row relative to the trustworthy step (chainage) column. Row 0’s station label encodes chainage 19.7991 m, which is row 1’s step value, not row 0’s:

>>> gps_ref[["station", "step"]].head(3)
       station     step
0  K0+019.7991   0.0000
1  K0+040.3187  19.7991
2  K0+060.6005  40.3187

and the table’s last row is a bad shot outright – its step value claims chainage 1640 m (the far end of the line), but its coordinates sit almost exactly on top of row 0’s, at the profile’s start:

>>> gps_ref[["station", "lon", "lat", "step"]].iloc[[0, -1]]
        station          lon          lat       step
0   K0+019.7991  2850828.525  362607.5696     0.0000
79  K0+000.7991  2850835.864  362589.1739  1640.4414

build_gps_aligned.py treats step as the only trustworthy chainage (never station), drops that last row as a mislabeled re-shot rather than trusting its claimed position, and reconstructs the true endpoint (chainage 1640 m, stations 86 and 87) by a linear fit through the last five good points instead. The three ungapped chainages above are filled by linear interpolation between their chainage-adjacent neighbours. Every row in the resulting 87-row table records how its coordinate was actually obtained:

>>> aligned = pd.read_csv("data/stratagem/K2/k2-gps-aligned.csv")
>>> aligned["source"].value_counts()
source
gps_exact           78
checkpoint_dup       4
interpolated         3
test_shot_approx     1
extrapolated         1
Name: count, dtype: int64
>>> aligned[aligned["station_num"].isin([86, 87])][["station_num", "source", "northing", "easting"]]
    station_num          source      northing        easting
85           86    extrapolated  2.850260e+06  364146.054375
86           87  checkpoint_dup  2.850260e+06  364146.054375

Stations 86 and 87 land on identical coordinates for a documented reason, not a bug in the reconciliation: 87 is the check-point repeat of 86, both shot at the same chainage, so 87 legitimately inherits 86’s extrapolated position rather than getting one of its own.

10.3.3. Running the Coordinate Injection#

With one coordinate row per station secured, injection itself is direct. Station 1’s calibration shot has no survey position – its use_for_survey flag is False – so it is filtered out of both the coordinate table and the EDI batch before fit() runs, the same 0-based drop_stations idea StratagemSurvey automates in The StratagemSurvey Pipeline:

>>> from pycsamt.stratagem import EDIBatch, CoordinateInjector

>>> survey_coords = aligned[aligned["use_for_survey"]]
>>> survey_coords.to_csv("k2_coords_for_injection.csv", index=False)
>>> len(survey_coords)
86

>>> batch = EDIBatch("data/stratagem/K2/k2-edi").fit()
>>> edi_objects = [e for i, e in enumerate(batch.edi_objects_) if i != 0]
>>> len(edi_objects)
86

>>> injector = CoordinateInjector(epsg=32649, order="forward")
>>> injector.fit(
...     edi_objects, "k2_coords_for_injection.csv",
...     easting_col="easting", northing_col="northing",
...     elev_col="elev", station_col="edi_file",
... )
CoordinateInjector(epsg=32649, utm_zone='49N', order='forward', n_stations_=86, reversed_=False)

>>> injector.coordinate_frame().head(3)
       station   latitude   longitude  elevation
0  Z2HX002.edi  25.769107  109.629891   257.9511
1  Z2HX003.edi  25.769048  109.630085   257.4241
2  Z2HX004.edi  25.768992  109.630278   257.4001

epsg=32649 (UTM Zone 49N WGS84) overrides the class’s own default of 15921 (Beijing 1954 / Gauss-Kruger Zone 49) – neither default is guaranteed right for a survey outside the specific belt each one covers, and 32649 is what actually reproduces plausible ground truth here: the injected line sits around 25.77°N, 109.63°E with elevations in the 200-350 m range, consistent with the hillside terrain the field report describes for this part of Guangxi. That agreement is corroborating evidence for the EPSG choice, not proof of it – an EDI’s >HEAD section has no field for “which EPSG was this projected from”, so a wrong-but-plausible zone would inject silently. Confirm the EPSG against your own project records before treating injected coordinates as final, particularly outside a survey area you already know.

10.3.4. Station Ordering#

Stratagem numbers its raw files from the first measurement taken; nothing guarantees the GPS table was recorded walking the same direction. StationLocator, called internally by CoordinateInjector.fit, is what maps EDI index i to the correct GPS row – order="forward" for i -> i, order="reversed" for i -> n-1-i, or an explicit order="mapping" list for anything else. K2’s own coordinate table already runs in acquisition order, which is why the injection above used order="forward" and reported reversed_=False.

order="auto" exists for the case where you do not already know the relationship, but it is worth being precise about what it currently does: it accepts a candidate direction and returns a valid permutation of GPS rows, but its resolution today always chooses forward, regardless of the GPS table’s actual geographic direction – the median-northing comparison the class docstring describes is computed but not yet used to decide between the two:

>>> from pycsamt.stratagem.gis_correct import StationLocator
>>> import numpy as np

>>> lats_north_to_south = np.linspace(26.0, 25.5, 5)  # decreasing latitude
>>> loc = StationLocator(order="auto").fit([None] * 5, lats_north_to_south, np.zeros(5))
>>> loc.index_map_, loc.reversed_
([0, 1, 2, 3, 4], False)

A GPS table running clearly north-to-south while station numbers increase south-to-north is exactly the case order="reversed" exists for, but order="auto" returns the forward map regardless. Until that heuristic is filled in, treat "auto" as a synonym for "forward" and pass order="reversed" (or an explicit mapping) whenever you have independent evidence – a field sketch, chainage direction, a known survey convention – that acquisition ran opposite the table’s row order. Quality Control picks up from here, now that every station has a real position to report QC results against.