feat(section7): editable section footprint + корпус plural/program hint (#1953) #2102

Merged
bot-backend merged 2 commits from feat/section7-editable-footprint into main 2026-06-30 09:48:12 +00:00
9 changed files with 214 additions and 29 deletions

View file

@ -20,6 +20,18 @@ class BuildingProgramItem(BaseModel):
floors: int = Field(..., ge=1, le=40, description="Floors for this house-type group") floors: int = Field(..., ge=1, le=40, description="Floors for this house-type group")
# Сколько секций этого типа разместить. # Сколько секций этого типа разместить.
count: int = Field(..., ge=1, le=50, description="Number of sections to place") count: int = Field(..., ge=1, le=50, description="Number of sections to place")
# ADDITIVE (Stage 3c): опциональное ручное пятно секции (ширина × глубина, м).
# None → габариты берутся из каталога по ``section_type`` (byte-for-byte
# backward-compat). Заданы → place_program строит footprint из них вместо
# каталожных (пользователь «вписал пятно»). Оба должны быть заданы вместе;
# частичное задание (только w / только d) трактуется как «не задано» на слое
# размещения. Границы [4, 120] м — вменяемый габарит секции/корпуса.
footprint_w_m: float | None = Field(
None, ge=4, le=120, description="Optional manual section footprint width, m"
)
footprint_d_m: float | None = Field(
None, ge=4, le=120, description="Optional manual section footprint depth, m"
)
class HouseTypeCatalogItem(BaseModel): class HouseTypeCatalogItem(BaseModel):

View file

@ -273,13 +273,18 @@ def place_program(
for item in program: for item in program:
house = catalog.get_house_type(item.section_type) house = catalog.get_house_type(item.section_type)
requested += item.count requested += item.count
# Ручное пятно (Stage 3c): если пользователь задал ОБА габарита — кладём
# его вместо каталожного («вписать пятно»). Частичное задание игнорируем
# (нужны и ширина, и глубина), падая обратно на каталог.
if item.footprint_w_m is not None and item.footprint_d_m is not None:
fp_w, fp_d = item.footprint_w_m, item.footprint_d_m
else:
fp_w, fp_d = house.footprint_w_m, house.footprint_d_m
placed_for_item = 0 placed_for_item = 0
for cell in parcel.grid: for cell in parcel.grid:
if placed_for_item >= item.count: if placed_for_item >= item.count:
break break
footprint = _centered_footprint( footprint = _centered_footprint(cell.cx, cell.cy, fp_w, fp_d)
cell.cx, cell.cy, house.footprint_w_m, house.footprint_d_m
)
if placer.try_place(footprint, buildable, half_gap): if placer.try_place(footprint, buildable, half_gap):
placed_sections.append( placed_sections.append(
_PlacedSection( _PlacedSection(

View file

@ -9,8 +9,8 @@ from __future__ import annotations
from shapely.geometry import shape from shapely.geometry import shape
from app.schemas.concept import ConceptInput from app.schemas.concept import BuildingProgramItem, ConceptInput
from app.services.generative import geometry, placement from app.services.generative import catalog, geometry, placement
_PARCEL_COORDS = [ _PARCEL_COORDS = [
[60.60, 56.830], [60.60, 56.830],
@ -67,10 +67,7 @@ def test_max_area_denser_than_max_insolation() -> None:
parcel = geometry.parse_parcel(payload) parcel = geometry.parse_parcel(payload)
variants = {v.strategy: v for v in placement.place_all_strategies(parcel, payload)} variants = {v.strategy: v for v in placement.place_all_strategies(parcel, payload)}
# Максимум площади даёт большее пятно/жилую, чем максимум инсоляции. # Максимум площади даёт большее пятно/жилую, чем максимум инсоляции.
assert ( assert variants["max_area"].teap.built_area_sqm > variants["max_insolation"].teap.built_area_sqm
variants["max_area"].teap.built_area_sqm
> variants["max_insolation"].teap.built_area_sqm
)
assert ( assert (
variants["max_area"].teap.residential_area_sqm variants["max_area"].teap.residential_area_sqm
> variants["max_insolation"].teap.residential_area_sqm > variants["max_insolation"].teap.residential_area_sqm
@ -104,6 +101,45 @@ def test_buildings_geojson_features_are_valid_polygons() -> None:
assert feat["properties"]["floors"] >= 1 assert feat["properties"]["floors"] >= 1
def test_program_footprint_override_changes_section_area() -> None:
# Stage 3c: ручное пятно (footprint_w_m/_d_m) кладётся вместо каталожного.
parcel = geometry.parse_parcel(_payload())
section_type = next(iter(catalog.available_section_types()))
house = catalog.get_house_type(section_type)
override = BuildingProgramItem(
section_type=section_type,
floors=10,
count=2,
footprint_w_m=10.0,
footprint_d_m=10.0,
)
placed = placement.place_program(parcel, [override])
assert placed.placed_count >= 1
# Каждая размещённая секция несёт пятно override (10×10 = 100 м²), не каталог.
for sec in placed.sections:
assert abs(sec.footprint.area - 100.0) < 1.0
# Без override — пятно из каталога (контроль: байт-в-байт прежнее поведение).
base = BuildingProgramItem(section_type=section_type, floors=10, count=2)
placed_base = placement.place_program(parcel, [base])
catalog_area = house.footprint_w_m * house.footprint_d_m
for sec in placed_base.sections:
assert abs(sec.footprint.area - catalog_area) < 1.0
def test_program_partial_footprint_falls_back_to_catalog() -> None:
# Только ширина задана (без глубины) → трактуем как «не задано», берём каталог.
parcel = geometry.parse_parcel(_payload())
section_type = next(iter(catalog.available_section_types()))
house = catalog.get_house_type(section_type)
item = BuildingProgramItem(section_type=section_type, floors=10, count=1, footprint_w_m=10.0)
placed = placement.place_program(parcel, [item])
catalog_area = house.footprint_w_m * house.footprint_d_m
for sec in placed.sections:
assert abs(sec.footprint.area - catalog_area) < 1.0
def test_placement_deterministic() -> None: def test_placement_deterministic() -> None:
payload = _payload() payload = _payload()
p1 = geometry.parse_parcel(payload) p1 = geometry.parse_parcel(payload)

View file

@ -685,12 +685,15 @@ def test_nspd_path_used_when_zouit_count_nonzero() -> None:
Проверяем source='nspd-quarter-dump' (не cad_zouit). Проверяем source='nspd-quarter-dump' (не cad_zouit).
""" """
# Строка дампа: все поля через MagicMock # Строка дампа: все поля через MagicMock
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
dump_row = MagicMock() dump_row = MagicMock()
dump_row.__getitem__ = lambda self, i: [ dump_row.__getitem__ = lambda self, i: [
"66:41:0603016", # quarter_cad "66:41:0603016", # quarter_cad
datetime(2026, 1, 1, tzinfo=UTC), # fetched_at_utc (свежий) # Свежесть ОТНОСИТЕЛЬНО now: был хардкод datetime(2026, 1, 1) → date-bomb
# (с 2026-06-30 перевалил _DUMP_MAX_AGE_DAYS=180 → stale-ветка → _trigger_harvest
# → _empty_unavailable → overlaps=[] → assert 0 == 1). now 1 день всегда свеж.
datetime.now(UTC) - timedelta(days=1), # fetched_at_utc (свежий)
100, # total_features 100, # total_features
None, # harvest_error None, # harvest_error
1, # territorial_zones_count 1, # territorial_zones_count

View file

@ -48,6 +48,11 @@ const FLOOR_HEIGHT_MIN = 2.4;
const FLOOR_HEIGHT_MAX = 4.5; const FLOOR_HEIGHT_MAX = 4.5;
const FLOOR_HEIGHT_DEFAULT = 3.0; const FLOOR_HEIGHT_DEFAULT = 3.0;
// Ручное пятно секции (м). Границы зеркалят backend BuildingProgramItem
// (footprint_w_m / footprint_d_m, ge=4 le=120).
const FOOTPRINT_MIN = 4;
const FOOTPRINT_MAX = 120;
// ── Public payload (lifted to the parent on submit) ─────────────────────────── // ── Public payload (lifted to the parent on submit) ───────────────────────────
/** /**
@ -66,6 +71,10 @@ export interface ConceptProgramFormState {
floor_height_m: number; floor_height_m: number;
/** Каталожный тип секции → building_program item `section_type`. */ /** Каталожный тип секции → building_program item `section_type`. */
section_type: string; section_type: string;
/** Пятно секции (ширина, м) → building_program item `footprint_w_m`. */
footprint_w_m: number;
/** Пятно секции (глубина, м) → building_program item `footprint_d_m`. */
footprint_d_m: number;
housing_class: HousingClass; housing_class: HousingClass;
development_type: DevelopmentType; development_type: DevelopmentType;
land_cost_rub: number | null; land_cost_rub: number | null;
@ -165,6 +174,11 @@ export function ConceptProgramForm({
const [floors, setFloors] = useState<number>(FLOORS_FALLBACK); const [floors, setFloors] = useState<number>(FLOORS_FALLBACK);
const [floorHeight, setFloorHeight] = useState<number>(FLOOR_HEIGHT_DEFAULT); const [floorHeight, setFloorHeight] = useState<number>(FLOOR_HEIGHT_DEFAULT);
const [sectionType, setSectionType] = useState<string>(""); const [sectionType, setSectionType] = useState<string>("");
// Ручное пятно: null → используем габариты каталога по типу секции; число →
// пользователь «вписал» свой габарит (override). Сбрасывается в null при смене
// типа (новый тип несёт свои каталожные габариты).
const [footprintW, setFootprintW] = useState<number | null>(null);
const [footprintD, setFootprintD] = useState<number | null>(null);
const [housingClass, setHousingClass] = useState<HousingClass>( const [housingClass, setHousingClass] = useState<HousingClass>(
seeded.housing_class, seeded.housing_class,
); );
@ -191,9 +205,16 @@ export function ConceptProgramForm({
[catalog, sectionType], [catalog, sectionType],
); );
// Эффективное пятно секции: ручной override (footprintW/D) или каталог по типу.
const effFootprintW = footprintW ?? selectedType?.footprint_w_m ?? null;
const effFootprintD = footprintD ?? selectedType?.footprint_d_m ?? null;
// ── Derived read-only readouts (closes «видеть характеристики» complaint) ── // ── Derived read-only readouts (closes «видеть характеристики» complaint) ──
const buildingHeightM = floors * floorHeight; const buildingHeightM = floors * floorHeight;
const footprintSqm = selectedType?.footprint_sqm ?? null; const footprintSqm =
effFootprintW != null && effFootprintD != null
? effFootprintW * effFootprintD
: null;
const totalFootprintSqm = const totalFootprintSqm =
footprintSqm != null ? corpuses * footprintSqm : null; footprintSqm != null ? corpuses * footprintSqm : null;
const ksitEstimate = const ksitEstimate =
@ -207,11 +228,14 @@ export function ConceptProgramForm({
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (disabled || sectionType === "") return; if (disabled || sectionType === "") return;
if (effFootprintW == null || effFootprintD == null) return;
onSubmit({ onSubmit({
corpuses, corpuses,
floors, floors,
floor_height_m: floorHeight, floor_height_m: floorHeight,
section_type: sectionType, section_type: sectionType,
footprint_w_m: effFootprintW,
footprint_d_m: effFootprintD,
housing_class: housingClass, housing_class: housingClass,
development_type: developmentType, development_type: developmentType,
land_cost_rub: landCostRub, land_cost_rub: landCostRub,
@ -223,6 +247,9 @@ export function ConceptProgramForm({
setSectionType(nextType); setSectionType(nextType);
const ht = catalog.find((c) => c.section_type === nextType); const ht = catalog.find((c) => c.section_type === nextType);
if (ht && ht.default_floors > 0) setFloors(ht.default_floors); if (ht && ht.default_floors > 0) setFloors(ht.default_floors);
// Смена типа сбрасывает ручной override пятна на каталожные габариты нового типа.
setFootprintW(null);
setFootprintD(null);
} }
return ( return (
@ -375,7 +402,7 @@ export function ConceptProgramForm({
</p> </p>
</div> </div>
{/* 4. Тип секции + пятно секции readout. */} {/* 4. Тип секции. */}
<div> <div>
<label htmlFor="cpf-section-type" style={fieldLabelStyle}> <label htmlFor="cpf-section-type" style={fieldLabelStyle}>
Тип секции Тип секции
@ -394,14 +421,69 @@ export function ConceptProgramForm({
</option> </option>
))} ))}
</select> </select>
{selectedType && ( <p style={helperStyle}>
Габариты пятна по умолчанию из типа секции; ниже можно вписать
свои.
</p>
</div>
{/* 4b. Пятно секции — редактируемое (ширина × глубина). */}
<div>
<span style={fieldLabelStyle}>
Пятно секции, м (ширина × глубина)
</span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="number"
aria-label="Ширина пятна секции, м"
min={FOOTPRINT_MIN}
max={FOOTPRINT_MAX}
step={1}
value={effFootprintW ?? ""}
disabled={!catalogReady}
onChange={(e) =>
// Пусто → null (вернуться к каталожным габаритам), иначе clamp.
setFootprintW(
e.target.value.trim() === ""
? null
: clampFloat(
Number(e.target.value),
FOOTPRINT_MIN,
FOOTPRINT_MAX,
),
)
}
style={{ ...controlStyle, flex: 1 }}
/>
<span style={{ color: "var(--fg-tertiary)", fontSize: 14 }}>×</span>
<input
type="number"
aria-label="Глубина пятна секции, м"
min={FOOTPRINT_MIN}
max={FOOTPRINT_MAX}
step={1}
value={effFootprintD ?? ""}
disabled={!catalogReady}
onChange={(e) =>
// Пусто → null (вернуться к каталожным габаритам), иначе clamp.
setFootprintD(
e.target.value.trim() === ""
? null
: clampFloat(
Number(e.target.value),
FOOTPRINT_MIN,
FOOTPRINT_MAX,
),
)
}
style={{ ...controlStyle, flex: 1 }}
/>
</div>
{footprintSqm != null && (
<p style={helperStyle}> <p style={helperStyle}>
Пятно секции {nf0.format(selectedType.footprint_w_m)} ×{" "} <span style={tabularStyle}>{nf0.format(footprintSqm)}</span> м²
{nf0.format(selectedType.footprint_d_m)} м {" "} на секцию
<span style={tabularStyle}> {footprintW == null && footprintD == null ? " (из типа)" : ""}.
{nf0.format(selectedType.footprint_sqm)}
</span>{" "}
м² задаётся типом секции.
</p> </p>
)} )}
</div> </div>

View file

@ -91,6 +91,32 @@ function variantLabel(variant: ConceptVariant): string {
: STRATEGY_LABELS[variant.strategy]; : STRATEGY_LABELS[variant.strategy];
} }
/**
* Variant subtitle hint: program variants get a program-appropriate sentence
* (the 1b STRATEGY_HINTS speak of «приоритет площади/инсоляции/баланс», which
* misdescribes a user-defined program reported under the "balanced" slot).
*/
function variantHint(variant: ConceptVariant): string {
return isProgramVariant(variant)
? "Раскладка заданной программы по геометрии участка."
: STRATEGY_HINTS[variant.strategy];
}
/** Russian plural for «корпус» (1 корпус / 24 корпуса / 5+ корпусов). */
function pluralizeCorpus(n: number): string {
const mod100 = n % 100;
const mod10 = n % 10;
if (mod100 >= 11 && mod100 <= 14) return "корпусов";
if (mod10 === 1) return "корпус";
if (mod10 >= 2 && mod10 <= 4) return "корпуса";
return "корпусов";
}
/** «3 корпуса» / «1 корпус» / «12 корпусов» — count + agreed plural. */
function formatCorpuses(n: number): string {
return `${formatInt(n)} ${pluralizeCorpus(n)}`;
}
function formatPct(fraction: number): string { function formatPct(fraction: number): string {
return `${(fraction * 100).toFixed(1)}%`; return `${(fraction * 100).toFixed(1)}%`;
} }
@ -149,8 +175,8 @@ function VariantPanel({ parcel, variant }: PanelProps) {
}} }}
> >
<div style={{ fontSize: 15, fontWeight: 600 }}> <div style={{ fontSize: 15, fontWeight: 600 }}>
{variantLabel(variant)}: {formatInt(buildingCount(variant))} корпусов {variantLabel(variant)}: {formatCorpuses(buildingCount(variant))} ·{" "}
· {formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "} {formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "}
{formatInt(teap.apartments_count)} квартир · чистая прибыль{" "} {formatInt(teap.apartments_count)} квартир · чистая прибыль{" "}
{formatMoneyCompact(financial.net_profit_rub)} · ROI{" "} {formatMoneyCompact(financial.net_profit_rub)} · ROI{" "}
{formatPct(financial.roi)} {formatPct(financial.roi)}
@ -164,9 +190,8 @@ function VariantPanel({ parcel, variant }: PanelProps) {
color: "var(--fg-on-dark-muted)", color: "var(--fg-on-dark-muted)",
}} }}
> >
{STRATEGY_HINTS[variant.strategy]} NPV{" "} {variantHint(variant)} NPV {formatMoneyCompact(financial.npv_rub)} ·
{formatMoneyCompact(financial.npv_rub)} · IRR{" "} IRR {formatPct(financial.irr)}
{formatPct(financial.irr)}
{financial.irr_is_proxy ? " (оценочный)" : ""} · окупаемость{" "} {financial.irr_is_proxy ? " (оценочный)" : ""} · окупаемость{" "}
{formatPayback(financial.payback_months)} · плотность (FAR){" "} {formatPayback(financial.payback_months)} · плотность (FAR){" "}
{teap.density.toLocaleString("ru-RU", { {teap.density.toLocaleString("ru-RU", {
@ -193,8 +218,8 @@ function VariantPanel({ parcel, variant }: PanelProps) {
}} }}
> >
Размещено {formatInt(partialFit.placed)} из{" "} Размещено {formatInt(partialFit.placed)} из{" "}
{formatInt(partialFit.requested)} корпусов участок вмещает меньше, {formatCorpuses(partialFit.requested)} участок вмещает меньше, чем в
чем в заданной программе. ТЭП и финмодель рассчитаны по фактически заданной программе. ТЭП и финмодель рассчитаны по фактически
размещённым корпусам. размещённым корпусам.
</div> </div>
)} )}
@ -202,9 +227,9 @@ function VariantPanel({ parcel, variant }: PanelProps) {
{/* Placement map */} {/* Placement map */}
<Section <Section
title="Размещение застройки" title="Размещение застройки"
subtitle={`Полигон участка (пунктир) и сгенерированные корпуса (заливка) — ${formatInt( subtitle={`Полигон участка (пунктир) и сгенерированные корпуса (заливка) — ${formatCorpuses(
buildingCount(variant), buildingCount(variant),
)} корпусов.`} )}.`}
> >
<ConceptResultMap <ConceptResultMap
parcel={parcel} parcel={parcel}

View file

@ -180,6 +180,8 @@ export function Section7Concept({ analysis }: Props) {
section_type: state.section_type, section_type: state.section_type,
floors: state.floors, floors: state.floors,
count: state.corpuses, count: state.corpuses,
footprint_w_m: state.footprint_w_m,
footprint_d_m: state.footprint_d_m,
}, },
]; ];
concept.mutate({ concept.mutate({
@ -217,6 +219,8 @@ export function Section7Concept({ analysis }: Props) {
floors: def.default_floors > 0 ? def.default_floors : FLOORS_FALLBACK, floors: def.default_floors > 0 ? def.default_floors : FLOORS_FALLBACK,
floor_height_m: 3.0, floor_height_m: 3.0,
section_type: def.section_type, section_type: def.section_type,
footprint_w_m: def.footprint_w_m,
footprint_d_m: def.footprint_d_m,
housing_class: seeded.housing_class, housing_class: seeded.housing_class,
development_type: seeded.development_type, development_type: seeded.development_type,
land_cost_rub: seeded.land_cost_rub, land_cost_rub: seeded.land_cost_rub,

View file

@ -3046,6 +3046,16 @@ export interface components {
* @description Number of sections to place * @description Number of sections to place
*/ */
count: number; count: number;
/**
* Footprint W M
* @description Optional manual section footprint width, m
*/
footprint_w_m?: number | null;
/**
* Footprint D M
* @description Optional manual section footprint depth, m
*/
footprint_d_m?: number | null;
}; };
/** /**
* BulkGeoEnqueueRequest * BulkGeoEnqueueRequest

View file

@ -39,6 +39,14 @@ export interface BuildingProgramItem {
floors: number; floors: number;
/** Сколько секций этого типа разместить, 150. */ /** Сколько секций этого типа разместить, 150. */
count: number; count: number;
/**
* Опциональное ручное пятно секции (ширина, м), 4120. None / не задано
* габариты берёт бэк из каталога по `section_type`. Должно задаваться вместе с
* `footprint_d_m` (частичное задание бэк игнорирует). ADDITIVE (Stage 3c).
*/
footprint_w_m?: number | null;
/** Опциональное ручное пятно секции (глубина, м), 4120. См. `footprint_w_m`. */
footprint_d_m?: number | null;
} }
export interface ConceptInput { export interface ConceptInput {