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:
2026-10-28 17:24:07 +09:00
co-authored by Claude Opus 5.5
parent 45b8188121
commit 92d952d8ac
7 changed files with 72 additions and 4 deletions
+1
View File
@@ -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,
+11 -1
View File
@@ -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"]