feat(532-3): E02 지하수 갈래 — 우기 규칙 넷(KDS 기본 · 지표 아래 깊이 비율 · r_u · 사용자 수위선 구간) · 엔진 꼴 바꿈 · 지하수 카드

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 20:44:00 +09:00
co-authored by Claude Opus 5.5
parent 8cc8122d3f
commit f4077fbcf6
11 changed files with 763 additions and 35 deletions
@@ -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 · 물량 가름용 값) — 토사 측점은 경계 없음"
+42 -1
View File
@@ -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_측점_적용():
@@ -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"] == []