From 5f056ec6615c3bf4ef30d3c6339504b3456e2172 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 18:15:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=204=ED=83=AD=20=ED=99=8D=EC=88=98?= =?UTF-8?q?=EB=9F=89=20=EA=B3=84=EC=82=B0=EC=84=9C=20=E2=80=94=20=EB=B0=B0?= =?UTF-8?q?=EC=88=98=EC=A7=80=EC=A0=90=20=EC=B8=A1=EC=A0=90=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B0=20=ED=83=AD=20=C2=B7=20=EA=B2=AC=EB=B3=B8=20=EB=AA=A8?= =?UTF-8?q?=EC=96=91=20=ED=95=9C=20=EC=9E=A5=20=C2=B7=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EA=B3=84=EC=82=B0(PLAN=2062-2=20=C2=B7=2062-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 측점 보조 탭 = 저장된 pipe_points 측점만 읽음 · 입력은 이 탭 자기 기본값(B05/B06 · 배수유역 값과 안 이음) - 1-1 합리식(Qd = Q × 할증) · 1-2 Kirpich 도달시간(최소 5분) · 1-3 rt = (R24/24)·(24/T)^0.557 - 2 단면검토 — 관 단면 그림 · 단면적 · 윤변 · 경심 · 경사 · n · Manning 유속 · Q = A·V · OK/NG 점선 상자 · 유출율 - 선택지(할증 2.0/1.5/1.2 · 만관/240°/유효 70 % · 관 종류별 n) · 기본값 한 파일(B08_Quantity_Hydro_Defaults.py) - 계산 = 서버 단독(B08_Quantity_Engine_Hydro · /quantity/hydro · /quantity/hydro/calc · 최신 응답만) · 입력은 캐시만 · 어떤 파일에도 안 씀 - 흰 종이 계산서(입력 빨강 · 계산 파랑 · 주요 입력 노랑) · 쪽 표시 · 시험 test_62_2_b08_hydro_engine.py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- A00_Common/b_page_state.ts | 2 + B08_Quantity/B08_Quantity_Engine_Hydro.py | 78 ++++ B08_Quantity/B08_Quantity_Hydro_Defaults.py | 45 +++ B08_Quantity/B08_Quantity_Router_Hydro.py | 72 ++++ B08_Quantity/B08_Quantity_UI_Hydro.css | 248 +++++++++++++ B08_Quantity/B08_Quantity_UI_Hydro.ts | 135 +++++++ B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts | 349 ++++++++++++++++++ B08_Quantity/B08_Quantity_UI_Page.ts | 7 +- B08_Quantity/B08_Quantity_UI_Summary.ts | 2 +- main.py | 2 + .../tester/test_62_2_b08_hydro_engine.py | 73 ++++ 11 files changed, 1011 insertions(+), 2 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Engine_Hydro.py create mode 100644 B08_Quantity/B08_Quantity_Hydro_Defaults.py create mode 100644 B08_Quantity/B08_Quantity_Router_Hydro.py create mode 100644 B08_Quantity/B08_Quantity_UI_Hydro.css create mode 100644 B08_Quantity/B08_Quantity_UI_Hydro.ts create mode 100644 B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts create mode 100644 resources/tester/test_62_2_b08_hydro_engine.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 24958ad89..3891fae18 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -164,6 +164,8 @@ export const STATE_REGISTRY = { * 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고 * [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */ crossdesign: { bucket: "draft", scope: "route" }, + /** B08 홍수량 계산서 입력 `{측점: 입력}` — 지금은 캐시만 · 배수유역 · B05/B06 값과 안 이음(62-2). */ + "b08-hydro": { bucket: "draft", scope: "project" }, /** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c). * 옛 키는 노선 번호만 붙은 `b05-profile-alignment-draft:{노선}` 이었다. */ "alignment-draft": { diff --git a/B08_Quantity/B08_Quantity_Engine_Hydro.py b/B08_Quantity/B08_Quantity_Engine_Hydro.py new file mode 100644 index 000000000..06b2d6fc1 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Hydro.py @@ -0,0 +1,78 @@ +"""B08 4탭 홍수량 계산서 엔진 — 순수 식 (PLAN 62-2 · 계산 자리 ③ 서버 단독). + +배수유역 계산 코드(`common_util_drainage_detail` 등)를 부르지 않음 — 화면 입력만으로 계산. + + 1-1 합리식 Q = (1/3.6)·f·rt·A[㎢] · Qd = Q × 할증 + 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 +""" + +from __future__ import annotations + +import math +from typing import Any + +from B08_Quantity.B08_Quantity_Hydro_Defaults import PIPE_KINDS + +MONONOBE_EXPONENT = 0.557 +EFFECTIVE_RATIO = 0.7 + + +def section_props(section: str, diameter_m: float) -> dict[str, float]: + """단면 가정별 단면적 A · 윤변 P · 경심 R.""" + d = diameter_m + full_area = math.pi * d * d / 4.0 + if section == "arc240": + # 240° 부채꼴 + 현 아래 삼각형(반지름 둘 · 끼인각 120°) — 수심 0.75D + area = full_area * 240.0 / 360.0 + (d / 2.0) ** 2 * math.sin(math.radians(60)) * math.cos( + math.radians(60) + ) + perimeter = math.pi * d * 240.0 / 360.0 + return {"area": area, "perimeter": perimeter, "radius": area / perimeter} + if section == "eff70": + # 배관 제안 방식 — 유속은 만관 경심(D/4) · 통수단면은 70 % + return {"area": EFFECTIVE_RATIO * full_area, "perimeter": math.pi * d, "radius": d / 4.0} + return {"area": full_area, "perimeter": math.pi * d, "radius": d / 4.0} + + +def compute(inputs: dict[str, Any]) -> dict[str, Any]: + """입력 → 계산서 값 전부(반올림 없음 · 화면이 자릿수를 정함).""" + length_m = float(inputs["length_m"]) + height_m = float(inputs["height_m"]) + slope_ground = height_m / length_m + tc_raw_hr = 0.0663 * (length_m / 1000.0) ** 0.77 * slope_ground**-0.385 + tc_hr = max(tc_raw_hr, float(inputs["tc_min_minutes"]) / 60.0) + r24 = float(inputs["r24_mm"]) + intensity = (r24 / 24.0) * (24.0 / tc_hr) ** MONONOBE_EXPONENT + runoff = float(inputs["runoff"]) + area_km2 = float(inputs["area_km2"]) + flow = runoff * intensity * area_km2 / 3.6 + design_flow = flow * float(inputs["factor"]) + + n = inputs.get("n") + n = float(n) if n else PIPE_KINDS[inputs["pipe_kind"]] + diameter_m = float(inputs["diameter_mm"]) / 1000.0 + props = section_props(inputs["section"], diameter_m) + slope_bed = 1.0 / float(inputs["slope_denominator"]) + velocity = (1.0 / n) * props["radius"] ** (2.0 / 3.0) * math.sqrt(slope_bed) + capacity = props["area"] * velocity + return { + "area_ha": area_km2 * 100.0, + "slope_ground": slope_ground, + "tc_raw_hr": tc_raw_hr, + "tc_hr": tc_hr, + "tc_floored": tc_hr > tc_raw_hr, + "intensity": intensity, + "flow": flow, + "design_flow": design_flow, + "n": n, + "section_area": props["area"], + "perimeter": props["perimeter"], + "radius": props["radius"], + "slope_bed": slope_bed, + "velocity": velocity, + "capacity": capacity, + "ok": capacity >= design_flow, + "ratio_pct": capacity / design_flow * 100.0 if design_flow > 0 else None, + } diff --git a/B08_Quantity/B08_Quantity_Hydro_Defaults.py b/B08_Quantity/B08_Quantity_Hydro_Defaults.py new file mode 100644 index 000000000..4de09e8bf --- /dev/null +++ b/B08_Quantity/B08_Quantity_Hydro_Defaults.py @@ -0,0 +1,45 @@ +"""B08 4탭 홍수량 계산서 — 기본값 · 선택지 한 곳 (PLAN 62-3). + +임의로 정한 값 — 사용자가 나중에 고침. B05/B06 · 배수유역 값과 잇지 않음(자기 기본값만). +근거 후보: docs/raw/조사/2026-10-06_62-1_홍수량_방법.md (별표2 2.0배 · KDS 표 2.7-1 · 실무 견본). +""" + +from __future__ import annotations + +from typing import Any + +#: 할증(Qd = Q × 할증) — 2.0 별표2 법정 · 1.5 견본(울진 2공구) · 1.2 구기준 +FACTORS = (2.0, 1.5, 1.2) + +#: 단면 가정 — 만관 · 240° 유효(수심 0.75D) · 유효 70 %(배관 제안 방식) +SECTIONS = {"full": "만관", "arc240": "240° 유효단면", "eff70": "유효 70 %"} + +#: 관 종류별 조도계수 n — 파형강관은 KDS 표에 없음(지금 배관 제안 0.024) +PIPE_KINDS = {"파형강관": 0.024, "흄관": 0.013, "콘크리트관": 0.015} + +DEFAULTS: dict[str, Any] = { + "factor": 2.0, + "runoff": 0.80, # 유출계수 f — KDS 가파른 산지 + "area_km2": 0.132, + "length_m": 480.0, # 산정에서 하천시점까지 거리 + "height_m": 185.0, # 산정에서 하천시점까지 표고차 + "tc_min_minutes": 5.0, # 최소 도달시간(KDS 2.4) + "r24_mm": 305.9, + "region": "울진", + "return_period": 100, + "pipe_kind": "파형강관", + "n": None, # None = 관 종류 값 + "diameter_mm": 1200.0, + "slope_denominator": 20.0, # 하상경사 S = 1 / 이 값 + "section": "full", +} + + +def options() -> dict[str, Any]: + """화면 선택지 — 기본값 · 할증 · 단면 가정 · 관 종류 n.""" + return { + "defaults": DEFAULTS, + "factors": list(FACTORS), + "sections": SECTIONS, + "pipe_kinds": PIPE_KINDS, + } diff --git a/B08_Quantity/B08_Quantity_Router_Hydro.py b/B08_Quantity/B08_Quantity_Router_Hydro.py new file mode 100644 index 000000000..7cdb51edb --- /dev/null +++ b/B08_Quantity/B08_Quantity_Router_Hydro.py @@ -0,0 +1,72 @@ +"""B08 4탭 홍수량 계산서 길 (PLAN 62-2 · 62-3) — 읽기 · 계산만, 아무 파일에도 쓰지 않음. + + GET /api/projects/{id}/quantity/hydro 배수지점 측점(저장된 pipe_points) + 기본값 · 선택지 + POST /api/projects/{id}/quantity/hydro/calc 화면 입력 → 계산서 값(B08_Quantity_Engine_Hydro) + +측점 목록만 관 지점 정본에서 읽음 — 관경 · 관 종류 등 B05/B06 값은 잇지 않음(자기 기본값). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Literal +from uuid import UUID + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +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 +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 + +router = APIRouter(prefix="/api/projects", tags=["B08 Hydro"]) + + +class HydroInputs(BaseModel): + factor: float = Field(gt=0) + runoff: float = Field(gt=0, le=1) + area_km2: float = Field(gt=0) + length_m: float = Field(gt=0) + height_m: float = Field(gt=0) + tc_min_minutes: float = Field(ge=0) + r24_mm: float = Field(gt=0) + region: str = "" + return_period: float = Field(gt=0) + pipe_kind: Literal[tuple(PIPE_KINDS)] # type: ignore[valid-type] + n: float | None = Field(default=None, gt=0) + diameter_mm: float = Field(gt=0) + slope_denominator: float = Field(gt=0) + section: Literal["full", "arc240", "eff70"] + + +async def _project_root(project_id: UUID) -> Path: + try: + async with get_db_pool().acquire() as connection: + stored = await get_project_storage_relative_path(connection, project_id) + except LookupError: + stored = None + if not stored: + raise HTTPException(status_code=404, detail="프로젝트 저장 경로를 찾을 수 없습니다.") + return Path(resolve_stored_project_path(stored)) + + +@router.get("/{project_id}/quantity/hydro") +async def hydro_sheet(project_id: UUID) -> dict[str, Any]: + root = await _project_root(project_id) + points = read_pipe_points_file(pipe_points_path_in(root)) + stations = sorted( + ({"chainage_m": p.chainage_m, "facility": p.facility} for p in points), + key=lambda s: s["chainage_m"], + ) + return {"stations": stations, **options()} + + +@router.post("/{project_id}/quantity/hydro/calc") +async def hydro_calc(project_id: UUID, body: HydroInputs) -> dict[str, Any]: + await _project_root(project_id) # 없는 프로젝트는 404 + if body.factor not in FACTORS: + raise HTTPException(status_code=400, detail=f"할증은 {FACTORS} 중 하나") + return compute(body.model_dump()) diff --git a/B08_Quantity/B08_Quantity_UI_Hydro.css b/B08_Quantity/B08_Quantity_UI_Hydro.css new file mode 100644 index 000000000..43f4fa418 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Hydro.css @@ -0,0 +1,248 @@ +/* B08 4탭 홍수량 계산서(PLAN 62-2) — 인쇄 같은 흰 종이. 종이 안 색은 테마와 상관없이 고정 + (어두움 테마에서도 같은 종이로 읽힘): 입력 빨강 · 계산 파랑 · 주요 입력 노랑 바탕. */ + +.b08-hydro__tabs { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4, 4px); + margin-bottom: var(--spacing-12, 12px); +} + +.b08-hydro__paper { + --paper: #ffffff; + --ink: #111111; + --input: #d40000; + --calc: #0033cc; + --key: #ffff00; + --ha: #f8cbad; + --rule: #9aa0a6; + /* 오른쪽 「진행단계」 떠 있는 판에 안 가리게 */ + max-width: 1000px; + padding: 20px 24px 12px; + background: var(--paper); + color: var(--ink); + border: 1px solid var(--rule); + box-shadow: 0 2px 10px rgb(0 0 0 / 25%); + font-family: "Malgun Gothic", "Noto Sans KR", sans-serif; + font-size: 14px; +} + +.b08-hydro__body { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 24px; +} + +@media (max-width: 960px) { + .b08-hydro__body { + grid-template-columns: minmax(0, 1fr); + } +} + +.b08-hydro__h1 { + margin: 0 0 8px; + font-size: 19px; + font-weight: 700; + color: var(--ink); +} + +.b08-hydro__h2 { + margin: 16px 0 6px; + font-size: 14px; + font-weight: 700; + color: var(--ink); +} + +.b08-hydro__station mark { + padding: 0 10px; + background: var(--key); + color: var(--ink); + font-weight: 700; +} + +.b08-hydro__note { + margin: 4px 0 4px 16px; + color: var(--ink); +} + +.b08-hydro__formula { + margin: 6px 0 6px 16px; + font-family: "Cambria Math", "Times New Roman", serif; + font-size: 15px; + color: var(--ink); +} + +.b08-hydro__row { + display: grid; + grid-template-columns: minmax(0, 1fr) 96px 110px; + align-items: center; + gap: 8px; + min-height: 26px; + padding-left: 16px; +} + +.b08-hydro__row--f { + grid-template-columns: max-content minmax(0, 1fr) 96px; + padding-left: 0; +} + +.b08-hydro__hint { + color: #666666; + font-size: 12px; +} + +.b08-hydro__row.is-key .b08-hydro__label, +.b08-hydro__row.is-key .b08-hydro__in { + background: var(--key); +} + +.b08-hydro__label { + margin: 0; + color: var(--ink); +} + +.b08-hydro__out, +.b08-hydro__in { + box-sizing: border-box; + width: 96px; + padding: 2px 6px; + border: 1px solid #3355cc; + background: var(--paper); + text-align: right; + font: inherit; +} + +.b08-hydro__out { + color: var(--calc); +} + +.b08-hydro__in { + color: var(--input); +} + +.b08-hydro__in--text { + text-align: center; +} + +.b08-hydro__select { + width: auto; + text-align: left; + color: var(--input); +} + +.b08-hydro__row .b08-hydro__formula { + margin: 0; + font-size: 13px; +} + +.b08-hydro__ha { + width: auto; + border: none; + background: var(--ha); +} + +.b08-hydro__tcnote { + width: auto; + border: none; + font-size: 12px; +} + +.b08-hydro__place { + display: grid; + grid-template-columns: 96px 96px; + margin: 6px 0 0 16px; + border: 1px solid var(--ink); + width: max-content; +} + +.b08-hydro__place-head { + padding: 2px 6px; + text-align: center; + border-bottom: 1px solid var(--ink); +} + +.b08-hydro__place .b08-hydro__in { + border: none; + color: var(--calc); +} + +.b08-hydro__box { + padding: 12px 18px; + border: 2px solid var(--ink); +} + +.b08-hydro__kind { + margin-bottom: 10px; + font-weight: 700; +} + +.b08-hydro__row--wide { + padding-left: 0; + font-weight: 700; +} + +.b08-hydro__figure { + display: grid; + grid-template-columns: minmax(0, 1fr); + justify-items: center; + gap: 4px; +} + +.b08-hydro__dims { + justify-self: stretch; +} + +.b08-hydro__dims .b08-hydro__row { + padding-left: 0; +} + +.b08-hydro__pipe { + width: 170px; + height: 145px; +} + +.b08-hydro__pipe text { + font-size: 12px; + fill: var(--ink); +} + +.b08-hydro__water { + fill: #cfe3ff; +} + +.b08-hydro__wall { + fill: none; + stroke: var(--ink); + stroke-width: 2; +} + +.b08-hydro__line { + fill: none; + stroke: var(--ink); + stroke-width: 1; +} + +.b08-hydro__verdict { + width: max-content; + margin: 12px auto; + padding: 6px 40px; + border: 2px dashed var(--ink); + color: var(--input); + font-weight: 700; +} + +.b08-hydro__verdict.is-ng { + background: #ffe0e0; +} + +.b08-hydro__status { + margin: 8px 0 0; + color: var(--input); +} + +.b08-hydro__page { + margin-top: 8px; + text-align: center; + color: #666666; + font-size: 12px; +} diff --git a/B08_Quantity/B08_Quantity_UI_Hydro.ts b/B08_Quantity/B08_Quantity_UI_Hydro.ts new file mode 100644 index 000000000..9cdb87bbe --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Hydro.ts @@ -0,0 +1,135 @@ +/* ============================================================================= + * B08_Quantity_UI_Hydro.ts + * 4탭 수리집수면적표 — 배수지점 측점마다 보조 탭 · 한 장 = 홍수량 계산서(PLAN 62-2 · 62-3). + * + * 측점 목록만 저장된 관 지점(pipe_points)에서 · 입력은 이 탭 자기 기본값(B05/B06 값과 안 이음) + * 계산 = 서버 단독(POST …/quantity/hydro/calc · 최신 응답만 채택) · 입력은 캐시(sessionStorage)만 + * 어디에도 쓰지 않음 — 배수유역 · B05/B06 파일 그대로. + * ========================================================================== */ + +import "./B08_Quantity_UI_Hydro.css"; +import { API_BASE_URL } from "@config/config_frontend"; +import { createButton } from "@ui/ui_template_elements"; +import { readState, writeState } from "../A00_Common/b_page_state"; +import { stationLabel } from "../common_util/common_util_station"; +import { stationInterval } from "./B08_Quantity_UI_Summary"; +import { + buildHydroSheet, + type HydroInputs, + type HydroOptions, + type HydroResult, +} from "./B08_Quantity_UI_Hydro_Sheet"; + +interface HydroStation { + chainage_m: number; + facility: string; +} + +type Drafts = Record>; + +async function request(path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...init, + }); + const body = (await response.json().catch(() => null)) as { detail?: unknown } | null; + if (!response.ok) { + const detail = body?.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); + } + return body as T; +} + +function note(text: string): HTMLElement { + const p = document.createElement("p"); + p.className = "ui-general-block__empty"; + p.textContent = text; + return p; +} + +const keyOf = (station: HydroStation): string => station.chainage_m.toFixed(3); + +export async function renderHydro(host: HTMLElement, projectId: string): Promise { + host.replaceChildren(note("홍수량 계산서를 불러오는 중…")); + let sheet: { stations: HydroStation[] } & HydroOptions; + let interval = 20; + try { + [sheet, interval] = await Promise.all([ + request<{ stations: HydroStation[] } & HydroOptions>(`/projects/${projectId}/quantity/hydro`), + stationInterval(projectId).catch(() => 20), + ]); + } catch (error) { + host.replaceChildren(note(`홍수량 계산서를 불러오지 못했습니다 — ${String(error)}`)); + return; + } + if (!sheet.stations.length) { + host.replaceChildren( + note("저장된 배수지점(관 지점)이 없습니다 — 배수유역 · 종단에서 관을 저장하세요."), + ); + return; + } + + const drafts: Drafts = readState("b08-hydro", projectId) ?? {}; + const tabs = document.createElement("div"); + tabs.className = "b08-hydro__tabs"; + tabs.setAttribute("role", "tablist"); + const page = document.createElement("div"); + host.replaceChildren(tabs, page); + let seq = 0; + + const open = (index: number): void => { + const station = sheet.stations[index]; + tabs.replaceChildren( + ...sheet.stations.map((s, i) => { + const button = createButton({ + label: stationLabel(s.chainage_m, interval), + variant: i === index ? "filled" : "ghost", + size: "sm", + onClick: () => i !== index && open(i), + }); + button.setAttribute("role", "tab"); + button.setAttribute("aria-selected", String(i === index)); + button.dataset.b08HydroStation = String(s.chainage_m); + button.title = s.facility; + return button; + }), + ); + const draw = (inputs: HydroInputs): void => { + const view = buildHydroSheet( + stationLabel(station.chainage_m, interval), + { index: index + 1, total: sheet.stations.length }, + inputs, + sheet, + (next) => { + drafts[keyOf(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); + }, + ); + page.replaceChildren(view.root); + void calc(inputs, view.show); + }; + draw({ ...sheet.defaults, ...drafts[keyOf(station)] } as HydroInputs); + }; + + const calc = async ( + inputs: HydroInputs, + show: (r: HydroResult | null, message?: string) => void, + ): Promise => { + const mine = ++seq; + try { + const result = await request(`/projects/${projectId}/quantity/hydro/calc`, { + method: "POST", + body: JSON.stringify(inputs), + }); + if (mine === seq) show(result); + } catch (error) { + if (mine === seq) show(null, `계산하지 못했습니다 — ${String(error)}`); + } + }; + + open(0); +} diff --git a/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts b/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts new file mode 100644 index 000000000..24ac61ad2 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts @@ -0,0 +1,349 @@ +/* ============================================================================= + * B08_Quantity_UI_Hydro_Sheet.ts + * 홍수량 계산서 한 장(PLAN 62-2) — 견본(울진 2공구 수리계산서) 모양. + * + * 왼쪽 「1. 홍수량 계산」 1-1 합리식 · 1-2 도달시간(Kirpich) · 1-3 강우강도 + * 오른쪽 「2. 단면검토」 관 단면 그림 · 단면적 · 윤변 · 경심 · 경사 · n · 유속 · 유출량 · 판정 + * 입력값 빨강 · 계산값 파랑 · 주요 입력 노랑 바탕 · 흰 종이(어두움 테마에서도 같음). + * + * 입력 칸은 한 번 만들고 계산 결과만 제자리에 바꿈 — 고치는 중 칸 포커스를 잃지 않게. + * ========================================================================== */ + +export interface HydroInputs { + factor: number; + runoff: number; + area_km2: number; + length_m: number; + height_m: number; + tc_min_minutes: number; + r24_mm: number; + region: string; + return_period: number; + pipe_kind: string; + n: number | null; + diameter_mm: number; + slope_denominator: number; + section: "full" | "arc240" | "eff70"; +} + +export interface HydroResult { + area_ha: number; + slope_ground: number; + tc_raw_hr: number; + tc_hr: number; + tc_floored: boolean; + intensity: number; + flow: number; + design_flow: number; + n: number; + section_area: number; + perimeter: number; + radius: number; + slope_bed: number; + velocity: number; + capacity: number; + ok: boolean; + ratio_pct: number | null; +} + +export interface HydroOptions { + defaults: HydroInputs; + factors: number[]; + sections: Record; + pipe_kinds: Record; +} + +export interface HydroSheet { + root: HTMLElement; + /** 계산값 칸을 바꿈 — null 이면 비움 + 까닭 한 줄 */ + show(result: HydroResult | null, message?: string): void; +} + +type Key = keyof HydroInputs; + +const fmt = (value: number | null | undefined, digits: number): string => + value == null || !Number.isFinite(value) ? "-" : value.toFixed(digits); + +function node( + tag: K, + className = "", + text = "", +): HTMLElementTagNameMap[K] { + const el = document.createElement(tag); + if (className) el.className = className; + if (text) el.textContent = text; + return el; +} + +/** 식 글 — `^…^` 사이는 위첨자 · `_…_` 사이는 아래첨자. */ +function formula(text: string, className = "b08-hydro__formula"): HTMLElement { + const box = node("div", className); + for (const part of text.split(/(\^[^^]+\^|_[^_]+_)/)) { + if (part.startsWith("^") && part.endsWith("^")) box.append(node("sup", "", part.slice(1, -1))); + else if (part.startsWith("_") && part.endsWith("_")) + box.append(node("sub", "", part.slice(1, -1))); + else if (part) box.append(document.createTextNode(part)); + } + return box; +} + +/** 관 단면 그림 — 단면 가정대로 물 찬 자리를 칠함. */ +function pipeFigure(section: HydroInputs["section"]): 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 [cx, cy, r] = [75, 85, 70]; + 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); + }; + // 물 찬 자리 — 만관 = 원 전체 · 240° = 위 120° 활꼴 뺌(수심 0.75D) · 70 % = 수심 0.626D + const level = section === "full" ? -r : section === "arc240" ? -0.5 * r : -0.252 * r; + if (section === "full") add("circle", { cx, cy, r, class: "b08-hydro__water" }); + else { + const half = Math.sqrt(r * r - level * level); + const [x1, x2, y] = [cx - half, cx + half, cy + level]; + add("path", { d: `M${x1} ${y} A${r} ${r} 0 1 0 ${x2} ${y} Z`, class: "b08-hydro__water" }); + if (section === "arc240") { + add("path", { d: `M${x1} ${y} L${cx} ${cy} L${x2} ${y}`, class: "b08-hydro__line" }); + add("text", { x: cx, y: y + 18, "text-anchor": "middle" }, "120°"); + } + } + add("circle", { cx, cy, r, class: "b08-hydro__wall" }); + add("circle", { cx, cy, r: r + 5, class: "b08-hydro__wall" }); + // 지름 치수선 + const right = cx + r + 14; + add("path", { d: `M${right} ${cy - r} L${right} ${cy + r}`, class: "b08-hydro__line" }); + const depth = section === "full" ? "D" : section === "arc240" ? "0.75D" : "0.63D"; + add("text", { x: right + 4, y: cy + 4 }, depth); + return svg; +} + +const SECTION_TEXT: Record< + HydroInputs["section"], + { area: string; perimeter: string; radius: string } +> = { + full: { area: "A = π D^2^ / 4 = 0.785 D^2^", perimeter: "p = π D", radius: "R = A / P = D / 4" }, + arc240: { + area: "A = π D^2^/4 × 240/360 + (D/2)^2^ × sin60 × cos60 = 0.632 D^2^", + perimeter: "p = π D × 240/360", + radius: "R = A / P", + }, + eff70: { + area: "A = 0.7 × π D^2^ / 4", + perimeter: "p = π D", + radius: "R = D / 4 (만관 경심)", + }, +}; + +export function buildHydroSheet( + stationText: string, + page: { index: number; total: number }, + inputs: HydroInputs, + options: HydroOptions, + onChange: (next: HydroInputs) => void, +): HydroSheet { + const out = new Map(); + const outCell = (id: string, className = ""): HTMLElement => { + const cell = node("span", `b08-hydro__out ${className}`.trim()); + out.set(id, cell); + return cell; + }; + let current = inputs; + const commit = (key: Key, value: unknown): void => { + current = { ...current, [key]: value } as HydroInputs; + onChange(current); + }; + const numberInput = (key: Key, step = "any"): HTMLInputElement => { + const input = node("input", "b08-hydro__in"); + input.type = "number"; + input.step = step; + input.dataset.b08Hydro = key; + const value = inputs[key]; + input.value = value == null ? "" : String(value); + input.addEventListener("change", () => { + // n 은 비우면 관 종류 값 · 최소 도달시간은 0 까지 · 그 밖은 0 보다 커야 함 — 아니면 되돌림 + const parsed = input.value === "" ? null : Number(input.value); + const valid = + parsed === null + ? key === "n" + : Number.isFinite(parsed) && (key === "tc_min_minutes" ? parsed >= 0 : parsed > 0); + if (valid) commit(key, parsed); + else input.value = current[key] == null ? "" : String(current[key]); + }); + return input; + }; + const textInput = (key: Key): HTMLInputElement => { + const input = node("input", "b08-hydro__in b08-hydro__in--text"); + input.value = String(inputs[key] ?? ""); + input.dataset.b08Hydro = key; + input.addEventListener("change", () => commit(key, input.value)); + return input; + }; + const select = (key: Key, choices: Array<[string, string]>): HTMLSelectElement => { + const el = node("select", "b08-hydro__in b08-hydro__select"); + el.dataset.b08Hydro = key; + for (const [value, label] of choices) el.append(new Option(label, value)); + el.value = String(inputs[key]); + el.addEventListener("change", () => + commit(key, key === "factor" ? Number(el.value) : el.value), + ); + return el; + }; + /** 표 줄 — 이름 · 값 칸(입력 또는 계산) · 덧칸 */ + const row = (label: string, value: HTMLElement, extra?: HTMLElement, key = false) => { + const line = node("div", `b08-hydro__row${key ? " is-key" : ""}`); + line.append(node("span", "b08-hydro__label", label), value, extra ?? node("span")); + return line; + }; + /** 오른쪽 줄 — 이름 · 식(또는 입력) · 값 칸 */ + const rowF = (label: string, middle: HTMLElement | null, value: HTMLElement, key = false) => { + const line = node("div", `b08-hydro__row b08-hydro__row--f${key ? " is-key" : ""}`); + line.append(node("span", "b08-hydro__label", label), middle ?? node("span"), value); + return line; + }; + + // ── 왼쪽 1. 홍수량 계산 ── + const left = node("section", "b08-hydro__col"); + const station = node("div", "b08-hydro__station"); + station.append("측점 : ", node("mark", "", stationText)); + const factor = select( + "factor", + options.factors.map((f) => [String(f), f.toFixed(1)]), + ); + const qdLabel = node("span", "b08-hydro__label"); + qdLabel.append("Qd : 홍수량 × ", factor); + const qdRow = node("div", "b08-hydro__row"); + qdRow.append(qdLabel, outCell("design_flow")); + left.append( + node("h3", "b08-hydro__h1", "1. 홍 수 량 계 산"), + station, + node("h4", "b08-hydro__h2", "1-1. Rational Method 공식"), + formula("Q = 1/3.6 × f × rt × A"), + qdRow, + row("Q : 홍수량 ㎥/sec", outCell("flow")), + row("f : 유출계수", numberInput("runoff")), + row("rt : 평균시간 강우량 mm/hr", outCell("intensity")), + row("A : 유역면적 ㎢", numberInput("area_km2"), outCell("area_ha", "b08-hydro__ha"), true), + node("h4", "b08-hydro__h2", "1-2. 홍수도달시간"), + node("p", "b08-hydro__note", "산지부 (Kirpich 공식)"), + formula("T = 0.0663 × L^0.77^ × s^-0.385^ (L : ㎞ · T : hr)"), + row("T : 홍수도달시간 (hr)", outCell("tc_hr"), outCell("tc_note", "b08-hydro__tcnote")), + row("L : 산정에서 하천시점까지 거리 (m)", numberInput("length_m"), undefined, true), + row("s : 지표면경사 (H/L)", outCell("slope_ground")), + row("H : 산정에서 하천시점까지 표고차 (m)", numberInput("height_m"), undefined, true), + row("최소 도달시간 (분)", numberInput("tc_min_minutes")), + node("h4", "b08-hydro__h2", "1-3. 강우강도 rt의 결정"), + ); + const rain = node("div", "b08-hydro__rain"); + const r24 = node("div", "b08-hydro__row is-key"); + r24.append(formula("R_24_ : 확률강우량 (mm/day)", "b08-hydro__label"), numberInput("r24_mm")); + const place = node("div", "b08-hydro__place"); + place.append( + node("span", "b08-hydro__place-head", "지 역"), + node("span", "b08-hydro__place-head", "재현기간"), + textInput("region"), + numberInput("return_period"), + ); + const rtRow = node("div", "b08-hydro__row"); + rtRow.append(formula("rt = (R_24_/24) × (24/T)^0.557^"), outCell("intensity2")); + rain.append(rtRow, r24, place); + left.append( + rain, + node("p", "b08-hydro__note", "(확률강우량 — 기본값 · 지역 관측소 값으로 고침)"), + ); + + // ── 오른쪽 2. 단면검토 ── + const right = node("section", "b08-hydro__col"); + const box = node("div", "b08-hydro__box"); + const kinds = Object.entries(options.pipe_kinds).map( + ([kind, n]) => [kind, `${kind} (n ${n})`] as [string, string], + ); + const kindLine = node("div", "b08-hydro__kind"); + kindLine.append( + "- ", + select("pipe_kind", kinds), + " · ", + select("section", Object.entries(options.sections) as Array<[string, string]>), + ); + const total = node("div", "b08-hydro__row b08-hydro__row--wide"); + total.append(node("span", "b08-hydro__label", "1. 총 홍수량 Qd (㎥/s)"), outCell("design_flow2")); + const texts = SECTION_TEXT[inputs.section]; + const dims = node("div", "b08-hydro__dims"); + const nInput = numberInput("n"); + nInput.placeholder = String(options.pipe_kinds[inputs.pipe_kind] ?? ""); + dims.append( + rowF("단 면 : D (mm)", null, numberInput("diameter_mm"), true), + 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", node("span", "b08-hydro__hint", "비우면 관 종류 값"), nInput), + ); + const figure = node("div", "b08-hydro__figure"); + figure.append(pipeFigure(inputs.section), dims); + const verdict = node("div", "b08-hydro__verdict"); + out.set("verdict", verdict); + box.append( + kindLine, + total, + node("div", "b08-hydro__h2", "2. 유 출 량"), + figure, + rowF("- 유 속 :", formula("V = 1/n × R^2/3^ × S^1/2^ (m/sec)"), outCell("velocity")), + rowF("- 유출량 :", formula("Q = A × V (㎥/sec)"), outCell("capacity")), + verdict, + rowF("- 유출율 (%)", formula("Q / Qd × 100"), outCell("ratio_pct")), + ); + right.append(node("h3", "b08-hydro__h1", "2. 단면검토"), box); + + const status = node("p", "b08-hydro__status"); + const paper = node("article", "b08-hydro__paper"); + paper.dataset.b08HydroSheet = stationText; + const body = node("div", "b08-hydro__body"); + body.append(left, right); + paper.append( + body, + status, + node("footer", "b08-hydro__page", `— ${page.index} / ${page.total} 쪽 —`), + ); + + const set = (id: string, text: string): void => { + const cell = out.get(id); + if (cell) cell.textContent = text; + }; + return { + root: paper, + show(result, message = "") { + status.textContent = message; + const r = result; + set("design_flow", fmt(r?.design_flow, 2)); + set("design_flow2", fmt(r?.design_flow, 2)); + set("flow", fmt(r?.flow, 2)); + set("intensity", fmt(r?.intensity, 2)); + set("intensity2", fmt(r?.intensity, 2)); + set("area_ha", r ? `${fmt(r.area_ha, 1)} ha` : "-"); + set("tc_hr", fmt(r?.tc_hr, 3)); + set( + "tc_note", + r?.tc_floored + ? `Kirpich ${fmt(r.tc_raw_hr * 60, 1)}분 → 최소 ${current.tc_min_minutes}분` + : "", + ); + set("slope_ground", fmt(r?.slope_ground, 3)); + set("section_area", r ? `${fmt(r.section_area, 3)} ㎡` : "-"); + set("perimeter", r ? `${fmt(r.perimeter, 3)} m` : "-"); + set("radius", fmt(r?.radius, 3)); + set("slope_pct", r ? `${fmt(r.slope_bed * 100, 2)} %` : "-"); + set("velocity", fmt(r?.velocity, 3)); + set("capacity", fmt(r?.capacity, 3)); + set("ratio_pct", fmt(r?.ratio_pct, 0)); + verdict.classList.toggle("is-ng", !!r && !r.ok); + verdict.textContent = r + ? `${fmt(r.capacity, 3)}㎥/s ${r.ok ? ">" : "<"} Qd = ${fmt(r.design_flow, 2)}㎥/s - ${r.ok ? "O K" : "N G"} -` + : "-"; + }, + }; +} diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 51b04b5d4..cd994fe11 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -3,7 +3,7 @@ * 로그인 후 08: 5차 워크플로우 (수량 산출) — 위 탭 화면 (PLAN 61) * * 탭 = 구조물 집계표 · 구조물도 · 토적표 · 수리집수면적표 — 차례대로 만듦. - * 지금은 1탭(구조물 집계표 · 61-1)만 내용 · 나머지는 빈 탭. + * 1탭(구조물 집계표 · 61-1) · 4탭(수리집수면적표 · 홍수량 계산서 62-2) 내용 · 나머지는 빈 탭. * 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀. * ========================================================================== */ @@ -16,6 +16,7 @@ import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; import { renderStructureSummary } from "./B08_Quantity_UI_Summary"; +import { renderHydro } from "./B08_Quantity_UI_Hydro"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -38,6 +39,10 @@ function renderTab(host: HTMLElement, tab: TabId, projectId: string | null): voi void renderStructureSummary(view, projectId); return; } + if (tab === "hydrology" && projectId) { + void renderHydro(view, projectId); + return; + } const pending = document.createElement("p"); pending.className = "ui-general-block__empty"; pending.textContent = L("B08_Quantity_Tab_Pending"); diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index 2dc9f171e..2b9e507bc 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -28,7 +28,7 @@ function L(key: keyof typeof ui_locales): string { } /** 측점 간격 — 노선 설정값 · 못 읽으면 20m. */ -async function stationInterval(projectId: string): Promise { +export async function stationInterval(projectId: string): Promise { const latest = readRouteLatestCache(projectId) ?? (await fetchLatestRoute(projectId)); return latest.route_params?.station_interval_m || 20; } diff --git a/main.py b/main.py index 2157c0f1e..bfc974584 100644 --- a/main.py +++ b/main.py @@ -61,6 +61,7 @@ from B06_Section.B06_Section_Router_HaulPlan import ( from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import router as b07_plan_kinds_router +from B08_Quantity.B08_Quantity_Router_Hydro import router as b08_hydro_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router @@ -640,6 +641,7 @@ app.include_router(b06_section_haul_plan_router, dependencies=protected_with_com app.include_router(b07_design_router, dependencies=protected_with_company) app.include_router(b07_frame_router, dependencies=protected_with_company) app.include_router(b07_plan_kinds_router, dependencies=protected_with_company) +app.include_router(b08_hydro_router, dependencies=protected_with_company) # B08 수량·B09 원가계산 라우터 13 개는 끊음 — 코드는 `old_code/` 에 있고 화면은 빈 틀만 뜬다(PLAN 7-3). # 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다. system_admin_only = [Depends(verify_session), Depends(require_system_admin)] diff --git a/resources/tester/test_62_2_b08_hydro_engine.py b/resources/tester/test_62_2_b08_hydro_engine.py new file mode 100644 index 000000000..189024d14 --- /dev/null +++ b/resources/tester/test_62_2_b08_hydro_engine.py @@ -0,0 +1,73 @@ +"""B08 4탭 홍수량 계산서 엔진 — 견본 입력 손계산 대조 (PLAN 62-2 · 62-3). + +견본(울진 2공구 71+13) 입력: A 0.132 ㎢ · L 480 m · H 185 m · R24 305.9 · D 1,200 · S 1/20 · +n 0.013 · 할증 1.5 · 만관. 도달시간만 Kirpich(사용자 결정). +""" + +from __future__ import annotations + +import math +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 + +SAMPLE = { + "factor": 1.5, + "runoff": 0.8, + "area_km2": 0.132, + "length_m": 480.0, + "height_m": 185.0, + "tc_min_minutes": 5.0, + "r24_mm": 305.9, + "region": "울진", + "return_period": 100, + "pipe_kind": "흄관", + "n": 0.013, + "diameter_mm": 1200.0, + "slope_denominator": 20.0, + "section": "full", +} + + +def test_견본_입력_손계산과_같다() -> None: + out = compute(SAMPLE) + # Kirpich: 0.0663 × 0.48^0.77 × (185/480)^-0.385 = 0.05439 hr(3.26 분) → 최소 5 분 + assert out["tc_raw_hr"] * 60 == pytest.approx(3.2632, abs=1e-3) + assert out["tc_hr"] == pytest.approx(5 / 60) and out["tc_floored"] + # rt = 305.9/24 × (24 / (5/60))^0.557 = 12.7458 × 288^0.557 + assert out["intensity"] == pytest.approx(298.71, abs=0.01) + assert out["flow"] == pytest.approx(0.8 * 298.71 * 0.132 / 3.6, rel=1e-4) # 8.762 + assert out["design_flow"] == pytest.approx(1.5 * out["flow"]) + # 만관 Ø1.2: A 1.1310 · P 3.7699 · R 0.3 · V = (1/0.013)·0.3^(2/3)·0.05^0.5 = 7.708 + assert out["section_area"] == pytest.approx(1.13097, abs=1e-4) + assert out["radius"] == pytest.approx(0.3) + assert out["velocity"] == pytest.approx(7.708, abs=1e-3) + assert out["capacity"] == pytest.approx(8.718, abs=1e-3) + assert out["ok"] is False # 8.718 < Qd 13.14 + assert out["ratio_pct"] == pytest.approx(8.718 / 13.143 * 100, abs=0.1) + + +def test_최소_도달시간을_끄면_Kirpich_값_그대로() -> None: + out = compute({**SAMPLE, "tc_min_minutes": 0}) + assert out["tc_hr"] == out["tc_raw_hr"] and not out["tc_floored"] + + +def test_단면_가정과_관_종류() -> None: + d = 1.2 + arc = compute({**SAMPLE, "section": "arc240"}) + assert arc["section_area"] == pytest.approx(0.63185 * d * d, abs=1e-4) + assert arc["perimeter"] == pytest.approx(math.pi * d * 2 / 3) + eff = compute({**SAMPLE, "section": "eff70"}) + assert eff["section_area"] == pytest.approx(0.7 * math.pi * d * d / 4) + assert eff["radius"] == pytest.approx(d / 4) + # n 비우면 관 종류 값(파형강관 0.024) + kind = compute({**SAMPLE, "n": None, "pipe_kind": "파형강관"}) + assert kind["n"] == 0.024 + # 할증 · 단면만 바꾸면 홍수량 쪽 Q 는 그대로 + assert compute({**SAMPLE, "factor": 2.0})["flow"] == pytest.approx(arc["flow"])