diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 605621048..78c1bff9a 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -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; diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index b1171532f..0cfea5cf6 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -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): diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index 34ff50281..aa6b89415 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -66,6 +66,8 @@ _AREA_KEYS = ( "cut_soil_area_m2", "cut_rock_area_m2", "pipe_length_m", + "pipe_slope_pct", + "box_ground_slope_pct", ) diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts index b647cdb67..4a5b91695 100644 --- a/B06_Section/B06_Section_Structure_Layouts.ts +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -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 | 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 = { 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; diff --git a/B08_Quantity/B08_Quantity_Hydro_Defaults.py b/B08_Quantity/B08_Quantity_Hydro_Defaults.py index 8a8931ab9..311c4e4e0 100644 --- a/B08_Quantity/B08_Quantity_Hydro_Defaults.py +++ b/B08_Quantity/B08_Quantity_Hydro_Defaults.py @@ -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"), diff --git a/B08_Quantity/B08_Quantity_Router_Hydro.py b/B08_Quantity/B08_Quantity_Router_Hydro.py index d51f3294c..dbfb95982 100644 --- a/B08_Quantity/B08_Quantity_Router_Hydro.py +++ b/B08_Quantity/B08_Quantity_Router_Hydro.py @@ -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, diff --git a/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts b/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts index 04cd6efbf..a1d5ee66e 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts +++ b/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts @@ -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>; 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(); 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); } diff --git a/resources/tester/test_110_b08_bed_slope.py b/resources/tester/test_110_b08_bed_slope.py new file mode 100644 index 000000000..27e57b27d --- /dev/null +++ b/resources/tester/test_110_b08_bed_slope.py @@ -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()) diff --git a/resources/tester/test_96_b08_hydro_linked.py b/resources/tester/test_96_b08_hydro_linked.py index ffa10618d..62fd9c4ce 100644 --- a/resources/tester/test_96_b08_hydro_linked.py +++ b/resources/tester/test_96_b08_hydro_linked.py @@ -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"]) diff --git a/resources/tester/test_b06_design_key_split.py b/resources/tester/test_b06_design_key_split.py index b41698357..e30b220df 100644 --- a/resources/tester/test_b06_design_key_split.py +++ b/resources/tester/test_b06_design_key_split.py @@ -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", } diff --git a/resources/tester/test_b06_pipe_length.py b/resources/tester/test_b06_pipe_length.py index f49d38916..6ad4cb70b 100644 --- a/resources/tester/test_b06_pipe_length.py +++ b/resources/tester/test_b06_pipe_length.py @@ -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" )