Skip to content

Commit 707ea56

Browse files
feature: support parsing Norway (Banenor) kmm2 format
Norway files are identified by a "NorKmmToKmm" token in the VER header and store WGS84 lat/lon in the coordinate columns instead of SWEREF99 TM northing/easting. read_kmm2 now detects this and converts lat/lon to northing/easting on read, so the rest of the pipeline (sync + geodetic) behaves identically to Swedish files. Also adds revert_banenor_kmm2.py, which reverses the earlier SWEREF conversion applied to delivered Norway files, restoring the original lat/lon format (only genuine SWEREF files are reverted). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5b433a7 commit 707ea56

3 files changed

Lines changed: 191 additions & 1 deletion

File tree

kmm/positions/read_kmm2.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,17 @@
55
import numpy as np
66
import pandas as pd
77
from pydantic import validate_call
8+
from sweref99 import projections
89

910
pattern = re.compile(r".+\[.+\]")
1011
pattern2 = re.compile(r"CMAST")
1112

13+
# Norway ("Banenor") kmm2 files are identified by this token in the VER header
14+
# line. They store WGS84 latitude/longitude in the coordinate columns instead of
15+
# SWEREF99 TM northing/easting.
16+
norway_header_token = "NorKmmToKmm"
17+
tm = projections.make_transverse_mercator("SWEREF_99_TM")
18+
1219
expected_columns = [
1320
"code",
1421
"centimeter",
@@ -46,6 +53,29 @@
4653
)
4754

4855

56+
def latlon_to_sweref(dataframe):
57+
"""
58+
Norway kmm2 files store WGS84 latitude/longitude in the coordinate columns
59+
(parsed here into the ``northing``/``easting`` columns of the shared layout).
60+
Convert them to SWEREF99 TM northing/easting so the rest of the pipeline,
61+
including the geodetic transform, behaves identically to Swedish files.
62+
"""
63+
if len(dataframe) == 0 or not {"northing", "easting"}.issubset(dataframe.columns):
64+
return dataframe
65+
66+
latitude = dataframe["northing"].to_numpy()
67+
longitude = dataframe["easting"].to_numpy()
68+
grid = [
69+
(np.nan, np.nan)
70+
if np.isnan(lat) or np.isnan(lon)
71+
else tm.geodetic_to_grid(float(lat), float(lon))
72+
for lat, lon in zip(latitude, longitude)
73+
]
74+
dataframe["northing"] = np.array([n for n, _ in grid], dtype=np.float32)
75+
dataframe["easting"] = np.array([e for _, e in grid], dtype=np.float32)
76+
return dataframe
77+
78+
4979
@validate_call
5080
def read_kmm2(
5181
path: Path, raise_on_malformed_data: bool = True, replace_commas: bool = True
@@ -55,10 +85,12 @@ def read_kmm2(
5585
for index, line in enumerate(path.read_text(encoding="latin1").splitlines())
5686
if pattern.match(line) or pattern2.match(line)
5787
]
88+
norway = False
5889
with open(path, "r", encoding="latin1") as f:
5990
line = f.readline()
6091
if line.startswith("VER"):
6192
skiprows = [0] + skiprows
93+
norway = norway_header_token in line
6294
elif raise_on_malformed_data and not line.startswith("POS"):
6395
raise ValueError("Malformed data, first line is not POS or VER")
6496

@@ -92,12 +124,15 @@ def read_kmm2(
92124
else:
93125
columns = expected_columns
94126

95-
return pd.read_csv(
127+
dataframe = pd.read_csv(
96128
file_obj,
97129
**parser_kwargs,
98130
names=columns,
99131
dtype=expected_dtypes,
100132
)
133+
if norway:
134+
dataframe = latlon_to_sweref(dataframe)
135+
return dataframe
101136
except pd.errors.EmptyDataError:
102137
return pd.DataFrame(columns=expected_columns)
103138
except Exception as e:
@@ -114,3 +149,20 @@ def test_patterns():
114149

115150
def test_extra_columns():
116151
read_kmm2(Path("tests/extra_columns.kmm2"))
152+
153+
154+
def test_norway_latlon_to_sweref():
155+
# Norway ("Banenor") files store WGS84 lat/lon in the coordinate columns;
156+
# read_kmm2 must convert them to SWEREF99 TM northing/easting so the rest of
157+
# the pipeline (including .geodetic()) recovers the original coordinates.
158+
dataframe = read_kmm2(Path("tests/norway.kmm2"))
159+
assert len(dataframe) == 4
160+
# SWEREF99 TM: northing in the millions, easting in the hundred-thousands.
161+
assert (dataframe["northing"] > 6_000_000).all()
162+
assert (dataframe["easting"] > 100_000).all()
163+
164+
latitude, longitude = tm.grid_to_geodetic(
165+
dataframe["northing"].iloc[0], dataframe["easting"].iloc[0]
166+
)
167+
assert abs(latitude - 59.9100025) < 1e-4
168+
assert abs(longitude - 10.7545870) < 1e-4

revert_banenor_kmm2.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""
2+
Reverse the SWEREF conversion that was applied to Norway ("Banenor") kmm2 files.
3+
4+
The files in ``banenor-wire-positions-20260720`` that carry a
5+
``VER\\tNorKmmToKmm...`` header were originally delivered in the Norway format
6+
(WGS84 lat/lon, comma decimals, integer fields, 21 columns) but had their
7+
coordinate columns rewritten to SWEREF99 TM northing/easting (period decimals,
8+
float fields, 24 columns) so the old library could parse them.
9+
10+
This script converts those files back to their original Norway format and
11+
writes them to ``banenor-wire-positions-20260720-converted-to-original``.
12+
Files without the Norway header are left untouched (not copied).
13+
"""
14+
15+
from pathlib import Path
16+
17+
from sweref99 import projections
18+
from tqdm import tqdm
19+
20+
tm = projections.make_transverse_mercator("SWEREF_99_TM")
21+
22+
input_dir = Path("/home/felix/data/banenor-wire-positions-20260720")
23+
output_dir = Path(
24+
"/home/felix/data/banenor-wire-positions-20260720-converted-to-original"
25+
)
26+
27+
NORWAY_TOKEN = "NorKmmToKmm"
28+
29+
# Column layout of a converted file (kmm.positions.read_kmm2.expected_columns).
30+
# The original Norway format only keeps the first 21 columns.
31+
NORTHING = 10
32+
EASTING = 11
33+
N_ORIGINAL_COLUMNS = 21
34+
# Fields that are integers in the original Norway format but were written as
35+
# floats (e.g. "3.0") or zero-padded strings (e.g. "0010") after conversion.
36+
INT_COLUMNS = {1, 2, 3, 4, 6, 7, 8, 9, 12, 20}
37+
38+
39+
def revert_int(value):
40+
value = value.strip()
41+
if value == "":
42+
return value
43+
try:
44+
return str(int(float(value)))
45+
except ValueError:
46+
# Some fields (e.g. alphanumeric track sections like "2d") are not
47+
# numeric; leave them untouched.
48+
return value
49+
50+
51+
def revert_coordinate(value):
52+
# WGS84 with comma decimals, mirroring the original delivered precision.
53+
return repr(float(value)).replace(".", ",")
54+
55+
56+
def revert_line(line):
57+
if not line.startswith("POS"):
58+
return line
59+
fields = line.split("\t")
60+
61+
northing = float(fields[NORTHING])
62+
easting = float(fields[EASTING])
63+
latitude, longitude = tm.grid_to_geodetic(northing, easting)
64+
fields[NORTHING] = revert_coordinate(latitude)
65+
fields[EASTING] = revert_coordinate(longitude)
66+
67+
for index in INT_COLUMNS:
68+
if index < len(fields):
69+
fields[index] = revert_int(fields[index])
70+
71+
return "\t".join(fields[:N_ORIGINAL_COLUMNS])
72+
73+
74+
def revert_file(path):
75+
# latin1 + CRLF, matching the original delivery.
76+
text = path.read_text(encoding="latin1")
77+
lines = text.split("\n")
78+
out_lines = [revert_line(line.rstrip("\r")) for line in lines if line != ""]
79+
return "\r\n".join(out_lines) + "\r\n"
80+
81+
82+
def classify(path):
83+
"""
84+
Categorise a kmm2 file:
85+
- "non-norway": missing the NorKmmToKmm header (leave as is).
86+
- "sweref": norway header, coordinate columns hold SWEREF99 TM
87+
northing/easting (converted) -> reverse to lat/lon.
88+
- "latlon": norway header, coordinate columns already hold WGS84
89+
lat/lon (never converted) -> no reversion needed.
90+
- "unknown": norway header but no parseable/degenerate coordinates.
91+
"""
92+
with open(path, "r", encoding="latin1") as f:
93+
first_line = f.readline()
94+
if not (first_line.startswith("VER") and NORWAY_TOKEN in first_line):
95+
return "non-norway"
96+
for line in f:
97+
if line.startswith("POS"):
98+
fields = line.rstrip("\r\n").split("\t")
99+
try:
100+
northing = float(fields[NORTHING].replace(",", "."))
101+
except (ValueError, IndexError):
102+
return "unknown"
103+
if abs(northing) > 100_000:
104+
return "sweref"
105+
if 3 < abs(northing) < 100:
106+
return "latlon"
107+
return "unknown"
108+
return "unknown"
109+
110+
111+
def main():
112+
output_dir.mkdir(exist_ok=True)
113+
paths = sorted(input_dir.glob("*.kmm2"))
114+
115+
categories = {"non-norway": [], "sweref": [], "latlon": [], "unknown": []}
116+
for path in tqdm(paths):
117+
kind = classify(path)
118+
categories[kind].append(path.name)
119+
if kind == "sweref":
120+
(output_dir / path.name).write_text(revert_file(path), encoding="latin1")
121+
122+
print(f"reverse-converted (sweref -> original latlon): {len(categories['sweref'])}")
123+
print(f"non-norway (left as is): {len(categories['non-norway'])}")
124+
print(f"already original latlon (left as is): {len(categories['latlon'])}")
125+
for name in categories["latlon"]:
126+
print(f" {name}")
127+
print(f"unknown/degenerate (left as is): {len(categories['unknown'])}")
128+
for name in categories["unknown"]:
129+
print(f" {name}")
130+
131+
132+
if __name__ == "__main__":
133+
main()

tests/norway.kmm2

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
VER NorKmmToKmm2_1.03 SplSetup NO_NORM
2+
POS 23060200 10 0 200 ? 3 0 0 0 59,9100025118578 10,7545870162215 10 ? 0
3+
POS 23060300 10 0 201 ? 3 0 0 0 59,9099990016758 10,7546024063035 10 ? 0
4+
POS 23060400 10 0 202 ? 3 0 0 0 59,909994982491 10,754618120436 10 ? 0
5+
POS 23060500 10 0 203 ? 3 0 0 0 59,9099912415574 10,7546337465434 10 ? 0

0 commit comments

Comments
 (0)