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:
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)]),
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user