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
|
geom_wkt: WKT геометрии линии в EPSG:4326
|
||||||
intersection_area_sqm: площадь пересечения с участком, м² (null если только nearby)
|
intersection_length_m: длина пересечения линии с участком, м
|
||||||
|
(null если только nearby, не intersect)
|
||||||
distance_m: расстояние от ближайшей точки линии до участка, м
|
distance_m: расстояние от ближайшей точки линии до участка, м
|
||||||
(null если линия пересекает участок)
|
(null если линия пересекает участок)
|
||||||
|
|
||||||
UI-семантика:
|
UI-семантика:
|
||||||
intersection_area_sqm != null → красная линия ПЕРЕСЕКАЕТ участок (ALERT)
|
intersection_length_m != null → красная линия ПЕРЕСЕКАЕТ участок (ALERT)
|
||||||
distance_m != null → красная линия рядом (warning)
|
distance_m != null → красная линия рядом (warning)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
geom_wkt: str | None
|
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
|
distance_m: float | None # null если intersect
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -685,11 +685,15 @@ def _get_red_lines(
|
||||||
"""TIER 4: красные линии застройки (layer 879243).
|
"""TIER 4: красные линии застройки (layer 879243).
|
||||||
|
|
||||||
Возвращает red lines пересекающие участок ИЛИ ближайшие (до 200м) с полями:
|
Возвращает 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
|
- intersecting: intersection_length_m >= 0, distance_m = None
|
||||||
- nearby only: intersection_area_sqm = None, distance_m = расстояние
|
- 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 —
|
layer_counts — денормализованные счётчики. Если red_lines_count == 0 —
|
||||||
пропускаем heavy jsonb_array_elements scan.
|
пропускаем heavy jsonb_array_elements scan.
|
||||||
|
|
@ -719,7 +723,7 @@ def _get_red_lines(
|
||||||
),
|
),
|
||||||
ST_GeomFromText(:wkt, 4326)
|
ST_GeomFromText(:wkt, 4326)
|
||||||
) AS does_intersect,
|
) AS does_intersect,
|
||||||
ST_Area(
|
ST_Length(
|
||||||
ST_Intersection(
|
ST_Intersection(
|
||||||
ST_Transform(
|
ST_Transform(
|
||||||
ST_SetSRID(
|
ST_SetSRID(
|
||||||
|
|
@ -727,10 +731,10 @@ def _get_red_lines(
|
||||||
3857
|
3857
|
||||||
),
|
),
|
||||||
4326
|
4326
|
||||||
)::geography,
|
),
|
||||||
ST_GeomFromText(:wkt, 4326)::geography
|
ST_GeomFromText(:wkt, 4326)
|
||||||
)
|
)::geography
|
||||||
) AS intersection_area_sqm,
|
) AS intersection_length_m,
|
||||||
ST_Distance(
|
ST_Distance(
|
||||||
ST_Transform(
|
ST_Transform(
|
||||||
ST_SetSRID(
|
ST_SetSRID(
|
||||||
|
|
@ -769,14 +773,14 @@ def _get_red_lines(
|
||||||
for r in rows:
|
for r in rows:
|
||||||
geom_wkt_val: str | None = r[0]
|
geom_wkt_val: str | None = r[0]
|
||||||
does_intersect: bool = bool(r[1]) if r[1] is not None else False
|
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]
|
raw_dist: Any = r[3]
|
||||||
|
|
||||||
intersection_area_sqm: float | None = None
|
intersection_length_m: float | None = None
|
||||||
if does_intersect and raw_area is not None:
|
if does_intersect and raw_length is not None:
|
||||||
try:
|
try:
|
||||||
val = float(raw_area)
|
val = float(raw_length)
|
||||||
intersection_area_sqm = round(val, 1) if not math.isnan(val) else None
|
intersection_length_m = round(val, 1) if not math.isnan(val) else None
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -790,7 +794,7 @@ def _get_red_lines(
|
||||||
result.append(
|
result.append(
|
||||||
{
|
{
|
||||||
"geom_wkt": geom_wkt_val,
|
"geom_wkt": geom_wkt_val,
|
||||||
"intersection_area_sqm": intersection_area_sqm,
|
"intersection_length_m": intersection_length_m,
|
||||||
"distance_m": distance_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:
|
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, ...]] = [
|
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)",
|
"LINESTRING(0 0, 1 1)",
|
||||||
True,
|
True,
|
||||||
500.0,
|
45.3, # длина пересечения в метрах
|
||||||
0.0, # intersecting → distance_m becomes None in result
|
0.0, # intersecting → distance_m becomes None in result
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
@ -314,14 +314,14 @@ def test_get_red_lines_intersects() -> None:
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
r = result[0]
|
r = result[0]
|
||||||
assert r["geom_wkt"] == "LINESTRING(0 0, 1 1)"
|
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
|
assert r["distance_m"] is None # null when intersecting
|
||||||
|
|
||||||
|
|
||||||
def test_get_red_lines_nearby_only() -> None:
|
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, ...]] = [
|
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)",
|
"LINESTRING(10 10, 20 20)",
|
||||||
False,
|
False,
|
||||||
|
|
@ -335,10 +335,18 @@ def test_get_red_lines_nearby_only() -> None:
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
r = result[0]
|
r = result[0]
|
||||||
assert r["intersection_area_sqm"] is None
|
assert r["intersection_length_m"] is None
|
||||||
assert r["distance_m"] == 85.5
|
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:
|
def test_get_red_lines_empty() -> None:
|
||||||
"""Нет red lines features — пустой список."""
|
"""Нет red lines features — пустой список."""
|
||||||
db = _make_mock_db_with_rows([])
|
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.
|
-- Context : TIER 4 opportunity denorm columns for nspd_quarter_dumps.
|
||||||
-- Enables fast map filter "find quarters with auction parcels" without
|
-- Enables fast map filter "find quarters with auction parcels" without
|
||||||
-- unpacking features_json JSONB on every query.
|
-- unpacking features_json JSONB on every query.
|
||||||
|
|
@ -6,10 +6,10 @@ interface Props {
|
||||||
redLines: RedLine[] | null | undefined;
|
redLines: RedLine[] | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatArea(sqm: number | null): string | null {
|
function formatLength(m: number | null): string | null {
|
||||||
if (sqm === null) return null;
|
if (m === null) return null;
|
||||||
if (sqm >= 10000) return `${(sqm / 10000).toFixed(2)} га`;
|
if (m >= 1000) return `${(m / 1000).toFixed(2)} км`;
|
||||||
return `${Math.round(sqm).toLocaleString("ru-RU")} м²`;
|
return `${Math.round(m)} м`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDistance(m: number | null): string | null {
|
function formatDistance(m: number | null): string | null {
|
||||||
|
|
@ -25,8 +25,8 @@ export function NspdRedLinesBlock({ redLines }: Props) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const intersecting = 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_area_sqm === null);
|
const nearbyOnly = lines.filter((l) => l.intersection_length_m === null);
|
||||||
const hasIntersect = intersecting.length > 0;
|
const hasIntersect = intersecting.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -64,11 +64,11 @@ export function NspdRedLinesBlock({ redLines }: Props) {
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: "#7f1d1d", marginTop: 4 }}>
|
<div style={{ fontSize: 12, color: "#7f1d1d", marginTop: 4 }}>
|
||||||
{intersecting.map((l, idx) => {
|
{intersecting.map((l, idx) => {
|
||||||
const areaStr = formatArea(l.intersection_area_sqm);
|
const lenStr = formatLength(l.intersection_length_m);
|
||||||
return (
|
return (
|
||||||
<div key={idx}>
|
<div key={idx}>
|
||||||
Линия {idx + 1}
|
Линия {idx + 1}
|
||||||
{areaStr ? `: пересечение ${areaStr}` : ""}
|
{lenStr ? `: длина пересечения ${lenStr}` : ""}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export interface OpportunityParcel {
|
||||||
|
|
||||||
export interface RedLine {
|
export interface RedLine {
|
||||||
geom_wkt: string | null;
|
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
|
distance_m: number | null; // null if intersecting
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue