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")
|
# (SiteFinderReport._SCHEMA_VERSION), отличный от ANALYZE_SCHEMA_VERSION ("analyze-1.0")
|
||||||
# inline-dict analyze. GET /{cad}/forecast читает именно "1.0".
|
# inline-dict analyze. GET /{cad}/forecast читает именно "1.0".
|
||||||
_FORECAST_SCHEMA_VERSION = "1.0"
|
_FORECAST_SCHEMA_VERSION = "1.0"
|
||||||
# Допустимые горизонты прогноза (мес), #995 — иное → 422.
|
# Допустимые горизонты прогноза (мес), #995 — иное → 422. ТЗ §12.1 называет
|
||||||
_ALLOWED_FORECAST_HORIZONS = frozenset({6, 12, 18})
|
# 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)
|
@router.get("/by-bbox", response_model=ParcelBboxResponse)
|
||||||
|
|
@ -1277,7 +1283,7 @@ def analyze_parcel(
|
||||||
] = None,
|
] = None,
|
||||||
horizon: Annotated[
|
horizon: Annotated[
|
||||||
int,
|
int,
|
||||||
Query(description="Горизонт прогноза, мес (#995): один из {6, 12, 18}"),
|
Query(description="Горизонт прогноза, мес (#995, ТЗ §12.1): один из {6, 12, 18, 24}"),
|
||||||
] = 12,
|
] = 12,
|
||||||
body: Annotated[
|
body: Annotated[
|
||||||
AnalyzeRequest | None,
|
AnalyzeRequest | None,
|
||||||
|
|
@ -1310,8 +1316,8 @@ def analyze_parcel(
|
||||||
|
|
||||||
§22-форсайт (3b-ii, #995): после успешного persist рана best-effort enqueue'им
|
§22-форсайт (3b-ii, #995): после успешного persist рана best-effort enqueue'им
|
||||||
`forecast_site_finder_report.delay(cad_num, horizon, ...)` — fire-and-forget,
|
`forecast_site_finder_report.delay(cad_num, horizon, ...)` — fire-and-forget,
|
||||||
провал Celery/Redis НЕ валит ответ (он уже успешен). `horizon` ∈ {6, 12, 18}
|
провал Celery/Redis НЕ валит ответ (он уже успешен). `horizon` ∈ {6, 12, 18, 24}
|
||||||
(иначе 422). Результат добавляется в ответ как `forecast` stub (additive); сам
|
(ТЗ §12.1; иначе 422). Результат добавляется в ответ как `forecast` stub (additive); сам
|
||||||
§22-отчёт клиент забирает через GET /{cad}/forecast (202 pending → 200 ready).
|
§22-отчёт клиент забирает через GET /{cad}/forecast (202 pending → 200 ready).
|
||||||
"""
|
"""
|
||||||
# Валидация горизонта прогноза (#995) — до любой работы с БД: иное → 422.
|
# Валидация горизонта прогноза (#995) — до любой работы с БД: иное → 422.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"""Тесты §22-форсайта в parcels API (3b-ii, #995).
|
"""Тесты §22-форсайта в parcels API (3b-ii, #995).
|
||||||
|
|
||||||
Покрывает:
|
Покрывает:
|
||||||
• `horizon` query-param на POST /analyze: default 12; валидные {6,12,18} приняты;
|
• `horizon` query-param на POST /analyze: default 12; валидные {6,12,18,24} приняты
|
||||||
невалидные (7, 0) → 422 ДО любой работы с БД.
|
(24 — ТЗ §12.1, Q1); невалидные (7, 0, 30) → 422 ДО любой работы с БД.
|
||||||
• Best-effort enqueue после успешного analyze: `forecast_site_finder_report.delay`
|
• Best-effort enqueue после успешного analyze: `forecast_site_finder_report.delay`
|
||||||
вызван ОДИН раз с (cad_num, horizon, created_by); ответ несёт forecast.status="pending".
|
вызван ОДИН раз с (cad_num, horizon, created_by); ответ несёт forecast.status="pending".
|
||||||
• Enqueue failure: `.delay` бросает → analyze всё равно 200, forecast.status="unavailable",
|
• 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:
|
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
|
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()
|
db = _make_db_for_analyze()
|
||||||
app.dependency_overrides[get_db] = _override_db(db)
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
_start_patches()
|
_start_patches()
|
||||||
|
|
@ -172,10 +177,14 @@ def test_horizon_valid_values_accepted() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_horizon_invalid_returns_422() -> 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
|
from app.core.db import get_db
|
||||||
|
|
||||||
for h in (7, 0):
|
for h in (7, 0, 30):
|
||||||
db = _make_db_for_analyze()
|
db = _make_db_for_analyze()
|
||||||
app.dependency_overrides[get_db] = _override_db(db)
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
_start_patches()
|
_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 (
|
from app.services.forecasting.demand_supply_forecast import (
|
||||||
_CONFIDENCE_CAP,
|
_CONFIDENCE_CAP,
|
||||||
|
_CONFIDENCE_RANK,
|
||||||
_DEFICIT_RATIO_SATURATION,
|
_DEFICIT_RATIO_SATURATION,
|
||||||
_HIDDEN_RELEASE_MONTHS,
|
_HIDDEN_RELEASE_MONTHS,
|
||||||
_TOP_COMPETITORS,
|
_TOP_COMPETITORS,
|
||||||
|
|
@ -1067,6 +1068,57 @@ class TestHorizonsAndRatePath:
|
||||||
norm_calls = mocks[_NORM].call_args_list
|
norm_calls = mocks[_NORM].call_args_list
|
||||||
assert any(call.kwargs.get("rate_future") == 19.5 for call in norm_calls)
|
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) ──────────────────────────
|
# ── orchestrator: §9.7 competitors (top-N, graceful) ──────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,18 @@
|
||||||
/**
|
/**
|
||||||
* HorizonSelector — segmented control «Горизонт прогноза» (958-B1 / #996).
|
* HorizonSelector — segmented control «Горизонт прогноза» (958-B1 / #996).
|
||||||
*
|
*
|
||||||
* Controlled segmented control over the three forecast horizons accepted by
|
* Controlled segmented control over the forecast horizons accepted by
|
||||||
* POST /api/v1/parcels/{cad}/analyze?horizon= (6 / 12 / 18; default 12).
|
* POST /api/v1/parcels/{cad}/analyze?horizon= (6 / 12 / 18 / 24; default 12).
|
||||||
* Drives useParcelAnalyzeQuery on the Site Finder analysis screen.
|
* Drives useParcelAnalyzeQuery on the Site Finder analysis screen.
|
||||||
*
|
*
|
||||||
* A11y: role="radiogroup" wrapper + role="radio"/aria-checked per <button>
|
* A11y: role="radiogroup" wrapper + role="radio"/aria-checked per <button>
|
||||||
* segment (real buttons → native keyboard focus; visible focus ring kept).
|
* 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 {
|
interface Props {
|
||||||
/** Currently selected horizon (one of 6 / 12 / 18). */
|
/** Currently selected horizon (one of 6 / 12 / 18 / 24). */
|
||||||
value: number;
|
value: number;
|
||||||
/** Called with the newly selected horizon. */
|
/** Called with the newly selected horizon. */
|
||||||
onChange: (horizon: number) => void;
|
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 {
|
interface Props {
|
||||||
cad: string;
|
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
|
* 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
|
* instantly drives which horizon is treated as the target (client-side), even
|
||||||
* before the recomputed forecast finishes (see AnalysisPageContent comment).
|
* before the recomputed forecast finishes (see AnalysisPageContent comment).
|
||||||
|
|
|
||||||
|
|
@ -420,7 +420,7 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
|
||||||
return fixtureAnalyze as ParcelAnalyzeResponse;
|
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(
|
const analyzeUrl = `/api/v1/parcels/${encodeURIComponent(
|
||||||
cad,
|
cad,
|
||||||
)}/analyze?horizon=${horizon}`;
|
)}/analyze?horizon=${horizon}`;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue