Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"} -`
|
||||
: "-";
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
Reference in New Issue
Block a user