fix(extract): set_precision statt Koordinatenrundung gegen selbstschneidende Ringe

_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.
This commit is contained in:
knso committed 2026-09-18 06:47:14 +02:00
1 parent 83140f89e1
commit a397892847
2 files changed
+74 -2

No files matched your search

+48 -1
View File
@@ -1,5 +1,6 @@
import pytest
from shapely.geometry import Polygon
from leonida.emit import chunk_payload, split_by_chunk, split_by_chunk_all
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"}
@@ -53,6 +54,52 @@ def test_polygon_holes_survive_into_the_payload():
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.