feat(B08): 수리 판정 하상경사 ← B06 관 경사 · 박스 원지반 경사 연결값(110-1)
- B06 정본 키 둘(pipe_slope_pct · box_ground_slope_pct) — 관 길이와 같은 길(STRUCTURE_ROW_KEYS ↔ _AREA_KEYS ↔ CrossSectionPatch) · 주인 측점에만 - B08 수리 표 linked 에 slope_denominator = 100 / 경사 · 0 % 이하 · 짝 없음은 빈칸 · 세월교 · 물넘이는 장 기본값 그대로 - 수리 표 원천 표 「B06」 · 96 「하상경사 안 이음」 주석 고침 · 시험 test_110 새로 · 짝 시험 키 더함 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
This commit is contained in:
@@ -289,6 +289,8 @@ export interface FordSet {
|
||||
/** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */
|
||||
export interface BoxSet {
|
||||
type: "box";
|
||||
/** 이 시설이 **놓인** 누가거리(m) — 걸친 옆 측점과 주인 측점을 가리는 열쇠(110). */
|
||||
chainage_m?: number;
|
||||
/** 사용자 입력 내공(유로) 폭·높이(m). */
|
||||
inner_width_m: number;
|
||||
inner_height_m: number;
|
||||
@@ -561,6 +563,9 @@ export interface CrossSectionPatch {
|
||||
cut_slope_ratio_user?: number;
|
||||
/** 배수관 연장(m) — 구조물 면적과 같은 길로 정본에 실린다(2026-09-08). */
|
||||
pipe_length_m?: number;
|
||||
/** B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 관 길이와 같은 길. */
|
||||
pipe_slope_pct?: number;
|
||||
box_ground_slope_pct?: number;
|
||||
inlet_structure?: "auto" | "revet" | "I" | "L" | "U";
|
||||
basin_adjust?: {
|
||||
innerWidthM: number;
|
||||
|
||||
@@ -184,6 +184,10 @@ class CrossSectionPatch(BaseModel):
|
||||
# 공종을 세려면 정본에 있어야 한다(2026-09-08). 계산은 서버가 같은 코드를 Node 로
|
||||
# 돌려 내므로 한 벌이다(`B06_Section_Server_Calc_Node`).
|
||||
pipe_length_m: float | None = Field(default=None, ge=0)
|
||||
# B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 기하가 낸 값
|
||||
# (관 길이와 같은 길). 0 · 음수도 그대로 — 빈칸 판정은 B08 몫.
|
||||
pipe_slope_pct: float | None = None
|
||||
box_ground_slope_pct: float | None = None
|
||||
|
||||
|
||||
class SectionConfirmRequest(BaseModel):
|
||||
|
||||
@@ -66,6 +66,8 @@ _AREA_KEYS = (
|
||||
"cut_soil_area_m2",
|
||||
"cut_rock_area_m2",
|
||||
"pipe_length_m",
|
||||
"pipe_slope_pct",
|
||||
"box_ground_slope_pct",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ import { computeStructureAreas, wallBodyInFillM2 } from "@util/common_util_cross
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import { withoutGuardedFill } from "./B06_Section_UI_Cross_Body_Guard";
|
||||
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
|
||||
import type { BoxLayout } from "./B06_Section_UI_Cross_Box_Geom";
|
||||
import { REVET_EMBED_DEPTH_M } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
|
||||
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type { BasinShape, WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import {
|
||||
@@ -68,16 +70,15 @@ const inletStructure: InletStructureControl = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 그 관의 **주인 측점** 누가거리 — 관 자리에서 가장 가까운 측점 하나. 관 자리를 모르면 `null`.
|
||||
* 그 시설의 **주인 측점** 누가거리 — 시설 자리(`target`)에서 가장 가까운 측점 하나. 자리를 모르면 `null`.
|
||||
*
|
||||
* ⚠ 관 자리와 측점 자리는 스냅 때문에 어긋날 수 있다(위 설명 참조). 거리 한계를 두지 않고
|
||||
* **가장 가까운 하나**만 고르는 것이 요점 — 두 측점이 같이 「주인」이 되면 같은 관을 두 번 센다.
|
||||
*/
|
||||
function pipeOwnerChainage(
|
||||
section: CrossSection,
|
||||
function ownerChainage(
|
||||
target: number | undefined,
|
||||
sections: readonly CrossSection[],
|
||||
): number | null {
|
||||
const target = section.culvert?.chainage_m;
|
||||
if (typeof target !== "number") return null;
|
||||
let best: number | null = null;
|
||||
for (const item of sections) {
|
||||
@@ -155,8 +156,21 @@ export const STRUCTURE_ROW_KEYS = [
|
||||
"cut_soil_area_m2",
|
||||
"cut_rock_area_m2",
|
||||
"pipe_length_m",
|
||||
// B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 같은 길로 실음.
|
||||
"pipe_slope_pct",
|
||||
"box_ground_slope_pct",
|
||||
] as const;
|
||||
|
||||
/** 박스 두 끝(구체 끝) 원지반 경사(%) — 유입 − 유출 ÷ 수평거리. 본체는 늘 수평이라 원지반으로(110). */
|
||||
function boxGroundSlopePct(section: CrossSection, box: BoxLayout): number | null {
|
||||
const groundAt = groundInterpolator(section.samples);
|
||||
const inlet = box.sides.find((side) => side.wingRole === "inlet");
|
||||
const outlet = box.sides.find((side) => side.wingRole === "outlet");
|
||||
const run = inlet && outlet ? Math.abs(inlet.offset - outlet.offset) : 0;
|
||||
if (!groundAt || !inlet || !outlet || !(run > 0)) return null;
|
||||
return ((groundAt(inlet.offset) - groundAt(outlet.offset)) / run) * 100;
|
||||
}
|
||||
|
||||
/** 폐회로 면적 입력(설계선·지반선·트림) — 트림이나 설계선이 모자라면 null. */
|
||||
function areaInputOf(section: CrossSection, layouts: StoredLayouts) {
|
||||
const trim = trimOfLayouts(layouts);
|
||||
@@ -195,18 +209,26 @@ function areaRowOf(
|
||||
// B08 이 「연장 없음」으로 막아 **금액이 통째로 빠진다**(실측: 배수관 넷).
|
||||
// ⇒ **가장 가까운 측점 하나**를 주인으로 본다. 거리로 자르지 않으므로 스냅 폭이
|
||||
// 바뀌어도 따라가고, 하나만 고르므로 두 번 세지도 않는다.
|
||||
const ownerChainage = pipeOwnerChainage(section, sections);
|
||||
const isOwner = (target: number | undefined): boolean => {
|
||||
const owner = ownerChainage(target, sections);
|
||||
return owner === null || Math.abs(owner - section.chainage_m) <= CHAINAGE_TOLERANCE_M;
|
||||
};
|
||||
// 세월교 · 박스 흙막이 세트(73-4)는 관이 아님 — 관 연장을 안 실음.
|
||||
const pipeOwner =
|
||||
!!section.culvert &&
|
||||
!section.culvert.body &&
|
||||
(ownerChainage === null ||
|
||||
Math.abs(ownerChainage - section.chainage_m) <= CHAINAGE_TOLERANCE_M);
|
||||
const pipeLengthM = pipeOwner ? layouts.culvert?.pipe?.lengthM : undefined;
|
||||
const pipeRow: Record<string, number> | null =
|
||||
typeof pipeLengthM === "number" && pipeLengthM > 0
|
||||
? { chainage_m: section.chainage_m, pipe_length_m: Number(pipeLengthM.toFixed(4)) }
|
||||
!!section.culvert && !section.culvert.body && isOwner(section.culvert.chainage_m);
|
||||
const pipe = pipeOwner ? layouts.culvert?.pipe : undefined;
|
||||
const ownRow: Record<string, number> = { chainage_m: section.chainage_m };
|
||||
if (pipe && pipe.lengthM > 0) {
|
||||
ownRow.pipe_length_m = Number(pipe.lengthM.toFixed(4));
|
||||
ownRow.pipe_slope_pct = Number(pipe.slopePct.toFixed(4));
|
||||
}
|
||||
// 박스도 걸친 옆 측점이 아니라 주인 측점에만(관과 같은 규칙).
|
||||
const boxSlope =
|
||||
layouts.box && isOwner(section.box?.chainage_m)
|
||||
? boxGroundSlopePct(section, layouts.box)
|
||||
: null;
|
||||
if (boxSlope !== null) ownRow.box_ground_slope_pct = Number(boxSlope.toFixed(4));
|
||||
const pipeRow = Object.keys(ownRow).length > 1 ? ownRow : null;
|
||||
const areaInput = areaInputOf(section, layouts);
|
||||
if (!areaInput) return pipeRow;
|
||||
const design = layouts.design;
|
||||
@@ -280,7 +302,7 @@ export function extraWallRows(sections: readonly CrossSection[]): ExtraWallRow[]
|
||||
const rows: ExtraWallRow[] = [];
|
||||
for (const section of sections) {
|
||||
if (!section.culvert) continue;
|
||||
const owner = pipeOwnerChainage(section, sections);
|
||||
const owner = ownerChainage(section.culvert.chainage_m, sections);
|
||||
if (owner !== null && Math.abs(owner - section.chainage_m) > CHAINAGE_TOLERANCE_M) continue;
|
||||
const layouts = computeStoredLayouts(section, sections);
|
||||
const culvert = layouts?.culvert;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""B08 4탭 홍수량 계산서 — 기본값 · 선택지 · B05 연결값 한 곳 (PLAN 62-3 · 73-11 · 73-12 · 96).
|
||||
|
||||
표준값(유출계수 · 할증 · 재현기간 · 최소 도달시간)은 B05 배수 설정(config DRAINAGE_*)과 같은 값.
|
||||
측점 고유 값은 B05 배수유역 저장본의 연결값(linked · 96-1) · 하상경사 · 관 종류 · n 은 안 이음.
|
||||
측점 고유 값은 B05 배수유역 저장본의 연결값(linked · 96-1) · 하상경사는 B06 횡단 경사(110) ·
|
||||
관 종류 · n 은 안 이음.
|
||||
근거 후보: docs/raw/조사/2026-10-06_62-1_홍수량_방법.md (별표2 2.0배 · KDS 표 2.7-1 · 실무 견본).
|
||||
"""
|
||||
|
||||
@@ -17,6 +18,7 @@ from config.config_system_design import (
|
||||
DRAINAGE_RUNOFF_COEFFICIENT,
|
||||
DRAINAGE_TC_MIN_MINUTES,
|
||||
)
|
||||
from M02_MasterTemplete.M02_Table_Fill import _nearest
|
||||
|
||||
#: 할증(Qd = Q × 할증) — 2.0 별표2 법정 · 1.5 견본(울진 2공구) · 1.2 구기준
|
||||
FACTORS = (2.0, 1.5, 1.2)
|
||||
@@ -66,6 +68,11 @@ SHEET_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
}
|
||||
|
||||
|
||||
#: 장 → B06 횡단 정본 경사 키(110) — 관 = 관 바닥 두 끝 경사 · 박스 = 박스 두 끝 원지반 경사
|
||||
#: (본체는 늘 수평) · 세월교 · 물넘이는 장 기본값(B05 세월교 높이 계산과 같은 값)
|
||||
SLOPE_KEYS = {"pipe": "pipe_slope_pct", "box": "box_ground_slope_pct"}
|
||||
|
||||
|
||||
def _positive(value: Any) -> float | None:
|
||||
"""양수만 — 글 '1000' 도 숫자로 · 0 · 빈 값 · 글자는 None(빈칸)."""
|
||||
try:
|
||||
@@ -75,9 +82,29 @@ def _positive(value: Any) -> float | None:
|
||||
return number if number > 0 else None
|
||||
|
||||
|
||||
def _linked(sheet: str, options: dict[str, Any], basin: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""B05 값 → 계산서 칸(96-1) — 값이 있는 칸만 · A · L · H 는 유역 · 치수는 시설 옵션."""
|
||||
def bed_slopes(designs: list[dict[str, Any]] | None) -> dict[str, dict[float, float]]:
|
||||
"""B06 횡단 설계 `[{chainage_m, design}]` → 장별 `{측점: 경사 %}` — 0 % 이하는 안 담음(빈칸)."""
|
||||
found: dict[str, dict[float, float]] = {sheet: {} for sheet in SLOPE_KEYS}
|
||||
for row in designs or []:
|
||||
design = row.get("design") or {}
|
||||
for sheet, key in SLOPE_KEYS.items():
|
||||
pct = _positive(design.get(key))
|
||||
if pct:
|
||||
found[sheet][round(float(row.get("chainage_m") or 0.0), 3)] = pct
|
||||
return found
|
||||
|
||||
|
||||
def _linked(
|
||||
sheet: str,
|
||||
options: dict[str, Any],
|
||||
basin: dict[str, Any] | None,
|
||||
slope_pct: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""연결값 → 계산서 칸(96-1) — 값이 있는 칸만 · A · L · H 는 유역 · 치수는 시설 옵션 ·
|
||||
하상경사 S = 1 / 분모 는 B06 경사(110 · 0 % 이하면 빈칸 · 손 입력)."""
|
||||
values: dict[str, Any] = {}
|
||||
if slope_pct:
|
||||
values["slope_denominator"] = round(100 / slope_pct, 3)
|
||||
if basin is not None:
|
||||
area_m2 = _positive(basin.get("area_m2"))
|
||||
values["area_km2"] = area_m2 / 1e6 if area_m2 else None
|
||||
@@ -94,13 +121,17 @@ def _linked(sheet: str, options: dict[str, Any], basin: dict[str, Any] | None) -
|
||||
|
||||
|
||||
def station_rows(
|
||||
points: list[Any], basins: list[dict[str, Any]] | None = None
|
||||
points: list[Any],
|
||||
basins: list[dict[str, Any]] | None = None,
|
||||
designs: list[dict[str, Any]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""관 지점 → 보조 탭 줄(측점 차례) · 장 종류(pipe · box · ford).
|
||||
|
||||
basins(B05 세부유역 속성) 를 주면 측점마다 연결값 `linked` · 비교용 `b05`(Qd · T) — 측점 짝은
|
||||
`carry_indices`(±0.05 m) · 안 주면 둘 다 None.
|
||||
`carry_indices`(±0.05 m) · 안 주면 둘 다 None. designs(B06 횡단 설계) 경사는 관 지점과
|
||||
가장 가까운 횡단 측점(±0.5 m · 관 연장과 같은 짝)에서.
|
||||
"""
|
||||
slopes = bed_slopes(designs)
|
||||
points = sorted(points, key=lambda p: p.chainage_m)
|
||||
picks = (
|
||||
carry_indices([p.chainage_m for p in points], [float(b["chainage_m"]) for b in basins])
|
||||
@@ -114,7 +145,8 @@ def station_rows(
|
||||
row["linked"] = row["b05"] = None
|
||||
if basins is not None:
|
||||
basin = basins[pick] if pick is not None else None
|
||||
row["linked"] = _linked(sheet, getattr(p, "options", None) or {}, basin)
|
||||
slope = _nearest(slopes.get(sheet, {}), p.chainage_m)
|
||||
row["linked"] = _linked(sheet, getattr(p, "options", None) or {}, basin, slope)
|
||||
if basin is not None:
|
||||
row["b05"] = {
|
||||
"design_flow_m3s": basin.get("design_flow_m3s"),
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""B08 4탭 홍수량 계산서 길 (PLAN 62-2 · 62-3 · 69-5 · 69-6 · 73-11 · 73-12).
|
||||
|
||||
GET /api/projects/{id}/quantity/hydro 배수지점 측점(저장된 pipe_points) + B05 연결값
|
||||
GET /api/projects/{id}/quantity/hydro 배수지점 측점(저장된 pipe_points) + B05 · B06 연결값
|
||||
+ 기본값 · 선택지 + 공사지(지역 · R24) + 저장본
|
||||
POST /api/projects/{id}/quantity/hydro/calc 화면 입력 → 계산서 값(B08_Quantity_Engine_Hydro)
|
||||
PUT /api/projects/{id}/quantity/hydro [저장] — 측점별 입력을 B08 자기 파일에만 씀
|
||||
|
||||
측점 목록 · 시설 치수는 관 지점 정본 · A · L · H 는 B05 세부유역 저장본에서 연결값(96-1) —
|
||||
화면이 빈칸만 채움 · 하상경사 · 관 종류 · n 은 잇지 않음. B05/B06 · 배수유역 파일에는 쓰지 않음.
|
||||
측점 목록 · 시설 치수는 관 지점 정본 · A · L · H 는 B05 세부유역 저장본에서 연결값(96-1) ·
|
||||
하상경사는 B06 횡단 설계(관 경사 · 박스 원지반 경사 · 110) — 화면이 빈칸만 채움 · 관 종류 · n 은
|
||||
잇지 않음. B05/B06 · 배수유역 파일에는 쓰지 않음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,6 +20,11 @@ from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
get_confirmed_route_context,
|
||||
get_cross_section_designs,
|
||||
get_workflow_route_context,
|
||||
)
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B08_Quantity.B08_Quantity_Engine_Hydro import compute
|
||||
from B08_Quantity.B08_Quantity_Hydro_Defaults import FACTORS, PIPE_KINDS, options, station_rows
|
||||
@@ -81,7 +87,15 @@ async def _project_root(project_id: UUID) -> Path:
|
||||
@router.get("/{project_id}/quantity/hydro")
|
||||
async def hydro_sheet(project_id: UUID) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
stations = station_rows(read_pipe_points_file(pipe_points_path_in(root)), read_basins(root))
|
||||
# 설계 = B08 집계표와 같은 길(확정 노선 먼저) · 노선 전이면 경사 없이
|
||||
async with get_db_pool().acquire() as connection:
|
||||
context = await get_confirmed_route_context(connection, project_id)
|
||||
context = context or await get_workflow_route_context(connection, project_id)
|
||||
designs = (
|
||||
await get_cross_section_designs(connection, int(context["route_id"])) if context else []
|
||||
)
|
||||
points = read_pipe_points_file(pipe_points_path_in(root))
|
||||
stations = station_rows(points, read_basins(root), designs)
|
||||
project = await get_project_by_id(str(project_id)) or {}
|
||||
return {
|
||||
"stations": stations,
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* 장(sheet) 갈래 — 박스 = 사각 내공 B · H · 수심비(73-11) · 세월교 · 물넘이 = 월류 폭 → 월류 높이(73-12)
|
||||
* 테두리는 입력 · 선택 칸만(공용 ui-input · ui-select) · 계산 칸은 글자만
|
||||
* 굵게 + 붉은 계열은 중요 결과(Qd · 유출량 · 판정 · 유출율)만 · 빈칸에 기대는 계산 칸은 「-」.
|
||||
* B05 연결값과 같은 칸은 이름 옆 작은 「B05」(풍선 「B05 분석」) · Qd · T 옆에 B05 값 참고(96-2).
|
||||
* 연결값과 같은 칸은 이름 옆 작은 원천 표 — 「B05」(풍선 「B05 분석」) · 하상경사는 「B06」
|
||||
* (풍선 「B06 횡단 경사」 · 110) · Qd · T 옆에 B05 값 참고(96-2).
|
||||
*
|
||||
* 입력 칸은 한 번 만들고 계산 결과만 제자리에 바꿈 — 고치는 중 칸 포커스를 잃지 않게.
|
||||
* ========================================================================== */
|
||||
@@ -78,7 +79,7 @@ export interface HydroOptions {
|
||||
site: HydroSite;
|
||||
}
|
||||
|
||||
/** B05 배수유역 연결(96) — 칸 값 · 비교용 설계유량 · 도달시간(분) */
|
||||
/** 연결(96 · 110) — 칸 값(B05 배수유역 · 하상경사는 B06 횡단) · 비교용 설계유량 · 도달시간(분) */
|
||||
export interface HydroLink {
|
||||
values: Partial<Record<keyof HydroInputs, number>>;
|
||||
b05: { design_flow_m3s: number | null; tc_minutes: number | null } | null;
|
||||
@@ -236,7 +237,7 @@ export function buildHydroSheet(
|
||||
link: HydroLink = { values: {}, b05: null },
|
||||
): HydroSheet {
|
||||
const site = options.site;
|
||||
/** 연결된 칸의 「B05」 표 — 값이 연결값과 같을 때만 보임 */
|
||||
/** 연결된 칸의 원천 표(「B05」 · 「B06」) — 값이 연결값과 같을 때만 보임 */
|
||||
const marks = new Map<Key, HTMLElement>();
|
||||
const markLinked = (key: Key): void => {
|
||||
const mark = marks.get(key);
|
||||
@@ -263,9 +264,12 @@ export function buildHydroSheet(
|
||||
input.value = value == null ? "" : String(value);
|
||||
const linked = link.values[key];
|
||||
if (linked != null) {
|
||||
input.title = `B05 분석 ${linked}`;
|
||||
const mark = node("span", "b08-hydro__hint", "B05");
|
||||
mark.title = "B05 분석";
|
||||
// 원천 표 — 하상경사는 B06 횡단 경사(관 바닥 · 박스 원지반 두 끝 · 110) · 그 밖은 B05
|
||||
const [source, title] =
|
||||
key === "slope_denominator" ? ["B06", "B06 횡단 경사"] : ["B05", "B05 분석"];
|
||||
input.title = `${title} ${linked}`;
|
||||
const mark = node("span", "b08-hydro__hint", source);
|
||||
mark.title = title;
|
||||
mark.dataset.b08HydroLinked = key;
|
||||
marks.set(key, mark);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""110 — B08 수리 판정 하상경사 ← B06 횡단 경사.
|
||||
|
||||
관 = 관 바닥 두 끝 경사(`CulvertLayout.pipe.slopePct`) · 박스 = 박스 두 끝 원지반 경사(본체는 늘 수평) ·
|
||||
관 길이와 같은 길(`STRUCTURE_ROW_KEYS` → 서버 Node → `_AREA_KEYS` → 정본) · B08 은 연결값(linked)으로
|
||||
실음(저장된 측점 null 이 덮지 않게) · 0 % 이하는 빈칸(손 입력) · 세월교 · 물넘이는 장 기본값 그대로.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Engine_Hydro import compute # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Hydro_Defaults import ( # noqa: E402
|
||||
DEFAULTS,
|
||||
SHEET_DEFAULTS,
|
||||
station_rows,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import PipePoint # noqa: E402
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
NODE_MODULES = ROOT / "config" / "node_modules"
|
||||
needs_node = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치")
|
||||
|
||||
BASINS = [
|
||||
{"kind": "detail_basin", "chainage_m": c, "area_m2": 10717.0, "flow_length_m": 163.9,
|
||||
"relief_m": 120.0, "design_flow_m3s": 1.0, "tc_minutes": 5.0}
|
||||
for c in (85.05, 173.09, 300.0, 400.0, 500.0)
|
||||
] # fmt: skip
|
||||
POINTS = [
|
||||
PipePoint(chainage_m=85.052, options={"pipe_diameter_mm": "1000"}),
|
||||
PipePoint(chainage_m=173.09, facility="ford_bridge", options={"ford_width_m": 10}),
|
||||
PipePoint(chainage_m=300.004, facility="box_culvert",
|
||||
options={"body_width_m": 2.0, "body_height_m": 1.5}),
|
||||
PipePoint(chainage_m=400.0, options={"pipe_diameter_mm": 800}),
|
||||
PipePoint(chainage_m=500.0, options={"pipe_diameter_mm": 800}),
|
||||
] # fmt: skip
|
||||
DESIGNS = [
|
||||
# 관 자리 85.052 ↔ 횡단 85.0(스냅 · 0.5 m 안)
|
||||
{"chainage_m": 85.0, "design": {"pipe_slope_pct": 5.0, "pipe_length_m": 9}},
|
||||
# 세월교에 경사 키가 있어도 안 이음(장 기본값)
|
||||
{"chainage_m": 173.09, "design": {"pipe_slope_pct": 4.0, "box_ground_slope_pct": 4.0}},
|
||||
{"chainage_m": 300.0, "design": {"box_ground_slope_pct": 2.0}},
|
||||
{"chainage_m": 400.0, "design": {"pipe_slope_pct": 0.0}}, # 수평 관 → 빈칸
|
||||
{"chainage_m": 501.0, "design": {"pipe_slope_pct": 3.0}}, # 1 m 떨어짐 → 짝 없음
|
||||
]
|
||||
|
||||
|
||||
def _rows() -> dict[float, dict]:
|
||||
return {r["chainage_m"]: r for r in station_rows(POINTS, BASINS, DESIGNS)}
|
||||
|
||||
|
||||
def test_관_경사가_하상경사_칸으로() -> None:
|
||||
assert _rows()[85.052]["linked"]["slope_denominator"] == 20.0 # 5 % → 1/20
|
||||
|
||||
|
||||
def test_박스는_원지반_경사() -> None:
|
||||
assert _rows()[300.004]["linked"]["slope_denominator"] == 50.0 # 2 % → 1/50
|
||||
|
||||
|
||||
def test_0_퍼센트_이하와_짝_없음은_빈칸() -> None:
|
||||
rows = _rows()
|
||||
assert "slope_denominator" not in rows[400.0]["linked"]
|
||||
assert "slope_denominator" not in rows[500.0]["linked"]
|
||||
assert DEFAULTS["slope_denominator"] is None # 손 입력 자리
|
||||
|
||||
|
||||
def test_세월교는_장_기본값_그대로() -> None:
|
||||
assert "slope_denominator" not in _rows()[173.09]["linked"]
|
||||
assert SHEET_DEFAULTS["ford"]["slope_denominator"] > 0
|
||||
|
||||
|
||||
def test_연결값으로_판정이_나옴() -> None:
|
||||
row = _rows()[85.052]
|
||||
out = compute({**DEFAULTS, **row["linked"], "r24_mm": 231.56, "sheet": "pipe"})
|
||||
assert out["ok"] in (True, False)
|
||||
assert out["slope_bed"] == pytest.approx(0.05)
|
||||
|
||||
|
||||
def test_저장된_측점_null_이_연결값을_덮지_않음() -> None:
|
||||
"""화면은 저장본 · 초안의 null 을 연결값 칸에서 건너뜀 — 연결값으로 실어야 저장된 측점도 바뀜."""
|
||||
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Hydro.ts").read_text(encoding="utf-8")
|
||||
assert "v != null || !(k in linkedOf(station))" in ui
|
||||
assert "...station.linked," in ui
|
||||
|
||||
|
||||
def test_패치가_받는다() -> None:
|
||||
patch = CrossSectionPatch(chainage_m=1, pipe_slope_pct=0.0, box_ground_slope_pct=-3.5)
|
||||
assert (patch.pipe_slope_pct, patch.box_ground_slope_pct) == (0.0, -3.5)
|
||||
|
||||
|
||||
def _server_rows(sections: list[dict]) -> dict[float, dict]:
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import _NPM_SCRIPT, BUNDLE
|
||||
from common_util.common_util_node_bundle import run_bundle_json
|
||||
|
||||
output = run_bundle_json(BUNDLE, _NPM_SCRIPT, {"detail": {"cross_sections": sections}})
|
||||
assert output, "서버 Node 번들 실행 실패"
|
||||
return {round(r["chainage_m"], 3): r for r in output["areas"]}
|
||||
|
||||
|
||||
@needs_node
|
||||
def test_서버가_관_경사를_관_길이와_같이_냄() -> None:
|
||||
sections = json.loads((FIXTURES / "b07_cross_overlay_sections.json").read_text("utf-8"))
|
||||
row = _server_rows(sections["cross_sections"])[85.052]
|
||||
assert row["pipe_length_m"] > 0
|
||||
# 관 바닥 두 끝 경사 — 유입이 유출보다 낮게 그려지지 않음(역경사는 수평으로 클램프)
|
||||
assert row["pipe_slope_pct"] >= 0
|
||||
|
||||
|
||||
@needs_node
|
||||
def test_서버가_박스_원지반_경사를_주인_측점에만() -> None:
|
||||
data = json.loads((FIXTURES / "b06_73_4_body_guard_sections.json").read_text("utf-8"))
|
||||
sections = data["cross_sections"]
|
||||
box_at = next(s for s in sections if s.get("box"))
|
||||
# 옆 측점에 같은 박스가 걸쳐도(폭 안 연동) 주인 측점에만 실림
|
||||
neighbour = next(s for s in sections if s["chainage_m"] == 340)
|
||||
neighbour["box"] = copy.deepcopy(box_at["box"])
|
||||
rows = _server_rows(sections)
|
||||
assert isinstance(rows[352.14]["box_ground_slope_pct"], float)
|
||||
assert "box_ground_slope_pct" not in rows[340]
|
||||
# 세월교 · 박스 흙막이는 관이 아님 — 관 경사 안 실음
|
||||
assert all("pipe_slope_pct" not in r for r in rows.values())
|
||||
@@ -68,6 +68,7 @@ def test_측점마다_연결값() -> None:
|
||||
|
||||
|
||||
def test_잇지_않는_칸() -> None:
|
||||
# 하상경사는 B06 횡단 경사가 있을 때만 이음(110 · test_110_b08_bed_slope) — 설계 없이는 빈칸
|
||||
for row in station_rows(POINTS, BASINS):
|
||||
assert not {"slope_denominator", "pipe_kind", "n"} & set(row["linked"])
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ _COMPUTED_FIELDS = {
|
||||
# 다리**(`STRUCTURE_ROW_KEYS` → `B06_Section_Server_Calc_Node` →
|
||||
# `recompute_server_side`)로 서버가 다시 내므로 재계산에서 안 사라진다.
|
||||
"pipe_length_m",
|
||||
# B08 하상경사 원천(110) — 관 경사 · 박스 원지반 경사(%) · 관 길이와 같은 다리.
|
||||
"pipe_slope_pct",
|
||||
"box_ground_slope_pct",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ def test_면적을_못_내는_측점에서도_길이는_낸다():
|
||||
"""설계선이 모자라 폐회로 면적을 못 내는 자리에도 **관은 서 있다** — 수량은 그 길이가 필요하다."""
|
||||
source = _LAYOUTS.read_text(encoding="utf-8")
|
||||
assert "return pipeRow;" in source, "면적 실패 시 null 로 떨어지면 관 길이까지 사라진다"
|
||||
assert "layouts.culvert?.pipe?.lengthM" in source
|
||||
assert "layouts.culvert?.pipe" in source and "pipe.lengthM" in source
|
||||
|
||||
|
||||
def test_이름이_한_벌이다():
|
||||
@@ -99,8 +99,8 @@ def test_소유_측점에만_싣는다():
|
||||
**하나만 고르는 것**은 그대로라 두 번 세지 않는다.
|
||||
"""
|
||||
layouts = _LAYOUTS.read_text(encoding="utf-8")
|
||||
assert "pipeOwnerChainage" in layouts, "소유 측점을 안 가리고 있다"
|
||||
assert "section.culvert?.chainage_m" in layouts, "관이 놓인 자리를 안 읽고 있다"
|
||||
assert "ownerChainage" in layouts, "소유 측점을 안 가리고 있다"
|
||||
assert "isOwner(section.culvert.chainage_m)" in layouts, "관이 놓인 자리를 안 읽고 있다"
|
||||
py = (PROJECT_ROOT / "B06_Section" / "B06_Section_Engine_Culvert.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user