diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index d1b9b599f..75850c9cb 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -164,8 +164,8 @@ export const STATE_REGISTRY = { * 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고 * [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */ crossdesign: { bucket: "draft", scope: "route" }, - /** B08 홍수량 계산서 입력 `{측점: 입력}` — [저장] 때 B08 자기 파일로 · 배수유역 · B05/B06 값과 안 이음(69-6). */ - "b08-hydro": { bucket: "draft", scope: "project", version: 2 }, + /** B08 홍수량 계산서 입력 `{측점: 입력}` — [저장] 때 B08 자기 파일로 · B05 연결값과 같은 칸은 null(연동 · 96). */ + "b08-hydro": { bucket: "draft", scope: "project", version: 3 }, /** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c). * 옛 키는 노선 번호만 붙은 `b05-profile-alignment-draft:{노선}` 이었다. */ "alignment-draft": { diff --git a/B08_Quantity/B08_Quantity_Hydro_Defaults.py b/B08_Quantity/B08_Quantity_Hydro_Defaults.py index d226def3f..8a8931ab9 100644 --- a/B08_Quantity/B08_Quantity_Hydro_Defaults.py +++ b/B08_Quantity/B08_Quantity_Hydro_Defaults.py @@ -1,6 +1,7 @@ -"""B08 4탭 홍수량 계산서 — 기본값 · 선택지 한 곳 (PLAN 62-3 · 73-11 · 73-12). +"""B08 4탭 홍수량 계산서 — 기본값 · 선택지 · B05 연결값 한 곳 (PLAN 62-3 · 73-11 · 73-12 · 96). -임의로 정한 값 — 사용자가 나중에 고침. B05/B06 · 배수유역 값과 잇지 않음(자기 기본값만). +표준값(유출계수 · 할증 · 재현기간 · 최소 도달시간)은 B05 배수 설정(config DRAINAGE_*)과 같은 값. +측점 고유 값은 B05 배수유역 저장본의 연결값(linked · 96-1) · 하상경사 · 관 종류 · n 은 안 이음. 근거 후보: docs/raw/조사/2026-10-06_62-1_홍수량_방법.md (별표2 2.0배 · KDS 표 2.7-1 · 실무 견본). """ @@ -8,7 +9,14 @@ from __future__ import annotations from typing import Any +from common_util.common_util_drainage_pipes import carry_indices from config.config_system import FORD_MANNING_N, FORD_SLOPE +from config.config_system_design import ( + DRAINAGE_DESIGN_FLOW_FACTOR, + DRAINAGE_DESIGN_RETURN_PERIOD_YR, + DRAINAGE_RUNOFF_COEFFICIENT, + DRAINAGE_TC_MIN_MINUTES, +) #: 할증(Qd = Q × 할증) — 2.0 별표2 법정 · 1.5 견본(울진 2공구) · 1.2 구기준 FACTORS = (2.0, 1.5, 1.2) @@ -22,15 +30,15 @@ PIPE_KINDS = {"파형강관": 0.024, "흄관": 0.013, "콘크리트관": 0.015} #: 처음 값 = 표준 값만(69-6) · 측점 고유 값(유역면적 · L · H · D · S)은 None(빈칸) #: R24 · 지역은 공사지 자료(B08_Quantity_Hydro_Site) — 자료가 없을 때만 화면 입력 DEFAULTS: dict[str, Any] = { - "factor": 2.0, - "runoff": 0.80, # 유출계수 f — KDS 가파른 산지 + "factor": DRAINAGE_DESIGN_FLOW_FACTOR, + "runoff": DRAINAGE_RUNOFF_COEFFICIENT, # 유출계수 f — KDS 가파른 산지 "area_km2": None, "length_m": None, # 산정에서 하천시점까지 거리 "height_m": None, # 산정에서 하천시점까지 표고차 - "tc_min_minutes": 5.0, # 최소 도달시간(KDS 2.4) + "tc_min_minutes": DRAINAGE_TC_MIN_MINUTES, # 최소 도달시간(KDS 2.4) "r24_mm": None, "region": "", - "return_period": 100, + "return_period": DRAINAGE_DESIGN_RETURN_PERIOD_YR, "pipe_kind": "파형강관", "n": None, # None = 관 종류 값 "diameter_mm": None, @@ -58,15 +66,62 @@ SHEET_DEFAULTS: dict[str, dict[str, Any]] = { } -def station_rows(points: list[Any]) -> list[dict[str, Any]]: - """관 지점 → 보조 탭 줄(측점 차례) · 장 종류(pipe · box · ford).""" +def _positive(value: Any) -> float | None: + """양수만 — 글 '1000' 도 숫자로 · 0 · 빈 값 · 글자는 None(빈칸).""" + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if number > 0 else None + + +def _linked(sheet: str, options: dict[str, Any], basin: dict[str, Any] | None) -> dict[str, Any]: + """B05 값 → 계산서 칸(96-1) — 값이 있는 칸만 · A · L · H 는 유역 · 치수는 시설 옵션.""" + values: dict[str, Any] = {} + if basin is not None: + area_m2 = _positive(basin.get("area_m2")) + values["area_km2"] = area_m2 / 1e6 if area_m2 else None + values["length_m"] = _positive(basin.get("flow_length_m")) + values["height_m"] = _positive(basin.get("relief_m")) + if sheet == "pipe": + values["diameter_mm"] = _positive(options.get("pipe_diameter_mm")) + elif sheet == "box": + values["box_width_m"] = _positive(options.get("body_width_m")) + values["box_height_m"] = _positive(options.get("body_height_m")) + else: + values["overflow_width_m"] = _positive(options.get("ford_width_m")) + return {k: v for k, v in values.items() if v is not None} + + +def station_rows( + points: list[Any], basins: list[dict[str, Any]] | None = None +) -> list[dict[str, Any]]: + """관 지점 → 보조 탭 줄(측점 차례) · 장 종류(pipe · box · ford). + + basins(B05 세부유역 속성) 를 주면 측점마다 연결값 `linked` · 비교용 `b05`(Qd · T) — 측점 짝은 + `carry_indices`(±0.05 m) · 안 주면 둘 다 None. + """ + points = sorted(points, key=lambda p: p.chainage_m) + picks = ( + carry_indices([p.chainage_m for p in points], [float(b["chainage_m"]) for b in basins]) + if basins is not None + else [None] * len(points) + ) rows = [] - for p in points: + for p, pick in zip(points, picks): 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"]) + row = {"chainage_m": p.chainage_m, "facility": p.facility, "kind": kind, "sheet": sheet} + row["linked"] = row["b05"] = None + if basins is not None: + basin = basins[pick] if pick is not None else None + row["linked"] = _linked(sheet, getattr(p, "options", None) or {}, basin) + if basin is not None: + row["b05"] = { + "design_flow_m3s": basin.get("design_flow_m3s"), + "tc_minutes": basin.get("tc_minutes"), + } + rows.append(row) + return rows def options() -> dict[str, Any]: diff --git a/B08_Quantity/B08_Quantity_Hydro_Site.py b/B08_Quantity/B08_Quantity_Hydro_Site.py index 5ce27adf7..6a6f83234 100644 --- a/B08_Quantity/B08_Quantity_Hydro_Site.py +++ b/B08_Quantity/B08_Quantity_Hydro_Site.py @@ -1,7 +1,8 @@ """B08 4탭 홍수량 계산서 — 공사지 지역 · 확률강우량 · 저장 파일 (PLAN 69-5 · 69-6). 읽기만: 전처리 강우량표(`B04_PreProcess/drainage/rainfall_table.json` · 관측소 확률강우량) -· 행정구역(`B04_PreProcess/processed/행정구역_*_bounds.geojson` · WGS84) · 프로젝트 등록 지역(DB). +· 행정구역(`B04_PreProcess/processed/행정구역_*_bounds.geojson` · WGS84) · 프로젝트 등록 지역(DB) +· B05 세부유역(`B04_PreProcess/drainage/04_detailed_basins.geojson` · B05 [저장] 때 씀 · 96-1). 배수유역 계산 코드는 부르지 않음 — 이미 저장된 파일만 읽음. 쓰기: B08 자기 파일 `B08_Quantity/quantities/hydro_inputs.json` 하나([저장] 때만). @@ -16,7 +17,10 @@ from typing import Any from shapely.geometry import Point, shape -RAINFALL_FILE = Path("B04_PreProcess") / "drainage" / "rainfall_table.json" +from config.config_system_design import DRAINAGE_CACHE_DIRNAME, DRAINAGE_DETAIL_FILENAME + +RAINFALL_FILE = Path("B04_PreProcess") / DRAINAGE_CACHE_DIRNAME / "rainfall_table.json" +BASINS_FILE = Path("B04_PreProcess") / DRAINAGE_CACHE_DIRNAME / DRAINAGE_DETAIL_FILENAME PROCESSED_DIR = Path("B04_PreProcess") / "processed" EMD_FILE = "행정구역_읍면동_bounds.geojson" SGG_FILE = "행정구역_시군구_bounds.geojson" @@ -105,6 +109,18 @@ def site_r24(root: Path, return_period: float) -> float | None: return _r24_table(_read_json(root / RAINFALL_FILE)).get(int(return_period)) +def read_basins(root: Path) -> list[dict[str, Any]]: + """B05 세부유역 속성(kind detail_basin) — 파일이 없으면 빈 목록(연결값은 시설 옵션만).""" + basins = [] + for feature in _features(root / BASINS_FILE): + props = feature.get("properties") or {} + if props.get("kind") == "detail_basin" and isinstance( + props.get("chainage_m"), (int, float) + ): + basins.append(props) + return basins + + def read_saved(root: Path) -> dict[str, dict[str, Any]]: data = _read_json(root / SAVED_FILE) or {} stations = data.get("stations") diff --git a/B08_Quantity/B08_Quantity_Router_Hydro.py b/B08_Quantity/B08_Quantity_Router_Hydro.py index 1c442f995..d51f3294c 100644 --- a/B08_Quantity/B08_Quantity_Router_Hydro.py +++ b/B08_Quantity/B08_Quantity_Router_Hydro.py @@ -1,12 +1,12 @@ """B08 4탭 홍수량 계산서 길 (PLAN 62-2 · 62-3 · 69-5 · 69-6 · 73-11 · 73-12). - GET /api/projects/{id}/quantity/hydro 배수지점 측점(저장된 pipe_points) + 기본값 · 선택지 - + 공사지(지역 · 재현기간별 R24) + 저장한 입력 + GET /api/projects/{id}/quantity/hydro 배수지점 측점(저장된 pipe_points) + B05 연결값 + + 기본값 · 선택지 + 공사지(지역 · R24) + 저장본 POST /api/projects/{id}/quantity/hydro/calc 화면 입력 → 계산서 값(B08_Quantity_Engine_Hydro) PUT /api/projects/{id}/quantity/hydro [저장] — 측점별 입력을 B08 자기 파일에만 씀 -측점 목록만 관 지점 정본에서 읽음 — 관경 · 관 종류 등 B05/B06 값은 잇지 않음(자기 기본값). -B05/B06 · 배수유역 파일에는 쓰지 않음. +측점 목록 · 시설 치수는 관 지점 정본 · A · L · H 는 B05 세부유역 저장본에서 연결값(96-1) — +화면이 빈칸만 채움 · 하상경사 · 관 종류 · n 은 잇지 않음. B05/B06 · 배수유역 파일에는 쓰지 않음. """ from __future__ import annotations @@ -22,7 +22,13 @@ from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B08_Quantity.B08_Quantity_Engine_Hydro import compute from B08_Quantity.B08_Quantity_Hydro_Defaults import FACTORS, PIPE_KINDS, options, station_rows -from B08_Quantity.B08_Quantity_Hydro_Site import read_saved, site_info, site_r24, write_saved +from B08_Quantity.B08_Quantity_Hydro_Site import ( + read_basins, + read_saved, + site_info, + site_r24, + write_saved, +) from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool @@ -75,7 +81,7 @@ async def _project_root(project_id: UUID) -> Path: @router.get("/{project_id}/quantity/hydro") async def hydro_sheet(project_id: UUID) -> dict[str, Any]: root = await _project_root(project_id) - stations = station_rows(read_pipe_points_file(pipe_points_path_in(root))) + stations = station_rows(read_pipe_points_file(pipe_points_path_in(root)), read_basins(root)) project = await get_project_by_id(str(project_id)) or {} return { "stations": stations, diff --git a/B08_Quantity/B08_Quantity_UI_Hydro.ts b/B08_Quantity/B08_Quantity_UI_Hydro.ts index fea648f6d..80512c3b3 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro.ts +++ b/B08_Quantity/B08_Quantity_UI_Hydro.ts @@ -2,7 +2,8 @@ * B08_Quantity_UI_Hydro.ts * 4탭 수리집수면적표 — 배수지점 측점마다 보조 탭 · 한 장 = 홍수량 계산서(PLAN 62-2 · 69-4~6). * - * 측점 목록만 저장된 관 지점(pipe_points)에서 · 입력은 이 탭 자기 값(B05/B06 값과 안 이음) + * 측점 목록은 저장된 관 지점(pipe_points) · B05 연결값(A · L · H · 관경 · 내공 · 월류 폭 · 96)이 빈칸을 채움 + * 값 차례 = 기본 < 장 기본 < 연결 < 저장 < 초안 · 연결값과 같은 칸은 null 로 저장(연동 유지) * 박스암거 = 사각 단면 장(73-11) · 세월교 · 물넘이포장 = 월류 높이 장(73-12) · 그 밖 = 관 장 * 처음 값 = 표준 값 · 측점 고유 값은 빈칸 · 지역 · R24 = 공사지 자료(서버) * 조작 = 캐시(sessionStorage) · [저장] = B08 자기 파일(PUT …/quantity/hydro) · 뒤 캐시 비움 @@ -23,6 +24,7 @@ import { stationInterval } from "./B08_Quantity_UI_Summary"; import { buildHydroSheet, type HydroInputs, + type HydroLink, type HydroOptions, type HydroResult, } from "./B08_Quantity_UI_Hydro_Sheet"; @@ -34,6 +36,9 @@ interface HydroStation { kind: string; /** 계산서 장 — 시설이 정함(서버 station_rows) */ sheet: HydroInputs["sheet"]; + /** B05 연결값(값이 있는 칸만) · 비교용 B05 Qd · T — 서버 station_rows */ + linked: HydroLink["values"] | null; + b05: HydroLink["b05"]; } type Drafts = Record>; @@ -83,14 +88,26 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise } let drafts: Drafts = readState("b08-hydro", projectId) ?? {}; + const linkedOf = (station: HydroStation): Record => station.linked ?? {}; + /** 저장본 · 초안 한 겹 — 연결값이 있는 칸의 null 은 건너뜀(연결값을 덮지 않게) */ + const layer = (station: HydroStation, values: Partial = {}) => + Object.fromEntries( + Object.entries(values).filter(([k, v]) => v != null || !(k in linkedOf(station))), + ); const inputsOf = (station: HydroStation): HydroInputs => ({ ...sheet.defaults, ...(station.sheet === "pipe" ? {} : sheet.sheet_defaults[station.sheet]), - ...sheet.saved[keyOf(station)], - ...drafts[keyOf(station)], + ...station.linked, + ...layer(station, sheet.saved[keyOf(station)]), + ...layer(station, drafts[keyOf(station)]), sheet: station.sheet, }) as HydroInputs; + /** 연결값과 같은 칸은 null — 저장 · 초안이 연동을 유지(B05 값이 바뀌면 따라옴) · 다르면 손 값 */ + const unlinked = (station: HydroStation, inputs: HydroInputs): HydroInputs => + Object.fromEntries( + Object.entries(inputs).map(([k, v]) => [k, linkedOf(station)[k] === v ? null : v]), + ) as unknown as HydroInputs; const tabs = document.createElement("div"); tabs.className = "b08-quantity__tabs b08-hydro__tabs"; @@ -134,13 +151,14 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise inputs, sheet, (next) => { - drafts[keyOf(station)] = next; + drafts[keyOf(station)] = unlinked(station, next); writeState("b08-hydro", drafts, projectId); // 단면 가정 · 관 종류는 그림 · 식이 바뀜 — 장을 다시 그림 · 그 밖은 값만 if (next.section !== inputs.section || next.pipe_kind !== inputs.pipe_kind) draw(next); else void calc(next, view.show); }, station.kind, + { values: station.linked ?? {}, b05: station.b05 }, ); page.replaceChildren(view.root); void calc(inputs, view.show); @@ -166,7 +184,9 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise /** [저장] — 측점 전부의 지금 입력을 B08 자기 파일로 · 성공하면 캐시를 비우고 저장본으로 다시 엶 */ const saveAll = async (): Promise => { - const stations = Object.fromEntries(sheet.stations.map((s) => [keyOf(s), inputsOf(s)])); + const stations = Object.fromEntries( + sheet.stations.map((s) => [keyOf(s), unlinked(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 60adc4a11..04cd6efbf 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts +++ b/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts @@ -7,6 +7,7 @@ * 장(sheet) 갈래 — 박스 = 사각 내공 B · H · 수심비(73-11) · 세월교 · 물넘이 = 월류 폭 → 월류 높이(73-12) * 테두리는 입력 · 선택 칸만(공용 ui-input · ui-select) · 계산 칸은 글자만 * 굵게 + 붉은 계열은 중요 결과(Qd · 유출량 · 판정 · 유출율)만 · 빈칸에 기대는 계산 칸은 「-」. + * B05 연결값과 같은 칸은 이름 옆 작은 「B05」(풍선 「B05 분석」) · Qd · T 옆에 B05 값 참고(96-2). * * 입력 칸은 한 번 만들고 계산 결과만 제자리에 바꿈 — 고치는 중 칸 포커스를 잃지 않게. * ========================================================================== */ @@ -77,6 +78,12 @@ export interface HydroOptions { site: HydroSite; } +/** B05 배수유역 연결(96) — 칸 값 · 비교용 설계유량 · 도달시간(분) */ +export interface HydroLink { + values: Partial>; + b05: { design_flow_m3s: number | null; tc_minutes: number | null } | null; +} + export interface HydroSheet { root: HTMLElement; /** 계산값 칸을 바꿈 — null 이면 비움 + 까닭 한 줄 */ @@ -103,6 +110,14 @@ const DASH = "-"; const fmt = (value: number | null | undefined, digits: number, unit = ""): string => value == null || !Number.isFinite(value) ? DASH : `${value.toFixed(digits)}${unit}`; +/** 입력 칸이 놓인 줄의 이름 칸 */ +function sheetLabel(left: HTMLElement, right: HTMLElement, key: string): HTMLElement | null { + const input = [left, right] + .map((card) => card.querySelector(`input[data-b08-hydro="${key}"]`)) + .find(Boolean); + return input?.closest(".b08-hydro__row")?.querySelector(".b08-hydro__label") ?? null; +} + function node( tag: K, className = "", @@ -218,8 +233,15 @@ export function buildHydroSheet( onChange: (next: HydroInputs) => void, /** 시설 이름(박스암거 · 세월교 · 물넘이포장) — 관이면 "" */ kind = "", + link: HydroLink = { values: {}, b05: null }, ): HydroSheet { const site = options.site; + /** 연결된 칸의 「B05」 표 — 값이 연결값과 같을 때만 보임 */ + const marks = new Map(); + const markLinked = (key: Key): void => { + const mark = marks.get(key); + if (mark) mark.hidden = current[key] !== link.values[key]; + }; const out = new Map(); const outCell = (id: string, key = false): HTMLElement => { const cell = node("span", `b08-hydro__out${key ? " is-key" : ""}`, DASH); @@ -229,6 +251,7 @@ export function buildHydroSheet( let current = inputs; const commit = (key: Key, value: unknown): void => { current = { ...current, [key]: value } as HydroInputs; + markLinked(key); onChange(current); }; const numberInput = (key: Key, placeholder = ""): HTMLInputElement => { @@ -238,6 +261,14 @@ export function buildHydroSheet( input.dataset.b08Hydro = key; const value = inputs[key]; input.value = value == null ? "" : String(value); + const linked = link.values[key]; + if (linked != null) { + input.title = `B05 분석 ${linked}`; + const mark = node("span", "b08-hydro__hint", "B05"); + mark.title = "B05 분석"; + mark.dataset.b08HydroLinked = key; + marks.set(key, mark); + } input.addEventListener("change", () => { // 빈칸은 NULLABLE 만 · 최소 도달시간은 0 까지 · 그 밖은 0 보다 커야 함 — 아니면 되돌림 const parsed = input.value === "" ? null : Number(input.value); @@ -283,6 +314,18 @@ export function buildHydroSheet( }; const sub = (text: string): HTMLElement => node("h4", "b08-hydro__h2", text); const hint = (text: string): HTMLElement => node("span", "b08-hydro__hint", text); + /** B05 분석 값 참고(비교만) — 값이 없으면 칸 없음 */ + const b05Hint = ( + key: "design_flow_m3s" | "tc_minutes", + text: (value: number) => string, + ): HTMLElement | undefined => { + const value = link.b05?.[key]; + if (value == null) return undefined; + const el = hint(text(value)); + el.title = "B05 분석"; + el.dataset.b08HydroB05 = key; + return el; + }; // ── 왼쪽 1. 홍수량 계산 ── const factor = select( @@ -332,6 +375,7 @@ export function buildHydroSheet( return label; })(), outCell("design_flow", true), + b05Hint("design_flow_m3s", (v) => `B05 ${fmt(v, 2)}`), ), row("Q : 홍수량 (㎥/s)", outCell("flow")), row("f : 유출계수", numberInput("runoff")), @@ -339,7 +383,16 @@ export function buildHydroSheet( row("A : 유역면적 (㎢)", numberInput("area_km2", "입력"), outCell("area_ha")), sub("1-2. 홍수 도달시간 — 산지부 (Kirpich)"), formula("T = 0.0663 × L^0.77^ × s^-0.385^ (L : ㎞ · T : hr)"), - row("T : 홍수 도달시간 (hr)", outCell("tc_hr"), outCell("tc_note")), + row( + "T : 홍수 도달시간 (hr)", + outCell("tc_hr"), + (() => { + const note = node("span"); + const b05 = b05Hint("tc_minutes", (v) => ` B05 ${fmt(v, 1)}분`); + note.append(outCell("tc_note"), ...(b05 ? [b05] : [])); + return note; + })(), + ), row("L : 산정에서 하천시점까지 거리 (m)", numberInput("length_m", "입력")), row("H : 산정에서 하천시점까지 표고차 (m)", numberInput("height_m", "입력")), row("s : 지표면 경사 (H / L)", outCell("slope_ground")), @@ -443,6 +496,13 @@ export function buildHydroSheet( }); right.classList.add("b08-hydro__card"); + // 「B05」 표는 그 칸 이름 옆에 + for (const [key, mark] of marks) { + const label = sheetLabel(left, right, key); + label?.append(" ", mark); + markLinked(key); + } + const status = node("p", "b08-hydro__status"); const sheet = node("div", "b08-hydro__sheet"); sheet.dataset.b08HydroSheet = stationText; diff --git a/resources/tester/test_96_b08_hydro_linked.py b/resources/tester/test_96_b08_hydro_linked.py new file mode 100644 index 000000000..ffa10618d --- /dev/null +++ b/resources/tester/test_96_b08_hydro_linked.py @@ -0,0 +1,102 @@ +"""B08 수리 표 ← B05 배수유역 연결값 (PLAN 96-1 · 96-3). + +견본 세부유역 + 관 지점 → 측점마다 linked(A · L · H · 관경 · 박스 내공 · 월류 폭) · +그 값으로 계산한 Qd ≈ B05 설계유량 · 표준값은 B05 배수 설정 하나. +""" + +from __future__ import annotations + +import json +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 # noqa: E402 +from B08_Quantity.B08_Quantity_Hydro_Defaults import DEFAULTS, station_rows # noqa: E402 +from B08_Quantity.B08_Quantity_Hydro_Site import BASINS_FILE, read_basins # noqa: E402 +from common_util.common_util_drainage_detail import size_pipe # noqa: E402 +from common_util.common_util_drainage_pipes import PipePoint # noqa: E402 +from common_util.common_util_wamis_station import mononobe_idf # noqa: E402 +from config import config_system_design as cfg # noqa: E402 + +R24 = 231.56 +BASINS = [ + # B05 저장 꼴 — 측점 0.01 반올림 · 면적 0.1 · 표고차 0.01 · 길이 0.1 + {"kind": "detail_basin", "chainage_m": 85.05, "area_m2": 10717.0, "flow_length_m": 163.9, + "relief_m": 120.0}, + {"kind": "detail_basin", "chainage_m": 173.09, "area_m2": 141145.0, "flow_length_m": 603.3, + "relief_m": 320.0}, + {"kind": "detail_basin", "chainage_m": 300.0, "area_m2": 52000.0, "flow_length_m": 410.0, + "relief_m": 0.0}, +] # fmt: skip +for _b in BASINS: + _out = size_pipe( + _b["area_m2"], max(_b["relief_m"], 0.1), _b["flow_length_m"], mononobe_idf(R24) + ) + _b.update(design_flow_m3s=_out["design_flow_m3s"], tc_minutes=_out["tc_minutes"]) + +POINTS = [ + PipePoint(chainage_m=300.004, facility="box_culvert", + options={"body_width_m": 2.0, "body_height_m": 1.5}), + PipePoint(chainage_m=85.052, options={"pipe_diameter_mm": "1000", "pipe_kind": "파형강관"}), + PipePoint(chainage_m=173.09, facility="ford_bridge", options={"ford_width_m": 10}), + PipePoint(chainage_m=500.0, options={"pipe_diameter_mm": 800}), # 유역 짝 없음 +] # fmt: skip + + +def test_측점마다_연결값() -> None: + rows = station_rows(POINTS, BASINS) + assert [r["chainage_m"] for r in rows] == [85.052, 173.09, 300.004, 500.0] + pipe, ford, box, lone = (r["linked"] for r in rows) + assert pipe == { + "area_km2": 0.010717, + "length_m": 163.9, + "height_m": 120.0, + "diameter_mm": 1000.0, + } + assert ford == {"area_km2": 0.141145, "length_m": 603.3, "height_m": 320.0, + "overflow_width_m": 10.0} # fmt: skip + # 표고차 0 은 빈칸(저장 검사 gt 0) · 박스는 내공 + assert box == {"area_km2": 0.052, "length_m": 410.0, "box_width_m": 2.0, "box_height_m": 1.5} + # 유역 짝이 없으면 시설 치수만 · B05 참고값 없음 + assert lone == {"diameter_mm": 800.0} and rows[3]["b05"] is None + assert rows[0]["b05"] == {"design_flow_m3s": BASINS[0]["design_flow_m3s"], "tc_minutes": 5.0} + + +def test_잇지_않는_칸() -> None: + for row in station_rows(POINTS, BASINS): + assert not {"slope_denominator", "pipe_kind", "n"} & set(row["linked"]) + + +def test_basins_를_안_주면_linked_없음() -> None: + rows = station_rows(POINTS) + assert all(r["linked"] is None and r["b05"] is None for r in rows) + + +@pytest.mark.parametrize("index", [0, 1]) +def test_연결값으로_계산한_Qd_는_B05_설계유량(index: int) -> None: + row = station_rows(POINTS, BASINS)[index] + out = compute({**DEFAULTS, **row["linked"], "r24_mm": R24, "sheet": row["sheet"]}) + assert out["design_flow"] == pytest.approx(row["b05"]["design_flow_m3s"], abs=1e-4) + assert out["tc_hr"] * 60 == pytest.approx(row["b05"]["tc_minutes"], abs=0.05) + + +def test_표준값은_B05_배수_설정() -> None: + assert DEFAULTS["runoff"] == cfg.DRAINAGE_RUNOFF_COEFFICIENT == 0.8 + assert DEFAULTS["factor"] == cfg.DRAINAGE_DESIGN_FLOW_FACTOR == 2.0 + assert DEFAULTS["return_period"] == cfg.DRAINAGE_DESIGN_RETURN_PERIOD_YR == 100 + assert DEFAULTS["tc_min_minutes"] == cfg.DRAINAGE_TC_MIN_MINUTES == 5.0 + + +def test_세부유역_파일_읽기(tmp_path: Path) -> None: + assert read_basins(tmp_path) == [] # 분석 저장 전 + features = [{"type": "Feature", "properties": b, "geometry": None} for b in BASINS] + features.append({"type": "Feature", "properties": {"kind": "pipe", "chainage_m": 85.05}}) + path = tmp_path / BASINS_FILE + path.parent.mkdir(parents=True) + path.write_text(json.dumps({"type": "FeatureCollection", "features": features}), "utf-8") + assert [b["chainage_m"] for b in read_basins(tmp_path)] == [85.05, 173.09, 300.0]