- LEM 절편: 바닥 물성 = 벽 인 절편이 하나라도 있으면 그 면 무효 · 벽 밑으로 도는 면만 후보 - 시험: 벽 한가운데 지나는 꺾은선 = 값 없음 · 벽 바닥 아래 꺾은선 = 값 있음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
309 lines
13 KiB
Python
309 lines
13 KiB
Python
# -*- 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, evaluate_specified # 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_벽을_지나는_면은_버림(found):
|
||
entry = found["100.00"]
|
||
model = section_model_from_b06(CROSS, design_with_walls(DESIGN, entry), walls=walls_of(entry))
|
||
side = next(s for s in model["sides"] if s["role"] == "fill")
|
||
through = [[-0.5, 10.0], [2.0, 8.5], [6.0, 6.4]] # 벽(x 1.4~3.2 · z 7~10) 한가운데
|
||
under = [[-0.5, 10.0], [2.0, 6.0], [8.0, 5.2]] # 벽 바닥 아래
|
||
for line, ok in ((through, False), (under, True)):
|
||
got = evaluate_specified(
|
||
model, polyline=line, direction=side["direction"], methods=("janbu", "spencer")
|
||
)
|
||
assert all((got[m]["F"] is not None) == ok for m in got)
|
||
|
||
|
||
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 # 끼운 모형이 엔진에서 돎
|
||
|
||
|
||
def test_측점_해석_groundwater_for_인자():
|
||
ground = ground_model.build_initial(ground_model.load_defaults(), [])
|
||
ground["groundwater"].update(wet_mode="ru", ru=0.3)
|
||
seen = []
|
||
|
||
def gw_for(cond, model):
|
||
seen.append(cond)
|
||
return ground_model.station_groundwater(ground, cond, model)
|
||
|
||
kw = {"conditions": ("dry", "wet"), "methods": ("bishop",), "noncircular": False}
|
||
base = analyze_station(CROSS, DESIGN, **kw)
|
||
out = analyze_station(CROSS, DESIGN, groundwater_for=gw_for, **kw)
|
||
assert seen == ["dry", "wet"]
|
||
F = {(c["condition"], c["side"]): c["min_F"]["bishop"] for c in out["cases"]}
|
||
F0 = {(c["condition"], c["side"]): c["min_F"]["bishop"] for c in base["cases"]}
|
||
for (cond, side), f in F.items():
|
||
if cond == "dry":
|
||
assert f == pytest.approx(F0[cond, side]) # 건기 = 수위 없음 그대로
|
||
else:
|
||
assert f > F0[cond, side] # r_u 0.3 < 지표 포화
|
||
|
||
|
||
@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"] == []
|