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")
# Сколько секций этого типа разместить.
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):

View file

@ -273,13 +273,18 @@ def place_program(
for item in program:
house = catalog.get_house_type(item.section_type)
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
for cell in parcel.grid:
if placed_for_item >= item.count:
break
footprint = _centered_footprint(
cell.cx, cell.cy, house.footprint_w_m, house.footprint_d_m
)
footprint = _centered_footprint(cell.cx, cell.cy, fp_w, fp_d)
if placer.try_place(footprint, buildable, half_gap):
placed_sections.append(
_PlacedSection(

View file

@ -9,8 +9,8 @@ from __future__ import annotations
from shapely.geometry import shape
from app.schemas.concept import ConceptInput
from app.services.generative import geometry, placement
from app.schemas.concept import BuildingProgramItem, ConceptInput
from app.services.generative import catalog, geometry, placement
_PARCEL_COORDS = [
[60.60, 56.830],
@ -67,10 +67,7 @@ def test_max_area_denser_than_max_insolation() -> None:
parcel = geometry.parse_parcel(payload)
variants = {v.strategy: v for v in placement.place_all_strategies(parcel, payload)}
# Максимум площади даёт большее пятно/жилую, чем максимум инсоляции.
assert (
variants["max_area"].teap.built_area_sqm
> variants["max_insolation"].teap.built_area_sqm
)
assert variants["max_area"].teap.built_area_sqm > variants["max_insolation"].teap.built_area_sqm
assert (
variants["max_area"].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
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:
payload = _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).
"""
# Строка дампа: все поля через MagicMock
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
dump_row = MagicMock()
dump_row.__getitem__ = lambda self, i: [
"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
None, # harvest_error
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_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) ───────────────────────────
/**
@ -66,6 +71,10 @@ export interface ConceptProgramFormState {
floor_height_m: number;
/** Каталожный тип секции → building_program item `section_type`. */
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;
development_type: DevelopmentType;
land_cost_rub: number | null;
@ -165,6 +174,11 @@ export function ConceptProgramForm({
const [floors, setFloors] = useState<number>(FLOORS_FALLBACK);
const [floorHeight, setFloorHeight] = useState<number>(FLOOR_HEIGHT_DEFAULT);
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>(
seeded.housing_class,
);
@ -191,9 +205,16 @@ export function ConceptProgramForm({
[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) ──
const buildingHeightM = floors * floorHeight;
const footprintSqm = selectedType?.footprint_sqm ?? null;
const footprintSqm =
effFootprintW != null && effFootprintD != null
? effFootprintW * effFootprintD
: null;
const totalFootprintSqm =
footprintSqm != null ? corpuses * footprintSqm : null;
const ksitEstimate =
@ -207,11 +228,14 @@ export function ConceptProgramForm({
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (disabled || sectionType === "") return;
if (effFootprintW == null || effFootprintD == null) return;
onSubmit({
corpuses,
floors,
floor_height_m: floorHeight,
section_type: sectionType,
footprint_w_m: effFootprintW,
footprint_d_m: effFootprintD,
housing_class: housingClass,
development_type: developmentType,
land_cost_rub: landCostRub,
@ -223,6 +247,9 @@ export function ConceptProgramForm({
setSectionType(nextType);
const ht = catalog.find((c) => c.section_type === nextType);
if (ht && ht.default_floors > 0) setFloors(ht.default_floors);
// Смена типа сбрасывает ручной override пятна на каталожные габариты нового типа.
setFootprintW(null);
setFootprintD(null);
}
return (
@ -375,7 +402,7 @@ export function ConceptProgramForm({
</p>
</div>
{/* 4. Тип секции + пятно секции readout. */}
{/* 4. Тип секции. */}
<div>
<label htmlFor="cpf-section-type" style={fieldLabelStyle}>
Тип секции
@ -394,14 +421,69 @@ export function ConceptProgramForm({
</option>
))}
</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}>
Пятно секции {nf0.format(selectedType.footprint_w_m)} ×{" "}
{nf0.format(selectedType.footprint_d_m)} м {" "}
<span style={tabularStyle}>
{nf0.format(selectedType.footprint_sqm)}
</span>{" "}
м² задаётся типом секции.
<span style={tabularStyle}>{nf0.format(footprintSqm)}</span> м²
на секцию
{footprintW == null && footprintD == null ? " (из типа)" : ""}.
</p>
)}
</div>

View file

@ -91,6 +91,32 @@ function variantLabel(variant: ConceptVariant): string {
: 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 {
return `${(fraction * 100).toFixed(1)}%`;
}
@ -149,8 +175,8 @@ function VariantPanel({ parcel, variant }: PanelProps) {
}}
>
<div style={{ fontSize: 15, fontWeight: 600 }}>
{variantLabel(variant)}: {formatInt(buildingCount(variant))} корпусов
· {formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "}
{variantLabel(variant)}: {formatCorpuses(buildingCount(variant))} ·{" "}
{formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "}
{formatInt(teap.apartments_count)} квартир · чистая прибыль{" "}
{formatMoneyCompact(financial.net_profit_rub)} · ROI{" "}
{formatPct(financial.roi)}
@ -164,9 +190,8 @@ function VariantPanel({ parcel, variant }: PanelProps) {
color: "var(--fg-on-dark-muted)",
}}
>
{STRATEGY_HINTS[variant.strategy]} NPV{" "}
{formatMoneyCompact(financial.npv_rub)} · IRR{" "}
{formatPct(financial.irr)}
{variantHint(variant)} NPV {formatMoneyCompact(financial.npv_rub)} ·
IRR {formatPct(financial.irr)}
{financial.irr_is_proxy ? " (оценочный)" : ""} · окупаемость{" "}
{formatPayback(financial.payback_months)} · плотность (FAR){" "}
{teap.density.toLocaleString("ru-RU", {
@ -193,8 +218,8 @@ function VariantPanel({ parcel, variant }: PanelProps) {
}}
>
Размещено {formatInt(partialFit.placed)} из{" "}
{formatInt(partialFit.requested)} корпусов участок вмещает меньше,
чем в заданной программе. ТЭП и финмодель рассчитаны по фактически
{formatCorpuses(partialFit.requested)} участок вмещает меньше, чем в
заданной программе. ТЭП и финмодель рассчитаны по фактически
размещённым корпусам.
</div>
)}
@ -202,9 +227,9 @@ function VariantPanel({ parcel, variant }: PanelProps) {
{/* Placement map */}
<Section
title="Размещение застройки"
subtitle={`Полигон участка (пунктир) и сгенерированные корпуса (заливка) — ${formatInt(
subtitle={`Полигон участка (пунктир) и сгенерированные корпуса (заливка) — ${formatCorpuses(
buildingCount(variant),
)} корпусов.`}
)}.`}
>
<ConceptResultMap
parcel={parcel}

View file

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

View file

@ -3046,6 +3046,16 @@ export interface components {
* @description Number of sections to place
*/
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

View file

@ -39,6 +39,14 @@ export interface BuildingProgramItem {
floors: number;
/** Сколько секций этого типа разместить, 150. */
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 {