diff --git a/A00_Common/design/design_structure_ford_calc.ts b/A00_Common/design/design_structure_ford_calc.ts index d7d963874..83ed21f4f 100644 --- a/A00_Common/design/design_structure_ford_calc.ts +++ b/A00_Common/design/design_structure_ford_calc.ts @@ -11,6 +11,8 @@ * 설계유량이 없으면 null = 손 입력. * 짝: 자동 배치 새 세월교 빈 높이 = 서버 `B04_PreProcess_Router_Basins.ford_height_m`(같은 풀이 · 65-4) · * 거울 시험 test_65_ford_defaults. + * 짝: B08 수리집수면적표 세월교 · 물넘이 월류 높이 = 서버 `B08_Quantity_Engine_Hydro.ford_depth` + * (같은 풀이 · n · S 를 받음) · 거울 시험 test_73_11_b08_hydro_box_ford. * ========================================================================== */ import { fordSection } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields"; diff --git a/B08_Quantity/B08_Quantity_Engine_Hydro.py b/B08_Quantity/B08_Quantity_Engine_Hydro.py index 3bd039bec..fd70ed906 100644 --- a/B08_Quantity/B08_Quantity_Engine_Hydro.py +++ b/B08_Quantity/B08_Quantity_Engine_Hydro.py @@ -6,6 +6,12 @@ 1-2 도달시간 Kirpich T = 0.0663·L[㎞]^0.77·s^-0.385 [hr] · s = H/L · 최소 도달시간 적용 1-3 강우강도 rt = (R24/24)·(24/T)^0.557 (Mononobe형) 2 단면검토 V = (1/n)·R^(2/3)·S^(1/2) · Q = A·V · 판정 Q ≥ Qd · 유출율 = Q/Qd + 장(sheet) pipe = 관 단면 · box = 사각 h = 수심비 × H · A = B·h · P = B + 2h (73-11) + ford = 세월교 · 물넘이 월류 높이 = 폭 · S · n 으로 Qd 를 흘리는 수심(73-12) + +짝: `ford_depth` ↔ `A00_Common/design/design_structure_ford_calc.fordHeightM` + (`fordSection` 같은 풀이) + · 거울 시험 test_73_11_b08_hydro_box_ford · 관 몫은 빼지 않음 · 여유(0.9H) 안 넣음. """ from __future__ import annotations @@ -13,7 +19,7 @@ from __future__ import annotations import math from typing import Any -from B08_Quantity.B08_Quantity_Hydro_Defaults import PIPE_KINDS +from B08_Quantity.B08_Quantity_Hydro_Defaults import PIPE_KINDS, SHEET_DEFAULTS MONONOBE_EXPONENT = 0.557 EFFECTIVE_RATIO = 0.7 @@ -36,6 +42,29 @@ def section_props(section: str, diameter_m: float) -> dict[str, float]: return {"area": full_area, "perimeter": math.pi * d, "radius": d / 4.0} +def rect_props(width_m: float, depth_m: float) -> dict[str, float]: + """사각 단면(천장 빼고) — A = B·h · P = B + 2h · R = A/P.""" + area, perimeter = width_m * depth_m, width_m + 2.0 * depth_m + return {"area": area, "perimeter": perimeter, "radius": area / perimeter} + + +def ford_depth(flow_m3s: float, width_m: float, n: float, slope: float) -> float: + """월류 폭(m)으로 유량을 흘리는 수심(m) · 0.01 m 올림 — Manning 개수로 반복 풀이.""" + root = math.sqrt(slope) + + def velocity(depth: float) -> float: + return (1 / n) * ((width_m * depth) / (width_m + 2 * depth)) ** (2 / 3) * root + + depth = ((flow_m3s * 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 _num(inputs: dict[str, Any], key: str) -> float | None: value = inputs.get(key) return None if value is None or value == "" else float(value) @@ -59,10 +88,29 @@ def compute(inputs: dict[str, Any]) -> dict[str, Any]: flow = float(inputs["runoff"]) * intensity * area_km2 / 3.6 design_flow = flow * float(inputs["factor"]) - n = _num(inputs, "n") or PIPE_KINDS[inputs["pipe_kind"]] - diameter_mm, slope_den = _num(inputs, "diameter_mm"), _num(inputs, "slope_denominator") - props = section_props(inputs["section"], diameter_mm / 1000.0) if diameter_mm else {} + sheet = inputs.get("sheet") or "pipe" + if sheet == "pipe": + n = _num(inputs, "n") or PIPE_KINDS[inputs["pipe_kind"]] + else: + n = _num(inputs, "n") or SHEET_DEFAULTS[sheet]["n"] + slope_den = _num(inputs, "slope_denominator") slope_bed = 1.0 / slope_den if slope_den else None + props: dict[str, float] = {} + depth = None + if sheet == "pipe": + diameter_mm = _num(inputs, "diameter_mm") + props = section_props(inputs["section"], diameter_mm / 1000.0) if diameter_mm else {} + elif sheet == "box": + width, height = _num(inputs, "box_width_m"), _num(inputs, "box_height_m") + ratio = _num(inputs, "depth_ratio") or SHEET_DEFAULTS["box"]["depth_ratio"] + if width and height: + depth = ratio * height + props = rect_props(width, depth) + else: + width = _num(inputs, "overflow_width_m") + if width and slope_bed and design_flow: + depth = ford_depth(design_flow, width, n, slope_bed) + props = rect_props(width, depth) velocity = capacity = None if props and slope_bed: velocity = (1.0 / n) * props["radius"] ** (2.0 / 3.0) * math.sqrt(slope_bed) @@ -79,6 +127,7 @@ def compute(inputs: dict[str, Any]) -> dict[str, Any]: "flow": flow, "design_flow": design_flow, "n": n, + "depth": depth, "section_area": props.get("area"), "perimeter": props.get("perimeter"), "radius": props.get("radius"), diff --git a/B08_Quantity/B08_Quantity_Hydro_Defaults.py b/B08_Quantity/B08_Quantity_Hydro_Defaults.py index ea30b98e8..d226def3f 100644 --- a/B08_Quantity/B08_Quantity_Hydro_Defaults.py +++ b/B08_Quantity/B08_Quantity_Hydro_Defaults.py @@ -1,4 +1,4 @@ -"""B08 4탭 홍수량 계산서 — 기본값 · 선택지 한 곳 (PLAN 62-3). +"""B08 4탭 홍수량 계산서 — 기본값 · 선택지 한 곳 (PLAN 62-3 · 73-11 · 73-12). 임의로 정한 값 — 사용자가 나중에 고침. B05/B06 · 배수유역 값과 잇지 않음(자기 기본값만). 근거 후보: docs/raw/조사/2026-10-06_62-1_홍수량_방법.md (별표2 2.0배 · KDS 표 2.7-1 · 실무 견본). @@ -8,6 +8,8 @@ from __future__ import annotations from typing import Any +from config.config_system import FORD_MANNING_N, FORD_SLOPE + #: 할증(Qd = Q × 할증) — 2.0 별표2 법정 · 1.5 견본(울진 2공구) · 1.2 구기준 FACTORS = (2.0, 1.5, 1.2) @@ -37,21 +39,33 @@ DEFAULTS: dict[str, Any] = { } -#: 계산 방법 정하는 중인 시설(73-1) — 관 계산 대신 빈 장 · 값은 관 지점 시설 속성(pipe_points) -PENDING_FACILITIES = {"box_culvert": "박스암거", "ford_bridge": "세월교"} +#: 시설 → 계산서 장(73-11 · 73-12) — 박스 = 사각 단면 · 세월교 · 물넘이 = 월류 높이 · 그 밖 = 관 +SHEETS = { + "box_culvert": ("box", "박스암거"), + "ford_bridge": ("ford", "세월교"), + "ford_pavement": ("ford", "물넘이포장"), +} + +#: 장별 처음 값 — 박스 수심비 0.8H · n 0.013(울진 산출서 · 조사 69-7) · 내공 · 경사는 빈칸 +#: 세월교 · 물넘이 월류 폭 10 m(사용자 10-7) · n · 경사 = B05 세월교 높이 계산과 같은 값 +SHEET_DEFAULTS: dict[str, dict[str, Any]] = { + "box": {"n": 0.013, "depth_ratio": 0.8, "box_width_m": None, "box_height_m": None}, + "ford": { + "n": FORD_MANNING_N, + "overflow_width_m": 10.0, + "slope_denominator": round(1 / FORD_SLOPE, 6), + }, +} def station_rows(points: list[Any]) -> list[dict[str, Any]]: - """관 지점 → 보조 탭 줄(측점 차례) · 박스암거 · 세월교는 pending(빈 장).""" - rows = [ - { - "chainage_m": p.chainage_m, - "facility": p.facility, - "kind": PENDING_FACILITIES.get(p.facility, ""), - "pending": p.facility in PENDING_FACILITIES, - } - for p in points - ] + """관 지점 → 보조 탭 줄(측점 차례) · 장 종류(pipe · box · ford).""" + rows = [] + for p in points: + sheet, kind = SHEETS.get(p.facility, ("pipe", "")) + rows.append( + {"chainage_m": p.chainage_m, "facility": p.facility, "kind": kind, "sheet": sheet} + ) return sorted(rows, key=lambda s: s["chainage_m"]) @@ -62,4 +76,5 @@ def options() -> dict[str, Any]: "factors": list(FACTORS), "sections": SECTIONS, "pipe_kinds": PIPE_KINDS, + "sheet_defaults": SHEET_DEFAULTS, } diff --git a/B08_Quantity/B08_Quantity_Router_Hydro.py b/B08_Quantity/B08_Quantity_Router_Hydro.py index 1b823eaaa..1c442f995 100644 --- a/B08_Quantity/B08_Quantity_Router_Hydro.py +++ b/B08_Quantity/B08_Quantity_Router_Hydro.py @@ -1,4 +1,4 @@ -"""B08 4탭 홍수량 계산서 길 (PLAN 62-2 · 62-3 · 69-5 · 69-6). +"""B08 4탭 홍수량 계산서 길 (PLAN 62-2 · 62-3 · 69-5 · 69-6 · 73-11 · 73-12). GET /api/projects/{id}/quantity/hydro 배수지점 측점(저장된 pipe_points) + 기본값 · 선택지 + 공사지(지역 · 재현기간별 R24) + 저장한 입력 @@ -47,6 +47,12 @@ class HydroInputs(BaseModel): diameter_mm: float | None = Field(default=None, gt=0) slope_denominator: float | None = Field(default=None, gt=0) section: Literal["full", "arc240", "eff70"] + # 장 — 박스(사각 단면 · 73-11) · 세월교 · 물넘이(월류 높이 · 73-12) · 그 밖 관 + sheet: Literal["pipe", "box", "ford"] = "pipe" + box_width_m: float | None = Field(default=None, gt=0) + box_height_m: float | None = Field(default=None, gt=0) + depth_ratio: float | None = Field(default=None, gt=0, le=1) + overflow_width_m: float | None = Field(default=None, gt=0) class HydroSave(BaseModel): diff --git a/B08_Quantity/B08_Quantity_UI_Hydro.ts b/B08_Quantity/B08_Quantity_UI_Hydro.ts index ffda4cd5e..fea648f6d 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro.ts +++ b/B08_Quantity/B08_Quantity_UI_Hydro.ts @@ -3,7 +3,7 @@ * 4탭 수리집수면적표 — 배수지점 측점마다 보조 탭 · 한 장 = 홍수량 계산서(PLAN 62-2 · 69-4~6). * * 측점 목록만 저장된 관 지점(pipe_points)에서 · 입력은 이 탭 자기 값(B05/B06 값과 안 이음) - * 박스암거 · 세월교 지점 = 빈 장(계산 방법 정하는 중 · 73-1) · [저장]에서 빠짐 + * 박스암거 = 사각 단면 장(73-11) · 세월교 · 물넘이포장 = 월류 높이 장(73-12) · 그 밖 = 관 장 * 처음 값 = 표준 값 · 측점 고유 값은 빈칸 · 지역 · R24 = 공사지 자료(서버) * 조작 = 캐시(sessionStorage) · [저장] = B08 자기 파일(PUT …/quantity/hydro) · 뒤 캐시 비움 * 계산 = 서버 단독(POST …/quantity/hydro/calc · 최신 응답만 채택) @@ -13,7 +13,6 @@ import "./B08_Quantity_UI_Hydro.css"; import { API_BASE_URL } from "@config/config_frontend"; import { createButton, - createCard, hideLoadingOverlay, showLoadingOverlay, showToast, @@ -31,10 +30,10 @@ import { interface HydroStation { chainage_m: number; facility: string; - /** 박스암거 · 세월교 등 계산 방법 정하는 중인 시설 이름(관이면 "") */ + /** 박스암거 · 세월교 · 물넘이포장(관이면 "") */ kind: string; - /** true = 관 계산 대신 빈 장(73-1) */ - pending: boolean; + /** 계산서 장 — 시설이 정함(서버 station_rows) */ + sheet: HydroInputs["sheet"]; } type Drafts = Record>; @@ -63,39 +62,6 @@ function note(text: string): HTMLElement { const keyOf = (station: HydroStation): string => station.chainage_m.toFixed(3); -/** 박스암거 · 세월교 지점 빈 장(73-1) — 계산 방법이 정해지면 이 자리에 계산서가 들어감. */ -function pendingSheet(stationText: string, kind: string): HTMLElement { - const row = (label: string, value: string): HTMLElement => { - const line = document.createElement("div"); - line.className = "b08-hydro__row"; - const name = document.createElement("span"); - name.className = "b08-hydro__label"; - name.textContent = label; - const text = document.createElement("span"); - text.className = "b08-hydro__value"; - text.textContent = value; - line.append(name, text); - return line; - }; - const card = createCard({ - title: `홍수량 계산 — ${kind}`, - body: [ - row("측점", stationText), - row("종류", kind), - note("계산 방법 정하는 중 — 관과 다른 계산(사각 단면 · 월류)을 정한 뒤 이 장에 넣음."), - ], - }); - card.classList.add("b08-hydro__card"); - const sheet = document.createElement("div"); - sheet.className = "b08-hydro__sheet b08-hydro__sheet--pending"; - sheet.dataset.b08HydroPending = kind; - const body = document.createElement("div"); - body.className = "b08-hydro__body"; // 관 장과 같은 격자 — 카드 폭이 같음 - body.append(card); - sheet.append(body); - return sheet; -} - export async function renderHydro(host: HTMLElement, projectId: string): Promise { host.replaceChildren(note("홍수량 계산서를 불러오는 중…")); let sheet: HydroPayload; @@ -120,8 +86,10 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise const inputsOf = (station: HydroStation): HydroInputs => ({ ...sheet.defaults, + ...(station.sheet === "pipe" ? {} : sheet.sheet_defaults[station.sheet]), ...sheet.saved[keyOf(station)], ...drafts[keyOf(station)], + sheet: station.sheet, }) as HydroInputs; const tabs = document.createElement("div"); @@ -148,7 +116,7 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise ...sheet.stations.map((s, i) => { const label = stationLabel(s.chainage_m, interval); const button = createButton({ - label: s.pending ? `${label} ${s.kind}` : label, + label: s.kind ? `${label} ${s.kind}` : label, variant: i === index ? "filled" : "ghost", size: "sm", onClick: () => i !== index && open(i), @@ -160,11 +128,6 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise return button; }), ); - if (station.pending) { - seq++; // 앞 장의 늦은 계산 응답을 버림 - page.replaceChildren(pendingSheet(stationLabel(station.chainage_m, interval), station.kind)); - return; - } const draw = (inputs: HydroInputs): void => { const view = buildHydroSheet( stationLabel(station.chainage_m, interval), @@ -177,6 +140,7 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise if (next.section !== inputs.section || next.pipe_kind !== inputs.pipe_kind) draw(next); else void calc(next, view.show); }, + station.kind, ); page.replaceChildren(view.root); void calc(inputs, view.show); @@ -202,9 +166,7 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise /** [저장] — 측점 전부의 지금 입력을 B08 자기 파일로 · 성공하면 캐시를 비우고 저장본으로 다시 엶 */ const saveAll = async (): Promise => { - const stations = Object.fromEntries( - sheet.stations.filter((s) => !s.pending).map((s) => [keyOf(s), inputsOf(s)]), - ); + const stations = Object.fromEntries(sheet.stations.map((s) => [keyOf(s), inputsOf(s)])); showLoadingOverlay(); try { const body = await request<{ saved: Drafts }>(`/projects/${projectId}/quantity/hydro`, { diff --git a/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts b/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts index 93d3b0187..60adc4a11 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts +++ b/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts @@ -4,6 +4,7 @@ * * 왼쪽 카드 「1. 홍수량 계산」 1-1 합리식 · 1-2 도달시간(Kirpich) · 1-3 강우강도 * 오른쪽 카드 「2. 단면 검토」 관 단면 그림 · 단면적 · 윤변 · 경심 · 경사 · n · 유속 · 유출량 · 판정 + * 장(sheet) 갈래 — 박스 = 사각 내공 B · H · 수심비(73-11) · 세월교 · 물넘이 = 월류 폭 → 월류 높이(73-12) * 테두리는 입력 · 선택 칸만(공용 ui-input · ui-select) · 계산 칸은 글자만 * 굵게 + 붉은 계열은 중요 결과(Qd · 유출량 · 판정 · 유출율)만 · 빈칸에 기대는 계산 칸은 「-」. * @@ -27,6 +28,12 @@ export interface HydroInputs { diameter_mm: number | null; slope_denominator: number | null; section: "full" | "arc240" | "eff70"; + /** 장 — 측점 시설이 정함(박스 · 세월교 · 물넘이 · 관) */ + sheet: "pipe" | "box" | "ford"; + box_width_m?: number | null; + box_height_m?: number | null; + depth_ratio?: number | null; + overflow_width_m?: number | null; } export interface HydroResult { @@ -40,6 +47,8 @@ export interface HydroResult { flow: number | null; design_flow: number | null; n: number; + /** 박스 수심 h · 세월교 · 물넘이 월류 높이 */ + depth: number | null; section_area: number | null; perimeter: number | null; radius: number | null; @@ -63,6 +72,8 @@ export interface HydroOptions { factors: number[]; sections: Record; pipe_kinds: Record; + /** 장별 처음 값(박스 n · 수심비 · 세월교 · 물넘이 n · 월류 폭 · 경사) */ + sheet_defaults: Record<"box" | "ford", Partial>; site: HydroSite; } @@ -83,6 +94,9 @@ const NULLABLE = new Set([ "slope_denominator", "n", "r24_mm", + "box_width_m", + "box_height_m", + "overflow_width_m", ]); const DASH = "-"; @@ -147,6 +161,39 @@ function pipeFigure(section: HydroInputs["section"]): SVGSVGElement { return svg; } +/** 사각 단면 그림 — 박스(닫힌 내공 · 수심 h) · 월류(열린 노면 · 월류 높이 h). */ +function rectFigure(closed: boolean): SVGSVGElement { + const ns = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(ns, "svg"); + svg.setAttribute("viewBox", "0 0 200 170"); + svg.setAttribute("class", "b08-hydro__pipe"); + const add = (tag: string, attrs: Record, text = ""): void => { + const el = document.createElementNS(ns, tag); + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v)); + if (text) el.textContent = text; + svg.append(el); + }; + const [x, w, bottom] = [20, 150, 140]; + const top = closed ? 40 : 110; // 박스 = 내공 높이 H · 월류 = 얕은 물 + const water = closed ? bottom - 0.8 * (bottom - top) : top; + add("rect", { x, y: water, width: w, height: bottom - water, class: "b08-hydro__water" }); + add("path", { + d: closed + ? `M${x} ${top} L${x + w} ${top} L${x + w} ${bottom} L${x} ${bottom} Z` + : `M${x} ${top - 40} L${x} ${bottom} L${x + w} ${bottom} L${x + w} ${top - 40}`, + class: "b08-hydro__wall", + }); + add("text", { x: x + w / 2, y: bottom + 18, "text-anchor": "middle" }, "B"); + const right = x + w + 8; + add("path", { d: `M${right} ${water} L${right} ${bottom}`, class: "b08-hydro__line" }); + add("text", { x: right + 4, y: (water + bottom) / 2 + 4 }, "h"); + if (closed) { + add("path", { d: `M${x - 8} ${top} L${x - 8} ${bottom}`, class: "b08-hydro__line" }); + add("text", { x: x - 8, y: top - 6, "text-anchor": "middle" }, "H"); + } + return svg; +} + const SECTION_TEXT: Record< HydroInputs["section"], { area: string; perimeter: string; radius: string } @@ -169,6 +216,8 @@ export function buildHydroSheet( inputs: HydroInputs, options: HydroOptions, onChange: (next: HydroInputs) => void, + /** 시설 이름(박스암거 · 세월교 · 물넘이포장) — 관이면 "" */ + kind = "", ): HydroSheet { const site = options.site; const out = new Map(); @@ -195,7 +244,9 @@ export function buildHydroSheet( const valid = parsed === null ? NULLABLE.has(key) - : Number.isFinite(parsed) && (key === "tc_min_minutes" ? parsed >= 0 : parsed > 0); + : Number.isFinite(parsed) && + (key === "tc_min_minutes" ? parsed >= 0 : parsed > 0) && + (key !== "depth_ratio" || parsed <= 1); if (valid) commit(key, parsed); else input.value = current[key] == null ? "" : String(current[key]); }); @@ -302,36 +353,88 @@ export function buildHydroSheet( }); left.classList.add("b08-hydro__card"); - // ── 오른쪽 2. 단면 검토 ── - const kinds = Object.entries(options.pipe_kinds).map( - ([kind, n]) => [kind, `${kind} (n ${n})`] as [string, string], + // ── 오른쪽 2. 단면 검토 ── 장마다 단면 칸이 다름 · 유속 · 유출량 · 판정은 같음 + const sheetDefaults = inputs.sheet === "pipe" ? {} : options.sheet_defaults[inputs.sheet]; + const nDefault = inputs.sheet === "pipe" ? options.pipe_kinds[inputs.pipe_kind] : sheetDefaults.n; + const nInput = numberInput("n", String(nDefault ?? "")); + const nHint = inputs.sheet === "pipe" ? "비우면 관 종류 값" : `기본 ${nDefault}`; + const slopeRow = rowF( + "하상경사 S = 1 /", + numberInput("slope_denominator", "입력"), + outCell("slope_pct"), ); - const texts = SECTION_TEXT[inputs.section]; - const nInput = numberInput("n", String(options.pipe_kinds[inputs.pipe_kind] ?? "")); const dims = node("div", "b08-hydro__dims"); - dims.append( - rowF("단면 D (mm)", null, numberInput("diameter_mm", "입력")), - rowF("단면적", formula(texts.area), outCell("section_area")), - rowF("윤변", formula(texts.perimeter), outCell("perimeter")), - rowF("경심", formula(texts.radius), outCell("radius")), - rowF("하상경사 S = 1 /", numberInput("slope_denominator", "입력"), outCell("slope_pct")), - rowF("조도계수 n", hint("비우면 관 종류 값"), nInput), - ); const figure = node("div", "b08-hydro__figure"); - figure.append(pipeFigure(inputs.section), dims); - const verdict = node("div", "b08-hydro__verdict", DASH); - verdict.dataset.b08HydroVerdict = ""; - const right = createCard({ - title: "2. 단면 검토", - body: [ + let head: HTMLElement[] = []; + let title = "2. 단면 검토"; + if (inputs.sheet === "box") { + title = `2. 단면 검토 — ${kind}`; + dims.append( + rowF("내공 폭 B (m)", null, numberInput("box_width_m", "입력")), + rowF("내공 높이 H (m)", null, numberInput("box_height_m", "입력")), + rowF("수심비 h / H", hint(`기본 ${sheetDefaults.depth_ratio}`), numberInput("depth_ratio")), + rowF("수심 h (m)", formula("h = 수심비 × H"), outCell("depth")), + rowF("단면적", formula("A = B × h"), outCell("section_area")), + rowF("윤변", formula("P = B + 2h"), outCell("perimeter")), + rowF("경심", formula("R = A / P"), outCell("radius")), + slopeRow, + rowF("조도계수 n", hint(nHint), nInput), + ); + figure.append(rectFigure(true), dims); + } else if (inputs.sheet === "ford") { + title = `2. 월류 높이 — ${kind}`; + dims.append( + rowF( + "월류 폭 B (m)", + hint(`기본 ${sheetDefaults.overflow_width_m}`), + numberInput("overflow_width_m", "입력"), + ), + slopeRow, + rowF("조도계수 n", hint(nHint), nInput), + rowF( + "월류 높이 h (m)", + formula("Q_d_ = B·h × 1/n × R^2/3^ × S^1/2^ 인 h"), + outCell("depth", true), + ), + rowF("단면적", formula("A = B × h"), outCell("section_area")), + rowF("윤변", formula("P = B + 2h"), outCell("perimeter")), + rowF("경심", formula("R = A / P"), outCell("radius")), + ); + figure.append(rectFigure(false), dims); + } else { + const kinds = Object.entries(options.pipe_kinds).map( + ([pipeKind, n]) => [pipeKind, `${pipeKind} (n ${n})`] as [string, string], + ); + const texts = SECTION_TEXT[inputs.section]; + dims.append( + rowF("단면 D (mm)", null, numberInput("diameter_mm", "입력")), + rowF("단면적", formula(texts.area), outCell("section_area")), + rowF("윤변", formula(texts.perimeter), outCell("perimeter")), + rowF("경심", formula(texts.radius), outCell("radius")), + slopeRow, + rowF("조도계수 n", hint(nHint), nInput), + ); + figure.append(pipeFigure(inputs.section), dims); + head = [ row("관 종류", select("pipe_kind", kinds)), row( "단면 가정", select("section", Object.entries(options.sections) as Array<[string, string]>), ), + ]; + } + const verdict = node("div", "b08-hydro__verdict", DASH); + verdict.dataset.b08HydroVerdict = ""; + const right = createCard({ + title, + body: [ + ...head, row("총 홍수량 Qd (㎥/s)", outCell("design_flow2", true)), - sub("유출량"), + sub(inputs.sheet === "ford" ? "월류 높이 · 유출량" : "유출량"), figure, + ...(inputs.sheet === "ford" + ? [hint("관 몫은 빼지 않음(월류만으로 Qd) · 여유 없음 · 0.01 m 올림")] + : []), rowF("유속 (m/s)", formula("V = 1/n × R^2/3^ × S^1/2^"), outCell("velocity")), rowF("유출량 (㎥/s)", formula("Q = A × V"), outCell("capacity", true)), verdict, @@ -370,6 +473,7 @@ export function buildHydroSheet( : "", ); set("slope_ground", fmt(r?.slope_ground, 3)); + set("depth", fmt(r?.depth, 2)); set("section_area", fmt(r?.section_area, 3, " ㎡")); set("perimeter", fmt(r?.perimeter, 3, " m")); set("radius", fmt(r?.radius, 3)); diff --git a/resources/tester/test_73_11_b08_hydro_box_ford.py b/resources/tester/test_73_11_b08_hydro_box_ford.py new file mode 100644 index 000000000..1d5f6a96a --- /dev/null +++ b/resources/tester/test_73_11_b08_hydro_box_ford.py @@ -0,0 +1,125 @@ +"""B08 수리집수면적표 — 박스암거 사각 단면 장(73-11) · 세월교 · 물넘이 월류 높이 장(73-12). + +못박는 것 + - 장 = 시설이 정함(박스 · 세월교 · 물넘이포장 · 관) + - 박스 h = 수심비 × H · A = B·h · P = B + 2h · 울진 1공구 B 3.0 · H 1.5 → h 1.2 · A 3.6 · P 5.4 + - 박스 n · 수심비 · 세월교 n · 월류 폭 · 경사 기본값 · 고치면 계산이 따라감 + - 월류 높이 = TS `fordHeightM` 거울(helper_65_ford_box 표) · 폭 바꾸면 높이 바뀜 + - [저장] 모델이 새 칸을 받음 +""" + +from __future__ import annotations + +import json +import math +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Hydro import compute, ford_depth # noqa: E402 +from B08_Quantity.B08_Quantity_Hydro_Defaults import ( # noqa: E402 + DEFAULTS, + SHEET_DEFAULTS, + station_rows, +) +from B08_Quantity.B08_Quantity_Router_Hydro import HydroSave # noqa: E402 +from config.config_system import FORD_MANNING_N, FORD_SLOPE # noqa: E402 + +HERE = os.path.dirname(os.path.abspath(__file__)) +HYDRO = {"area_km2": 0.5, "length_m": 800.0, "height_m": 200.0, "r24_mm": 300.0} + + +def _inputs(sheet: str, **extra) -> dict: + return {**DEFAULTS, **SHEET_DEFAULTS[sheet], "sheet": sheet, **HYDRO, **extra} + + +def test_장은_시설이_정함() -> None: + class P: + def __init__(self, c: float, f: str) -> None: + self.chainage_m, self.facility = c, f + + rows = station_rows( + [P(3, "ford_pavement"), P(1, "pipe"), P(2, "box_culvert"), P(4, "ford_bridge")] + ) + assert [(r["sheet"], r["kind"]) for r in rows] == [ + ("pipe", ""), + ("box", "박스암거"), + ("ford", "물넘이포장"), + ("ford", "세월교"), + ] + + +def test_박스_울진_산출서_단면() -> None: + r = compute(_inputs("box", box_width_m=3.0, box_height_m=1.5, slope_denominator=50)) + assert SHEET_DEFAULTS["box"]["n"] == 0.013 and SHEET_DEFAULTS["box"]["depth_ratio"] == 0.8 + assert r["n"] == 0.013 + assert r["depth"] == pytest.approx(1.2) + assert r["section_area"] == pytest.approx(3.6) + assert r["perimeter"] == pytest.approx(5.4) + assert r["radius"] == pytest.approx(3.6 / 5.4) + # 실무 29.236 = 1/n 75 · R 0.67 반올림 — 같은 n 이면 R 반올림 차(0.3 %)만 남음 + r75 = compute(_inputs("box", box_width_m=3.0, box_height_m=1.5, slope_denominator=50, n=1 / 75)) + assert r75["capacity"] == pytest.approx(29.236, rel=0.004) + assert 75 * 0.67 ** (2 / 3) * math.sqrt(1 / 50) * 3.6 == pytest.approx(29.236, abs=0.001) + assert r["capacity"] > r75["capacity"] # n 고치면 따라감 + assert r["ok"] is (r["capacity"] >= r["design_flow"]) + deeper = compute( + _inputs("box", box_width_m=3.0, box_height_m=1.5, slope_denominator=50, depth_ratio=0.9) + ) + assert deeper["depth"] == pytest.approx(1.35) + + +def test_박스_내공_경사_빈칸이면_대시() -> None: + r = compute(_inputs("box")) + assert r["design_flow"] is not None + assert r["depth"] is None and r["capacity"] is None and r["ok"] is None + + +@pytest.fixture(scope="module") +def ts_table() -> list: + 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)["table"] + + +def test_월류_높이_TS_거울(ts_table) -> None: + assert SHEET_DEFAULTS["ford"]["n"] == FORD_MANNING_N + assert 1 / SHEET_DEFAULTS["ford"]["slope_denominator"] == pytest.approx(FORD_SLOPE) + assert SHEET_DEFAULTS["ford"]["overflow_width_m"] == 10.0 + for q, b, h in ts_table: + assert ford_depth(q, b, FORD_MANNING_N, FORD_SLOPE) == h, (q, b) + + +def test_세월교_폭_10_높이_자동_폭_바꾸면_바뀜(ts_table) -> None: + r = compute(_inputs("ford")) + qd = r["design_flow"] + assert r["depth"] == ford_depth(qd, 10.0, FORD_MANNING_N, FORD_SLOPE) + assert r["section_area"] == pytest.approx(10.0 * r["depth"]) + assert r["capacity"] >= qd and r["ok"] is True + narrow = compute(_inputs("ford", overflow_width_m=5.0)) + assert narrow["depth"] > r["depth"] + rough = compute(_inputs("ford", n=0.025)) + assert rough["depth"] > r["depth"] + assert compute(_inputs("ford", overflow_width_m=None))["depth"] is None + + +def test_저장_모델이_새_칸을_받음() -> None: + box = _inputs("box", box_width_m=3.0, box_height_m=1.5, depth_ratio=0.8) + ford = _inputs("ford", overflow_width_m=12.0) + saved = HydroSave(stations={"1.000": box, "2.000": ford}).model_dump()["stations"] + assert saved["1.000"]["sheet"] == "box" and saved["1.000"]["box_height_m"] == 1.5 + assert saved["2.000"]["sheet"] == "ford" and saved["2.000"]["overflow_width_m"] == 12.0 + with pytest.raises(ValueError): + HydroSave(stations={"1.000": {**box, "depth_ratio": 1.2}}) diff --git a/resources/tester/test_73_1_b08_hydro_pending.py b/resources/tester/test_73_1_b08_hydro_pending.py index 16f9d1978..68c6bd93a 100644 --- a/resources/tester/test_73_1_b08_hydro_pending.py +++ b/resources/tester/test_73_1_b08_hydro_pending.py @@ -1,4 +1,4 @@ -"""B08 수리집수면적표 — 박스암거 · 세월교 지점은 빈 장(계산 방법 정하는 중 · PLAN 73-1).""" +"""B08 수리집수면적표 — 박스암거 · 세월교 지점은 따로 장(73-1 빈 장 → 73-11 · 73-12 계산 장).""" from __future__ import annotations @@ -29,9 +29,9 @@ def test_박스_세월교만_빈_장_탭_수는_그_구조물_수() -> None: ] rows = station_rows(points) assert [r["chainage_m"] for r in rows] == [85.0, 173.0, 300.0, 352.0, 400.0] - pending = [r for r in rows if r["pending"]] - assert len(pending) == 3 # 세월교 2 · 박스 1 - assert {r["kind"] for r in pending} == {"세월교", "박스암거"} - # 관 · 물넘이포장은 지금 관 계산 장 그대로 - assert [r["facility"] for r in rows if not r["pending"]] == ["pipe", "ford_pavement"] - assert all(r["kind"] == "" for r in rows if not r["pending"]) + # 73-11 · 73-12 뒤 — 빈 장 대신 박스 · 월류 장(물넘이포장도 월류 장) + special = [r for r in rows if r["sheet"] != "pipe"] + assert len(special) == 4 # 세월교 2 · 박스 1 · 물넘이 1 + assert {r["kind"] for r in special} == {"세월교", "박스암거", "물넘이포장"} + assert [r["facility"] for r in rows if r["sheet"] == "pipe"] == ["pipe"] + assert all(r["kind"] == "" for r in rows if r["sheet"] == "pipe")