_rings() rundete bislang jede Koordinate unabhängig auf 2 Stellen, nach normalize(). Das kann zwei Ringpunkte so verschieben, dass sich der Ring selbst schneidet, obwohl das Ausgangspolygon gültig war - Ursache der acht neuen ungültigen Polygone in den ausgelieferten Chunks (carriageway 3, land 2, sidewalk 2, surfaces 1). shapely.set_precision(poly, 0.01) läuft jetzt vor normalize() und vor der Rundung: Es ist eine echte Geometrieoperation (GEOS hält das Ergebnis mit mode="valid_output" aktiv gültig) statt eines blinden round() je Zahl. Gegen alle 29.037 Polygone der committeten (bereits einmal falsch gerundeten) Chunks probeweise gerechnet: kein Absturz, 8 der 9 ungültigen Polygone (die neunte ist die bekannte, von diesem Zweig unabhängige Vorbelastung in "paths", chunk_10_6, schon vor diesem Zweig ungültig) werden gültig, Flächenänderung insgesamt -24,35 m² von 11.711.354 m² (-0,00021 %). Zwei Sonderfälle brauchten eine eigene Behandlung, die die Zeilen im Befund nicht erwähnen, ohne die aber der Vier-Minuten-Lauf hätte abbrechen oder eine falsche Fläche ausliefern können: - An einer Engstelle unter 1 cm kann set_precision ein Polygon in mehrere Teile spalten (3 von 29.037 Fällen, größter abgeschnittener Teil 25,9 m²) - poly.exterior auf einer MultiPolygon wäre ein AttributeError gewesen. Es bleibt jetzt das größere Teilstück. - Ganz vereinzelt lehnt GEOS das Schnappen mit einer TopologyException ab (in der Stichprobe nur an einem bereits vorher ungültigen Polygon); dann bleibt das Polygon ungerundet, und die alte Rundung je Koordinate greift wie vor diesem Befund, statt den Lauf abzubrechen. Zwei Tests, beide ohne die Korrektur rot: ein valides Polygon, dessen unabhängig gerundete Ecken sich kreuzen (Beispiel selbst nachgerechnet, nicht nur behauptet), und eine Hantelform mit einem Steg unter 1 cm, die am unkorrigierten _rings() unter der Rundung selbst ungültig wird statt sich sauber zu teilen.
241 lines
14 KiB
Python
241 lines
14 KiB
Python
import pytest
|
|
from shapely.geometry import Polygon
|
|
from leonida.emit import _rings, chunk_payload, split_by_chunk, split_by_chunk_all
|
|
|
|
BIG = Polygon([(-1000, 2500), (-700, 2500), (-700, 2200), (-1000, 2200)]) # Chunk (0,0) und Nachbarn
|
|
HEIGHT = {"height": 9.0, "source": "district"}
|
|
|
|
def test_polygon_is_clipped_into_its_chunks():
|
|
parts = split_by_chunk(BIG)
|
|
assert set(parts) >= {(0, 0), (1, 0), (0, 1), (1, 1)}
|
|
assert abs(sum(p.area for p in parts.values()) - BIG.area) < 1.0
|
|
|
|
def test_coordinates_are_chunk_local_three_js():
|
|
# chunk_payload() nimmt (wie in run.py) bereits chunk-geschnittene Geometrie entgegen;
|
|
# BIG selbst überspannt vier Chunks (siehe Test oben), darum hier zuerst zuschneiden.
|
|
clipped = split_by_chunk(BIG)[(0, 0)]
|
|
payload = chunk_payload(0, 0, land=[clipped], surfaces=[], carriageway=[], curb=[], sidewalk=[],
|
|
markings=[], lots=[], bridges=[], edges=[], buildings=[], palms=[])
|
|
ring = payload["land"][0][0] # Ruling 34: [0] ist jetzt der Aussenring
|
|
assert payload["origin"] == [-1000, -2500] # three-z = -Y
|
|
assert min(x for x, _ in ring) >= -0.01
|
|
assert max(x for x, _ in ring) <= 250.01
|
|
|
|
def test_payload_matches_schema():
|
|
payload = chunk_payload(2, 3, land=[], surfaces=[], carriageway=[], curb=[], sidewalk=[], markings=[],
|
|
lots=[], bridges=[], edges=[], buildings=[], palms=[])
|
|
assert set(payload) == {"chunk", "origin", "land", "surfaces", "carriageway", "curb", "sidewalk",
|
|
"markings", "lots", "bridges", "edges", "buildings", "palms", "paths"}
|
|
assert payload["chunk"] == [2, 3]
|
|
|
|
def test_paths_are_chunk_local_rings_like_roads():
|
|
path = Polygon([(-990, 2490), (-980, 2490), (-980, 2480), (-990, 2480)])
|
|
payload = chunk_payload(0, 0, land=[], surfaces=[], carriageway=[], curb=[], sidewalk=[], markings=[],
|
|
lots=[], bridges=[], edges=[], buildings=[], palms=[], paths=[path])
|
|
(entry,) = payload["paths"]
|
|
assert set(entry) == {"poly"}
|
|
ring = entry["poly"][0]
|
|
assert (min(x for x, _ in ring), max(x for x, _ in ring)) == (10, 20)
|
|
assert (min(z for _, z in ring), max(z for _, z in ring)) == (10, 20)
|
|
|
|
def test_polygon_holes_survive_into_the_payload():
|
|
"""Ein von Strassen umschlossener Block darf nicht zu Asphalt werden. An echten Daten
|
|
gehen sonst 449.876 m2 Strassenloch und 208.657 m2 Stadtloch verloren."""
|
|
outer = Polygon([(-1000, 2250), (-800, 2250), (-800, 2450), (-1000, 2450)],
|
|
[[(-950, 2300), (-870, 2300), (-870, 2400), (-950, 2400)]])
|
|
payload = chunk_payload(0, 0, land=[outer], surfaces=[], carriageway=[], curb=[], sidewalk=[],
|
|
markings=[], lots=[], bridges=[], edges=[], buildings=[], palms=[])
|
|
assert len(payload["land"]) == 1
|
|
rings = payload["land"][0]
|
|
assert len(rings) == 2 # Aussenring und Loch
|
|
exterior, hole = rings
|
|
# Das Loch liegt innerhalb des Aussenrings, nicht daneben.
|
|
assert min(x for x, _ in exterior) < min(x for x, _ in hole)
|
|
assert max(x for x, _ in exterior) > max(x for x, _ in hole)
|
|
assert all(0 <= x <= 250 and 0 <= z <= 250 for x, z in hole)
|
|
|
|
def test_rings_bleiben_gueltig_wenn_koordinatenweises_runden_selbstschnitte_erzeugen_wuerde():
|
|
"""Befund 5 der Fixwelle: _rings() rundete bislang jede Koordinate unabhaengig auf 2 Stellen,
|
|
NACH normalize(). Das kann zwei Ringpunkte so verschieben, dass sich der Ring selbst
|
|
schneidet, obwohl das Ausgangspolygon gueltig war. Am echten Netz betraf das 8 von 144
|
|
Chunks (carriageway 3, land 2, sidewalk 2, surfaces 1) - je zwei ungueltige Polygone, alle
|
|
neu in diesem Zweig, Ursache 2-Stellen-Rundung statt einer echten Geometrieaenderung.
|
|
|
|
Das folgende Polygon ist gueltig; unabhaengig gerundete Ecken (die alte Methode) kreuzen
|
|
sich trotzdem - der Befund ist damit am Beispiel nachvollzogen, nicht nur behauptet.
|
|
set_precision() vor normalize() (die neue Methode) haelt das Ergebnis gueltig, weil es eine
|
|
echte Geometrieoperation ist statt eines blinden round() je Zahl.
|
|
"""
|
|
poly = Polygon([(16.083798, 6.586106), (20.804728, 8.005357),
|
|
(18.203748, 0.906454), (18.305129, 1.203848)])
|
|
assert poly.is_valid
|
|
naiv = Polygon([(round(x, 2), round(y, 2)) for x, y in poly.exterior.coords])
|
|
assert not naiv.is_valid, "das Beispiel muss den Befund tatsaechlich zeigen"
|
|
|
|
rings = _rings(poly, 0, 0)
|
|
ergebnis = Polygon(rings[0], rings[1:])
|
|
assert ergebnis.is_valid
|
|
assert ergebnis.area == pytest.approx(poly.area, rel=0.01)
|
|
|
|
|
|
def test_rings_behaelt_nur_das_groessere_teilstueck_an_einer_engstelle_unter_einem_zentimeter():
|
|
"""set_precision() kann ein Polygon mit einem Steg unter 1 cm Breite beim Schnappen auf das
|
|
1-cm-Raster in zwei Teile spalten (an einer Stichprobe der ausgelieferten Daten: 3 von
|
|
29.037 Polygonen, groesster abgeschnittener Teil 25,9 m²). _rings() liefert aber immer genau
|
|
ein Ringset - eine MultiPolygon ungeprueft an poly.exterior gereicht waere ein
|
|
AttributeError. Hier bleibt das groessere der beiden Teilstuecke.
|
|
|
|
Am unkorrigierten _rings() (kein set_precision) kollabiert derselbe Steg unter der
|
|
Koordinatenrundung auf exakt dieselbe Zeile und macht das EINE Ergebnis-Polygon ungueltig,
|
|
statt es sauber zu teilen - auch das ist also gegen den Befund rot.
|
|
"""
|
|
hantel = Polygon([
|
|
(0.0, 0.0), (10.0, 0.0), (10.0, 4.997), (20.0, 4.997), (20.0, 2.0), (26.0, 2.0), (26.0, 8.0),
|
|
(20.0, 8.0), (20.0, 5.003), (10.0, 5.003), (10.0, 10.0), (0.0, 10.0),
|
|
])
|
|
assert hantel.is_valid and hantel.area == pytest.approx(136.06)
|
|
rings = _rings(hantel, 0, 0) # darf nicht mit AttributeError abstuerzen
|
|
ergebnis = Polygon(rings[0], rings[1:])
|
|
assert ergebnis.is_valid
|
|
assert ergebnis.area == pytest.approx(100.0, abs=0.5), "das groessere der beiden Teilstuecke"
|
|
|
|
|
|
def test_the_same_geometry_in_another_order_gives_the_same_output():
|
|
"""Die Ausgabe darf nicht am Rechenweg haengen, nur an der Geometrie.
|
|
|
|
unary_union() in park.py fasst das kartenweite Strassenpolygon neu zusammen und gibt dieselbe
|
|
Flaeche danach an anderer Listenstelle und mit anderem Ringstartpunkt zurueck. Das aenderte
|
|
88 Chunkdateien, in denen sich geometrisch nichts bewegt hatte (symmetrische Differenz der
|
|
Strassenvereinigung 0,00 m2 in allen 88). Hier steht dieselbe Welt zweimal: einmal in der
|
|
einen, einmal in der anderen Reihenfolge, und `zweite` beschreibt `a` ausserdem mit anderem
|
|
Startpunkt und anderer Wicklung."""
|
|
a = Polygon([(-990, 2490), (-980, 2490), (-980, 2480), (-990, 2480)])
|
|
b = Polygon([(-960, 2460), (-950, 2460), (-950, 2450), (-960, 2450)])
|
|
a_gedreht = Polygon([(-980, 2480), (-980, 2490), (-990, 2490), (-990, 2480)])
|
|
assert a.equals(a_gedreht) and list(a.exterior.coords) != list(a_gedreht.exterior.coords)
|
|
erste = chunk_payload(0, 0, land=[a, b], surfaces=[(a, "urban"), (b, "urban")],
|
|
carriageway=[(a, "wohnstrasse"), (b, "wohnstrasse")], curb=[], sidewalk=[],
|
|
markings=[], lots=[], bridges=[b], edges=[],
|
|
buildings=[(a, HEIGHT, False), (b, HEIGHT, True)],
|
|
palms=[(-990.0, 2490.0), (-960.0, 2460.0)], paths=[a, b])
|
|
zweite = chunk_payload(0, 0, land=[b, a_gedreht], surfaces=[(b, "urban"), (a_gedreht, "urban")],
|
|
carriageway=[(b, "wohnstrasse"), (a_gedreht, "wohnstrasse")], curb=[], sidewalk=[],
|
|
markings=[], lots=[], bridges=[b], edges=[],
|
|
buildings=[(b, HEIGHT, True), (a_gedreht, HEIGHT, False)],
|
|
palms=[(-960.0, 2460.0), (-990.0, 2490.0)], paths=[b, a_gedreht])
|
|
assert erste == zweite
|
|
|
|
|
|
def test_surfaces_keep_the_order_of_their_kinds():
|
|
"""Gegenprobe zum Sortieren: overview.py malt die Belaege in Listenreihenfolge uebereinander.
|
|
|
|
Der Parkrasen liegt unter den Feldern; sortierte man ueber die Arten hinweg, deckte der grosse
|
|
Rasen sie auf map.png wieder zu (19.573 m2 Feld allein im Flamingo Park)."""
|
|
rasen = Polygon([(-1000, 2500), (-900, 2500), (-900, 2400), (-1000, 2400)])
|
|
feld = Polygon([(-980, 2480), (-940, 2480), (-940, 2440), (-980, 2440)])
|
|
payload = chunk_payload(0, 0, land=[], surfaces=[(rasen, "park"), (feld, "field")],
|
|
carriageway=[], curb=[], sidewalk=[], markings=[], lots=[], bridges=[],
|
|
edges=[], buildings=[], palms=[])
|
|
assert [s["kind"] for s in payload["surfaces"]] == ["park", "field"]
|
|
|
|
|
|
def test_split_by_chunk_all_yields_polygons_for_a_multi_part_intersection():
|
|
"""split_by_chunk_all() ist die Variante, die run.py tatsaechlich benutzt (nicht
|
|
split_by_chunk), und hat einen MultiPolygon-Zweig, den kein anderer Test trifft: ein
|
|
Polygon, dessen Schnitt mit der Chunk-Zelle in mehrere Teile zerfaellt. Hier eine
|
|
Klammerform (Steg ausserhalb der Zelle, zwei Zinken, die je einmal hineinreichen)."""
|
|
staple = Polygon([
|
|
(-1100, 2495), (-850, 2495), (-850, 2470), (-1010, 2470),
|
|
(-1010, 2280), (-850, 2280), (-850, 2255), (-1010, 2255),
|
|
(-1100, 2255),
|
|
])
|
|
parts = list(split_by_chunk_all([staple], 0, 0))
|
|
assert len(parts) == 2
|
|
assert all(p.geom_type == "Polygon" for p in parts)
|
|
|
|
|
|
def test_neue_schichten_stehen_im_chunk():
|
|
from shapely.geometry import LineString, box
|
|
payload = chunk_payload(
|
|
0, 0,
|
|
land=[box(0, 2400, 100, 2500)], surfaces=[],
|
|
carriageway=[(box(10, 2400, 20, 2500), "wohnstrasse")],
|
|
curb=[LineString([(10, 2400), (10, 2500)])],
|
|
sidewalk=[box(20, 2400, 23, 2500)],
|
|
markings=[(box(14, 2440, 16, 2443), "gelb_gestrichelt")],
|
|
lots=[box(40, 2400, 60, 2420)],
|
|
bridges=[], edges=[(LineString([(15, 2400), (15, 2500)]), "wohnstrasse", 12.0, 7.0)],
|
|
buildings=[], palms=[])
|
|
assert payload["carriageway"][0]["klasse"] == "wohnstrasse"
|
|
assert payload["markings"][0]["art"] == "gelb_gestrichelt"
|
|
assert payload["edges"][0]["klasse"] == "wohnstrasse"
|
|
assert payload["edges"][0]["fahrbahn"] == 7.0
|
|
assert len(payload["curb"][0]) == 2, "der Bordstein ist ein Linienzug, keine Fläche"
|
|
assert "roads" not in payload and "centerlines" not in payload
|
|
|
|
|
|
def test_neue_schichten_sind_lauf_stabil():
|
|
"""Zwei Aufrufe mit vertauschter Eingabe liefern dieselbe Ausgabe (Fixrunde 3 aus Teil 1)."""
|
|
from shapely.geometry import LineString, box
|
|
a, b = box(10, 2400, 20, 2450), box(30, 2400, 40, 2450)
|
|
l1, l2 = LineString([(0, 2400), (0, 2450)]), LineString([(5, 2400), (5, 2450)])
|
|
eins = chunk_payload(0, 0, land=[], surfaces=[], carriageway=[(a, "gasse"), (b, "gasse")],
|
|
curb=[l1, l2], sidewalk=[a], markings=[(a, "zebra"), (b, "zebra")],
|
|
lots=[a, b], bridges=[], edges=[], buildings=[], palms=[])
|
|
zwei = chunk_payload(0, 0, land=[], surfaces=[], carriageway=[(b, "gasse"), (a, "gasse")],
|
|
curb=[l2, l1], sidewalk=[a], markings=[(b, "zebra"), (a, "zebra")],
|
|
lots=[b, a], bridges=[], edges=[], buildings=[], palms=[])
|
|
assert eins == zwei
|
|
|
|
|
|
def test_edges_sortierschluessel_deckt_alle_felder_ab():
|
|
"""Regressionsschutz fuer den Fund aus Fixrunde 1: Zwei Kanten mit gleicher Linie und
|
|
gleicher Klasse, aber unterschiedlicher Breite, duerfen nicht an der Eingabereihenfolge
|
|
haengen bleiben. Mit dem Schluessel (line, klasse) allein - wie im urspruenglichen
|
|
Auftragscode - loest keiner der beiden Faelle den Tie auf, sorted() bleibt dann stabil und
|
|
reicht die Eingabereihenfolge unveraendert durch; erst breite/fahrbahn im Schluessel machen
|
|
die zwei Eintraege wieder unterscheidbar und damit total geordnet."""
|
|
from shapely.geometry import LineString
|
|
linie = LineString([(70, 2400), (70, 2450)])
|
|
schmal = (linie, "gasse", 6.0, 6.0)
|
|
breit = (linie, "gasse", 99.0, 99.0)
|
|
eins = chunk_payload(0, 0, land=[], surfaces=[], carriageway=[], curb=[], sidewalk=[],
|
|
markings=[], lots=[], bridges=[], edges=[schmal, breit],
|
|
buildings=[], palms=[])
|
|
zwei = chunk_payload(0, 0, land=[], surfaces=[], carriageway=[], curb=[], sidewalk=[],
|
|
markings=[], lots=[], bridges=[], edges=[breit, schmal],
|
|
buildings=[], palms=[])
|
|
assert eins == zwei
|
|
|
|
|
|
def test_bridges_sind_lauf_stabil():
|
|
"""Gegenprobe zu test_neue_schichten_sind_lauf_stabil, die bridges leer laesst: zwei
|
|
Bruecken in vertauschter Reihenfolge muessen dieselbe Ausgabe ergeben."""
|
|
a = Polygon([(10, 2400), (20, 2400), (20, 2450), (10, 2450)])
|
|
b = Polygon([(30, 2400), (40, 2400), (40, 2450), (30, 2450)])
|
|
eins = chunk_payload(0, 0, land=[], surfaces=[], carriageway=[], curb=[], sidewalk=[],
|
|
markings=[], lots=[], bridges=[a, b], edges=[], buildings=[], palms=[])
|
|
zwei = chunk_payload(0, 0, land=[], surfaces=[], carriageway=[], curb=[], sidewalk=[],
|
|
markings=[], lots=[], bridges=[b, a], edges=[], buildings=[], palms=[])
|
|
assert eins == zwei
|
|
|
|
|
|
def test_curb_und_edges_sind_richtungsunabhaengig():
|
|
"""Die Zusicherung, auf der die ganze Normalisierung in _line() beruht (siehe dessen
|
|
Docstring): eine Linie und ihre exakte Umkehrung muessen fuer curb wie fuer edges dieselbe
|
|
Ausgabe ergeben. Einzeln geprueft (nur curb bzw. nur edges besetzt), damit ein kuenftiger
|
|
Bruch das betroffene Feld benennt statt nur "irgendwas stimmt nicht"."""
|
|
from shapely.geometry import LineString
|
|
hin = LineString([(10, 2400), (18, 2470), (22, 2500)])
|
|
zurueck = LineString(list(reversed(list(hin.coords))))
|
|
rest = dict(land=[], surfaces=[], carriageway=[], sidewalk=[], markings=[], lots=[],
|
|
bridges=[], buildings=[], palms=[])
|
|
|
|
eins_curb = chunk_payload(0, 0, curb=[hin], edges=[], **rest)
|
|
zwei_curb = chunk_payload(0, 0, curb=[zurueck], edges=[], **rest)
|
|
assert eins_curb["curb"] == zwei_curb["curb"]
|
|
|
|
eins_edges = chunk_payload(0, 0, curb=[], edges=[(hin, "wohnstrasse", 12.0, 7.0)], **rest)
|
|
zwei_edges = chunk_payload(0, 0, curb=[], edges=[(zurueck, "wohnstrasse", 12.0, 7.0)], **rest)
|
|
assert eins_edges["edges"] == zwei_edges["edges"]
|