feat(b08): 4탭 홍수량 계산서 — 배수지점 측점 보조 탭 · 견본 모양 한 장 · 서버 계산(PLAN 62-2 · 62-3)

- 측점 보조 탭 = 저장된 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
This commit is contained in:
2026-10-06 18:15:29 +09:00
co-authored by Claude Opus 5.5
parent 2a3ef43710
commit 5f056ec661
11 changed files with 1011 additions and 2 deletions
+2
View File
@@ -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": {
+78
View File
@@ -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,
}
@@ -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,
}
+72
View File
@@ -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())
+248
View File
@@ -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;
}
+135
View File
@@ -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<string, Partial<HydroInputs>>;
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
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<void> {
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<Drafts>("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<void> => {
const mine = ++seq;
try {
const result = await request<HydroResult>(`/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);
}
+349
View File
@@ -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<HydroInputs["section"], string>;
pipe_kinds: Record<string, number>;
}
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<K extends keyof HTMLElementTagNameMap>(
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<string, string | number>, 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<string, HTMLElement>();
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"} -`
: "-";
},
};
}
+6 -1
View File
@@ -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");
+1 -1
View File
@@ -28,7 +28,7 @@ function L(key: keyof typeof ui_locales): string {
}
/** 측점 간격 — 노선 설정값 · 못 읽으면 20m. */
async function stationInterval(projectId: string): Promise<number> {
export async function stationInterval(projectId: string): Promise<number> {
const latest = readRouteLatestCache(projectId) ?? (await fetchLatestRoute(projectId));
return latest.route_params?.station_interval_m || 20;
}
+2
View File
@@ -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)]
@@ -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"])