Skip to content

Commit 73eef48

Browse files
authored
Merge pull request #22 from TaskarCenterAtUW/bugfix-1654
Bugfix 1654
2 parents 4383c74 + f0b49de commit 73eef48

File tree

12 files changed

+314
-12
lines changed

12 files changed

+314
-12
lines changed

CHANGELOG.md

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

3+
### 0.2.7
4+
- Fixed [BUG 1654](https://dev.azure.com/TDEI-UW/TDEI/_workitems/edit/1654)
5+
- Added functionality to retain the `ext` tags
6+
## Unit Tests
7+
- Verified all output files are valid GeoJSON FeatureCollections
8+
- Ensured `nodes` files contain only Point geometries
9+
- Validated that all feature `_id` properties are strings
10+
- Asserted no features are missing geometry or coordinates
11+
- Checked that no duplicate `_id` values exist within any generated file
12+
313
### 0.2.6
414
- Added unit test cases
515
- Added unit test cases pipeline

src/osm_osw_reformatter/serializer/osm/osm_graph.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,9 @@ def way(self, w):
162162
for i in range(len(w.nodes)):
163163
u = w.nodes[i]
164164

165+
if not u.location.valid():
166+
continue
167+
165168
u_lon = float(u.lon)
166169
u_lat = float(u.lat)
167170

@@ -304,6 +307,17 @@ def area(self, a):
304307
self.G.add_node("g" + str(a.id), **d3)
305308
exteriors_count = exteriors_count + 1
306309

310+
class OSMTaggedNodeParser(osmium.SimpleHandler):
311+
def __init__(self, G: nx.MultiDiGraph):
312+
osmium.SimpleHandler.__init__(self)
313+
self.G = G
314+
315+
def node(self, n):
316+
# Only add nodes with tags
317+
if n.tags and len(n.tags) > 0:
318+
d = dict(n.tags)
319+
# Store OSM node id as string (to match the pattern in your output)
320+
self.G.add_node(n.id, lon=n.location.lon, lat=n.location.lat, **d)
307321

308322
class OSMGraph:
309323
def __init__(self, G: nx.MultiDiGraph = None) -> None:
@@ -313,6 +327,11 @@ def __init__(self, G: nx.MultiDiGraph = None) -> None:
313327
# Geodesic distance calculator. Assumes WGS84-like geometries.
314328
self.geod = pyproj.Geod(ellps='WGS84')
315329

330+
def node(self, n):
331+
if len(n.tags) > 0 and n.id not in self.G.nodes:
332+
d = dict(n.tags)
333+
self.G.add_node(n.id, lon=n.location.lon, lat=n.location.lat, **d)
334+
316335
@classmethod
317336
def from_osm_file(
318337
self, osm_file, way_filter: Optional[callable] = None, node_filter: Optional[callable] = None,
@@ -339,6 +358,13 @@ def from_osm_file(
339358
G = line_parser.G
340359
del line_parser
341360

361+
# --- PATCH START: Add all loose/tagged nodes ---
362+
tagged_node_parser = OSMTaggedNodeParser(G)
363+
tagged_node_parser.apply_file(osm_file)
364+
G = tagged_node_parser.G
365+
del tagged_node_parser
366+
# --- PATCH END ---
367+
342368
# zone_parser = OSMZoneParser(G, zone_filter, progressbar=progressbar)
343369
# zone_parser.apply_file(osm_file)
344370
# G = zone_parser.G
@@ -570,6 +596,8 @@ def to_geojson(self, *args) -> None:
570596
d_copy['_u_id'] = str(u)
571597
d_copy['_v_id'] = str(v)
572598

599+
d_copy['ext:osm_id'] = str(d['osm_id'])
600+
573601
if 'osm_id' in d_copy:
574602
d_copy.pop('osm_id')
575603

@@ -591,6 +619,7 @@ def to_geojson(self, *args) -> None:
591619
for n, d in self.G.nodes(data=True):
592620
d_copy = {**d}
593621
d_copy["_id"] = str(n)[1:]
622+
d_copy['ext:osm_id'] = str(d_copy.get('osm_id', d_copy["_id"]))
594623

595624
if OSWPointNormalizer.osw_point_filter(d):
596625
geometry = mapping(d_copy.pop("geometry"))

src/osm_osw_reformatter/serializer/osm/osm_normalizer.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,12 @@ def process_feature_post(self, osmgeometry, ogrfeature, ogrgeometry):
3838
ogr feature and ogr geometry used to create the object are passed as
3939
well. Note that any return values will be discarded by ogr2osm.
4040
'''
41-
if osmgeometry.tags['_id'][0]:
42-
osmgeometry.id = int(osmgeometry.tags.pop('_id')[0])
41+
osm_id = None
42+
# ext:osm_id is probably in the tags dictionary as 'ext:osm_id' or similar
43+
if 'ext:osm_id' in osmgeometry.tags and osmgeometry.tags['ext:osm_id'][0]:
44+
osm_id = int(osmgeometry.tags['ext:osm_id'][0])
45+
elif '_id' in osmgeometry.tags and osmgeometry.tags['_id'][0]:
46+
osm_id = int(osmgeometry.tags['_id'][0])
47+
48+
if osm_id is not None:
49+
osmgeometry.id = osm_id

src/osm_osw_reformatter/serializer/osw/osw_normalizer.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,8 @@ def normalize(self):
249249
elif self.is_street_lamp():
250250
return self._normalize_point({"highway": str})
251251
else:
252-
raise ValueError("This is an invalid point")
252+
print(f"Invalid point skipped. Tags: {self.tags}")
253+
return {}
253254

254255
def _normalize_point(self, keep_keys={}, defaults = {}):
255256
generic_keep_keys = {}
@@ -503,7 +504,13 @@ def _normalize(tags, keep_keys, defaults):
503504
pass
504505

505506
# Preserve order of keep_keys first followed by defaults
506-
return {**{**new_tags, **defaults}, **new_tags}
507+
new_tags.update(defaults)
508+
509+
# Keep all tags that start with "ext:"
510+
ext_tags = {k: v for k, v in tags.items() if k.startswith("ext:")}
511+
512+
return {**{**new_tags, **defaults}, **{**new_tags, **ext_tags}}
513+
507514

508515
def tactile_paving(tag_value, tags):
509516
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.6'
1+
__version__ = '0.2.7'
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<osm version="0.6" generator="ogr2osm 1.2.0" upload="false">
3+
<node visible="true" id="22592328" lat="34.052143" lon="-118.2672968"><tag k="_id" v="22592328"/></node>
4+
<node visible="true" id="726963844" lat="34.0517032" lon="-118.2675859"><tag k="_id" v="726963844"/></node>
5+
<node visible="true" id="20393501" lat="34.0521803" lon="-118.2673793"><tag k="_id" v="20393501"/></node>
6+
<way visible="true" id="1"><nd ref="22592328"/><nd ref="726963844"/><tag k="highway" v="residential"/><tag k="name" v="Hartford Avenue"/><tag k="ext:qm:fixed" v="33"/><tag k="ext:osm_id" v="1"/><tag k="_id" v="1"/><tag k="ext:my_surface" v="asphalt"/><tag k="ext:my_color" v="green"/></way>
7+
<way visible="true" id="2"><nd ref="22592328"/><nd ref="20393501"/><tag k="highway" v="tertiary"/><tag k="surface" v="asphalt"/><tag k="name" v="West 7th Street"/><tag k="ext:qm:fixed" v="38"/><tag k="ext:osm_id" v="2"/><tag k="_id" v="2"/><tag k="ext:my_surface" v="asphalt"/><tag k="ext:my_color" v="red"/></way>
8+
</osm>
341 KB
Binary file not shown.

tests/unit_tests/test_osm2osw/test_osm2osw.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import re
3+
import json
34
import asyncio
45
import unittest
56
from src.osm_osw_reformatter.osm2osw.osm2osw import OSM2OSW
@@ -69,6 +70,120 @@ async def mock_count_entities_error(osm_file_path, counter_cls):
6970
result = await osm2osw.convert()
7071
self.assertFalse(result.status)
7172

73+
def test_ext_tags_present_in_output(self):
74+
osm_file_path = TEST_FILE
75+
76+
async def run_test():
77+
osm2osw = OSM2OSW(osm_file=osm_file_path, workdir=OUTPUT_DIR, prefix='test')
78+
result = await osm2osw.convert()
79+
self.assertTrue(result.status)
80+
81+
has_ext_tag = False
82+
for file_path in result.generated_files:
83+
if file_path.endswith('.geojson'):
84+
with open(file_path) as f:
85+
geojson = json.load(f)
86+
for feature in geojson.get('features', []):
87+
props = feature.get('properties', {})
88+
if any(k.startswith("ext:") for k in props):
89+
has_ext_tag = True
90+
break
91+
if has_ext_tag:
92+
break
93+
94+
self.assertTrue(has_ext_tag, "No ext: tags found in generated GeoJSON features")
95+
96+
for file_path in result.generated_files:
97+
os.remove(file_path)
98+
99+
asyncio.run(run_test())
100+
101+
def test_nodes_file_has_point_geometry(self):
102+
osm_file_path = TEST_FILE
103+
104+
async def run_test():
105+
osm2osw = OSM2OSW(osm_file=osm_file_path, workdir=OUTPUT_DIR, prefix='test')
106+
result = await osm2osw.convert()
107+
self.assertTrue(result.status)
108+
109+
for file_path in result.generated_files:
110+
if "nodes" in file_path:
111+
with open(file_path) as f:
112+
geojson = json.load(f)
113+
for feature in geojson["features"]:
114+
self.assertEqual(feature["geometry"]["type"], "Point")
115+
break
116+
117+
for file_path in result.generated_files:
118+
os.remove(file_path)
119+
120+
asyncio.run(run_test())
121+
122+
def test_all_feature_ids_are_strings(self):
123+
osm_file_path = TEST_FILE
124+
125+
async def run_test():
126+
osm2osw = OSM2OSW(osm_file=osm_file_path, workdir=OUTPUT_DIR, prefix='test')
127+
result = await osm2osw.convert()
128+
self.assertTrue(result.status)
129+
130+
for file_path in result.generated_files:
131+
with open(file_path) as f:
132+
geojson = json.load(f)
133+
for feature in geojson.get("features", []):
134+
self.assertIn("_id", feature["properties"])
135+
self.assertIsInstance(feature["properties"]["_id"], str)
136+
137+
for file_path in result.generated_files:
138+
os.remove(file_path)
139+
140+
asyncio.run(run_test())
141+
142+
143+
def test_no_empty_features(self):
144+
osm_file_path = TEST_FILE
145+
146+
async def run_test():
147+
osm2osw = OSM2OSW(osm_file=osm_file_path, workdir=OUTPUT_DIR, prefix='test')
148+
result = await osm2osw.convert()
149+
self.assertTrue(result.status)
150+
151+
for file_path in result.generated_files:
152+
with open(file_path) as f:
153+
geojson = json.load(f)
154+
for feature in geojson.get("features", []):
155+
self.assertIn("geometry", feature)
156+
self.assertIsNotNone(feature["geometry"])
157+
self.assertIn("type", feature["geometry"])
158+
self.assertIn("coordinates", feature["geometry"])
159+
160+
for file_path in result.generated_files:
161+
os.remove(file_path)
162+
163+
asyncio.run(run_test())
164+
165+
def test_no_duplicate_ids_in_file(self):
166+
osm_file_path = TEST_FILE
167+
168+
async def run_test():
169+
osm2osw = OSM2OSW(osm_file=osm_file_path, workdir=OUTPUT_DIR, prefix='test')
170+
result = await osm2osw.convert()
171+
self.assertTrue(result.status)
172+
173+
for file_path in result.generated_files:
174+
with open(file_path) as f:
175+
geojson = json.load(f)
176+
seen_ids = set()
177+
for feature in geojson.get("features", []):
178+
_id = feature["properties"].get("_id")
179+
self.assertNotIn(_id, seen_ids, f"Duplicate _id: {_id} in {file_path}")
180+
seen_ids.add(_id)
181+
182+
for file_path in result.generated_files:
183+
os.remove(file_path)
184+
185+
asyncio.run(run_test())
186+
72187

73188
if __name__ == '__main__':
74189
unittest.main()

tests/unit_tests/test_roundtrip/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)