feat(forecast): расширить горизонт прогноза до 24 мес (ТЗ §12.1, Q1) #1306
7 changed files with 127 additions and 18 deletions
|
|
@ -918,8 +918,14 @@ _INLINE_FETCH_POLL_INTERVAL_S = 2
|
|||
# (SiteFinderReport._SCHEMA_VERSION), отличный от ANALYZE_SCHEMA_VERSION ("analyze-1.0")
|
||||
# inline-dict analyze. GET /{cad}/forecast читает именно "1.0".
|
||||
_FORECAST_SCHEMA_VERSION = "1.0"
|
||||
# Допустимые горизонты прогноза (мес), #995 — иное → 422.
|
||||
_ALLOWED_FORECAST_HORIZONS = frozenset({6, 12, 18})
|
||||
# Допустимые горизонты прогноза (мес), #995 — иное → 422. ТЗ §12.1 называет
|
||||
# 6/12/18/24; 24 добавлен (Q1). Движок уже считает 24 на каждом ране — это первый
|
||||
# горизонт в _DEFAULT_HORIZONS=(6,12,18,24) всего §9.x-стека (orchestrator/forecast-
|
||||
# task/demand_supply_forecast/scenarios/special_indices/report_assembler); фазированный
|
||||
# выход скрытого ПД (_hidden_release_fraction) и взвешивание future_supply по горизонту
|
||||
# (_horizon_weight) корректно масштабируются/клампятся на 24 без деградации. Сетка
|
||||
# дискретная (а не ge/le-диапазон): UI даёт фиксированные пресет-кнопки.
|
||||
_ALLOWED_FORECAST_HORIZONS = frozenset({6, 12, 18, 24})
|
||||
|
||||
|
||||
@router.get("/by-bbox", response_model=ParcelBboxResponse)
|
||||
|
|
@ -1277,7 +1283,7 @@ def analyze_parcel(
|
|||
] = None,
|
||||
horizon: Annotated[
|
||||
int,
|
||||
Query(description="Горизонт прогноза, мес (#995): один из {6, 12, 18}"),
|
||||
Query(description="Горизонт прогноза, мес (#995, ТЗ §12.1): один из {6, 12, 18, 24}"),
|
||||
] = 12,
|
||||
body: Annotated[
|
||||
AnalyzeRequest | None,
|
||||
|
|
@ -1310,8 +1316,8 @@ def analyze_parcel(
|
|||
|
||||
§22-форсайт (3b-ii, #995): после успешного persist рана best-effort enqueue'им
|
||||
`forecast_site_finder_report.delay(cad_num, horizon, ...)` — fire-and-forget,
|
||||
провал Celery/Redis НЕ валит ответ (он уже успешен). `horizon` ∈ {6, 12, 18}
|
||||
(иначе 422). Результат добавляется в ответ как `forecast` stub (additive); сам
|
||||
провал Celery/Redis НЕ валит ответ (он уже успешен). `horizon` ∈ {6, 12, 18, 24}
|
||||
(ТЗ §12.1; иначе 422). Результат добавляется в ответ как `forecast` stub (additive); сам
|
||||
§22-отчёт клиент забирает через GET /{cad}/forecast (202 pending → 200 ready).
|
||||
"""
|
||||
# Валидация горизонта прогноза (#995) — до любой работы с БД: иное → 422.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"""Тесты §22-форсайта в parcels API (3b-ii, #995).
|
||||
|
||||
Покрывает:
|
||||
• `horizon` query-param на POST /analyze: default 12; валидные {6,12,18} приняты;
|
||||
невалидные (7, 0) → 422 ДО любой работы с БД.
|
||||
• `horizon` query-param на POST /analyze: default 12; валидные {6,12,18,24} приняты
|
||||
(24 — ТЗ §12.1, Q1); невалидные (7, 0, 30) → 422 ДО любой работы с БД.
|
||||
• Best-effort enqueue после успешного analyze: `forecast_site_finder_report.delay`
|
||||
вызван ОДИН раз с (cad_num, horizon, created_by); ответ несёт forecast.status="pending".
|
||||
• Enqueue failure: `.delay` бросает → analyze всё равно 200, forecast.status="unavailable",
|
||||
|
|
@ -149,10 +149,15 @@ def test_horizon_default_12_and_enqueued() -> None:
|
|||
|
||||
|
||||
def test_horizon_valid_values_accepted() -> None:
|
||||
"""horizon ∈ {6,12,18} принимается → 200 + enqueue с этим горизонтом."""
|
||||
"""horizon ∈ {6,12,18,24} принимается → 200 + enqueue с этим горизонтом.
|
||||
|
||||
24 — ТЗ §12.1 (Q1): движок уже считает 24 на каждом ране (первый горизонт в
|
||||
_DEFAULT_HORIZONS всего §9.x-стека), здесь проверяем что API его ПРИНИМАЕТ как
|
||||
запрошенный primary и прокидывает в enqueue ровно как остальные.
|
||||
"""
|
||||
from app.core.db import get_db
|
||||
|
||||
for h in (6, 12, 18):
|
||||
for h in (6, 12, 18, 24):
|
||||
db = _make_db_for_analyze()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
_start_patches()
|
||||
|
|
@ -172,10 +177,14 @@ def test_horizon_valid_values_accepted() -> None:
|
|||
|
||||
|
||||
def test_horizon_invalid_returns_422() -> None:
|
||||
"""horizon вне {6,12,18} (7, 0) → 422 ДО enqueue (delay не вызывается)."""
|
||||
"""horizon вне {6,12,18,24} (7, 0, 30) → 422 ДО enqueue (delay не вызывается).
|
||||
|
||||
30 (за новым потолком 24) тоже отклоняется — сетка дискретная, а не ge/le-диапазон:
|
||||
guard от случайного расширения до неограниченного горизонта.
|
||||
"""
|
||||
from app.core.db import get_db
|
||||
|
||||
for h in (7, 0):
|
||||
for h in (7, 0, 30):
|
||||
db = _make_db_for_analyze()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
_start_patches()
|
||||
|
|
@ -351,7 +360,7 @@ _REPORT_FIXTURE = {
|
|||
}
|
||||
],
|
||||
},
|
||||
"meta": {"cad_num": _CAD, "district": "Кировский", "horizons": [6, 12, 18]},
|
||||
"meta": {"cad_num": _CAD, "district": "Кировский", "horizons": [6, 12, 18, 24]},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import pytest
|
|||
|
||||
from app.services.forecasting.demand_supply_forecast import (
|
||||
_CONFIDENCE_CAP,
|
||||
_CONFIDENCE_RANK,
|
||||
_DEFICIT_RATIO_SATURATION,
|
||||
_HIDDEN_RELEASE_MONTHS,
|
||||
_TOP_COMPETITORS,
|
||||
|
|
@ -1067,6 +1068,57 @@ class TestHorizonsAndRatePath:
|
|||
norm_calls = mocks[_NORM].call_args_list
|
||||
assert any(call.kwargs.get("rate_future") == 19.5 for call in norm_calls)
|
||||
|
||||
def test_horizon_24_meaningful_not_degenerate(self) -> None:
|
||||
"""ТЗ §12.1 (Q1): на 24 мес прогноз ОСМЫСЛЕННЫЙ, не пустой/деградировавший.
|
||||
|
||||
Не просто «принят» — проверяем что КАЖДОЕ числовое поле посчитано (не None),
|
||||
spрос линейно вырос (24 = 4×6-мес, 2×12-мес), скрытый ПД полностью созрел
|
||||
(_hidden_release_fraction(24)=1.0 → весь hidden в gross-предложении),
|
||||
deficit_index в валидном [−1,+1], confidence ≤ cap. С тем же стабом данных
|
||||
24 НЕ ломается и НЕ обрезается на 18.
|
||||
"""
|
||||
with _Patches(
|
||||
_METRICS=_metrics_stub(unit_velocity=10.0),
|
||||
_NORM=_norm_stub(coefficient=0.8),
|
||||
_MACRO_COEF=_macro_coef_stub(coefficient=1.1),
|
||||
_SUPPLY=_supply_stub(open_units=300, hidden_units=200, future_units_by_horizon=40.0),
|
||||
):
|
||||
res = _run(horizons=[24])
|
||||
f = res[0]
|
||||
assert f.horizon_months == 24
|
||||
# Спрос посчитан и ЛИНЕЙНО масштабирован на 24 (а не обрезан/занулён).
|
||||
assert f.projected_demand_units == pytest.approx(10.0 * 0.8 * 1.1 * 24)
|
||||
# Скрытый ПД на 24 ПОЛНОСТЬЮ созрел (фаза=1.0): весь hidden=200 в предложении.
|
||||
assert f.hidden_release_units == pytest.approx(200.0)
|
||||
# Предложение/баланс/индексы посчитаны (не None) — секция не деградировала.
|
||||
assert f.projected_supply_units is not None
|
||||
assert f.balance_units is not None
|
||||
assert f.deficit_index is not None and -1.0 <= f.deficit_index <= 1.0
|
||||
assert f.months_of_inventory is not None and f.months_of_inventory >= 0.0
|
||||
# advisory-движок: confidence не выше cap даже при всех 'high'-компонентах.
|
||||
assert f.confidence in _ALLOWED_CONFIDENCE
|
||||
assert _CONFIDENCE_RANK[f.confidence] <= _CONFIDENCE_RANK[_CONFIDENCE_CAP]
|
||||
|
||||
def test_horizon_24_differs_from_18(self) -> None:
|
||||
"""24 — отдельный горизонт, а не «то же что 18»: спрос/MOI растут с горизонтом.
|
||||
|
||||
Guard от скрытого clamp'а горизонта на 18 (если бы такой был — поля на 18 и 24
|
||||
совпали бы). Спрос строго линеен, поэтому demand(24) > demand(18); валовое
|
||||
предложение на 24 ≥ 18 (окно шире → больше future-юнитов попадает), значит MOI
|
||||
тоже не вырождается в равенство при ненулевом спросе.
|
||||
"""
|
||||
with _Patches(
|
||||
_METRICS=_metrics_stub(unit_velocity=10.0),
|
||||
_NORM=_norm_stub(coefficient=0.8),
|
||||
_MACRO_COEF=_macro_coef_stub(coefficient=1.1),
|
||||
):
|
||||
res = _run(horizons=[18, 24])
|
||||
f18, f24 = res
|
||||
assert f18.horizon_months == 18 and f24.horizon_months == 24
|
||||
assert f18.projected_demand_units is not None and f24.projected_demand_units is not None
|
||||
# Спрос строго растёт с горизонтом (линейность) — 24 ≠ 18.
|
||||
assert f24.projected_demand_units > f18.projected_demand_units
|
||||
|
||||
|
||||
# ── orchestrator: §9.7 competitors (top-N, graceful) ──────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -3,18 +3,18 @@
|
|||
/**
|
||||
* HorizonSelector — segmented control «Горизонт прогноза» (958-B1 / #996).
|
||||
*
|
||||
* Controlled segmented control over the three forecast horizons accepted by
|
||||
* POST /api/v1/parcels/{cad}/analyze?horizon= (6 / 12 / 18; default 12).
|
||||
* Controlled segmented control over the forecast horizons accepted by
|
||||
* POST /api/v1/parcels/{cad}/analyze?horizon= (6 / 12 / 18 / 24; default 12).
|
||||
* Drives useParcelAnalyzeQuery on the Site Finder analysis screen.
|
||||
*
|
||||
* A11y: role="radiogroup" wrapper + role="radio"/aria-checked per <button>
|
||||
* segment (real buttons → native keyboard focus; visible focus ring kept).
|
||||
*/
|
||||
|
||||
const HORIZONS = [6, 12, 18] as const;
|
||||
const HORIZONS = [6, 12, 18, 24] as const;
|
||||
|
||||
interface Props {
|
||||
/** Currently selected horizon (one of 6 / 12 / 18). */
|
||||
/** Currently selected horizon (one of 6 / 12 / 18 / 24). */
|
||||
value: number;
|
||||
/** Called with the newly selected horizon. */
|
||||
onChange: (horizon: number) => void;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { HorizonSelector } from "../HorizonSelector";
|
||||
|
||||
describe("HorizonSelector", () => {
|
||||
it("renders one radio per accepted horizon, incl. 24 (ТЗ §12.1)", () => {
|
||||
render(<HorizonSelector value={12} onChange={() => {}} />);
|
||||
const radios = screen.getAllByRole("radio");
|
||||
expect(radios.map((r) => r.textContent)).toEqual([
|
||||
"6 мес",
|
||||
"12 мес",
|
||||
"18 мес",
|
||||
"24 мес",
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks the selected horizon via aria-checked", () => {
|
||||
render(<HorizonSelector value={24} onChange={() => {}} />);
|
||||
const selected = screen.getByRole("radio", { name: "24 мес" });
|
||||
expect(selected).toHaveAttribute("aria-checked", "true");
|
||||
expect(screen.getByRole("radio", { name: "12 мес" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls onChange with 24 when the 24-month segment is clicked", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<HorizonSelector value={12} onChange={onChange} />);
|
||||
await userEvent.click(screen.getByRole("radio", { name: "24 мес" }));
|
||||
expect(onChange).toHaveBeenCalledWith(24);
|
||||
});
|
||||
|
||||
it("does not fire onChange while disabled", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<HorizonSelector value={12} onChange={onChange} disabled />);
|
||||
await userEvent.click(screen.getByRole("radio", { name: "24 мес" }));
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -47,7 +47,7 @@ import {
|
|||
interface Props {
|
||||
cad: string;
|
||||
/**
|
||||
* Horizon chosen in the analysis-screen HorizonSelector (6 / 12 / 18). When
|
||||
* Horizon chosen in the analysis-screen HorizonSelector (6 / 12 / 18 / 24). When
|
||||
* set it takes priority over the report-derived horizon so the user's choice
|
||||
* instantly drives which horizon is treated as the target (client-side), even
|
||||
* before the recomputed forecast finishes (see AnalysisPageContent comment).
|
||||
|
|
|
|||
|
|
@ -420,7 +420,7 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
|
|||
return fixtureAnalyze as ParcelAnalyzeResponse;
|
||||
}
|
||||
|
||||
// Backend accepts ?horizon= ∈ {6,12,18} (default 12; 422 otherwise, #995).
|
||||
// Backend accepts ?horizon= ∈ {6,12,18,24} (default 12; 422 otherwise, #995).
|
||||
const analyzeUrl = `/api/v1/parcels/${encodeURIComponent(
|
||||
cad,
|
||||
)}/analyze?horizon=${horizon}`;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue