fix(B04 · B06): 유입 캐시 상한 넷 · 흙막이 보낸 값 열쇠에 프로젝트 · 노선 (PLAN 572-2)
- B04 유입 배열 캐시 = 최근 프로젝트 넷만(_DETAIL_CACHE 꼴) · 서버 켠 뒤 본 프로젝트마다 쌓이던 것
- B06 soilSent 열쇠 = {프로젝트}:{노선}:측점:역할 · 다른 프로젝트 · 노선 같은 측점에 같은 값 안 먹던 흠
- 시험 test_572_2_inflow_cache 새로 · test_54_10 다른 프로젝트 같은 값 · helper_387_4b scope 칸
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -354,6 +354,7 @@ export async function mountCrossView(
|
||||
// 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리).
|
||||
const pipeOptionsContext: PipeOptionsContext = {
|
||||
detail: () => sectionDetail,
|
||||
scope: () => `${projectId}:${currentRouteId}`,
|
||||
ford: stationControls.ford,
|
||||
box: stationControls.box,
|
||||
revetOffset: stationControls.revetOffset,
|
||||
|
||||
@@ -34,7 +34,9 @@ router = APIRouter(prefix="/api/projects", tags=["B04 Surface Inflow"])
|
||||
|
||||
# 읽어 둔 배열을 프로세스 메모리에 들고 있는다. 마커를 누를 때마다 수십 MB npz를 다시 푸는
|
||||
# 것을 막는다. 파일이 새로 쓰이면(재분석) mtime이 달라져 자동으로 버려진다.
|
||||
# 프로젝트마다 한 벌(≈ 11 MB) — 최근 쓴 것 넷만 남김.
|
||||
_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
_CACHE_SIZE = 4
|
||||
# 유입 셀 외곽선은 조각이 많을 수 있다. 화면에서 읽을 수 있는 수준까지만 보낸다.
|
||||
_MAX_RINGS = 40
|
||||
|
||||
@@ -43,6 +45,13 @@ def _routing_path(stored_path: str) -> Path:
|
||||
return drainage_dir(stored_path) / f"{STAGES['road_routing']}_road_routing.npz"
|
||||
|
||||
|
||||
def _remember(key: str, value: tuple[float, dict[str, Any]]) -> None:
|
||||
_CACHE.pop(key, None)
|
||||
_CACHE[key] = value
|
||||
while len(_CACHE) > _CACHE_SIZE:
|
||||
_CACHE.pop(next(iter(_CACHE)))
|
||||
|
||||
|
||||
def _load_routing(stored_path: str) -> dict[str, Any] | None:
|
||||
"""`03_road_routing.npz`를 읽어 필요한 배열만 남긴다(mtime 기준 캐시)."""
|
||||
path = _routing_path(stored_path)
|
||||
@@ -51,6 +60,7 @@ def _load_routing(stored_path: str) -> dict[str, Any] | None:
|
||||
stamp = path.stat().st_mtime
|
||||
cached = _CACHE.get(stored_path)
|
||||
if cached is not None and cached[0] == stamp:
|
||||
_remember(stored_path, cached)
|
||||
return cached[1]
|
||||
with np.load(path, allow_pickle=False) as data:
|
||||
loaded = {
|
||||
@@ -65,7 +75,7 @@ def _load_routing(stored_path: str) -> dict[str, Any] | None:
|
||||
"road_chainage": data["road_chainage"],
|
||||
"path_length": data["path_length"],
|
||||
}
|
||||
_CACHE[stored_path] = (stamp, loaded)
|
||||
_remember(stored_path, (stamp, loaded))
|
||||
logger.info("배수유역: 도로 귀속 배열을 읽었습니다 (%s).", path.name)
|
||||
return loaded
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ type StationControls = ReturnType<typeof createStationControls>;
|
||||
/** 폼 → 캐시 반영에 필요한 페이지 창구만 모은 것. */
|
||||
export interface PipeOptionsContext {
|
||||
detail: () => SectionDetailResponse | null;
|
||||
/** 보낸 값 기억의 앞머리 = `{프로젝트}:{노선}` — 다른 프로젝트 · 노선의 같은 측점 값과 섞이지 않게(572-2). */
|
||||
scope: () => string;
|
||||
ford: StationControls["ford"];
|
||||
box: StationControls["box"];
|
||||
revetOffset: StationControls["revetOffset"];
|
||||
@@ -213,7 +215,7 @@ function syncBodyGuard(
|
||||
return !!guard;
|
||||
}
|
||||
|
||||
/** 흙막이 컨테이너가 마지막으로 보낸 높이 · 형태(`측점:extra{n}`) — 기하가 다시 그릴 때마다 키를 지형에
|
||||
/** 흙막이 컨테이너가 마지막으로 보낸 높이 · 형태(`{프로젝트}:{노선}:측점:extra{n}`) — 기하가 다시 그릴 때마다 키를 지형에
|
||||
* 맞춰 고쳐 되받으므로(syncApplied) 되받은 값과 견주면 안 바꾼 단도 다시 보냄(54-10). */
|
||||
const soilSent = new Map<string, { h: number | null; m: string | null }>();
|
||||
|
||||
@@ -239,7 +241,7 @@ function applySoilTiers(
|
||||
const clipped: Record<string, number> = {};
|
||||
for (let n = 1; n <= (ctx.soilTiers?.(owner.chainage_m) ?? 0); n++) {
|
||||
const role = `extra${n - 1}` as const;
|
||||
const key = `${owner.chainage_m.toFixed(2)}:${role}`;
|
||||
const key = `${ctx.scope()}:${owner.chainage_m.toFixed(2)}:${role}`;
|
||||
const form = patch[`soil_add_${n}_form`];
|
||||
const want = {
|
||||
h: value(n, "height") ?? blank,
|
||||
|
||||
@@ -43,7 +43,9 @@ function memoryStorage() {
|
||||
globalThis.window = { sessionStorage: memoryStorage(), localStorage: memoryStorage() };
|
||||
|
||||
const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`);
|
||||
const { structureAreaRows, computeStoredLayouts } = loadTs(B06("logic/B06_Section_Structure_Layouts"));
|
||||
const { structureAreaRows, computeStoredLayouts } = loadTs(
|
||||
B06("logic/B06_Section_Structure_Layouts"),
|
||||
);
|
||||
const { layPipeOptions } = loadTs(B06("B06_Section_Section_Adjusts"));
|
||||
const { applyPipeOptionsToCache } = loadTs(B06("B06_Section_UI_Page_Pipe_Options"));
|
||||
const { ZERO_ADJUST } = loadTs(B06("logic/B06_Section_Cross_Culvert_Types"));
|
||||
@@ -68,6 +70,7 @@ function b06(sections, options, extra = 0) {
|
||||
const round1 = (v) => Math.round(v * 10) / 10;
|
||||
const ctx = {
|
||||
detail: () => ({ cross_sections: sections }),
|
||||
scope: () => "p:1",
|
||||
ford: {},
|
||||
box: {},
|
||||
revetOffset: {
|
||||
|
||||
@@ -121,8 +121,10 @@ const section = {
|
||||
chainage_m: 100,
|
||||
culvert: { diameter_m: 1, hidden_pipe: false, inlet: {}, outlet: { revet_form: "돌쌓기(찰)" } },
|
||||
};
|
||||
let scope = "X:1";
|
||||
const ctx = {
|
||||
detail: () => ({ cross_sections: [section] }),
|
||||
scope: () => scope,
|
||||
ford: {},
|
||||
box: {},
|
||||
revetOffset: {
|
||||
@@ -164,6 +166,17 @@ applyPipeOptionsToCache(ctx, 100, {
|
||||
soil_add_2_height: 2.5,
|
||||
});
|
||||
out.same = { updates: updates.splice(0), tiers: tiers.splice(0) };
|
||||
// 다른 프로젝트 · 노선의 같은 측점에 같은 값 — 보낸 값 기억이 섞이지 않아 보냄(572-2)
|
||||
scope = "Y:1";
|
||||
for (const role of Object.keys(adjusts)) delete adjusts[role];
|
||||
applyPipeOptionsToCache(ctx, 100, {
|
||||
soil_add_1_form: "콘크리트",
|
||||
soil_add_1_height: 1.8,
|
||||
soil_add_2_form: "돌쌓기(메)",
|
||||
soil_add_2_height: 2.5,
|
||||
});
|
||||
out.otherScope = updates.splice(0);
|
||||
scope = "X:1";
|
||||
// 그림이 안 서는 높이(0 이하)만 하한 0.1 에서 멈춰 폼에 되돌림 · 토스트 없음(54-51)
|
||||
applyPipeOptionsToCache(ctx, 100, {
|
||||
soil_add_1_form: "콘크리트",
|
||||
|
||||
@@ -65,6 +65,14 @@ def test_같은_값은_다시_그리지_않음(out) -> None:
|
||||
assert out["same"]["tiers"] == []
|
||||
|
||||
|
||||
def test_다른_프로젝트_노선_같은_값은_보냄(out) -> None:
|
||||
"""572-2 — 보낸 값 기억 열쇠에 프로젝트 · 노선 · 옛 열쇠(측점만)면 Y 에서 건너뜀."""
|
||||
assert extras(out["otherScope"]) == [
|
||||
{"role": "extra0", "h": 1.8, "m": "콘크리트"},
|
||||
{"role": "extra1", "h": 2.5, "m": "돌쌓기(메)"},
|
||||
]
|
||||
|
||||
|
||||
def test_비운_칸은_자동_구간값은_그대로(out) -> None:
|
||||
assert extras(out["cleared"]["updates"]) == [
|
||||
{"role": "extra0", "h": None, "m": None},
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""572-2 B04 유입 캐시 상한 — 프로젝트 넷만 남김 · 다시 쓴 것은 뒤로."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_PreProcess import B04_PreProcess_Router_Inflow as inflow
|
||||
|
||||
|
||||
def _write(path):
|
||||
np.savez(
|
||||
path,
|
||||
x_min=0.0,
|
||||
y_max=0.0,
|
||||
cell_m=1.0,
|
||||
n_rows=1,
|
||||
n_cols=1,
|
||||
road_slot=np.zeros(1),
|
||||
road_chainage=np.zeros(1),
|
||||
path_length=np.zeros(1),
|
||||
)
|
||||
|
||||
|
||||
def test_cache_keeps_four_recent(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(inflow, "_routing_path", lambda stored: tmp_path / f"{stored}.npz")
|
||||
inflow._CACHE.clear()
|
||||
for name in "abcde":
|
||||
_write(tmp_path / f"{name}.npz")
|
||||
for name in "abcd":
|
||||
inflow._load_routing(name)
|
||||
inflow._load_routing("a") # 다시 씀 → 맨 뒤
|
||||
inflow._load_routing("e")
|
||||
assert list(inflow._CACHE) == ["c", "d", "a", "e"]
|
||||
Reference in New Issue
Block a user