From 4764977f9b51055388981b15ceb2e4e6c57f7d6b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 23:11:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(B04):=2065-4=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=20=EC=83=88=20=EC=84=B8=EC=9B=94=EA=B5=90=20?= =?UTF-8?q?=EB=B9=88=20=EC=9B=94=EB=A5=98=20=EB=86=92=EC=9D=B4=20=3D=20?= =?UTF-8?q?=EB=8B=B4=EB=8B=B9=20=EC=9C=A0=EC=97=AD=20=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=88=98=EC=8B=AC=20=C2=B7=20=EC=8B=9C=ED=97=98=2065?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _apply_new_defaults 가 유역 설계유량을 받아 새 세월교 빈 월류 높이를 채움(있는 값 · 유량 없음은 그대로) - ford_height_m(서버) ↔ fordHeightM(화면) 같은 풀이 · 거울 시험 - 시험 test_65_ford_defaults: 유입 · 유출 칸 목록 · 없음 · 저장 키 · 칸 차례 · 정의 기본값 · 물넘이 키 · 집계표 · B06 읽기 줄 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../design/design_structure_ford_calc.ts | 2 + .../B04_PreProcess_Router_Basins.py | 45 +++++- resources/tester/helper_65_ford_box.cjs | 94 +++++++++++ resources/tester/test_65_ford_defaults.py | 153 ++++++++++++++++++ 4 files changed, 291 insertions(+), 3 deletions(-) create mode 100644 resources/tester/helper_65_ford_box.cjs create mode 100644 resources/tester/test_65_ford_defaults.py diff --git a/A00_Common/design/design_structure_ford_calc.ts b/A00_Common/design/design_structure_ford_calc.ts index a9ec865a0..d7d963874 100644 --- a/A00_Common/design/design_structure_ford_calc.ts +++ b/A00_Common/design/design_structure_ford_calc.ts @@ -9,6 +9,8 @@ * 폭을 바꾸면 높이 칸을 이 값으로 채움 · 사용자는 올려 적을 수 있고 이 값 아래로는 안 받음. * 높이는 저장되는 입력값 — 서버는 받은 높이로 노면을 내림(`B06_Section_Engine_Culvert._ford_set`) · 재계산 안 함. * 설계유량이 없으면 null = 손 입력. + * 짝: 자동 배치 새 세월교 빈 높이 = 서버 `B04_PreProcess_Router_Basins.ford_height_m`(같은 풀이 · 65-4) · + * 거울 시험 test_65_ford_defaults. * ========================================================================== */ import { fordSection } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields"; diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index 7b42a9e0d..e905d5e03 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -16,6 +16,7 @@ from __future__ import annotations import asyncio import logging +import math from typing import Any from uuid import UUID @@ -41,7 +42,12 @@ from common_util.common_util_drainage_pipes import ( ) from common_util.common_util_json import LONLAT_DIGITS, round_floats from common_util.common_util_route_geometry import StructureCandidate -from config.config_system import DRAINAGE_PIPE_MAX_SPACING_M, DRAINAGE_PIPE_MIN_SPACING_M +from config.config_system import ( + DRAINAGE_PIPE_MAX_SPACING_M, + DRAINAGE_PIPE_MIN_SPACING_M, + FORD_MANNING_N, + FORD_SLOPE, +) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface Drainage Basins"]) @@ -78,15 +84,20 @@ def _build( carried = carry_facility_attributes(rebuilt, points or []) # 되붙인 뒤에 추천을 얹는다 — 순서가 뒤바뀌면 승계된 사용자 값이 추천에 덮인다. _apply_recommendations(detail, carried) - _apply_new_defaults([point for point, new in zip(carried, fresh) if new]) + flows = {round(basin.chainage_m, 2): basin.design_flow_m3s for basin in detail.basins} + _apply_new_defaults([point for point, new in zip(carried, fresh) if new], flows) return detail, carried -def _apply_new_defaults(points: list[PipePoint]) -> None: +def _apply_new_defaults( + points: list[PipePoint], flows: dict[float, float | None] | None = None +) -> None: """새로 놓인 관에만 등록부 기본값을 **빈 키에** 채움(PLAN 46-7 · 추천 뒤 — 추천 관경이 이김). 시설이 생길 때 기본값을 칸에 실제 값으로 넣어 두고 사용자가 고침 — 이미 있던 관의 빈 칸은 그대로 (옛 프로젝트 빈칸 = 미확정 유지 · 사용자 결정 2026-10-05). + 세월교 빈 월류 높이 = 담당 유역 설계유량(`flows` · 측점 → ㎥/s)을 월류 폭으로 넘기는 + 필요 수심(65-4). """ types = structure_type_map() for point in points: @@ -100,9 +111,37 @@ def _apply_new_defaults(points: list[PipePoint]) -> None: # 집수정 모양도 실제 값으로(54-39 · 54-36 기본 L형 — 화면 짝 `DEFAULT_BASIN_SHAPE`) if options.get("inlet_type") == "집수정": options.setdefault("inlet_structure", "L") + if point.facility == "ford_bridge" and "ford_height_m" not in options: + flow = (flows or {}).get(round(point.chainage_m, 2)) + height = ford_height_m(flow, float(options.get("ford_width_m") or 0)) + if height is not None: + options["ford_height_m"] = height point.options = options or None +def ford_height_m(flow_m3s: float | None, width_m: float) -> float | None: + """월류 폭(m) → 필요 월류 높이(m) · 0.01 m 올림 — 유량 · 폭이 없으면 None. + + 짝: `A00_Common/design/design_structure_ford_calc.fordHeightM`(+ `fordSection` 같은 풀이 · + Manning n · S = `FORD_MANNING_N` · `FORD_SLOPE`) · 거울 시험 test_65_ford_defaults. + """ + if not flow_m3s or flow_m3s <= 0 or not math.isfinite(width_m) or width_m <= 0: + return None + root = math.sqrt(FORD_SLOPE) + + def velocity(depth: float) -> float: + return (1 / FORD_MANNING_N) * ((width_m * depth) / (width_m + 2 * depth)) ** (2 / 3) * root + + depth = ((flow_m3s * FORD_MANNING_N) / (width_m * root)) ** (3 / 5) + for _ in range(20): + nxt = flow_m3s / (width_m * velocity(depth)) + if abs(nxt - depth) < 1e-6: + depth = nxt + break + depth = nxt + return math.ceil(depth * 100 - 1e-9) / 100 + + def _retag(pipes: list[StructureCandidate], points: list[PipePoint]) -> None: """확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다.""" by_chainage = {round(point.chainage_m, 2): point.source for point in points} diff --git a/resources/tester/helper_65_ford_box.cjs b/resources/tester/helper_65_ford_box.cjs new file mode 100644 index 000000000..4f80f6494 --- /dev/null +++ b/resources/tester/helper_65_ford_box.cjs @@ -0,0 +1,94 @@ +/* 65장 세월교 · 박스암거 · 물넘이 포장 명세 단위검증 헬퍼 — `design_structure_*.ts` 를 그 자리에서 트랜스파일해 + * Node 로 돌림(같은 폴더 명세 import 는 따라 옮김). 결과 = JSON 한 벌. 짝 = `test_65_ford_defaults.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const load = (file) => { + if (cache.has(file)) return cache.get(file); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(file, box.exports); + const near = (m) => { + const base = m.startsWith("@config/") + ? path.join(ROOT, "config", m.slice(8)) + : path.join(path.dirname(file), m); + return load(`${base}.ts`); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +}; +const D = (f) => path.join(ROOT, "A00_Common", "design", f); +const s = load(D("design_structure_spec.ts")); +const { FORD_SPEC, withLegacyGuards } = load(D("design_structure_ford.ts")); +const { BOX_SPEC } = load(D("design_structure_box.ts")); +const { FORD_PAVEMENT_SPEC } = load(D("design_structure_ford_pavement.ts")); +const calc = load(D("design_structure_ford_calc.ts")); + +/** 상자마다 칸 id · 갈래(구조 값 → 저장 키) — 칸 목록 대조용. */ +const items = (spec) => + Object.fromEntries( + spec.containers.map((c) => [ + c.id, + c.items.map((i) => [i.id, i.by ? Object.keys(i.by.map) : null, i.default ?? null]), + ]), + ); +const none = (spec) => + Object.fromEntries( + spec.containers.map((c) => [ + c.id, + [s.isNone(c, { structure: "없음" }), s.isNone(c, { structure: "흙막이" })], + ]), + ); + +// 세월교 유입 흙막이 · 유출 없음 · 추가 흙막이 1 · 도수로 1 → 저장 → 다시 읽기 +const fordState = s.readState(FORD_SPEC, withLegacyGuards({ pipe_diameter_mm: 1000 })); +fordState.inlet[0] = { + structure: "흙막이", + form: "돌망태", + length: "8", + height: "1.2", + before: "3", + after: "5", +}; +fordState.outlet[0] = { structure: "없음" }; +fordState.soil_add.push({ form: "바자", length: "6", height: "1", before: "3", after: "3" }); +fordState.chute_add.push({ structure: "돌붙임" }); +const fordOut = s.writeOptions(FORD_SPEC, fordState); +const fordBack = s.readState(FORD_SPEC, withLegacyGuards(fordOut)); + +// 박스 유출 흙막이 → 저장 키 +const boxState = s.readState(BOX_SPEC, {}); +boxState.outlet[0] = { structure: "흙막이", form: "콘크리트", length: "4", height: "2" }; +const boxOut = s.writeOptions(BOX_SPEC, boxState); + +// 물넘이 포장 경사각 · 월류 폭 · 높이 +const paveState = s.readState(FORD_PAVEMENT_SPEC, { ford_slope_deg: 3, ford_width_m: 5 }); +paveState.slope[0].height = String(calc.fordHeightM(2.54, 5)); +const paveOut = s.writeOptions(FORD_PAVEMENT_SPEC, paveState); + +process.stdout.write( + JSON.stringify({ + ford: items(FORD_SPEC), + box: items(BOX_SPEC), + pave: items(FORD_PAVEMENT_SPEC), + fordNone: none(FORD_SPEC), + boxNone: none(BOX_SPEC), + fordOut, + fordBack, + boxOut, + paveOut, + paveRetired: FORD_PAVEMENT_SPEC.retired, + fordKeys: s.specKeys(FORD_SPEC), + boxKeys: s.specKeys(BOX_SPEC), + paveKeys: s.specKeys(FORD_PAVEMENT_SPEC), + table: [0.5, 2.54, 8, 30].flatMap((q) => + [3, 5, 10, 20].map((b) => [q, b, calc.fordHeightM(q, b)]), + ), + }), +); diff --git a/resources/tester/test_65_ford_defaults.py b/resources/tester/test_65_ford_defaults.py new file mode 100644 index 000000000..ca0801634 --- /dev/null +++ b/resources/tester/test_65_ford_defaults.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +"""65장 세월교 · 박스암거 · 물넘이 포장 — 명세 · 기본값 · 자동 배치 월류 높이. + +못박는 것 + - 세월교 · 박스 유입 · 유출 = 구조 + 흙막이 칸(형태 · 길이 · 높이 · 전 · 후)만 · 높이 · 측벽 · 바닥판 칸 없음 + - 흙막이 칸 기본값(찰 · 10 · 1.5 · 5 · 5) · 추가 흙막이 · 도수로/돌붙임 상자 · 키 `{side}_revet_*` + - 「없음」 = 없는 상자(목록 램프 · 잇기 줄 뺌) + - 세월교 칸 차례 관경 → 수량(련) → 월류 폭 → 높이 · 정의 기본값 관경 1000 · 폭 10 · 련 2 + - 물넘이 포장 경사각 기본 3° · 월류 폭 · 높이 = 세월교와 같은 키 + - 자동 배치 새 세월교 빈 월류 높이 = 필요 수심(서버 `ford_height_m` ↔ TS `fordHeightM` 거울) + - B06 측벽 읽기 줄은 세월교 · 박스 유입 · 유출 칸에 안 옮김 +화면(ORCA)은 따로 봄. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +from B04_PreProcess import B04_PreProcess_Router_Basins as basins +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map +from common_util.common_util_drainage_pipes import PipePoint +from M02_MasterTemplete.M02_Table_Fill_Spec import container_rows + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = Path(HERE).resolve().parents[1] +GUARD = [ + ["structure", None, None], + ["form", ["흙막이"], "돌쌓기(찰)"], + ["length", ["흙막이"], "10"], + ["height", ["흙막이"], "1.5"], + ["before", ["흙막이"], "5"], + ["after", ["흙막이"], "5"], +] + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_65_ford_box.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_유입_유출은_구조와_흙막이_칸만(out): + for spec in ("ford", "box"): + for side in ("inlet", "outlet"): + assert out[spec][side] == GUARD, (spec, side) + assert [i[0] for i in out[spec]["soil_add"]] == [ + "form", + "length", + "height", + "before", + "after", + ] + assert out[spec]["chute_add"] == [["structure", None, None]] + keys = out[f"{spec}Keys"] + assert keys["prefixes"] == ["soil_add_", "chute_add_"] + # 측벽 높이 · 좌우 · 상하 · 바닥판 · 날개벽 치수 칸 없음 + assert not [ + k for k in keys["keys"] if "wall" in k or "slab" in k or k.startswith("wing_in_") + ] + + +def test_없음이면_없는_상자(out): + for spec in ("fordNone", "boxNone"): + assert out[spec]["inlet"] == [True, False] and out[spec]["outlet"] == [True, False] + assert out[spec]["soil_add"] == [False, False] + + +def test_흙막이_저장_키와_다시_열기(out): + o = out["fordOut"] + assert o["inlet_type"] == "흙막이" and o["wing_in"] == "없음" + assert (o["inlet_revet_form"], o["inlet_revet_length_m"], o["inlet_revet_height_m"]) == ( + "돌망태", + 8, + 1.2, + ) + assert o["outlet_type"] == "없음" and not any(k.startswith("outlet_revet_") for k in o) + assert o["soil_add_1_form"] == "바자" and o["chute_add_1_structure"] == "돌붙임" + back = out["fordBack"] + assert back["inlet"][0]["length"] == "8" and back["soil_add"][0]["form"] == "바자" + assert out["boxOut"]["wing_out"] == "흙막이" and out["boxOut"]["outlet_revet_length_m"] == 4 + + +def test_세월교_칸_차례와_정의_기본값(out): + assert [i[0] for i in out["ford"]["ford"]] == ["diameter", "count", "width", "height"] + ford = {o.key: o.default for o in structure_type_map()["ford_bridge"].options} + assert (str(ford["pipe_diameter_mm"]), ford["ford_width_m"], ford["pipe_count"]) == ( + "1000", + 10, + 2, + ) + + +def test_물넘이_경사각_기본_3_월류_폭_높이_같은_키(out): + assert [i[0] for i in out["pave"]["slope"]] == ["angle", "width", "height"] + assert out["paveOut"] == {"ford_slope_deg": 3, "ford_width_m": 5, "ford_height_m": 0.12} + assert "ford_width_m" not in out["paveRetired"] and "ford_height_m" not in out["paveRetired"] + pave = {o.key: o.default for o in structure_type_map()["ford_pavement"].options} + assert pave["ford_slope_deg"] == 3 + + +def test_서버_월류_높이_거울(out): + for q, b, h in out["table"]: + assert basins.ford_height_m(q, b) == h, (q, b) + assert basins.ford_height_m(None, 10) is None and basins.ford_height_m(2.54, 0) is None + + +def test_자동_배치_새_세월교는_기본값과_월류_높이(): + fresh = PipePoint(chainage_m=120.0, source="stream", facility="ford_bridge") + basins._apply_new_defaults([fresh], {120.0: 8.0}) + o = fresh.options + assert (str(o["pipe_diameter_mm"]), o["ford_width_m"], o["pipe_count"]) == ("1000", 10, 2) + assert o["ford_height_m"] == basins.ford_height_m(8.0, 10) == 0.16 + kept = PipePoint( + chainage_m=120.0, source="stream", facility="ford_bridge", options={"ford_height_m": 0.5} + ) + basins._apply_new_defaults([kept], {120.0: 8.0}) + assert kept.options["ford_height_m"] == 0.5 # 있는 값은 그대로 + blind = PipePoint(chainage_m=140.0, source="stream", facility="ford_bridge") + basins._apply_new_defaults([blind], {}) + assert "ford_height_m" not in blind.options # 유량 없으면 빈칸 + + +def test_집계표_세월교_흙막이_상자_건(): + item = { + "type_id": "ford_bridge", + "options": {"soil_add_1_form": "바자", "soil_add_2_form": "돌망태"}, + "복사본": "pipe@50.0", + "흙막이단": 0, # 배관 단 수는 세월교에 안 걸림 + } + rows = container_rows(item, {"gsoiladd2": "SS000001"}) + assert [(r["그룹"], r["summary_item"]) for r in rows] == [ + ("흙막이 1", None), + ("흙막이 2", "SS000001"), + ] + + +def test_B06_측벽_읽기_줄은_세월교_박스_칸에_안_옮김(): + dock = (ROOT / "B06_Section/B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8") + assert "slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return null" in dock + lists = (ROOT / "B05_Profile/B05_Profile_UI_Structures_List.ts").read_text(encoding="utf-8") + assert "return isNone(c, state[c.id][0])" in lists