- 길: POST analyze2d(뒤에서 · 일꾼 프로세스 병렬 · 바뀐 측점만) · GET progress · result2d · section · corridor3d · PUT representative - 입력 = E01 복사본(측점 · 횡단 · DTM 넓힘) + E02 지반 조건 · 엔진 = sub4 analyze_station · 벽 = 다음 일감(532-4) - 화면: 측점 목록(조건 · 정렬 · N.G 만 · 대표 단면) · 횡단(지층 · 지하수선 · 임계면 · 후보 · 등안전율 지도 · 중심 격자 · 절편) · 결과 · 절편표 · 자유물체도 · 절편력 그래프 · 평면 띠 · 3D 보기 - 계산 = logic/E03_StabSection_Logic.ts · 시험 test_532_3_e03_section.py Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
552 lines
21 KiB
Python
552 lines
21 KiB
Python
"""532-3 E03 2D 측점 해석 길 · 화면 계산(요구 345 · 347 · 351).
|
||
|
||
- 길: 지문이 같으면 다시 안 셈 · 바뀐 측점만 다시 셈 · 진행률 · 결과 꼴 · 대표 단면 저장 ·
|
||
공유 회사 POST 403(공용 접근 판정) · 노선면 솎음
|
||
- 실제 엔진 한 측점 → 화면 꼴(임계면 · 절편 · 격자)
|
||
- logic(TS) — 판정 규칙 = 파이썬 · 정렬 · 거름 · 등안전율 띠 · 화면 좌표 · 평면 띠 · 힘 화살
|
||
입력 = 검증_sub2 원본을 임시 폴더로 동결 복사(DB 읽기만) · 엔진 = 가짜(길) / 진짜(꼴).
|
||
"""
|
||
|
||
import asyncio
|
||
import copy
|
||
import json
|
||
import math
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
from uuid import UUID
|
||
|
||
import aiomysql
|
||
import pytest
|
||
from fastapi import HTTPException
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
from common_util import common_util_auth as auth # noqa: E402
|
||
from common_util import common_util_project_copy as cp # noqa: E402
|
||
from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER # noqa: E402
|
||
from E01_StabRegister import E01_StabRegister_Source as src # noqa: E402
|
||
from E02_StabGround import E02_StabGround_Model as ground_model # noqa: E402
|
||
from E03_StabSection import E03_StabSection_Router as router # noqa: E402
|
||
from E03_StabSection import E03_StabSection_View as view # noqa: E402
|
||
from E03_StabSection import E03_StabSection_Worker as worker # noqa: E402
|
||
from E03_StabSection.E03_StabSection_Engine_Model import section_model_from_b06 # noqa: E402
|
||
from E03_StabSection.E03_StabSection_Engine_Station import analyze_station # noqa: E402
|
||
|
||
SOURCE_ID = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(읽기만)
|
||
PID = "e" * 8 + "-" + "e" * 4 + "-" + "e" * 4 + "-" + "e" * 4 + "-" + "e" * 12
|
||
N_STATIONS = 3
|
||
TYPESCRIPT = ROOT / "config" / "node_modules" / "typescript"
|
||
TS = ROOT / "E03_StabSection" / "logic" / "E03_StabSection_Logic.ts"
|
||
|
||
|
||
async def _copy(target: Path) -> None:
|
||
connection = await aiomysql.connect(
|
||
host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, db=DB_NAME, autocommit=True
|
||
)
|
||
try:
|
||
await cp.copy_project(
|
||
connection, SOURCE_ID, scope="stability", new_id=PID, project_root=target
|
||
)
|
||
finally:
|
||
connection.close()
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def frozen():
|
||
folder = Path(tempfile.mkdtemp(prefix="aislo_e03_"))
|
||
try:
|
||
asyncio.run(_copy(folder / "copy"))
|
||
except Exception as error: # noqa: BLE001
|
||
shutil.rmtree(folder, ignore_errors=True)
|
||
pytest.skip(f"검증 원본 복사 실패: {error}")
|
||
yield folder / "copy"
|
||
shutil.rmtree(folder, ignore_errors=True)
|
||
|
||
|
||
CALLS: list[float] = []
|
||
|
||
|
||
def _fake_analyze(cross, design, *, layers=None, ground=None, conditions=("dry", "wet"), **kw):
|
||
"""엔진 출력 꼴 그대로 · 값은 누가거리로 정함(1.0 + ch/100 · 건기 +0.3)."""
|
||
CALLS.append(float(cross["chainage_m"]))
|
||
model = section_model_from_b06(
|
||
cross, design, layers=layers, ground=ground, materials=kw.get("materials")
|
||
)
|
||
cases = []
|
||
for cond in conditions:
|
||
for side in model["sides"]:
|
||
F = round(1.0 + float(cross["chainage_m"]) / 100 + (0.3 if cond == "dry" else 0.0), 3)
|
||
x0, x1 = side["limits"]
|
||
row = {
|
||
"x_left": x0,
|
||
"x_right": x1,
|
||
"width": x1 - x0,
|
||
"height": 1.0,
|
||
"alpha_deg": 30.0,
|
||
"weight": 20.0,
|
||
"surcharge": 0.0,
|
||
"pore_pressure": 0.0,
|
||
"base_length": 2.0,
|
||
"material": "weathered_soil",
|
||
"c": 15.0,
|
||
"phi_deg": 30.0,
|
||
"normal_eff": 15.0,
|
||
"shear_mobilized": 9.0,
|
||
"E_left": 0.0,
|
||
"X_left": 0.0,
|
||
}
|
||
found = {
|
||
"F": F,
|
||
"kind": "circle",
|
||
"points": [[x0, 800.0], [x1, 799.0]],
|
||
"entry": [x0, 800.0],
|
||
"exit": [x1, 799.0],
|
||
"iterations": 4,
|
||
"converged": True,
|
||
"slices": [row],
|
||
"center": [0.0, 820.0],
|
||
"radius": 20.0,
|
||
}
|
||
result = {
|
||
"direction": side["direction"],
|
||
"limits": list(side["limits"]),
|
||
"methods": {"bishop": {"circular": found, "min": found}},
|
||
"contour": {
|
||
"method": "bishop",
|
||
"x": [0.0, 1.0],
|
||
"z": [810.0, 811.0],
|
||
"F": [[F, None], [2.0, 3.0]],
|
||
},
|
||
"counts": {"circles": 1},
|
||
}
|
||
cases.append({"condition": cond, **side, "min_F": {"bishop": F}, "result": result})
|
||
return {
|
||
"chainage_m": cross["chainage_m"],
|
||
"label": cross.get("label"),
|
||
"cases": cases,
|
||
"areas": model["areas"],
|
||
"elapsed_s": 0.0,
|
||
}
|
||
|
||
|
||
@pytest.fixture
|
||
def project(frozen, monkeypatch):
|
||
"""E 프로젝트 루트(복사본 + 비운 결과) · 측점 셋 · 가짜 엔진 · 서버 안에서 차례로."""
|
||
for name in ("_derived", "E02_StabGround", "E03_StabSection", "initial_snapshot"):
|
||
shutil.rmtree(frozen / name, ignore_errors=True)
|
||
original = src.stations
|
||
monkeypatch.setattr(router.src, "stations", lambda root: original(root)[:N_STATIONS])
|
||
|
||
async def fake_project(_pid):
|
||
return frozen, {"company_id": 1, "project_kind": "STABILITY"}
|
||
|
||
monkeypatch.setattr(router.e02, "_project", fake_project)
|
||
monkeypatch.setattr(worker, "analyze_station", _fake_analyze)
|
||
monkeypatch.setattr(router, "WORKERS", 1)
|
||
router._JOBS.clear()
|
||
CALLS.clear()
|
||
return frozen
|
||
|
||
|
||
def _analyze() -> dict:
|
||
"""POST → 뒤 일이 끝날 때까지 기다림."""
|
||
|
||
async def go():
|
||
first = await router.analyze2d(UUID(PID))
|
||
while router._TASKS:
|
||
await asyncio.gather(*list(router._TASKS))
|
||
return first
|
||
|
||
return asyncio.run(go())
|
||
|
||
|
||
USER = {"role": "USER", "company_id": 1}
|
||
|
||
|
||
def test_지문이_같으면_다시_안셈(project):
|
||
first = _analyze()
|
||
assert first["status"] == "accepted" and len(CALLS) == N_STATIONS
|
||
job = asyncio.run(router.progress(UUID(PID)))
|
||
assert job == {**job, "running": False, "percent": 100, "error": None, "computed": N_STATIONS}
|
||
|
||
again = _analyze()
|
||
assert again == {"status": "fresh", "fingerprint": first["fingerprint"]}
|
||
assert len(CALLS) == N_STATIONS # 다시 안 셈
|
||
|
||
# 둘째 측점만 구간 덮어쓰기 → 그 측점만 다시 셈 · 다른 지문
|
||
ground = router._ground(project)
|
||
second = router.plan_run(project, ground)["items"][1]["chainage_m"]
|
||
ground["zones"] = [
|
||
{
|
||
"from_m": second,
|
||
"to_m": second,
|
||
"soil": "colluvium",
|
||
"rock": None,
|
||
"soil_thickness_m": None,
|
||
}
|
||
]
|
||
(project / "E02_StabGround").mkdir(exist_ok=True)
|
||
ground_model.ground_path(project).write_text(json.dumps(ground), encoding="utf-8")
|
||
CALLS.clear()
|
||
third = _analyze()
|
||
assert third["status"] == "accepted" and third["fingerprint"] != first["fingerprint"]
|
||
assert CALLS == [second]
|
||
|
||
# 기준안전율만 바꿈 → 엔진은 안 돌고 판정만 새로(요약 지문만 바뀜)
|
||
ground["safety_factors"]["dry"]["fill"] = 9.0
|
||
ground_model.ground_path(project).write_text(json.dumps(ground), encoding="utf-8")
|
||
CALLS.clear()
|
||
assert _analyze()["status"] == "accepted" and CALLS == []
|
||
body = asyncio.run(router.result2d(UUID(PID), USER))
|
||
fill_dry = [
|
||
c["dry"]["left"] for c in (r["cases"] for r in body["stations"]) if "left" in c["dry"]
|
||
]
|
||
assert body["fresh"] and any(v["required"] == 9.0 and v["ok"] is False for v in fill_dry)
|
||
|
||
|
||
def test_진행률_올라감(project, monkeypatch):
|
||
seen = []
|
||
|
||
def spy(*args, **kw):
|
||
seen.append(router._JOBS[PID]["percent"])
|
||
return _fake_analyze(*args, **kw)
|
||
|
||
monkeypatch.setattr(worker, "analyze_station", spy)
|
||
_analyze()
|
||
assert seen[0] == 2 and seen == sorted(seen) and len(set(seen)) == N_STATIONS
|
||
assert asyncio.run(router.progress(UUID(PID)))["percent"] == 100
|
||
|
||
|
||
def test_결과_꼴_대표단면(project):
|
||
none = asyncio.run(router.result2d(UUID(PID), USER))
|
||
assert none["status"] == "none" and len(none["stations"]) == N_STATIONS
|
||
_analyze()
|
||
body = asyncio.run(router.result2d(UUID(PID), USER))
|
||
assert body["status"] == "ok" and body["fresh"] and body["editable"]
|
||
assert [r["key"] for r in body["stations"]] == [r["key"] for r in none["stations"]]
|
||
row = body["stations"][0]
|
||
assert set(row["verdict"]) == {"dry", "wet"} and row["representative"]["on"] is True # 하위 5
|
||
other = asyncio.run(router.result2d(UUID(PID), {"role": "USER", "company_id": 2}))
|
||
assert other["editable"] is False
|
||
|
||
sec = asyncio.run(router.section(UUID(PID), row["key"]))
|
||
assert sec["regions"] and sec["surface"] and sec["surcharge"][0]["q"] == 10.0
|
||
assert sec["water"]["dry"] is None and sec["water"]["wet"]
|
||
a = sec["analyses"][0]
|
||
assert a["critical"]["points"] and a["grid"]["fs"][0][1] is None and a["slices"][0]["N"] == 15.0
|
||
with pytest.raises(HTTPException) as missing:
|
||
asyncio.run(router.section(UUID(PID), "99999.00"))
|
||
assert missing.value.status_code == 404
|
||
|
||
saved = asyncio.run(
|
||
router.put_representative(
|
||
UUID(PID), router.RepresentativeRequest(overrides={row["key"]: False})
|
||
)
|
||
)
|
||
assert saved == {"overrides": {row["key"]: False}}
|
||
after = asyncio.run(router.result2d(UUID(PID), USER))["stations"][0]["representative"]
|
||
assert after == {"auto": ["lowest"], "user": False, "on": False}
|
||
with pytest.raises(HTTPException) as bad:
|
||
asyncio.run(
|
||
router.put_representative(
|
||
UUID(PID), router.RepresentativeRequest(overrides={"x": True})
|
||
)
|
||
)
|
||
assert bad.value.status_code == 422
|
||
|
||
|
||
def test_노선면_솎음(project):
|
||
body = asyncio.run(router.corridor3d(UUID(PID)))
|
||
assert len(body["origin"]) == 3 and body["ribbons"]
|
||
for ribbon in body["ribbons"]:
|
||
chain = ribbon["chainages"]
|
||
assert len(ribbon["positions"]) == len(chain) * ribbon["colCount"] * 3
|
||
assert chain == sorted(set(chain)) # 1 m 칸마다 한 줄 + 끝 줄
|
||
assert len(chain) <= math.ceil((chain[-1] - chain[0]) / router.CORRIDOR_STEP_M) + 2
|
||
assert max(abs(v) for v in ribbon["positions"]) < 5000 # 원점 뺀 자리
|
||
|
||
|
||
class _Cursor:
|
||
def __init__(self, rows):
|
||
self.rows = rows
|
||
|
||
async def __aenter__(self):
|
||
return self
|
||
|
||
async def __aexit__(self, *exc):
|
||
return False
|
||
|
||
async def execute(self, sql, args=()):
|
||
self.last = sql
|
||
|
||
async def fetchone(self):
|
||
return self.rows["shared"] if "plan_route_assignees" in self.last else self.rows["project"]
|
||
|
||
|
||
class _Pool:
|
||
def __init__(self, rows):
|
||
self.rows = rows
|
||
|
||
def acquire(self):
|
||
pool = self
|
||
|
||
class _Conn:
|
||
async def __aenter__(self):
|
||
return self
|
||
|
||
async def __aexit__(self, *exc):
|
||
return False
|
||
|
||
def cursor(self):
|
||
return _Cursor(pool.rows)
|
||
|
||
return _Conn()
|
||
|
||
|
||
@pytest.mark.parametrize("method,ok", [("GET", True), ("POST", False), ("PUT", False)])
|
||
def test_공유_회사는_GET_만(monkeypatch, method, ok):
|
||
monkeypatch.setattr(auth, "get_db_pool", lambda: _Pool({"project": (1,), "shared": (1,)}))
|
||
request = SimpleNamespace(path_params={"project_id": PID}, method=method)
|
||
session = {"role": "USER", "company_id": 2}
|
||
if ok:
|
||
assert asyncio.run(auth.require_project_access(request, session)) is session
|
||
else:
|
||
with pytest.raises(HTTPException) as error:
|
||
asyncio.run(auth.require_project_access(request, session))
|
||
assert error.value.status_code == 403
|
||
|
||
|
||
def test_E03_길에_공용_접근_판정이_걸림():
|
||
import main
|
||
|
||
paths = {
|
||
"/api/projects/{project_id}/stab/" + p
|
||
for p in ("analyze2d", "result2d", "progress", "representative")
|
||
}
|
||
found = set()
|
||
for route in main.app.routes:
|
||
if getattr(route, "path", None) in paths:
|
||
calls = {d.call for d in route.dependant.dependencies}
|
||
assert auth.require_project_access in calls and auth.require_company in calls
|
||
found.add(route.path)
|
||
assert found == paths
|
||
|
||
|
||
def test_실제_엔진_한_측점_화면_꼴(frozen):
|
||
source = src.source_dir(frozen)
|
||
station = src.stations(source)[5]
|
||
cross = src.cross_section(source, station["chainage_m"])
|
||
out = analyze_station(cross, cross["design"], methods=("ordinary", "bishop"), noncircular=False)
|
||
meta = {
|
||
"key": "k",
|
||
"chainage_m": station["chainage_m"],
|
||
"label": cross["label"],
|
||
"section_mode": cross["design"]["section_mode"],
|
||
"center": [0, 0],
|
||
"fp": "f",
|
||
}
|
||
factors = {"dry": {"cut": 1.5, "fill": 1.5}, "wet": {"cut": 1.2, "fill": 1.3}}
|
||
row = view.station_row(meta, out, factors)
|
||
model = section_model_from_b06(cross, cross["design"])
|
||
sec = view.section_view(row, out, model, {"dry": None, "wet": model["surface"]})
|
||
assert {a["condition"] for a in sec["analyses"]} == {"dry", "wet"}
|
||
for a in sec["analyses"]:
|
||
c = a["critical"]
|
||
assert c and c["kind"] == "circle" and c["center"] and c["radius"] > 0
|
||
# 임계면 꼭짓점 = 원 위(반경 ±0.01 m)
|
||
for x, z in c["points"]:
|
||
assert abs(math.hypot(x - c["center"][0], z - c["center"][1]) - c["radius"]) < 0.02
|
||
assert a["slices"] and a["slices"][0]["x_left"] == pytest.approx(
|
||
min(p[0] for p in c["points"]), abs=0.01
|
||
)
|
||
assert a["min_by_method"]["bishop"] == pytest.approx(c["fs"], abs=1e-3)
|
||
assert row["cases"][a["condition"]][a["side"]]["ok"] == (c["fs"] >= a["required"])
|
||
assert len(a["grid"]["fs"]) == len(a["grid"]["z"])
|
||
|
||
|
||
def test_대표단면_자동_규칙():
|
||
rows = [
|
||
{"key": str(i), "sides": [{"role": "fill", "height_m": h}], "min": {"dry": 3.0 - i * 0.1}}
|
||
for i, h in enumerate([10.0, 9.99, 0, 0, 0, 0, 0, 0])
|
||
]
|
||
rows[3]["sides"] = [{"role": "cut", "height_m": 15.0}]
|
||
auto = view.auto_representative(rows)
|
||
assert (
|
||
auto["0"] == ["fill_height"] and "1" not in auto and auto["3"] == ["cut_height", "lowest"]
|
||
)
|
||
assert sorted(k for k, v in auto.items() if "lowest" in v) == ["3", "4", "5", "6", "7"]
|
||
|
||
|
||
# ---- logic(TS) --------------------------------------------------------------
|
||
SCRIPT = r"""
|
||
const ts = require(process.argv[1]);
|
||
const fs = require("fs");
|
||
const src = fs.readFileSync(process.argv[2], "utf8");
|
||
const opts = { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 };
|
||
const js = ts.transpileModule(src, { compilerOptions: opts }).outputText;
|
||
const box = { exports: {} };
|
||
new Function("exports", "module", js)(box.exports, box);
|
||
const m = box.exports;
|
||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||
const fit = m.fitView(input.view, 800, 500, 20);
|
||
const shape = input.shape;
|
||
process.stdout.write(JSON.stringify({
|
||
verdict: input.rows.map((r) => [m.verdictOf(r), m.verdictOf(r, "dry"), m.verdictOf(r, "wet")]),
|
||
sortFs: m.listRows(input.rows, "wet", "fs", false).map((r) => r.key),
|
||
ngOnly: m.listRows(input.rows, "wet", "chainage", true).map((r) => r.key),
|
||
bands: input.fsList.map((f) => m.bandIndex(f, 1.5)),
|
||
legend: m.bandLegend(1.5),
|
||
fit,
|
||
matrix: m.viewMatrix(fit),
|
||
screen: input.view.surface.map((p) => m.toScreen(fit, p)),
|
||
back: input.view.surface.map((p) => m.toModel(fit, m.toScreen(fit, p))),
|
||
segments: m.bandSegments(input.rows, "wet").map((s) => [s.verdict, s.keys]),
|
||
arrows: m.forceArrows(input.slice, shape, -1, 0.01),
|
||
arrowsPos: m.forceArrows(input.slice, shape, 1, 0.01),
|
||
stress: m.sliceStress([input.slice]),
|
||
ticks: m.niceTicks(-3, 47, 5),
|
||
zAt: [m.zAt([[0, 0], [10, 10]], 2.5), m.zAt([[10, 10], [0, 0]], -5)],
|
||
}));
|
||
"""
|
||
|
||
|
||
def _row(key, ch, dry, wet, center):
|
||
cases = {
|
||
"dry": {"left": {"fs": dry, "required": 1.5, "ok": None if dry is None else dry >= 1.5}},
|
||
"wet": {"left": {"fs": wet, "required": 1.3, "ok": None if wet is None else wet >= 1.3}},
|
||
}
|
||
verdict = {}
|
||
for cond, by_side in cases.items():
|
||
oks = [v["ok"] for v in by_side.values() if v["ok"] is not None]
|
||
verdict[cond] = "none" if not oks else ("ok" if all(oks) else "ng")
|
||
return {
|
||
"key": key,
|
||
"chainage_m": ch,
|
||
"label": key,
|
||
"center": center,
|
||
"cases": cases,
|
||
"sides": [{"side": "left", "role": "fill", "height_m": 1.0}],
|
||
"min": {"dry": dry, "wet": wet},
|
||
"verdict": verdict,
|
||
"error": None,
|
||
}
|
||
|
||
|
||
def test_logic_TS():
|
||
rows = [
|
||
_row("a", 0, 2.0, 1.4, [0, 0]),
|
||
_row("b", 20, 1.6, 1.1, [20, 0]),
|
||
_row("c", 40, None, None, [40, 5]),
|
||
_row("d", 60, 1.2, 1.0, [60, 10]),
|
||
_row("e", 80, 2.0, 1.5, [80, 10]),
|
||
]
|
||
surface = [[-20.0, 810.0], [-2.0, 800.0], [2.0, 800.0], [20.0, 790.0]]
|
||
sec = {"surface": surface, "bottom_z": 780.0, "analyses": []}
|
||
shape = [[0.0, 0.0], [2.0, 1.0], [2.0, 4.0], [0.0, 3.0]]
|
||
slice_row = {
|
||
"x_left": 0,
|
||
"x_right": 2,
|
||
"weight": 50,
|
||
"surcharge": 5,
|
||
"N_eff": 40,
|
||
"U": 10,
|
||
"S": 20,
|
||
"E_left": 3,
|
||
"E_right": 4,
|
||
"X_left": 0,
|
||
"X_right": 0,
|
||
"base_length": 2.236,
|
||
"pore_pressure": 4.47,
|
||
}
|
||
fs_list = [None, 1.0, 1.2, 1.35, 1.5, 1.65, 1.875, 2.25, 9.0]
|
||
payload = {"rows": rows, "view": sec, "fsList": fs_list, "shape": shape, "slice": slice_row}
|
||
proc = subprocess.run(
|
||
["node", "-e", SCRIPT, str(TYPESCRIPT), str(TS)],
|
||
input=json.dumps(payload),
|
||
capture_output=True,
|
||
text=True,
|
||
encoding="utf-8",
|
||
timeout=60,
|
||
)
|
||
assert proc.returncode == 0, proc.stderr
|
||
out = json.loads(proc.stdout)
|
||
|
||
# 판정 규칙 = 파이썬 overall_verdict
|
||
for r, got in zip(rows, out["verdict"]):
|
||
assert got == [
|
||
view.overall_verdict(r),
|
||
view.overall_verdict(r, "dry"),
|
||
view.overall_verdict(r, "wet"),
|
||
]
|
||
assert out["sortFs"] == ["d", "b", "a", "e", "c"] # 없음은 맨 뒤
|
||
assert out["ngOnly"] == ["b", "d"]
|
||
# 띠 = 안전율 ÷ 기준(1.5) 경계 0.8 · 0.9 · 1.0 · 1.1 · 1.25 · 1.5
|
||
assert out["bands"] == [None, 0, 1, 2, 3, 4, 5, 6, 6]
|
||
assert [(b["from"], b["to"]) for b in out["legend"]] == [
|
||
(None, 1.2),
|
||
(1.2, 1.35),
|
||
(1.35, 1.5),
|
||
(1.5, 1.65),
|
||
(1.65, 1.88),
|
||
(1.88, 2.25),
|
||
(2.25, None),
|
||
]
|
||
# 화면 좌표 — 좌측(+offset) 이 왼쪽 · 위 = 높은 표고 · 같은 축척 · 왕복 = 원래 값 · 판 안
|
||
k, tx, ty = out["fit"]["k"], out["fit"]["tx"], out["fit"]["ty"]
|
||
assert out["matrix"] == f"matrix({-k} 0 0 {-k} {tx} {ty})"
|
||
xs = [p[0] for p in out["screen"]]
|
||
assert xs == sorted(xs, reverse=True)
|
||
for (x, z), (sx, sy), back in zip(surface, out["screen"], out["back"]):
|
||
assert (sx, sy) == pytest.approx((-k * x + tx, -k * z + ty))
|
||
assert back == pytest.approx([x, z]) and 0 <= sx <= 800 and 0 <= sy <= 500
|
||
# 평면 띠 — 판정이 바뀔 때만 끊음 · 색 수 = 판정 수
|
||
assert out["segments"] == [
|
||
["ok", ["a"]],
|
||
["ng", ["b"]],
|
||
["none", ["c"]],
|
||
["ng", ["d"]],
|
||
["ok", ["e"]],
|
||
]
|
||
assert len({s[0] for s in out["segments"]}) == len(
|
||
{view.overall_verdict(r, "wet") for r in rows}
|
||
)
|
||
|
||
def vec(a):
|
||
return (a["to"][0] - a["from"][0], a["to"][1] - a["from"][1])
|
||
|
||
arrows = {a["name"]: a for a in out["arrows"]}
|
||
assert set(arrows) == {"W", "Q", "N", "U", "S", "EL", "ER"} # X = 0 은 뺌
|
||
assert vec(arrows["W"]) == pytest.approx((0, -0.5)) # 50 × 0.01 아래
|
||
base = (2 / math.sqrt(5), 1 / math.sqrt(5))
|
||
nx, nz = vec(arrows["N"])
|
||
assert nx * base[0] + nz * base[1] == pytest.approx(0, abs=1e-9) and nz > 0 # 바닥에 수직 · 위
|
||
sx, sz = vec(arrows["S"])
|
||
assert (sx, sz) == pytest.approx(
|
||
(0.2 * base[0], 0.2 * base[1])
|
||
) # 방향 −1 → 미끄럼 반대 = +바닥
|
||
assert vec({a["name"]: a for a in out["arrowsPos"]}["S"]) == pytest.approx(
|
||
(-0.2 * base[0], -0.2 * base[1])
|
||
)
|
||
assert arrows["EL"]["to"] == pytest.approx(
|
||
[2.0, 2.5]
|
||
) # 방향 −1 → 틀의 왼쪽 면 = 결과 오른쪽 면
|
||
assert vec(arrows["EL"]) == pytest.approx((-0.03, 0))
|
||
assert out["stress"][0] == pytest.approx(
|
||
{"x": 1, "sigma": 40 / 2.236, "u": 4.47, "tau": 20 / 2.236}
|
||
)
|
||
assert out["ticks"][0] <= -3 and out["ticks"][-1] >= 47 and 0 in out["ticks"]
|
||
assert out["zAt"] == [2.5, 0]
|
||
|
||
|
||
def test_엔진_지문은_코드_내용():
|
||
assert len(router.engine_hash()) == 12
|
||
item = copy.deepcopy({"upper": "a", "lower": "b", "soil_thickness_m": 0.5})
|
||
assert router._layers(item) == [["a", 0.5], ["b", None]]
|
||
assert router._layers({"upper": "a", "lower": None}) == [["a", None]]
|
||
assert router._layers(None) is None
|