diff --git a/E04_Stab3D/E04_Stab3D_Engine_Grid.py b/E04_Stab3D/E04_Stab3D_Engine_Grid.py index 18277cb89..97ea45d73 100644 --- a/E04_Stab3D/E04_Stab3D_Engine_Grid.py +++ b/E04_Stab3D/E04_Stab3D_Engine_Grid.py @@ -34,6 +34,7 @@ ROCK_KINDS = ("ripping_rock", "blasting_rock") #: 층 → 물성 id(E03 기본표) · 못 뚫는 층 LAYER_MATERIALS = ("fill", "weathered_soil", "weathered_rock", "soft_rock") LAYER_HARD = (False, False, False, True) +LAYER_LABELS = ("fill", "soil", "ripping_rock", "blasting_rock") # E02 label_map 열쇠 def station_from_b06(cross: dict, design: dict) -> dict: @@ -255,12 +256,15 @@ def build_grid( def strata_from_grid( grid: dict, materials: list[dict] | None = None, + label_map: dict[str, str] | None = None, *, water_depth_m: float | None = None, with_surcharge: bool = True, ) -> Strata: """격자 → 기둥 솔버 지층(층 차례 = 머리 주석) · 물성은 id 로 고름(E03 꼴 · 빠지면 기본표). + label_map — E02 층 이름 → 물성 id(soil · ripping_rock · blasting_rock · fill) · 빠지면 기본 짝. + water_depth_m — 지하수위면 = 합성 지표 − 깊이(0 = 지표까지 포화 · KDS 우기 규칙) · None = 지하수 없음. """ @@ -276,7 +280,7 @@ def strata_from_grid( grid["y"], z, np.stack([fill_bot, soil_bot, rip_bot]), - [by_id[k] for k in LAYER_MATERIALS], + [by_id[(label_map or {}).get(lab, k)] for lab, k in zip(LAYER_LABELS, LAYER_MATERIALS)], hard=list(LAYER_HARD), z_water=None if water_depth_m is None else z - water_depth_m, q=grid["q"] if with_surcharge else None, diff --git a/E04_Stab3D/E04_Stab3D_Run.py b/E04_Stab3D/E04_Stab3D_Run.py index 599886ce9..9b646b528 100644 --- a/E04_Stab3D/E04_Stab3D_Run.py +++ b/E04_Stab3D/E04_Stab3D_Run.py @@ -28,14 +28,13 @@ import numpy as np from scipy.spatial import cKDTree from E01_StabRegister import E01_StabRegister_Source as source -from E02_StabGround.E02_StabGround_Model import load_defaults as load_ground_defaults +from E02_StabGround import E02_StabGround_Model as ground_model from E03_StabSection.E03_StabSection_Types import DEFAULT_ROAD_SURCHARGE_KPA from E04_Stab3D import E04_Stab3D_Engine_Grid as grid_engine from E04_Stab3D import E04_Stab3D_Engine_Search as search_engine ENGINE_VERSION = "e04-3" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 STAGE_DIR = "E04_Stab3D" -GROUND_FILE = Path("E02_StabGround") / "ground.json" SECTION_LENGTH_M = 100.0 # 자동 구간 길이 MAX_SECTION_M = 500.0 # 한 번에 셀 구간 상한(시간 · 메모리) METHOD_NAMES = ("bishop", "janbu_corrected") @@ -69,10 +68,12 @@ def auto_sections(stations: list[dict[str, Any]]) -> list[dict[str, float]]: return out -def read_ground(project_root: Path) -> dict[str, Any] | None: - """E02 작업본(없으면 None = 기본값).""" - path = Path(project_root) / GROUND_FILE - return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None +def read_ground(project_root: Path) -> dict[str, Any]: + """E02 작업본 → 없으면 초기값 → 없으면 마스터 기본값(E05 와 같은 차례).""" + for path in (ground_model.ground_path(project_root), ground_model.initial_path(project_root)): + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + return ground_model.pick(ground_model.load_defaults()) def fingerprint( @@ -171,7 +172,7 @@ def _floor(strata: Any, best: dict[str, Any]) -> list[list[float]]: def reference_fs(ground: dict[str, Any] | None, condition: str) -> float: """기준안전율(E02 · 없으면 마스터 기본) — 깎기 · 쌓기 중 큰 값(보수).""" - factors = (ground or load_ground_defaults()).get("safety_factors") or {} + factors = (ground or ground_model.load_defaults()).get("safety_factors") or {} row = factors.get(condition) or {} return float(max(row.get("cut", 1.5), row.get("fill", 1.5))) @@ -202,7 +203,10 @@ def run_engine( ) wet = settings["condition"] == "wet" strata = grid_engine.strata_from_grid( - grid, (ground or {}).get("materials"), water_depth_m=0.0 if wet else None + grid, + (ground or {}).get("materials"), + (ground or {}).get("label_map"), + water_depth_m=0.0 if wet else None, ) mask = _node_mask(stations, grid, settings["start_m"], settings["end_m"], half) ratios = (1.0,) if settings["shape"] == "sphere" else (1.0, float(settings["ellipsoid_ratio"])) diff --git a/E04_Stab3D/E04_Stab3D_UI_Page.ts b/E04_Stab3D/E04_Stab3D_UI_Page.ts index 858502ba9..b7abbd9b8 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Page.ts +++ b/E04_Stab3D/E04_Stab3D_UI_Page.ts @@ -3,7 +3,7 @@ * E04 안정성평가 3D 해석(요구 347-1 · 요구서 4-2 · 4-3 · PLAN 533-3) — B05~B09 틀(group_workflow). * 좌 = 구간 목록 · 탐색 설정(기본 · 고급 접힘) · [해석] · 진행률 · [끊기] * 가운데 = 평면 지도(셀별 최소 안전율 · 임계 체적) · [3D 보기](지표 + 노선면 + 임계 활동면) - * 우 = 구간 결과(3D 최소 · 2D 최소(같은 구간 E03 측점 fs_min 최소) · 3D/2D 비 · 체적 · 방법) + * 우 = 구간 결과(3D 최소 · 2D 최소(같은 구간 E03 측점 · 3D 와 같은 조건 안전율 최소) · 3D/2D 비 · 체적 · 방법) * 계산 = 서버 단독(POST …/stab/analyze3d · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = sessionStorage. * ========================================================================== */ @@ -21,6 +21,7 @@ import { FS_STEPS, VOLUME_STEPS, legendRows, + fs2dOf, min2d, ratio3d2d, stepColor, @@ -73,18 +74,13 @@ async function api(path: string, init?: RequestInit): Promise { return body; } -/** E03 2D 측점 요약(GET …/stab/result2d · stations[].fs_min · 측점 하나에 깎기 · 쌓기 두 줄) — 없으면 빈 목록 */ -async function fetch2d( - projectId: string, -): Promise<{ chainage_m: number; fs_min: number | null }[]> { +/** E03 2D 측점 줄(GET …/stab/result2d · fs_by_condition · min = 조건별) — 없으면 빈 목록 */ +type Station2d = Parameters[0] & { chainage_m: number }; + +async function fetch2d(projectId: string): Promise { try { - const body = await api<{ stations?: Record[] }>( - `/projects/${projectId}/stab/result2d`, - ); - return (body.stations ?? []).map((s) => ({ - chainage_m: Number(s.chainage_m), - fs_min: typeof s.fs_min === "number" ? s.fs_min : null, - })); + const body = await api<{ stations?: Station2d[] }>(`/projects/${projectId}/stab/result2d`); + return (body.stations ?? []).map((s) => ({ ...s, chainage_m: Number(s.chainage_m) })); } catch { return []; } @@ -227,7 +223,7 @@ export function renderE04Stab3D(root: HTMLElement): void { // ---- 상태 ---- let overview: Overview | null = null; let result: Result3d | null = null; - let stations2d: { chainage_m: number; fs_min: number | null }[] = []; + let stations2d: Station2d[] = []; let running = false; const remember = (): void => @@ -290,7 +286,11 @@ export function renderE04Stab3D(root: HTMLElement): void { return; } const s = result.summary; - const f2d = min2d(stations2d, s.start_m, s.end_m); + const f2d = min2d( + stations2d.map((x) => ({ chainage_m: x.chainage_m, fs_min: fs2dOf(x, s.condition) })), + s.start_m, + s.end_m, + ); const ratio = ratio3d2d(s.f_min, f2d); const station = overview?.stations.reduce( (best, x) => diff --git a/E04_Stab3D/logic/E04_Stab3D_Raster.ts b/E04_Stab3D/logic/E04_Stab3D_Raster.ts index a98e95df8..9a50303c5 100644 --- a/E04_Stab3D/logic/E04_Stab3D_Raster.ts +++ b/E04_Stab3D/logic/E04_Stab3D_Raster.ts @@ -180,6 +180,18 @@ export function rasterRgba( return out; } +/** 2D 측점 한 줄의 3D 조건 안전율 — 지배 비탈 fs_by_condition · 없으면 조건별 최소 min · 없으면 null */ +export function fs2dOf( + row: { + fs_by_condition?: Record; + min?: Record; + }, + condition: string, +): number | null { + const v = row.fs_by_condition?.[condition] ?? row.min?.[condition]; + return typeof v === "number" && Number.isFinite(v) ? v : null; +} + /** 구간 안 2D 측점 최소 안전율(없으면 null) */ export function min2d( stations: readonly { chainage_m: number; fs_min: number | null }[], diff --git a/resources/tester/test_533_3_e04_engine.py b/resources/tester/test_533_3_e04_engine.py index 011197fd3..102294d2a 100644 --- a/resources/tester/test_533_3_e04_engine.py +++ b/resources/tester/test_533_3_e04_engine.py @@ -182,3 +182,35 @@ def test_node_mask_section_and_route_ends(root): assert mask[(np.abs(yy) <= 12.0) & (xx >= 1) & (xx <= 99)].all() part = run._node_mask(STATIONS, grid, 20.0, 60.0, 12.0) assert not part[(xx < 19.5) | (xx > 60.5)].any() and part[(yy == 0) & (xx == 40)].all() + + +def test_ground_order_and_label_map(tmp_path): + """물성 = E02 작업본 → 초기값 → 마스터 기본값(성토 φ 28) · 층 물성 = label_map 짝.""" + import json + + from E04_Stab3D import E04_Stab3D_Engine_Grid as grid_engine + + assert {m["id"]: m["phi"] for m in run.read_ground(tmp_path)["materials"]}["fill"] == 28.0 + init = tmp_path / "initial_snapshot" / "E02_StabGround" / "ground.json" + init.parent.mkdir(parents=True) + init.write_text(json.dumps({"tag": "initial"}), encoding="utf-8") + assert run.read_ground(tmp_path) == {"tag": "initial"} + work = tmp_path / "E02_StabGround" / "ground.json" + work.parent.mkdir(parents=True) + work.write_text(json.dumps({"tag": "work"}), encoding="utf-8") + assert run.read_ground(tmp_path) == {"tag": "work"} + + z = np.full((2, 2), 10.0) + grid = { + "x": np.arange(2.0), + "y": np.arange(2.0), + "z": z, + "z_orig": z, + "rock_top": z - 3, + "rock_kind": np.ones((2, 2), int), + "q": np.zeros((2, 2)), + } + ids = [m["id"] for m in grid_engine.strata_from_grid(grid).materials] + assert ids == ["fill", "weathered_soil", "weathered_rock", "soft_rock"] + mapped = grid_engine.strata_from_grid(grid, label_map={"soil": "colluvium", "fill": "fill"}) + assert [m["id"] for m in mapped.materials][:2] == ["fill", "colluvium"] diff --git a/resources/tester/test_533_3_e04_logic.py b/resources/tester/test_533_3_e04_logic.py index b26ff6e6a..e9f3d6350 100644 --- a/resources/tester/test_533_3_e04_logic.py +++ b/resources/tester/test_533_3_e04_logic.py @@ -56,6 +56,9 @@ writeFileSync(process.argv[3], JSON.stringify({ min2d: R.min2d([{ chainage_m: 0, fs_min: 1.1 }, { chainage_m: 20, fs_min: 1.4 }, { chainage_m: 40, fs_min: null }, { chainage_m: 60, fs_min: 0.9 }], 10, 50), ratio: [R.ratio3d2d(1.68, 1.4), R.ratio3d2d(1.5, null), R.ratio3d2d(1.5, 0)], + fs2d: [R.fs2dOf({ fs_by_condition: { wet: 1.2 }, min: { wet: 1.1 } }, "wet"), + R.fs2dOf({ fs_by_condition: { dry: 1.6 }, min: { wet: 1.1 } }, "wet"), + R.fs2dOf({ fs_by_condition: { wet: null }, min: {} }, "wet"), R.fs2dOf({}, "dry")], terrain: mesh(M.terrainMesh(c.grid, c.z)), sphere: mesh(M.slipMesh(c.grid, c.z, c.sphere)), ellipsoid: mesh(M.slipMesh(c.grid, c.z, c.ellipsoid)), @@ -120,6 +123,7 @@ def test_raster_min_pixels_ratio(ts): assert ts["pixelEmpty"] == 0 # 빈 셀 = 투명 assert ts["min2d"] == 1.4 # 10~50 m 안 = 20 m(1.4) · null 뺌 assert ts["ratio"][0] == pytest.approx(1.2) and ts["ratio"][1:] == [None, None] + assert ts["fs2d"] == [1.2, 1.1, None, None] # 지배 비탈 → 조건별 최소 → 없음 def _py_slip(best, x, y):