fix(nspd): rename migration 89→98, fix red lines ST_Area→ST_Length (#220)

- data/sql/98_*: rename from 89 to avoid collision with existing 89_drop_dead_brin
- _get_red_lines: ST_Length(ST_Intersection(planar)::geography) instead of
  ST_Area(ST_Intersection(::geography, ::geography)) — fixes PostGIS 3.4
  tolerance error and returns correct non-zero length for LINESTRING intersections
- Rename intersection_area_sqm → intersection_length_m across schema, TS types,
  frontend component, and tests; add test_get_red_lines_db_exception_returns_empty

Addresses review-bot feedback on PR #220.
This commit is contained in:
lekss361 2026-05-16 19:33:14 +03:00
parent ed3c128528
commit fdb54834e5
6 changed files with 47 additions and 34 deletions

View file

@ -105,17 +105,18 @@ class RedLine(BaseModel):
Поля:
geom_wkt: WKT геометрии линии в EPSG:4326
intersection_area_sqm: площадь пересечения с участком, м² (null если только nearby)
intersection_length_m: длина пересечения линии с участком, м
(null если только nearby, не intersect)
distance_m: расстояние от ближайшей точки линии до участка, м
(null если линия пересекает участок)
UI-семантика:
intersection_area_sqm != null красная линия ПЕРЕСЕКАЕТ участок (ALERT)
intersection_length_m != null красная линия ПЕРЕСЕКАЕТ участок (ALERT)
distance_m != null красная линия рядом (warning)
"""
geom_wkt: str | None
intersection_area_sqm: float | None # null если только nearby, не intersect
intersection_length_m: float | None # null если только nearby, не intersect
distance_m: float | None # null если intersect

View file

@ -685,11 +685,15 @@ def _get_red_lines(
"""TIER 4: красные линии застройки (layer 879243).
Возвращает red lines пересекающие участок ИЛИ ближайшие (до 200м) с полями:
geom_wkt, intersection_area_sqm, distance_m.
geom_wkt, intersection_length_m, distance_m.
Логика:
- intersecting: intersection_area_sqm > 0, distance_m = None
- nearby only: intersection_area_sqm = None, distance_m = расстояние
- intersecting: intersection_length_m >= 0, distance_m = None
- nearby only: intersection_length_m = None, distance_m = расстояние
ST_Intersection выполняется в planar EPSG:4326, затем результат кастуется
в ::geography для ST_Length это избегает PostGIS 3.4 tolerance bug
(ST_Intersection geography × geography на LINESTRING бросает transform error).
layer_counts денормализованные счётчики. Если red_lines_count == 0
пропускаем heavy jsonb_array_elements scan.
@ -719,7 +723,7 @@ def _get_red_lines(
),
ST_GeomFromText(:wkt, 4326)
) AS does_intersect,
ST_Area(
ST_Length(
ST_Intersection(
ST_Transform(
ST_SetSRID(
@ -727,10 +731,10 @@ def _get_red_lines(
3857
),
4326
)::geography,
ST_GeomFromText(:wkt, 4326)::geography
)
) AS intersection_area_sqm,
),
ST_GeomFromText(:wkt, 4326)
)::geography
) AS intersection_length_m,
ST_Distance(
ST_Transform(
ST_SetSRID(
@ -769,14 +773,14 @@ def _get_red_lines(
for r in rows:
geom_wkt_val: str | None = r[0]
does_intersect: bool = bool(r[1]) if r[1] is not None else False
raw_area: Any = r[2]
raw_length: Any = r[2]
raw_dist: Any = r[3]
intersection_area_sqm: float | None = None
if does_intersect and raw_area is not None:
intersection_length_m: float | None = None
if does_intersect and raw_length is not None:
try:
val = float(raw_area)
intersection_area_sqm = round(val, 1) if not math.isnan(val) else None
val = float(raw_length)
intersection_length_m = round(val, 1) if not math.isnan(val) else None
except (TypeError, ValueError):
pass
@ -790,7 +794,7 @@ def _get_red_lines(
result.append(
{
"geom_wkt": geom_wkt_val,
"intersection_area_sqm": intersection_area_sqm,
"intersection_length_m": intersection_length_m,
"distance_m": distance_m,
}
)

View file

@ -297,13 +297,13 @@ def test_get_opportunity_parcels_db_exception_returns_empty() -> None:
def test_get_red_lines_intersects() -> None:
"""Red line intersecting parcel → intersection_area_sqm filled, distance_m=None."""
"""Red line intersecting parcel → intersection_length_m filled, distance_m=None."""
rows: list[tuple[Any, ...]] = [
# (geom_wkt, does_intersect, intersection_area_sqm, distance_m)
# (geom_wkt, does_intersect, intersection_length_m, distance_m)
(
"LINESTRING(0 0, 1 1)",
True,
500.0,
45.3, # длина пересечения в метрах
0.0, # intersecting → distance_m becomes None in result
),
]
@ -314,14 +314,14 @@ def test_get_red_lines_intersects() -> None:
assert len(result) == 1
r = result[0]
assert r["geom_wkt"] == "LINESTRING(0 0, 1 1)"
assert r["intersection_area_sqm"] == 500.0
assert r["intersection_length_m"] == 45.3
assert r["distance_m"] is None # null when intersecting
def test_get_red_lines_nearby_only() -> None:
"""Red line nearby only (не intersect) → distance_m filled, intersection_area_sqm=None."""
"""Red line nearby only (не intersect) → distance_m filled, intersection_length_m=None."""
rows: list[tuple[Any, ...]] = [
# (geom_wkt, does_intersect, intersection_area_sqm, distance_m)
# (geom_wkt, does_intersect, intersection_length_m, distance_m)
(
"LINESTRING(10 10, 20 20)",
False,
@ -335,10 +335,18 @@ def test_get_red_lines_nearby_only() -> None:
assert len(result) == 1
r = result[0]
assert r["intersection_area_sqm"] is None
assert r["intersection_length_m"] is None
assert r["distance_m"] == 85.5
def test_get_red_lines_db_exception_returns_empty() -> None:
"""DB exception → пустой список (не propagate)."""
db = MagicMock()
db.execute.side_effect = Exception("DB connection lost")
result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))")
assert result == []
def test_get_red_lines_empty() -> None:
"""Нет red lines features — пустой список."""
db = _make_mock_db_with_rows([])

View file

@ -1,4 +1,4 @@
-- 89_nspd_quarter_dumps_opportunity_flag.sql
-- 98_nspd_quarter_dumps_opportunity_flag.sql
-- Context : TIER 4 opportunity denorm columns for nspd_quarter_dumps.
-- Enables fast map filter "find quarters with auction parcels" without
-- unpacking features_json JSONB on every query.

View file

@ -6,10 +6,10 @@ interface Props {
redLines: RedLine[] | null | undefined;
}
function formatArea(sqm: number | null): string | null {
if (sqm === null) return null;
if (sqm >= 10000) return `${(sqm / 10000).toFixed(2)} га`;
return `${Math.round(sqm).toLocaleString("ru-RU")} м²`;
function formatLength(m: number | null): string | null {
if (m === null) return null;
if (m >= 1000) return `${(m / 1000).toFixed(2)} км`;
return `${Math.round(m)} м`;
}
function formatDistance(m: number | null): string | null {
@ -25,8 +25,8 @@ export function NspdRedLinesBlock({ redLines }: Props) {
return null;
}
const intersecting = lines.filter((l) => l.intersection_area_sqm !== null);
const nearbyOnly = lines.filter((l) => l.intersection_area_sqm === null);
const intersecting = lines.filter((l) => l.intersection_length_m !== null);
const nearbyOnly = lines.filter((l) => l.intersection_length_m === null);
const hasIntersect = intersecting.length > 0;
return (
@ -64,11 +64,11 @@ export function NspdRedLinesBlock({ redLines }: Props) {
</div>
<div style={{ fontSize: 12, color: "#7f1d1d", marginTop: 4 }}>
{intersecting.map((l, idx) => {
const areaStr = formatArea(l.intersection_area_sqm);
const lenStr = formatLength(l.intersection_length_m);
return (
<div key={idx}>
Линия {idx + 1}
{areaStr ? `: пересечение ${areaStr}` : ""}
{lenStr ? `: длина пересечения ${lenStr}` : ""}
</div>
);
})}

View file

@ -59,7 +59,7 @@ export interface OpportunityParcel {
export interface RedLine {
geom_wkt: string | null;
intersection_area_sqm: number | null; // null if only nearby, not intersecting
intersection_length_m: number | null; // null if only nearby, not intersecting
distance_m: number | null; // null if intersecting
}