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:
parent
ed3c128528
commit
fdb54834e5
6 changed files with 47 additions and 34 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue