feat(B04): 65-4 자동 배치 새 세월교 빈 월류 높이 = 담당 유역 필요 수심 · 시험 65

- _apply_new_defaults 가 유역 설계유량을 받아 새 세월교 빈 월류 높이를 채움(있는 값 · 유량 없음은 그대로)
- ford_height_m(서버) ↔ fordHeightM(화면) 같은 풀이 · 거울 시험
- 시험 test_65_ford_defaults: 유입 · 유출 칸 목록 · 없음 · 저장 키 · 칸 차례 · 정의 기본값 · 물넘이 키 · 집계표 · B06 읽기 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
This commit is contained in:
2026-10-06 23:11:52 +09:00
co-authored by Claude Opus 5.5
parent d7faee1cd2
commit 4764977f9b
4 changed files with 291 additions and 3 deletions
@@ -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";
+42 -3
View File
@@ -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}
+94
View File
@@ -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)]),
),
}),
);
+153
View File
@@ -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