feat(section7): editable section footprint + корпус plural/program hint (#1953) #2102
9 changed files with 214 additions and 29 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
Пятно секции {nf0.format(selectedType.footprint_w_m)} ×{" "}
|
||||
{nf0.format(selectedType.footprint_d_m)} м ≈{" "}
|
||||
<span style={tabularStyle}>
|
||||
{nf0.format(selectedType.footprint_sqm)}
|
||||
</span>{" "}
|
||||
м² — задаётся типом секции.
|
||||
Габариты пятна по умолчанию — из типа секции; ниже можно вписать
|
||||
свои.
|
||||
</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}>
|
||||
≈ <span style={tabularStyle}>{nf0.format(footprintSqm)}</span> м²
|
||||
на секцию
|
||||
{footprintW == null && footprintD == null ? " (из типа)" : ""}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 корпус / 2–4 корпуса / 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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -39,6 +39,14 @@ export interface BuildingProgramItem {
|
|||
floors: number;
|
||||
/** Сколько секций этого типа разместить, 1–50. */
|
||||
count: number;
|
||||
/**
|
||||
* Опциональное ручное пятно секции (ширина, м), 4–120. None / не задано →
|
||||
* габариты берёт бэк из каталога по `section_type`. Должно задаваться вместе с
|
||||
* `footprint_d_m` (частичное задание бэк игнорирует). ADDITIVE (Stage 3c).
|
||||
*/
|
||||
footprint_w_m?: number | null;
|
||||
/** Опциональное ручное пятно секции (глубина, м), 4–120. См. `footprint_w_m`. */
|
||||
footprint_d_m?: number | null;
|
||||
}
|
||||
|
||||
export interface ConceptInput {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue