From 8cc8122d3f452cc631b2f095b91d096b696ac060 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 20:43:48 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(532-4):=20E03=202D=20=EB=AA=A8?= =?UTF-8?q?=ED=98=95=20=EB=B2=BD=20=E2=80=94=20B06=20=EB=B2=BD=20=EB=8F=84?= =?UTF-8?q?=ED=98=95=20TS=20=EC=84=9C=EB=B2=84=20Node=20=EB=AC=B6=EC=9D=8C?= =?UTF-8?q?(build:e03-walls)=20=C2=B7=20=EB=B3=B5=EC=82=AC=EB=B3=B8=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=E2=86=92=20=EC=B8=A1=EC=A0=90?= =?UTF-8?q?=EB=B3=84=20walls=20=C2=B7=20=EB=B2=BD=20=EC=84=A0=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EC=A7=80=ED=91=9C=20=3D=20=EA=B7=B8=EB=A0=A4?= =?UTF-8?q?=EC=A7=80=EB=8A=94=20=EC=84=A4=EA=B3=84=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - E03_StabSection_Walls_Node.ts — B06 computeStoredLayouts(옹벽 · 석축 · 돌쌓기 · 관 기슭막이) 꼭짓점 · drawnProfile 지표 · 계산 없음 - E03_StabSection_Walls.py — walls_by_station(복사본 루트) → {chainage_key: {walls, design_line}} · design_with_walls · walls_of · 입구 TS 가 묶음보다 새로우면 다시 지음 - common_util_cross_structure_areas.ts drawnProfile 내보냄(동작 그대로) · package.json build:e03-walls 줄 · .gitignore 묶음 폴더 Co-Authored-By: Claude Opus 5.5 --- .gitignore | 2 + E03_StabSection/E03_StabSection_Walls.py | 114 ++++++++++++++++++ E03_StabSection/E03_StabSection_Walls_Node.ts | 102 ++++++++++++++++ .../common_util_cross_structure_areas.ts | 5 +- package.json | 1 + 5 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 E03_StabSection/E03_StabSection_Walls.py create mode 100644 E03_StabSection/E03_StabSection_Walls_Node.ts diff --git a/.gitignore b/.gitignore index 122ae73b4..0b33c3120 100644 --- a/.gitignore +++ b/.gitignore @@ -72,5 +72,7 @@ config/b07_extent_node/ config/b07_note_node/ # 계산 등록부 서버 Node 입구 번들 — `npm run build:design-calc` 산출물(472-3). config/design_calc_node/ +# E03 안정성 모형 벽 도형 번들 — `npm run build:e03-walls` 산출물(532-4). +config/e03_walls_node/ ~$* diff --git a/E03_StabSection/E03_StabSection_Walls.py b/E03_StabSection/E03_StabSection_Walls.py new file mode 100644 index 000000000..6266b5cf8 --- /dev/null +++ b/E03_StabSection/E03_StabSection_Walls.py @@ -0,0 +1,114 @@ +"""E03 2D 모형의 벽(옹벽 · 석축 · 돌쌓기 · 관 기슭막이) — 복사본 → 측점별 walls +(532-4 · 분석 345-D ② ⑤). + +도형은 짓지 않음 — B06 화면 TS 를 서버 Node 로 돌림(`E03_StabSection_Walls_Node.ts` · 5장 ②). +얹기(어느 측점에 어느 벽) = B06 `attach_saved_shapes` 그대로(복사본 = 원본 프로젝트 배치). +벽 재료 = 모형 `WALL_MATERIAL`(고강도 — 활동면이 벽을 못 지남) · 꼴 = `build_section_model(walls)`. +벽이 선 측점은 지표도 B06 면적과 같은 「그려지는 설계선」(벽 상단에서 끊김)으로 바꿔 넘김. + +부르는 법(E03 분석 길): + found = walls_by_station(root) # 복사본 루트 → {chainage_key: entry} + entry = found.get(chainage_key(cross["chainage_m"])) + model = section_model_from_b06(cross, design_with_walls(design, entry), walls=walls_of(entry)) +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +from common_util.common_util_node_bundle import ROOT, build_bundle, bundle_stale, run_bundle_json +from common_util.common_util_station_key import chainage_key +from E01_StabRegister import E01_StabRegister_Source as source +from E03_StabSection.E03_StabSection_Engine_Model import WALL_MATERIAL + +logger = logging.getLogger(__name__) + +NPM_SCRIPT = "build:e03-walls" +ENTRY = ROOT / "E03_StabSection" / "E03_StabSection_Walls_Node.ts" +BUNDLE = ROOT / "config" / "e03_walls_node" / "E03_StabSection_Walls_Node.js" + + +def _sections(root: Path) -> list[dict[str, Any]]: + """복사본 측점 → B06 상세 꼴(chainage_m · 중심 · samples · design) + + 형상 얹기(revetment · culvert …).""" + from B06_Section.B06_Section_Engine_Attach import attach_saved_shapes + + sections = [] + for station in source.stations(root): + cross = source.cross_section(root, station["chainage_m"]) + sections.append( + { + **cross, + "center_x": station.get("center_x"), + "center_y": station.get("center_y"), + } + ) + route = source.db_snapshot(root).get("route") or {} + attach_saved_shapes(root, sections, route.get("route_data_path")) + return sections + + +def _fresh_bundle() -> bool: + """입구 TS 는 `SOURCE_DIRS` 밖(E03) — 입구가 묶음보다 새로우면 여기서 다시 지음.""" + stale = bundle_stale(BUNDLE) or ENTRY.stat().st_mtime > BUNDLE.stat().st_mtime + return not stale or build_bundle(NPM_SCRIPT) + + +def walls_by_station(root: Path | str) -> dict[str, dict[str, Any]]: + """복사본 루트 → {chainage_key: {walls: [모형 벽 …], design_line: [[offset, z] …] | None}}. + + 벽 · 트림이 없는 측점은 안 실음(그 측점 모형 = 종전 그대로). Node 실패는 던짐 — 벽을 빼고 + 해석하면 활동면이 벽을 지난 값이 조용히 남음. + """ + return walls_for_sections(_sections(Path(root))) + + +def walls_for_sections(sections: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + """형상 얹은 B06 횡단 목록 → 측점 entry(`walls_by_station` 몸 · 시험이 바로 부름).""" + from B05_Profile.B05_Profile_Structures_Schema import load_definition + + if not any(s.get("revetment") or s.get("culvert") for s in sections): + return {} + if not _fresh_bundle(): + raise RuntimeError("E03 벽 도형 번들을 못 지음") + payload = json.loads( + json.dumps({"definition": load_definition(), "sections": sections}, default=str) + ) + out = run_bundle_json(BUNDLE, NPM_SCRIPT, payload) + if not isinstance(out, dict): + raise RuntimeError("E03 벽 도형 Node 실패") + found: dict[str, dict[str, Any]] = {} + for row in out["stations"]: + walls = [ + { + "material_id": WALL_MATERIAL["id"], + "kind": w["kind"], + "name": w.get("name"), + "structure_id": w.get("structure_id"), + "form": w.get("form"), + "polygon": w["polygon"], + } + for w in row["walls"] + ] + if walls or row.get("design_line"): + found[chainage_key(float(row["chainage_m"]))] = { + "walls": walls, + "design_line": row.get("design_line"), + } + return found + + +def walls_of(entry: dict[str, Any] | None) -> list[dict[str, Any]] | None: + """측점 entry → `section_model_from_b06(walls=…)` 값(없으면 None = 벽 없음).""" + return (entry or {}).get("walls") or None + + +def design_with_walls(design: dict[str, Any], entry: dict[str, Any] | None) -> dict[str, Any]: + """벽이 선 측점 = 설계선을 그려지는 선(벽 상단에서 끊김)으로 바꾼 사본 · 아니면 그대로.""" + line = (entry or {}).get("design_line") + if not line: + return design + return {**design, "design_line": [{"offset_m": o, "elevation_m": z} for o, z in line]} diff --git a/E03_StabSection/E03_StabSection_Walls_Node.ts b/E03_StabSection/E03_StabSection_Walls_Node.ts new file mode 100644 index 000000000..8eb239fc9 --- /dev/null +++ b/E03_StabSection/E03_StabSection_Walls_Node.ts @@ -0,0 +1,102 @@ +/* ============================================================================= + * E03_StabSection_Walls_Node.ts + * E 2D 해석 모형의 벽(옹벽 · 석축 · 돌쌓기 · 관 기슭막이) 단면 꼭짓점을 **서버가** 내는 입구(532-4). + * + * 도형은 B06 화면이 그리는 TS 그대로(`computeStoredLayouts` — 정본만 읽는 한 벌 · CLAUDE.md 5장 ②) · + * 지표 = B06 면적과 같은 「실제로 그려지는 설계선」(`drawnProfile` · 벽 상단에서 끊김). 여기에 계산 없음. + * 파이썬 껍데기 = `E03_StabSection_Walls.py` · 번들 = `npm run build:e03-walls`. + * + * 실행: node <번들> <입력.json> <출력.json> + * 입력 { definition?: 구조물 정의 · sections: 형상 얹은 B06 횡단(chainage_m · samples · design · revetment …) } + * 출력 { stations: [{ chainage_m, walls: [{ kind, name, structure_id, form, polygon: [[offset, z], …] }], + * design_line: [[offset, z], …] | null(트림 없음 = 설계선 그대로) }] } — 입력 차례 그대로 + * 끝 코드: 0 성공 / 2 인자 오류 + * ========================================================================== */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { drawnProfile } from "@util/common_util_cross_structure_areas"; +import { useCatalogDefinition, type CatalogDefinition } from "@util/common_util_structure_catalog"; +import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; +import type { WallLayout } from "../B06_Section/logic/B06_Section_Cross_Culvert_Types"; +import { + computeStoredLayouts, + trimOfLayouts, +} from "../B06_Section/logic/B06_Section_Structure_Layouts"; + +/** 트림 경계 바로 바깥 점 — 벽 쪽 연직 단차를 살림(`computeStructureAreas` 와 같은 값). */ +const BOUNDARY_EPS_M = 1e-6; + +type Pair = [number, number]; + +function stationWalls(section: CrossSection, sections: readonly CrossSection[]) { + const layouts = computeStoredLayouts(section, sections); + const walls: Array<{ + kind: string; + name: string | null; + structure_id: string | null; + form: string | null; + polygon: Pair[]; + }> = []; + const push = (kind: string, list: readonly WallLayout[] | undefined, name: string | null) => { + for (const wall of list ?? []) { + if (wall.points.length >= 3) + walls.push({ + kind, + name, + structure_id: kind === "own" ? (layouts?.own?.structureId ?? null) : null, + form: wall.form, + polygon: wall.points.map((p) => [p.offset, p.elevation]), + }); + } + }; + push("own", layouts?.own?.walls, layouts?.own?.name ?? null); + push("culvert", layouts?.culvert?.walls, "기슭막이"); + push("culvert", layouts?.culvert?.basinExtras, "기슭막이"); + const trim = layouts ? trimOfLayouts(layouts) : null; + const line = layouts?.design.design_line; + let design_line: Pair[] | null = null; + if (trim && Array.isArray(line) && line.length >= 2) { + const ground = section.samples + .filter((s) => s.valid !== false && typeof s.elevation_m === "number") + .map((s) => ({ offset: s.offset_m ?? 0, elevation: s.elevation_m as number })) + .sort((a, b) => a.offset - b.offset); + const profile = drawnProfile({ + designLine: line as Array<{ offset_m: number; elevation_m: number }>, + ground, + trim, + }); + if (profile && ground.length >= 2) { + const lo = ground[0].offset; + const hi = ground[ground.length - 1].offset; + const grid = new Set(); + const add = (o: number) => { + if (o >= lo && o <= hi) grid.add(Math.round(o * 1e6) / 1e6); + }; + ground.forEach((g) => add(g.offset)); + line.forEach((p) => add(p.offset_m)); + for (const b of [trim.minOffset, trim.maxOffset]) + [b, b - BOUNDARY_EPS_M, b + BOUNDARY_EPS_M].forEach(add); + trim.minSlope?.points.forEach((p) => add(p.offset)); + trim.maxSlope?.points.forEach((p) => add(p.offset)); + design_line = [...grid].sort((a, b) => a - b).map((o) => [o, profile.drawnZ(o)]); + } + } + return { chainage_m: section.chainage_m, walls, design_line }; +} + +const [inputPath, outputPath] = process.argv.slice(2); +if (!inputPath || !outputPath) { + console.error("사용법: node <번들> <입력.json> <출력.json>"); + process.exit(2); +} +const input = JSON.parse(readFileSync(inputPath, "utf8")) as { + definition?: CatalogDefinition | null; + sections: CrossSection[]; +}; +if (input.definition) useCatalogDefinition(input.definition); +const sections = input.sections ?? []; +writeFileSync( + outputPath, + JSON.stringify({ stations: sections.map((s) => stationWalls(s, sections)) }), + "utf8", +); diff --git a/common_util/common_util_cross_structure_areas.ts b/common_util/common_util_cross_structure_areas.ts index 3f569edd9..aabf2d629 100644 --- a/common_util/common_util_cross_structure_areas.ts +++ b/common_util/common_util_cross_structure_areas.ts @@ -72,8 +72,9 @@ function interpolator( }; } -/** 폐회로의 위(실제로 그려지는 설계선)·아래(지반선) 보간기 — 면적과 벽 겹침이 같은 선을 봄. */ -function drawnProfile( +/** 폐회로의 위(실제로 그려지는 설계선)·아래(지반선) 보간기 — 면적과 벽 겹침이 같은 선을 봄 · + * E03 안정성 모형 지표도 이 선(`E03_StabSection_Walls_Node.ts`). */ +export function drawnProfile( input: StructureAreaInput, ): { drawnZ: (offset: number) => number; ground: (offset: number) => number } | null { const design = interpolator( diff --git a/package.json b/package.json index 44e4b3b76..8d74a12f5 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build:b07-extent": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts --outDir ../config/b07_extent_node", "build:b07-note": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B07_DesignDetail/B07_DesignDetail_Note_Options_Node.ts --outDir ../config/b07_note_node", "build:design-calc": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../A00_Common/design/logic/design_calc_node.ts --outDir ../config/design_calc_node", + "build:e03-walls": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../E03_StabSection/E03_StabSection_Walls_Node.ts --outDir ../config/e03_walls_node", "install:b07-cad": "npm --prefix B07_DesignDetail/openwebcad install", "build:b07-cad": "npm run install:b07-cad && npm --prefix B07_DesignDetail/openwebcad run build", "preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner", From f4077fbcf61bc2087f6871849f7357228ed0a2ea Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 20:44:00 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(532-3):=20E02=20=EC=A7=80=ED=95=98?= =?UTF-8?q?=EC=88=98=20=EA=B0=88=EB=9E=98=20=E2=80=94=20=EC=9A=B0=EA=B8=B0?= =?UTF-8?q?=20=EA=B7=9C=EC=B9=99=20=EB=84=B7(KDS=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=C2=B7=20=EC=A7=80=ED=91=9C=20=EC=95=84=EB=9E=98=20=EA=B9=8A?= =?UTF-8?q?=EC=9D=B4=20=EB=B9=84=EC=9C=A8=20=C2=B7=20r=5Fu=20=C2=B7=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=88=98=EC=9C=84=EC=84=A0=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84)=20=C2=B7=20=EC=97=94=EC=A7=84=20=EA=BC=B4?= =?UTF-8?q?=20=EB=B0=94=EA=BF=88=20=C2=B7=20=EC=A7=80=ED=95=98=EC=88=98=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ground.json groundwater 칸 덧붙임(wet_mode · depth_ratio · ru · lines · 기존 칸 그대로) · 기본값 = KDS · 범위 칸 넷 - check_ground ↔ checkGround 짝 고침(갈래 · 비율 · r_u · 수위선 구간 · 점) · 거울 시험 판 넷 더함 - groundwater_for · station_groundwater — 측점 모형 → {mode: none | ru | line}(좌 · 우 비탈 높이 따로 · 덮지 않은 측점 = KDS) - GET · [초기화] = 옛 정본 빠진 칸 기본값으로 채움 · 화면 옛 캐시도 서버 값으로 채움 - 화면 = 지하수 카드 따로(「KDS 기본 · 우기 기준안전율 1.2 와 짝」 안내) · 하중 카드 = 노면 하중 · 지진 - 시험 test_532_4_stab_walls_water(벽 · 지하수) · test_532_3 기본값 지하수 칸 대조 고침(칸 늘어남) Co-Authored-By: Claude Opus 5.5 --- E02_StabGround/E02_StabGround_Model.py | 135 ++++++++- E02_StabGround/E02_StabGround_Router.py | 8 +- E02_StabGround/E02_StabGround_UI_Page.ts | 4 + E02_StabGround/E02_StabGround_UI_Style.css | 14 + E02_StabGround/E02_StabGround_UI_Tables.ts | 46 ++- E02_StabGround/E02_StabGround_UI_Water.ts | 182 ++++++++++++ E02_StabGround/logic/E02_StabGround_Check.ts | 46 ++- .../stability/ground_defaults.json | 18 +- resources/tester/test_532_3_e02_ground.py | 43 ++- .../tester/test_532_4_stab_walls_water.py | 273 ++++++++++++++++++ ui_template/ui_template_locale_e.ts | 29 +- 11 files changed, 763 insertions(+), 35 deletions(-) create mode 100644 E02_StabGround/E02_StabGround_UI_Water.ts create mode 100644 resources/tester/test_532_4_stab_walls_water.py diff --git a/E02_StabGround/E02_StabGround_Model.py b/E02_StabGround/E02_StabGround_Model.py index 4db6ce336..c30ad2fcc 100644 --- a/E02_StabGround/E02_StabGround_Model.py +++ b/E02_StabGround/E02_StabGround_Model.py @@ -7,6 +7,8 @@ ground_defaults.json`(M02 마스터 층). 물성 한 줄 = `E03_StabSection_Type ⚠ 파이썬·TS 짝 — `check_ground` ↔ `logic/E02_StabGround_Check.ts` `checkGround` · `resolve_stations` ↔ `resolveStations`. 한쪽을 고치면 다른 쪽도 · 거울 시험 `resources/tester/test_532_3_e02_ground.py`. +지하수 → 엔진 꼴(`station_groundwater` · 서버 단독 · 짝 없음) — +시험 `test_532_4_stab_walls_water.py`. """ from __future__ import annotations @@ -17,6 +19,8 @@ import math from pathlib import Path from typing import Any +import numpy as np + PROJECT_ROOT = Path(__file__).resolve().parents[1] DEFAULTS_PATH = ( PROJECT_ROOT / "resources" / "master_template" / "stability" / "ground_defaults.json" @@ -32,6 +36,11 @@ CASES = ("dry", "wet", "seismic", "short_term") SLOPES = ("cut", "fill", "soft") WATER_KEYS = ("dry", "wet_soil", "wet_rock") WATER_RULES = ("none", "surface", "half_height") +# 우기 지하수 갈래 — kds = 위 규칙(wet_soil · wet_rock · 기본) · +# depth_ratio = 지표 − 비탈 높이 × 비율 · ru = 간극수압비 · +# line = 사용자 수위선(구간 · [offset, 지표 아래 깊이]) · 덮지 않은 측점은 kds +WET_MODES = ("kds", "depth_ratio", "ru", "line") +WATER_EXTRA_KEYS = ("wet_mode", "depth_ratio", "ru", "lines") # 정본에 남기는 칸 — 그 밖(limits · sources · note)은 기본값 파일에만 GROUND_KEYS = ( "version", @@ -63,6 +72,16 @@ def pick(doc: dict[str, Any]) -> dict[str, Any]: return copy.deepcopy({key: doc[key] for key in GROUND_KEYS if key in doc}) +def upgrade(ground: dict[str, Any], defaults: dict[str, Any]) -> dict[str, Any]: + """옛 정본(지하수 갈래 칸 전) = 빠진 지하수 칸만 기본값으로 채운 사본 · 있는 칸은 그대로.""" + out = copy.deepcopy(ground) + water = out.setdefault("groundwater", {}) + if isinstance(water, dict): + for key in WATER_EXTRA_KEYS: + water.setdefault(key, copy.deepcopy(defaults["groundwater"][key])) + return out + + def build_initial(defaults: dict[str, Any], stations: list[dict[str, Any]]) -> dict[str, Any]: """초기값 = 기본값 정본 칸 + B06 측점(암 경계).""" ground = pick(defaults) @@ -88,7 +107,7 @@ def _dict(value: Any) -> dict: def check_ground(ground: dict[str, Any], limits: dict[str, list[float]]) -> list[dict[str, str]]: """틀린 칸 목록 [{path, code}] — 빈 목록 = 통과. code = number · range · order · sat_lt_t · - name · name_dup · id_dup · enum · ref · empty · type.""" + name · name_dup · id_dup · enum · ref · empty · few_points · type.""" errors: list[dict[str, str]] = [] def bad(path: str, code: str) -> None: @@ -161,6 +180,36 @@ def check_ground(ground: dict[str, Any], limits: dict[str, list[float]]) -> list for key in WATER_KEYS: if water.get(key) not in WATER_RULES: bad(f"groundwater.{key}", "enum") + if water.get("wet_mode") not in WET_MODES: + bad("groundwater.wet_mode", "enum") + ranged("groundwater.depth_ratio", water.get("depth_ratio"), "depth_ratio") + ranged("groundwater.ru", water.get("ru"), "ru") + if not isinstance(water.get("lines"), list): + bad("groundwater.lines", "type") + for i, line in enumerate(_list(water.get("lines"))): + base = f"groundwater.lines.{i}" + if not isinstance(line, dict): + bad(base, "type") + continue + ranged(f"{base}.from_m", line.get("from_m"), "chainage_m") + ranged(f"{base}.to_m", line.get("to_m"), "chainage_m") + if _num(line.get("from_m")) and _num(line.get("to_m")): + if line["from_m"] >= line["to_m"]: + bad(f"{base}.to_m", "order") + points = _list(line.get("points")) + if len(points) < 2: + bad(f"{base}.points", "few_points") + last = None + for k, point in enumerate(points): + if not isinstance(point, list) or len(point) != 2: + bad(f"{base}.points.{k}", "type") + continue + ranged(f"{base}.points.{k}.0", point[0], "offset_m") + ranged(f"{base}.points.{k}.1", point[1], "water_depth_m") + if _num(point[0]): + if last is not None and point[0] <= last: + bad(f"{base}.points.{k}.0", "order") + last = point[0] seismic = _dict(ground.get("seismic")) if not isinstance(seismic.get("enabled"), bool): bad("seismic.enabled", "enum") @@ -187,7 +236,12 @@ def clean(ground: dict[str, Any]) -> dict[str, Any]: ] factors = out["safety_factors"] out["safety_factors"] = {c: {s: factors[c][s] for s in SLOPES} for c in CASES} - out["groundwater"] = {key: out["groundwater"][key] for key in WATER_KEYS} + water = out["groundwater"] + out["groundwater"] = {key: water[key] for key in (*WATER_KEYS, *WATER_EXTRA_KEYS)} + out["groundwater"]["lines"] = [ + {"from_m": w["from_m"], "to_m": w["to_m"], "points": [list(p) for p in w["points"]]} + for w in water["lines"] + ] out["seismic"] = {"enabled": out["seismic"]["enabled"], "kh": out["seismic"]["kh"]} return out @@ -228,3 +282,80 @@ def resolve_stations(ground: dict[str, Any]) -> list[dict[str, Any]]: } ) return out + + +def _face_water(ground: dict[str, Any], chainage_m: float) -> str: + """측점 비탈 겉 지층의 지하수 무리 — 토층 두께 0 = 아래(암) 지층 · 아니면 위 지층 · + 측점 없음 = soil.""" + names = {m["id"]: m for m in ground.get("materials") or []} + for row in resolve_stations(ground): + if abs(row["chainage_m"] - chainage_m) < 1e-3: + face = row["lower"] if row["lower"] and row["soil_thickness_m"] == 0 else row["upper"] + return (names.get(face) or {}).get("water", "soil") + return "soil" + + +def groundwater_for( + ground: dict[str, Any], + condition: str, + *, + chainage_m: float, + surface: list, + heights: dict[str, float], +) -> dict[str, Any]: + """E02 지하수 규칙 → 엔진 꼴 `{mode: none | ru | line}`(E03 Types `Groundwater`) — 측점마다. + + surface = 공사 뒤 지표 [[x, z]](좌 = +x) · heights = 비탈 높이 {left, right} m(x ≥ 0 = 좌 높이). + 건기 · 지진 · 단기 = `dry` 규칙 · 우기 = `wet_mode` 갈래 + (kds = 겉 지층 무리로 wet_soil | wet_rock). + """ + water = ground.get("groundwater") or {} + sx, sz = zip(*sorted((float(x), float(z)) for x, z in surface)) + + def below(depth_at, extra=()) -> dict[str, Any]: + # 좌 · 우 높이가 달라 x = 0 에서 끊길 수 있음 — 0 양쪽 점을 넣어 계단으로 + xs = sorted({*sx, *extra, -1e-6, 1e-6}) + zs = np.interp(xs, sx, sz) - np.array([depth_at(x) for x in xs]) + return {"mode": "line", "line": [[float(x), float(z)] for x, z in zip(xs, zs)]} + + def height(x: float) -> float: + return float(heights.get("left" if x >= 0 else "right") or 0.0) + + def rule(name: str) -> dict[str, Any]: + if name == "surface": + return below(lambda x: 0.0) + if name == "half_height": + return below(lambda x: 0.5 * height(x)) + return {"mode": "none"} + + if condition != "wet": + return rule(water.get("dry", "none")) + mode = water.get("wet_mode", "kds") + if mode == "ru": + return {"mode": "ru", "ru": float(water["ru"])} + if mode == "depth_ratio": + ratio = float(water["depth_ratio"]) + return below(lambda x: ratio * height(x)) + if mode == "line": + hit = None + for item in water.get("lines") or []: # 뒤 구간이 이김(지층 구간과 같은 규칙) + if item["from_m"] <= chainage_m <= item["to_m"]: + hit = item + if hit is not None: + ox, depth = zip(*sorted((float(o), float(d)) for o, d in hit["points"])) + return below(lambda x: float(np.interp(x, ox, depth)), ox) + group = _face_water(ground, chainage_m) + return rule(water.get("wet_rock" if group == "rock" else "wet_soil", "none")) + + +def station_groundwater(ground: dict[str, Any], condition: str, model: dict[str, Any]) -> dict: + """E03 측점 모형(`section_model_from_b06` 결과) → 그 측점 지하수 — 부르는 쪽이 + `model["groundwater"] = station_groundwater(ground, cond, model)` 로 갈아 끼움.""" + heights = {side["side"]: side["height_m"] for side in model.get("sides") or []} + return groundwater_for( + ground, + condition, + chainage_m=float((model.get("station") or {}).get("chainage_m") or 0.0), + surface=model["surface"], + heights=heights, + ) diff --git a/E02_StabGround/E02_StabGround_Router.py b/E02_StabGround/E02_StabGround_Router.py index 8b9ad1663..1b85d0c70 100644 --- a/E02_StabGround/E02_StabGround_Router.py +++ b/E02_StabGround/E02_StabGround_Router.py @@ -112,10 +112,12 @@ async def get_ground( root, project = await _project(project_id) initial = ensure_initial(root) working = _read_working(root) + defaults = model.load_defaults() return { - "ground": working or initial, + # 옛 정본(지하수 갈래 칸 전) = 빠진 칸만 기본값으로 채워 보냄(파일은 [저장] 때 씀) + "ground": model.upgrade(working or initial, defaults), "saved": working is not None, - "defaults": model.load_defaults(), + "defaults": defaults, "editable": _editable(session, project), } @@ -141,4 +143,4 @@ async def reset_ground(project_id: UUID) -> dict[str, Any]: root, _ = await _project(project_id) initial = ensure_initial(root) atomic_write_json(model.ground_path(root), initial) - return {"ground": initial} + return {"ground": model.upgrade(initial, model.load_defaults())} diff --git a/E02_StabGround/E02_StabGround_UI_Page.ts b/E02_StabGround/E02_StabGround_UI_Page.ts index 8bb750ca9..e0e985ef4 100644 --- a/E02_StabGround/E02_StabGround_UI_Page.ts +++ b/E02_StabGround/E02_StabGround_UI_Page.ts @@ -42,6 +42,8 @@ function limitKey(path: string): string { const last = path.split(".").pop() ?? ""; if (path.startsWith("safety_factors.")) return "safety_factor"; if (last === "from_m" || last === "to_m") return "chainage_m"; + if (/\.points\.\d+\.0$/.test(path)) return "offset_m"; + if (/\.points\.\d+\.1$/.test(path)) return "water_depth_m"; return last; } @@ -141,6 +143,8 @@ export function renderE02StabGround(root: HTMLElement): void { const cached = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null; if (cached) ground = JSON.parse(cached) as Ground; else sessionStorage.removeItem(cacheKey(projectId)); + // 옛 캐시(지하수 갈래 칸 전) — 빠진 칸은 서버 값(서버가 기본값으로 채워 보냄) + ground.groundwater = { ...body.ground.groundwater, ...ground.groundwater }; dirty = Boolean(cached); view.ground = ground; view.defaults = body.defaults; diff --git a/E02_StabGround/E02_StabGround_UI_Style.css b/E02_StabGround/E02_StabGround_UI_Style.css index e962124d4..88b33d304 100644 --- a/E02_StabGround/E02_StabGround_UI_Style.css +++ b/E02_StabGround/E02_StabGround_UI_Style.css @@ -170,3 +170,17 @@ .e02-card--wide { grid-column: 1 / -1; } + +/* 지하수 카드 — 사용자 수위선 점(offset · 깊이) 줄 */ +.e02-points { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); + align-items: center; +} + +.e02-point { + display: inline-flex; + gap: 2px; + align-items: center; +} diff --git a/E02_StabGround/E02_StabGround_UI_Tables.ts b/E02_StabGround/E02_StabGround_UI_Tables.ts index e3cfcc101..5b38bd0b1 100644 --- a/E02_StabGround/E02_StabGround_UI_Tables.ts +++ b/E02_StabGround/E02_StabGround_UI_Tables.ts @@ -1,7 +1,7 @@ /* ============================================================================= * E02_StabGround_UI_Tables.ts * E02 지반 조건 본문 카드 넷(요구 347-2 · 3 · 4) — 물성 표(지층 추가 · 지움 · B06 지반 연결) · - * 층 두께 · 구간 덮어쓰기 + 측점별 적용 · 기준안전율 · 하중 · 지하수 · 지진. 카드마다 [기본값]. + * 층 두께 · 구간 덮어쓰기 + 측점별 적용 · 기준안전율 · 하중 · 지진 · 지하수(`_UI_Water`). 카드마다 [기본값]. * 칸 = data-path(검사 열쇠 · 파이썬과 같음) · data-src(근거 열쇠) · data-def(기본값 글). * 값 바꿈 = view.change()(캐시 · 검사) · 줄 더함 · 지움 · [기본값] = view.rebuild(). * ========================================================================== */ @@ -16,13 +16,12 @@ import { MATERIAL_FIELDS, SLOPES, WATER_GROUPS, - WATER_KEYS, - WATER_RULES, resolveStations, type Ground, type GroundDefaults, type Material, } from "./logic/E02_StabGround_Check"; +import { waterCard } from "./E02_StabGround_UI_Water"; export interface E02View { ground: Ground; @@ -33,7 +32,7 @@ export interface E02View { } const L = (key: string): string => t(key as LocaleKey); -const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; +export const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; const FIELD_LABEL: Record<(typeof MATERIAL_FIELDS)[number], LocaleKey> = { gamma_t: "E02_Col_GammaT", gamma_sat: "E02_Col_GammaSat", @@ -42,7 +41,7 @@ const FIELD_LABEL: Record<(typeof MATERIAL_FIELDS)[number], LocaleKey> = { }; /** 숫자 칸 — 빈 칸 = null(nullable) · 아니면 숫자 아님으로 검사에 걸림 */ -function numInput( +export function numInput( view: E02View, path: string, value: number | null, @@ -68,7 +67,7 @@ function numInput( return input; } -function selectInput( +export function selectInput( view: E02View, path: string, options: { value: string; text: string }[], @@ -92,7 +91,7 @@ function selectInput( return select; } -function card( +export function card( title: LocaleKey, ...body: HTMLElement[] ): { section: HTMLElement; head: HTMLElement } { @@ -103,14 +102,18 @@ function card( return { section, head }; } -function headButtons(view: E02View, head: HTMLElement, ...buttons: HTMLButtonElement[]): void { +export function headButtons( + view: E02View, + head: HTMLElement, + ...buttons: HTMLButtonElement[] +): void { if (!view.editable) return; const row = el("div", { className: "e02-card__actions" }); row.append(...buttons); head.append(row); } -function table(headers: string[], rows: HTMLElement[][]): HTMLTableElement { +export function table(headers: string[], rows: HTMLElement[][]): HTMLTableElement { const node = el("table", { className: "e02-table" }) as HTMLTableElement; const tr = node.createTHead().insertRow(); for (const text of headers) tr.append(el("th", { text })); @@ -405,7 +408,7 @@ function safetyCard(view: E02View): HTMLElement { return section; } -/* --- 4. 노면 하중 · 지하수 · 지진 --- */ +/* --- 4. 노면 하중 · 지진 (지하수 = `E02_StabGround_UI_Water`) --- */ function loadsCard(view: E02View): HTMLElement { const g = view.ground; const d = view.defaults; @@ -429,19 +432,6 @@ function loadsCard(view: E02View): HTMLElement { kh.disabled = !view.editable || !g.seismic.enabled; view.change(); }); - const water = WATER_KEYS.map((key) => - field( - L(`E02_Water_${key}`), - selectInput( - view, - `groundwater.${key}`, - WATER_RULES.map((r) => ({ value: r, text: L(`E02_Rule_${r}`) })), - g.groundwater[key], - (v) => (g.groundwater[key] = v as (typeof WATER_RULES)[number]), - { src: `groundwater.${key}`, def: L(`E02_Rule_${d.groundwater[key]}`) }, - ), - ), - ); const grid = el("div", { className: "e02-grid" }); grid.append( field( @@ -457,7 +447,6 @@ function loadsCard(view: E02View): HTMLElement { }, ), ), - ...water, seismic.root, field(t("E02_Kh"), kh), ); @@ -471,7 +460,6 @@ function loadsCard(view: E02View): HTMLElement { size: "sm", onClick: () => { g.road_surcharge_kpa = d.road_surcharge_kpa; - g.groundwater = clone(d.groundwater); g.seismic = clone(d.seismic); view.rebuild(); }, @@ -485,6 +473,12 @@ export function buildCards(view: E02View): { root: HTMLElement; refreshStations: const root = el("div", { className: "e02-cards" }); const zones = zonesCard(view); zones.section.classList.add("e02-card--wide"); - root.append(materialsCard(view), safetyCard(view), loadsCard(view), zones.section); + root.append( + materialsCard(view), + safetyCard(view), + loadsCard(view), + waterCard(view), + zones.section, + ); return { root, refreshStations: zones.refresh }; } diff --git a/E02_StabGround/E02_StabGround_UI_Water.ts b/E02_StabGround/E02_StabGround_UI_Water.ts new file mode 100644 index 000000000..e613ae911 --- /dev/null +++ b/E02_StabGround/E02_StabGround_UI_Water.ts @@ -0,0 +1,182 @@ +/* ============================================================================= + * E02_StabGround_UI_Water.ts + * E02 지하수 카드(요구 347 · 351 · PLAN 532-3) — 건기 규칙 · 우기 갈래 넷(KDS 기본 · 지표 아래 깊이 비율 · + * r_u · 사용자 수위선 구간) · 「KDS 기본 · 우기 기준안전율 1.2 와 짝」 안내 · [기본값]. + * 엔진 꼴로 바꾸기는 서버(`E02_StabGround_Model.station_groundwater`) — 여기는 값만 고침. + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { WATER_KEYS, WATER_RULES, WET_MODES, type WetMode } from "./logic/E02_StabGround_Check"; +import { + card, + clone, + headButtons, + numInput, + selectInput, + table, + type E02View, +} from "./E02_StabGround_UI_Tables"; + +const L = (key: string): string => t(key as LocaleKey); + +function field(label: string, input: HTMLElement): HTMLElement { + const node = el("label", { className: "e02-field" }); + node.append(el("span", { text: label }), input); + return node; +} + +function removeButton(view: E02View, onClick: () => void): HTMLButtonElement { + const button = createButton({ + label: t("E02_Btn_Remove"), + variant: "danger-ghost", + size: "sm", + onClick, + }); + button.hidden = !view.editable; + return button; +} + +/** 사용자 수위선 구간 표 — 줄마다 시작 · 끝 · 점(offset · 깊이) · [점 추가] · [지움] */ +function linesBody(view: E02View): HTMLElement { + const water = view.ground.groundwater; + if (!water.lines.length) return el("p", { className: "e02-empty", text: t("E02_Line_Empty") }); + const rows = water.lines.map((line, i) => { + const base = `groundwater.lines.${i}`; + const points = el("div", { className: "e02-points" }); + points.dataset.path = `${base}.points`; + line.points.forEach((point, k) => { + const pair = el("span", { className: "e02-point" }); + pair.append( + numInput(view, `${base}.points.${k}.0`, point[0], (v) => (point[0] = v as number)), + numInput(view, `${base}.points.${k}.1`, point[1], (v) => (point[1] = v as number)), + removeButton(view, () => { + line.points.splice(k, 1); + view.rebuild(); + }), + ); + points.append(pair); + }); + const addPoint = createButton({ + label: t("E02_Btn_AddPoint"), + variant: "ghost", + size: "sm", + onClick: () => { + const last = line.points[line.points.length - 1]; + line.points.push(last ? [last[0] + 5, last[1]] : [0, 1]); + view.rebuild(); + }, + }); + addPoint.hidden = !view.editable; + points.append(addPoint); + return [ + numInput(view, `${base}.from_m`, line.from_m, (v) => (line.from_m = v as number)), + numInput(view, `${base}.to_m`, line.to_m, (v) => (line.to_m = v as number)), + points, + removeButton(view, () => { + water.lines.splice(i, 1); + view.rebuild(); + }), + ]; + }); + return table([t("E02_Col_From"), t("E02_Col_To"), t("E02_Col_Points"), ""], rows); +} + +export function waterCard(view: E02View): HTMLElement { + const water = view.ground.groundwater; + const d = view.defaults.groundwater; + const rule = (key: (typeof WATER_KEYS)[number]): HTMLElement => + field( + L(`E02_Water_${key}`), + selectInput( + view, + `groundwater.${key}`, + WATER_RULES.map((r) => ({ value: r, text: L(`E02_Rule_${r}`) })), + water[key], + (v) => (water[key] = v as (typeof WATER_RULES)[number]), + { src: `groundwater.${key}`, def: L(`E02_Rule_${d[key]}`) }, + ), + ); + const mode = selectInput( + view, + "groundwater.wet_mode", + WET_MODES.map((m) => ({ value: m, text: L(`E02_WetMode_${m}`) })), + water.wet_mode, + (v) => { + water.wet_mode = v as WetMode; + view.rebuild(); + }, + { src: "groundwater.wet_mode", def: L(`E02_WetMode_${d.wet_mode}`) }, + ); + const grid = el("div", { className: "e02-grid" }); + grid.append(rule("dry"), field(t("E02_WetMode"), mode)); + const body: HTMLElement[] = []; + if (water.wet_mode === "kds") grid.append(rule("wet_soil"), rule("wet_rock")); + if (water.wet_mode === "depth_ratio") + grid.append( + field( + t("E02_DepthRatio"), + numInput( + view, + "groundwater.depth_ratio", + water.depth_ratio, + (v) => (water.depth_ratio = v as number), + { src: "groundwater.depth_ratio", def: d.depth_ratio }, + ), + ), + ); + if (water.wet_mode === "ru") + grid.append( + field( + t("E02_Ru"), + numInput(view, "groundwater.ru", water.ru, (v) => (water.ru = v as number), { + src: "groundwater.ru", + def: d.ru, + }), + ), + ); + if (water.wet_mode === "line") + body.push(el("p", { className: "e02-hint", text: t("E02_Line_Hint") }), linesBody(view)); + + const { section, head } = card( + "E02_Card_Water", + el("p", { className: "e02-hint", text: t("E02_Water_Hint") }), + grid, + ...body, + ); + const stations = view.ground.stations; + const first = stations[0]?.chainage_m ?? 0; + const last = stations[stations.length - 1]?.chainage_m ?? 100; + const buttons = [ + createButton({ + label: t("E02_Btn_Defaults"), + variant: "ghost", + size: "sm", + onClick: () => { + view.ground.groundwater = clone(d); + view.rebuild(); + }, + }), + ]; + if (water.wet_mode === "line") + buttons.unshift( + createButton({ + label: t("E02_Btn_AddLine"), + variant: "ghost", + size: "sm", + onClick: () => { + water.lines.push({ + from_m: first, + to_m: last > first ? last : first + 20, + points: [ + [-10, 1], + [10, 1], + ], + }); + view.rebuild(); + }, + }), + ); + headButtons(view, head, ...buttons); + return section; +} diff --git a/E02_StabGround/logic/E02_StabGround_Check.ts b/E02_StabGround/logic/E02_StabGround_Check.ts index 99a4baf75..11c8bc1dc 100644 --- a/E02_StabGround/logic/E02_StabGround_Check.ts +++ b/E02_StabGround/logic/E02_StabGround_Check.ts @@ -9,6 +9,25 @@ export type WaterGroup = "soil" | "rock"; export type WaterRule = "none" | "surface" | "half_height"; +/** 우기 지하수 갈래 — kds = wet_soil · wet_rock 규칙(기본) · depth_ratio · ru · line(파이썬 `WET_MODES`) */ +export type WetMode = "kds" | "depth_ratio" | "ru" | "line"; + +/** 사용자 수위선 한 구간 — 점 = [offset m(좌 +), 공사 뒤 지표 아래 깊이 m] · offset 오름차순 */ +export interface WaterLine { + from_m: number; + to_m: number; + points: [number, number][]; +} + +export interface Groundwater { + dry: WaterRule; + wet_soil: WaterRule; + wet_rock: WaterRule; + wet_mode: WetMode; + depth_ratio: number; + ru: number; + lines: WaterLine[]; +} /** 물성 한 줄 = E03_StabSection_Types.Material + 지하수 무리 */ export interface Material { @@ -45,7 +64,7 @@ export interface Ground { zones: Zone[]; safety_factors: Record<(typeof CASES)[number], SlopeFactors>; road_surcharge_kpa: number; - groundwater: Record<(typeof WATER_KEYS)[number], WaterRule>; + groundwater: Groundwater; seismic: { enabled: boolean; kh: number }; } @@ -79,6 +98,7 @@ export const CASES = ["dry", "wet", "seismic", "short_term"] as const; export const SLOPES = ["cut", "fill", "soft"] as const; export const WATER_KEYS = ["dry", "wet_soil", "wet_rock"] as const; export const WATER_RULES: readonly WaterRule[] = ["none", "surface", "half_height"]; +export const WET_MODES: readonly WetMode[] = ["kds", "depth_ratio", "ru", "line"]; type Loose = Record; const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); @@ -149,6 +169,30 @@ export function checkGround(ground: unknown, limits: Limits): CheckError[] { const water = asDict(doc.groundwater); for (const key of WATER_KEYS) if (!WATER_RULES.includes(water[key] as WaterRule)) bad(`groundwater.${key}`, "enum"); + if (!WET_MODES.includes(water.wet_mode as WetMode)) bad("groundwater.wet_mode", "enum"); + ranged("groundwater.depth_ratio", water.depth_ratio, "depth_ratio"); + ranged("groundwater.ru", water.ru, "ru"); + if (!Array.isArray(water.lines)) bad("groundwater.lines", "type"); + asList(water.lines).forEach((line, i) => { + const base = `groundwater.lines.${i}`; + if (!isDict(line)) return bad(base, "type"); + ranged(`${base}.from_m`, line.from_m, "chainage_m"); + ranged(`${base}.to_m`, line.to_m, "chainage_m"); + if (isNum(line.from_m) && isNum(line.to_m) && line.from_m >= line.to_m) + bad(`${base}.to_m`, "order"); + const points = asList(line.points); + if (points.length < 2) bad(`${base}.points`, "few_points"); + let last: number | null = null; + points.forEach((point, k) => { + if (!Array.isArray(point) || point.length !== 2) return bad(`${base}.points.${k}`, "type"); + ranged(`${base}.points.${k}.0`, point[0], "offset_m"); + ranged(`${base}.points.${k}.1`, point[1], "water_depth_m"); + if (isNum(point[0])) { + if (last !== null && point[0] <= last) bad(`${base}.points.${k}.0`, "order"); + last = point[0]; + } + }); + }); const seismic = asDict(doc.seismic); if (typeof seismic.enabled !== "boolean") bad("seismic.enabled", "enum"); ranged("seismic.kh", seismic.kh, "kh"); diff --git a/resources/master_template/stability/ground_defaults.json b/resources/master_template/stability/ground_defaults.json index 65ceb41f6..1c5098c7a 100644 --- a/resources/master_template/stability/ground_defaults.json +++ b/resources/master_template/stability/ground_defaults.json @@ -19,13 +19,25 @@ "short_term": { "cut": 1.1, "fill": 1.1, "soft": 1.1 } }, "road_surcharge_kpa": 10.0, - "groundwater": { "dry": "none", "wet_soil": "surface", "wet_rock": "half_height" }, + "groundwater": { + "dry": "none", + "wet_soil": "surface", + "wet_rock": "half_height", + "wet_mode": "kds", + "depth_ratio": 0.5, + "ru": 0.2, + "lines": [] + }, "seismic": { "enabled": false, "kh": 0.0 }, "limits": { "gamma_t": [10.0, 30.0], "gamma_sat": [10.0, 30.0], "c": [0.0, 1000.0], "phi": [0.0, 60.0], + "depth_ratio": [0.0, 1.0], + "ru": [0.0, 0.9], + "offset_m": [-200.0, 200.0], + "water_depth_m": [0.0, 50.0], "safety_factor": [1.0, 3.0], "road_surcharge_kpa": [0.0, 100.0], "kh": [0.0, 0.5], @@ -66,6 +78,10 @@ "groundwater.dry": "KDS 11 70 05 표 4.3-3 건기 = 지하수 없음", "groundwater.wet_soil": "KDS 11 70 05 표 4.3-3 우기 토층 · 풍화암 = 지하수위 결정(지표 포화 가정)", "groundwater.wet_rock": "KDS 11 70 05 표 4.3-3 우기 연암 · 경암 = 비탈면 높이 ½ 심도까지 포화", + "groundwater.wet_mode": "우기 지하수 규칙 — 기본 = KDS 11 70 05 표 4.3-3(위 두 칸 · 우기 기준안전율 깎기 1.2 와 짝) · 그 밖 갈래는 사용자 판단", + "groundwater.depth_ratio": "지표 아래 깊이 = 비탈 높이 × 비율 — 근거 없음 · 사용자 입력(0.5 = KDS 암반 ½ 와 같은 값)", + "groundwater.ru": "간극수압비 u = r_u · γ·h — 근거 없음 · 사용자 입력", + "groundwater.lines": "사용자 수위선 — 구간(누가 m) · 점(offset m · 공사 뒤 지표 아래 깊이 m) · 덮지 않은 측점은 KDS 규칙", "seismic.enabled": "KDS 11 90 00 1.8.1 ② 영향 범위에 주구조물 없으면 비적용 · 임도는 발주자 협의 — 기본 끔", "seismic.kh": "유사정적 수평지진계수 — 지반가속도의 비율로 사용자 입력(KDS 11 70 05 표 4.3 지진 시)", "stations": "B06 횡단 암 경계(지반선 평행 · 기본 −0.5 m · 물량 가름용 값) — 토사 측점은 경계 없음" diff --git a/resources/tester/test_532_3_e02_ground.py b/resources/tester/test_532_3_e02_ground.py index ce9b1741d..745f3af09 100644 --- a/resources/tester/test_532_3_e02_ground.py +++ b/resources/tester/test_532_3_e02_ground.py @@ -79,6 +79,26 @@ CHECK_CASES = [ _bad(lambda g: g["groundwater"].update(wet_rock="full")), _bad(lambda g: g.update(seismic={"enabled": "yes", "kh": 0.6})), _bad(lambda g: g.update(label_map=[], safety_factors=None, groundwater="x")), + # 지하수 갈래(532-3 덧) — 갈래 · 비율 · r_u · 수위선 구간 · 점 + _bad(lambda g: g["groundwater"].update(wet_mode="rain", depth_ratio=1.5, ru=None)), + _bad(lambda g: g["groundwater"].update(ru=0.95, lines="x")), + _bad( + lambda g: g["groundwater"].update( + wet_mode="line", + lines=[ + {"from_m": 40, "to_m": 20, "points": [[5, 1]]}, + {"from_m": 0, "to_m": 20, "points": [[5, 1], [5, 2], [300, -1], "p", [1, 2, 3]]}, + 7, + ], + ) + ), + _ground( + groundwater={ + **DEFAULTS["groundwater"], + "wet_mode": "line", + "lines": [{"from_m": 0, "to_m": 20, "points": [[-10, 2], [0, 1.5], [10, 2]]}], + } + ), ] RESOLVE_CASES = [ @@ -124,6 +144,10 @@ def test_기본값_꼴은_Types_그대로(): "dry": "none", "wet_soil": "surface", "wet_rock": "half_height", + "wet_mode": "kds", + "depth_ratio": 0.5, + "ru": 0.2, + "lines": [], } assert DEFAULTS["seismic"] == {"enabled": False, "kh": 0.0} # 07 §4 종합 성토 φ 28~30 — 브레인 기본 25 는 범위 밖이라 아래값 28 @@ -152,6 +176,23 @@ def test_범위_검사(): ("safety_factors.wet.soft", "range"), } assert codes(CHECK_CASES[17]) == {("seismic.enabled", "enum"), ("seismic.kh", "range")} + assert codes(CHECK_CASES[19]) == { + ("groundwater.wet_mode", "enum"), + ("groundwater.depth_ratio", "range"), + ("groundwater.ru", "number"), + } + assert codes(CHECK_CASES[20]) == {("groundwater.ru", "range"), ("groundwater.lines", "type")} + assert codes(CHECK_CASES[21]) == { + ("groundwater.lines.0.to_m", "order"), + ("groundwater.lines.0.points", "few_points"), + ("groundwater.lines.1.points.1.0", "order"), + ("groundwater.lines.1.points.2.0", "range"), + ("groundwater.lines.1.points.2.1", "range"), + ("groundwater.lines.1.points.3", "type"), + ("groundwater.lines.1.points.4", "type"), + ("groundwater.lines.2", "type"), + } + assert codes(CHECK_CASES[22]) == set() def test_파이썬과_TS_같음(): @@ -168,7 +209,7 @@ def test_파이썬과_TS_같음(): out = json.loads(proc.stdout) assert out["check"] == [model.check_ground(g, LIMITS) for g in CHECK_CASES] assert out["resolve"] == [model.resolve_stations(g) for g in RESOLVE_CASES] - assert all(out["check"][1:]) # 틀린 판은 모두 걸림 + assert all(out["check"][1:-1]) and out["check"][-1] == [] # 틀린 판은 모두 걸림 · 끝 판 통과 def test_측점_적용(): diff --git a/resources/tester/test_532_4_stab_walls_water.py b/resources/tester/test_532_4_stab_walls_water.py new file mode 100644 index 000000000..4b688811b --- /dev/null +++ b/resources/tester/test_532_4_stab_walls_water.py @@ -0,0 +1,273 @@ +# -*- coding: utf-8 -*- +"""532-4 E03 벽(옹벽 · 석축 · 돌쌓기) · 532-3 덧 E02 지하수 갈래. + +- 벽 = B06 화면 TS(`computeStoredLayouts`)를 서버 Node 묶음(`build:e03-walls`)으로 — + 벽 선 측점 임계면이 벽 폴리곤을 안 지남(모서리 스침 ≤ 0.2 m · 절편 폭 아래) · + 벽 없는 측점 = 종전 그대로 · 복사본(구조물 정본 → B06 얹기) 끝까지. +- 지하수 = 건기 · 우기 갈래 넷(KDS · 깊이 비율 · r_u · 사용자 수위선) → 엔진 꼴 · + [저장] · [초기화] · 옛 정본 채움. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_532_4_stab_walls_water.py -q +""" + +from __future__ import annotations + +import asyncio +import copy +import json +import sys +from pathlib import Path +from uuid import UUID + +import numpy as np +import pytest +from shapely.geometry import LineString, Polygon + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from E02_StabGround import E02_StabGround_Model as ground_model # noqa: E402 +from E02_StabGround import E02_StabGround_Router as router # noqa: E402 +from E03_StabSection import E03_StabSection_Types as types # noqa: E402 +from E03_StabSection.E03_StabSection_Engine_Model import section_model_from_b06 # noqa: E402 +from E03_StabSection.E03_StabSection_Engine_Search import analyze # noqa: E402 +from E03_StabSection.E03_StabSection_Engine_Station import analyze_station # noqa: E402 +from E03_StabSection.E03_StabSection_Walls import ( # noqa: E402 + design_with_walls, + walls_by_station, + walls_for_sections, + walls_of, +) + +# 오른쪽(−) 산 · 왼쪽(+) 골 · 노면 −2~2 · 좌 성토 1:1.5(test_532_2 와 같은 단면) +GROUND = [(x / 2, 10.0 - 0.6 * (x / 2)) for x in range(-40, 41)] +LINE = [(-20.0, 22.0), (-6.0, 13.6), (-2.6, 10.0), (-2.0, 10.0), (2.0, 10.0), (14.0, 2.0)] +DESIGN = { + "section_mode": "right_cut", + "ground_type": "soil", + "road_edges": { + "left": {"offset_m": 2.0, "elevation_m": 10.0}, + "right": {"offset_m": -2.0, "elevation_m": 10.0}, + }, + "carriageway_edges": { + "left": {"offset_m": 1.5, "elevation_m": 10.0}, + "right": {"offset_m": -1.5, "elevation_m": 10.0}, + }, + "cross_slope_pct": 0, + "design_elevation_m": 10.0, + "design_line": [{"offset_m": a, "elevation_m": b} for a, b in LINE], +} +CROSS = { + "chainage_m": 100.0, + "label": "STA.0+100", + "samples": [{"offset_m": a, "elevation_m": b, "valid": True} for a, b in GROUND], + "design": DESIGN, +} +WALL = { + "structure_id": "w1", + "type_id": "masonry_wet", + "name": "돌쌓기(찰)", + "start_m": 90.0, + "end_m": 110.0, + "anchor_m": 100.0, + "form": "돌쌓기(찰)", + "height_m": 3.0, + "tiers": 1, + "extras": [], +} +FAST = {"conditions": ("dry",), "methods": ("bishop", "janbu")} +CORNER_M = 0.2 # 임계면이 벽 모서리를 스치는 길이 한도(절편 폭 아래) + + +@pytest.fixture(scope="module") +def found(): + return walls_for_sections([{**CROSS, "revetment": WALL}, {**CROSS, "chainage_m": 120.0}]) + + +def test_벽_도형_B06_산식(found): + assert list(found) == ["100.00"] # 벽 없는 측점은 안 실음 + entry = found["100.00"] + (wall,) = entry["walls"] + assert wall["material_id"] == "wall" and wall["kind"] == "own" + poly = Polygon(wall["polygon"]) + assert poly.is_valid and len(wall["polygon"]) == 4 + # 높이 = 바닥(근입 0.5 포함) ~ 상단 3 m · 상단 = 노견 높이 · 성토 쪽(좌 +) + zs = [p[1] for p in wall["polygon"]] + assert max(zs) - min(zs) == pytest.approx(3.0) and max(zs) == pytest.approx(10.0) + assert min(p[0] for p in wall["polygon"]) > 0 + # 지표 = 그려지는 설계선 — 벽 상단(노견)에서 끊겨 바깥은 원지반 + line = entry["design_line"] + gx, gz = zip(*GROUND) + out = [(x, z) for x, z in line if x > 2.0 + 1e-3] + assert out and all(abs(z - np.interp(x, gx, gz)) < 1e-6 for x, z in out) + + +def test_임계면이_벽을_안_지남(found): + entry = found["100.00"] + design = design_with_walls(DESIGN, entry) + res = analyze_station(CROSS, design, walls=walls_of(entry), **FAST) + poly = Polygon(entry["walls"][0]["polygon"]) + fill = [c for c in res["cases"] if c["role"] == "fill"] + assert fill + for case in fill: + for got in case["result"]["methods"].values(): + assert got["min"]["F"] > 0 + assert LineString(got["min"]["points"]).intersection(poly).length <= CORNER_M + plain = analyze_station(CROSS, DESIGN, **FAST) + # 벽이 성토 비탈을 대신함 — 성토 쪽 최소 F 가 달라짐 · 절토 쪽은 그대로 + for a, b in zip(plain["cases"], res["cases"]): + if a["role"] == "cut": + assert a["min_F"] == pytest.approx(b["min_F"], abs=1e-9) + else: + assert a["min_F"]["bishop"] != pytest.approx(b["min_F"]["bishop"], abs=1e-3) + + +def test_벽_없는_측점_모형_그대로(): + assert design_with_walls(DESIGN, None) is DESIGN and walls_of(None) is None + a = section_model_from_b06(CROSS, DESIGN) + b = section_model_from_b06(CROSS, design_with_walls(DESIGN, None), walls=walls_of(None)) + assert json.dumps(a, sort_keys=True) == json.dumps(b, sort_keys=True) + + +def _write_copy(root: Path, with_wall: bool) -> Path: + """E01 동결 복사본 꼴 — db.json(측점 행 · 노선) · 횡단 파일 · 구조물 정본.""" + from B05_Profile.B05_Profile_Structures_Repository import save_structures + from B05_Profile.B05_Profile_Structures_Schema import StructureInstance + + source = root / "E01_StabRegister" / "source" + (source / "B06_Section" / "cross_sections").mkdir(parents=True) + (source / "copy_manifest.json").write_text("{}", encoding="utf-8") + rows = [] + for k, chainage in enumerate((80.0, 100.0)): + rel = f"B06_Section/cross_sections/cross_{int(chainage):06d}.json" + cross = {k: v for k, v in CROSS.items() if k != "design"} + cross.update(chainage_m=chainage, center_x=float(chainage), center_y=0.0) + (source / rel).write_text(json.dumps(cross), encoding="utf-8") + rows.append( + { + "chainage_m": chainage, + "sequence_num": k, + "data": json.dumps({"design": DESIGN}), + "cross_section_file_path": rel, + } + ) + db = {"cross_sections": rows, "route": {"route_data_path": None}} + (source / "db.json").write_text(json.dumps(db), encoding="utf-8") + if with_wall: + item = StructureInstance( + type_id="masonry_wet", + placement="interval", + chainage_m=100, + start_m=95, + end_m=105, + options={"height_m": 3.0}, + ) + save_structures(str(source), [item], base_revision=0) + return source + + +def test_복사본_구조물_정본에서_측점별_벽(tmp_path): + source = _write_copy(tmp_path / "a", with_wall=True) + got = walls_by_station(source) + assert list(got) == ["100.00"] + (wall,) = got["100.00"]["walls"] + assert wall["name"] == "돌쌓기(찰)" and len(wall["polygon"]) == 4 + assert walls_by_station(_write_copy(tmp_path / "b", with_wall=False)) == {} + + +# ---- 지하수 갈래 ----------------------------------------------------------------- +STATIONS = [ + {"chainage_m": 0.0, "ground_type": "soil", "soil_thickness_m": None}, + {"chainage_m": 20.0, "ground_type": "blasting_rock", "soil_thickness_m": 0.0}, +] +SURFACE = [[-10.0, 16.0], [-2.0, 10.0], [2.0, 10.0], [10.0, 4.0]] +HEIGHTS = {"left": 6.0, "right": 4.0} + + +def _water(chainage=0.0, condition="wet", **groundwater): + ground = ground_model.build_initial(ground_model.load_defaults(), copy.deepcopy(STATIONS)) + ground["groundwater"].update(groundwater) + out = ground_model.groundwater_for( + ground, condition, chainage_m=chainage, surface=SURFACE, heights=HEIGHTS + ) + assert types.groundwater(out)["mode"] == out["mode"] # 엔진 꼴 그대로 + return out + + +def _depth(out): + sx, sz = zip(*SURFACE) + return {round(x, 6): round(float(np.interp(x, sx, sz)) - z, 6) for x, z in out["line"]} + + +def test_지하수_건기_KDS(): + assert _water(condition="dry") == {"mode": "none"} + assert _water(condition="seismic") == {"mode": "none"} + assert set(_depth(_water()).values()) == {0.0} # 토사 측점 = 지표 포화 + rock = _depth(_water(chainage=20.0)) # 연암(겉 = 암 · 두께 0) = 비탈 높이 ½ + assert rock[10.0] == 3.0 and rock[-10.0] == 2.0 + + +def test_지하수_깊이비율_ru_수위선(): + ratio = _depth(_water(wet_mode="depth_ratio", depth_ratio=0.25)) + assert ratio[10.0] == 1.5 and ratio[-10.0] == 1.0 and ratio[2.0] == 1.5 + assert _water(wet_mode="ru", ru=0.3) == {"mode": "ru", "ru": 0.3} + lines = [ + {"from_m": 0.0, "to_m": 10.0, "points": [[-5.0, 2.0], [5.0, 1.0]]}, + {"from_m": 0.0, "to_m": 5.0, "points": [[-5.0, 3.0], [5.0, 3.0]]}, # 뒤 구간이 이김 + ] + user = _depth(_water(wet_mode="line", lines=lines)) + assert set(user.values()) == {3.0} + one = _depth(_water(wet_mode="line", lines=lines[:1])) + assert one[-10.0] == 2.0 and one[2.0] == 1.3 and one[10.0] == 1.0 # 끝은 끝값 + # 덮지 않은 측점 = KDS + assert set(_depth(_water(chainage=20.0, wet_mode="line", lines=lines)).values()) == {2.0, 3.0} + + +def test_지하수_측점_모형에_끼움(): + model = section_model_from_b06(CROSS, DESIGN, wet=True) + ground = ground_model.build_initial(ground_model.load_defaults(), []) + ground["groundwater"].update(wet_mode="depth_ratio", depth_ratio=0.5) + gw = ground_model.station_groundwater(ground, "wet", model) + left = next(s["height_m"] for s in model["sides"] if s["side"] == "left") + sx, sz = zip(*model["surface"]) + x, z = next(p for p in gw["line"] if p[0] > 1) + assert float(np.interp(x, sx, sz)) - z == pytest.approx(0.5 * left) + model["groundwater"] = types.groundwater(gw) + side = next(s for s in model["sides"] if s["side"] == "left") + res = analyze(model, direction=side["direction"], limits=side["limits"], methods=("bishop",)) + assert res["methods"]["bishop"]["min"]["F"] > 0 # 끼운 모형이 엔진에서 돎 + + +@pytest.fixture +def project(tmp_path, monkeypatch): + async def fake_project(_project_id): + return tmp_path, {"company_id": 1, "project_kind": "STABILITY"} + + monkeypatch.setattr(router, "_project", fake_project) + monkeypatch.setattr(router, "_b06_stations", lambda _root: copy.deepcopy(STATIONS)) + return tmp_path + + +def test_지하수_저장_초기화_옛정본(project): + pid = UUID("9872e9a7-e483-427b-a6b2-72cdbc063bc3") + session = {"role": "USER", "company_id": 1} + first = asyncio.run(router.get_ground(pid, session)) + ground = copy.deepcopy(first["ground"]) + assert ground["groundwater"]["wet_mode"] == "kds" + line = {"from_m": 0.0, "to_m": 20.0, "points": [[10.0, 2.0], [-10.0, 1.0]][::-1]} + ground["groundwater"].update(wet_mode="line", lines=[line], ru=0.35) + asyncio.run(router.put_ground(pid, router.GroundRequest(ground=ground))) + stored = json.loads(ground_model.ground_path(project).read_text(encoding="utf-8")) + assert stored["groundwater"] == {**ground["groundwater"], "lines": [line]} + bad = copy.deepcopy(ground) + bad["groundwater"]["lines"][0]["points"] = [[1.0, 1.0]] + with pytest.raises(router.HTTPException): + asyncio.run(router.put_ground(pid, router.GroundRequest(ground=bad))) + back = asyncio.run(router.reset_ground(pid))["ground"] + assert back["groundwater"]["wet_mode"] == "kds" and back["groundwater"]["lines"] == [] + # 옛 정본(갈래 칸 없음) = GET 이 기본값으로 채움 · 있는 칸은 그대로 + old = json.loads(ground_model.ground_path(project).read_text(encoding="utf-8")) + old["groundwater"] = {"dry": "none", "wet_soil": "none", "wet_rock": "half_height"} + ground_model.ground_path(project).write_text(json.dumps(old), encoding="utf-8") + got = asyncio.run(router.get_ground(pid, session))["ground"]["groundwater"] + assert got["wet_soil"] == "none" and got["wet_mode"] == "kds" and got["lines"] == [] diff --git a/ui_template/ui_template_locale_e.ts b/ui_template/ui_template_locale_e.ts index 1fad828b7..eccacb836 100644 --- a/ui_template/ui_template_locale_e.ts +++ b/ui_template/ui_template_locale_e.ts @@ -13,7 +13,7 @@ export const ui_locales_e = { E02_Card_Zones: ["층 두께 · 구간 덮어쓰기", "Layer Thickness · Zone Overrides"], E02_Card_Stations: ["측점별 적용 지층", "Layers per Station"], E02_Card_Safety: ["기준안전율", "Required Factors of Safety"], - E02_Card_Loads: ["노면 하중 · 지하수 · 지진", "Surcharge · Groundwater · Seismic"], + E02_Card_Loads: ["노면 하중 · 지진", "Surcharge · Seismic"], E02_Note: [ "문헌 추정치 · 현장 시험 없음 — 값은 바꿀 수 있습니다. 칸에 마우스를 올리면 근거가 보입니다.", "Literature estimates without field tests — values are editable. Hover a cell for its source.", @@ -86,6 +86,32 @@ export const ui_locales_e = { E02_Rule_half_height: ["비탈 높이 ½ 포화", "Saturated to ½ height"], E02_Seismic: ["지진 검토", "Seismic check"], E02_Kh: ["수평지진계수 kh", "Horizontal coefficient kh"], + E02_Card_Water: ["지하수", "Groundwater"], + E02_Water_Hint: [ + "기본 = KDS 11 70 05 표 4.3-3(우기 토사 · 풍화암 지표 포화 · 암반 비탈 높이 ½) — 우기 기준안전율 깎기 1.2 와 짝. 다른 규칙을 고르면 기준안전율도 함께 검토하세요.", + "Default = KDS 11 70 05 Table 4.3-3 (wet: soil · weathered rock saturated to surface · rock to ½ slope height) — pairs with wet cut FS 1.2. Review the factors when choosing another rule.", + ], + E02_WetMode: ["우기 규칙", "Wet-season rule"], + E02_WetMode_kds: ["KDS 기본", "KDS default"], + E02_WetMode_depth_ratio: [ + "지표 아래 깊이 = 비탈 높이 × 비율", + "Depth below surface = slope height × ratio", + ], + E02_WetMode_ru: ["간극수압비 r_u", "Pore pressure ratio r_u"], + E02_WetMode_line: ["사용자 수위선", "User water line"], + E02_DepthRatio: ["깊이 비율", "Depth ratio"], + E02_Ru: ["r_u", "r_u"], + E02_Line_Hint: [ + "구간마다 점 = offset(m · 좌 +) · 공사 뒤 지표 아래 깊이(m). 덮지 않은 측점은 KDS 규칙.", + "Per zone, points = offset (m, left +) · depth below finished surface (m). Uncovered stations use the KDS rule.", + ], + E02_Line_Empty: [ + "수위선 구간이 없습니다 — 모든 측점이 KDS 규칙.", + "No water-line zones — all stations use the KDS rule.", + ], + E02_Btn_AddLine: ["수위선 구간 추가", "Add water line"], + E02_Btn_AddPoint: ["점 추가", "Add point"], + E02_Col_Points: ["점 (offset · 깊이)", "Points (offset · depth)"], /* --- 상태 · 알림 --- */ E02_Unsaved: ["저장 안 된 변경이 있습니다.", "There are unsaved changes."], @@ -113,4 +139,5 @@ export const ui_locales_e = { E02_Err_ref: ["지층을 고르세요", "Pick a layer"], E02_Err_empty: ["지층이 하나는 있어야 합니다", "At least one layer is required"], E02_Err_type: ["꼴이 틀립니다", "Invalid shape"], + E02_Err_few_points: ["점이 둘 이상 있어야 합니다", "At least two points are required"], } as const satisfies Record;