Skip to content

Commit d05234d

Browse files
authored
Merge pull request #34 from TaskarCenterAtUW/feature-osw-incline
Preserve incline tag during OSM to OSW conversion
2 parents f92d546 + 3ac1fda commit d05234d

File tree

12 files changed

+236
-15
lines changed

12 files changed

+236
-15
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Change log
22

3+
### 0.2.11
4+
- Retain numeric `incline` values and new `length` tags during way normalization
5+
- Recognize any `highway=steps` way as stairs, preserving valid `climb` tags
6+
- Add compliance test verifying `incline` survives OSW→OSM→OSW roundtrips
7+
38
### 0.2.10
49
- Removed `climb` tag generation from OSM normalizer
510

src/osm_osw_reformatter/serializer/osm/osm_normalizer.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,12 @@ def filter_tags(self, tags):
5151

5252
if 'incline' in tags:
5353
try:
54-
incline_val = float(str(tags['incline']).rstrip('%'))
54+
incline_val = float(str(tags['incline']))
5555
except (ValueError, TypeError):
56-
pass
56+
# Drop the incline tag if it cannot be interpreted as a float
57+
tags.pop('incline', '')
5758
else:
59+
# Normalise numeric incline values by casting to string
5860
tags['incline'] = str(incline_val)
5961

6062
self._check_datatypes(tags)
@@ -75,4 +77,4 @@ def process_feature_post(self, osmgeometry, ogrfeature, ogrgeometry):
7577
osm_id = int(osmgeometry.tags['_id'][0])
7678

7779
if osm_id is not None:
78-
osmgeometry.id = osm_id
80+
osmgeometry.id = osm_id

src/osm_osw_reformatter/serializer/osw/osw_normalizer.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ class OSWWayNormalizer:
1818
"trunk_link"
1919
)
2020

21+
CLIMB_VALUES = (
22+
"up",
23+
"down",
24+
)
25+
2126
def __init__(self, tags):
2227
self.tags = tags
2328

@@ -67,7 +72,16 @@ def normalize(self):
6772
raise ValueError("This is an invalid way")
6873

6974
def _normalize_way(self, keep_keys={}, defaults = {}):
70-
generic_keep_keys = {"highway": str, "width": float, "surface": surface, "name": str, "description": str, "foot": foot}
75+
generic_keep_keys = {
76+
"highway": str,
77+
"width": float,
78+
"surface": surface,
79+
"name": str,
80+
"description": str,
81+
"foot": foot,
82+
"incline": incline,
83+
"length": float,
84+
}
7185
generic_defaults = {}
7286

7387
new_tags = _normalize(self.tags, {**generic_keep_keys, **keep_keys}, {**generic_defaults, **defaults})
@@ -81,7 +95,7 @@ def _normalize_pedestrian(self, keep_keys = {}, defaults = {}):
8195
return new_tags
8296

8397
def _normalize_stairs(self, keep_keys = {}, defaults = {}):
84-
generic_keep_keys = {"step_count": int, "incline": ["climb", climb]}
98+
generic_keep_keys = {"step_count": int, "climb": climb}
8599
generic_defaults = {"foot": "yes"}
86100

87101
new_tags = self._normalize_way({**generic_keep_keys, **keep_keys}, {**generic_defaults, **defaults})
@@ -584,13 +598,16 @@ def crossing_markings(tag_value, tags):
584598
return None
585599

586600
def climb(tag_value, tags):
587-
if tag_value.lower() not in (
588-
"up",
589-
"down"
590-
):
601+
if tag_value.lower() not in OSWWayNormalizer.CLIMB_VALUES:
591602
return None
592603
else:
593604
return tag_value.lower()
605+
606+
def incline(tag_value, tags):
607+
try:
608+
return float(str(tag_value))
609+
except (ValueError, TypeError):
610+
return None
594611

595612
def foot(tag_value, tags):
596613
if tag_value.lower() not in (

src/osm_osw_reformatter/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '0.2.10'
1+
__version__ = '0.2.11'
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{"type": "FeatureCollection", "features": [
2+
{
3+
"type": "Feature",
4+
"geometry": {"type": "LineString", "coordinates": [[0.0, 0.0], [0.0, 1.0]]},
5+
"properties": {"_id": "1", "_u_id": "1", "_v_id": "2", "highway": "footway", "foot": "yes", "incline": 0.1}
6+
},
7+
{
8+
"type": "Feature",
9+
"geometry": {"type": "LineString", "coordinates": [[0.0, 1.0], [0.0, 2.0]]},
10+
"properties": {"_id": "2", "_u_id": "2", "_v_id": "3", "highway": "footway", "foot": "yes", "incline": "steep"}
11+
}
12+
]}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<osm version="0.6" generator="test" upload="false">
3+
<node id="1" lat="0.0" lon="0.0" />
4+
<node id="2" lat="0.0" lon="0.1" />
5+
<way id="1">
6+
<nd ref="1"/>
7+
<nd ref="2"/>
8+
<tag k="highway" v="footway"/>
9+
<tag k="incline" v="0.1"/>
10+
<tag k="_id" v="1"/>
11+
</way>
12+
</osm>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{"type": "FeatureCollection", "features": [
2+
{"type": "Feature", "geometry": {"type": "Point", "coordinates": [0.0, 0.0]}, "properties": {"_id": "1"}},
3+
{"type": "Feature", "geometry": {"type": "Point", "coordinates": [0.0, 1.0]}, "properties": {"_id": "2"}},
4+
{"type": "Feature", "geometry": {"type": "Point", "coordinates": [0.0, 2.0]}, "properties": {"_id": "3"}}
5+
]}

tests/unit_tests/test_osm2osw/test_osm2osw.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
OUTPUT_DIR = os.path.join(os.path.dirname(os.path.dirname(ROOT_DIR)), 'output')
1111
TEST_FILE = os.path.join(ROOT_DIR, 'test_files/wa.microsoft.osm.pbf')
1212
TEST_WIDTH_FILE = os.path.join(ROOT_DIR, 'test_files/width-test.xml')
13+
TEST_INCLINE_FILE = os.path.join(ROOT_DIR, 'test_files/incline-test.xml')
1314

1415

1516
def is_valid_float(value):
@@ -218,6 +219,33 @@ async def run_test():
218219

219220
asyncio.run(run_test())
220221

222+
def test_retains_incline_tag(self):
223+
osm_file_path = TEST_INCLINE_FILE
224+
225+
async def run_test():
226+
osm2osw = OSM2OSW(osm_file=osm_file_path, workdir=OUTPUT_DIR, prefix='test')
227+
result = await osm2osw.convert()
228+
self.assertTrue(result.status)
229+
230+
found_incline = False
231+
for file_path in result.generated_files:
232+
if file_path.endswith('edges.geojson'):
233+
with open(file_path) as f:
234+
geojson = json.load(f)
235+
for feature in geojson.get('features', []):
236+
props = feature.get('properties', {})
237+
if 'incline' in props:
238+
self.assertIsInstance(props['incline'], (int, float))
239+
found_incline = True
240+
break
241+
242+
for file_path in result.generated_files:
243+
os.remove(file_path)
244+
245+
self.assertTrue(found_incline, 'Incline tag not found in output edges')
246+
247+
asyncio.run(run_test())
248+
221249

222250
if __name__ == '__main__':
223251
unittest.main()

tests/unit_tests/test_osm_compliance/test_osm_compliance.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,36 @@ async def test_output_is_osm_compliant(self):
3636
os.remove(f)
3737
os.remove(zip_path)
3838
formatter.cleanup()
39+
40+
async def test_incline_tag_preserved(self):
41+
osw2osm = OSW2OSM(
42+
zip_file_path=TEST_DATA_WITH_INCLINE_ZIP_FILE,
43+
workdir=OUTPUT_DIR,
44+
prefix='incline'
45+
)
46+
result = osw2osm.convert()
47+
osm_file = result.generated_files
48+
49+
formatter = Formatter(workdir=OUTPUT_DIR, file_path=osm_file, prefix='incline')
50+
res = await formatter.osm2osw()
51+
osw_files = res.generated_files
52+
53+
found_incline = False
54+
for f in osw_files:
55+
if f.endswith('.geojson'):
56+
with open(f) as fh:
57+
data = json.load(fh)
58+
for feature in data.get('features', []):
59+
props = feature.get('properties', {})
60+
if 'incline' in props:
61+
found_incline = True
62+
break
63+
if found_incline:
64+
break
65+
66+
self.assertTrue(found_incline, 'No incline tag found in OSW output')
67+
68+
os.remove(osm_file)
69+
for f in osw_files:
70+
os.remove(f)
71+
formatter.cleanup()

tests/unit_tests/test_osw2osm/test_osw2osm.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import zipfile
23
import unittest
34
from src.osm_osw_reformatter.osw2osm.osw2osm import OSW2OSM
45
import xml.etree.ElementTree as ET
@@ -8,6 +9,27 @@
89
TEST_ZIP_FILE = os.path.join(ROOT_DIR, 'test_files/osw.zip')
910
TEST_WIDTH_ZIP_FILE = os.path.join(ROOT_DIR, 'test_files/width-test.zip')
1011
TEST_DATA_WITH_INCLINE_ZIP_FILE = os.path.join(ROOT_DIR, 'test_files/dataset_with_incline.zip')
12+
TEST_EDGES_WITH_INVALID_INCLINE_FILE = os.path.join(ROOT_DIR, 'test_files/edges_invalid_incline.geojson')
13+
TEST_NODES_WITH_INVALID_INCLINE_FILE = os.path.join(ROOT_DIR, 'test_files/nodes_invalid_incline.geojson')
14+
15+
16+
def _create_invalid_incline_zip(zip_path: str) -> str:
17+
"""Create a temporary OSW dataset with invalid incline values.
18+
19+
The resulting ZIP mirrors the structure expected by ``OSW2OSM`` and contains
20+
both ``edges.geojson`` and ``nodes.geojson`` files.
21+
22+
Args:
23+
zip_path: Location where the archive will be written.
24+
25+
Returns:
26+
The path to the generated ZIP archive.
27+
"""
28+
os.makedirs(os.path.dirname(zip_path), exist_ok=True)
29+
with zipfile.ZipFile(zip_path, 'w') as zf:
30+
zf.write(TEST_EDGES_WITH_INVALID_INCLINE_FILE, arcname='edges.geojson')
31+
zf.write(TEST_NODES_WITH_INVALID_INCLINE_FILE, arcname='nodes.geojson')
32+
return zip_path
1133

1234

1335
class TestOSW2OSM(unittest.IsolatedAsyncioTestCase):
@@ -105,6 +127,31 @@ def test_incline_tags_do_not_have_climb(self):
105127

106128
os.remove(result.generated_files)
107129

130+
def test_invalid_incline_values_are_excluded(self):
131+
zip_path = os.path.join(OUTPUT_DIR, 'dataset_with_invalid_incline.zip')
132+
zip_file = _create_invalid_incline_zip(zip_path)
133+
osw2osm = OSW2OSM(zip_file_path=zip_file, workdir=OUTPUT_DIR, prefix='invalid')
134+
result = osw2osm.convert()
135+
136+
# Ensure conversion succeeded so the XML file path is valid
137+
self.assertTrue(result.status, msg=getattr(result, 'error', 'Conversion failed'))
138+
xml_file_path = result.generated_files
139+
140+
tree = ET.parse(xml_file_path)
141+
root = tree.getroot()
142+
143+
for way in root.findall('.//way'):
144+
tags = {tag.get('k'): tag.get('v') for tag in way.findall('tag')}
145+
if tags.get('_id') == '2':
146+
self.assertNotIn('incline', tags)
147+
if tags.get('_id') == '1':
148+
self.assertEqual(tags.get('incline'), '0.1')
149+
150+
self.assertEqual(len(root.findall(".//tag[@k='incline']")), 1)
151+
152+
os.remove(result.generated_files)
153+
os.remove(zip_file)
154+
108155

109156
if __name__ == '__main__':
110157
unittest.main()

0 commit comments

Comments
 (0)