diff --git a/A00_Common/group_workflow.ts b/A00_Common/group_workflow.ts index 0fa1d7e21..1d6c0eabe 100644 --- a/A00_Common/group_workflow.ts +++ b/A00_Common/group_workflow.ts @@ -31,8 +31,15 @@ const FLOWS: readonly GroupFlow[] = [ }, { routes: STAB_STEP_ROUTES, - labels: ["DE_Step_StabGround", "DE_Step_StabSection", "DE_Step_Stab3D", "DE_Step_StabReport"], - icons: ["๐Ÿชจ", "๐Ÿ“", "๐ŸงŠ", "๐Ÿ“„"], + labels: [ + "DE_Step_StabGround", + "DE_Step_StabSection", + "DE_Step_Stab3D", + "DE_Step_StabReport", + "DE_Step_StabRock", + "DE_Step_StabArea", + ], + icons: ["๐Ÿชจ", "๐Ÿ“", "๐ŸงŠ", "๐Ÿ“„", "โ›ฐ๏ธ", "๐Ÿ—บ๏ธ"], }, ]; diff --git a/E04_Stab3D/E04_Stab3D_Router.py b/E04_Stab3D/E04_Stab3D_Router.py index a52acc53d..2416a95e8 100644 --- a/E04_Stab3D/E04_Stab3D_Router.py +++ b/E04_Stab3D/E04_Stab3D_Router.py @@ -184,7 +184,6 @@ def _overview(project_root: Path, root: Path) -> dict[str, Any]: for item in saved: current = run.fingerprint(finger_source, ground, item.pop("settings")) item["stale"] = item.pop("fingerprint") != current - points = source.db_snapshot(root).get("route_points") or [] return { "sections": run.auto_sections(stations), "stations": [ @@ -196,9 +195,10 @@ def _overview(project_root: Path, root: Path) -> dict[str, Any]: } for s in stations ], + # ์ค‘์‹ฌ์„  = ์ธก์  ์ค‘์‹ฌ(B06 ๋ˆ„๊ฐ€ ๊ทธ๋Œ€๋กœ ยท route_points ๋Š” ์ ‘์„  ๊บพ์€์„ ์ด๋ผ ๋ˆ„๊ฐ€๊ฐ€ ๋‹ค๋ฆ„) "line": [ - [float(p["model_x"]), float(p["model_y"]), float(p["chainage_m"])] - for p in sorted(points, key=lambda p: p["sequence_num"]) + [float(s["center_x"]), float(s["center_y"]), float(s["chainage_m"])] + for s in sorted(stations, key=lambda s: float(s["chainage_m"])) ], "saved": saved, "defaults": SearchSettings().model_dump(), diff --git a/E04_Stab3D/E04_Stab3D_Run.py b/E04_Stab3D/E04_Stab3D_Run.py index 8ffc5fbb7..599886ce9 100644 --- a/E04_Stab3D/E04_Stab3D_Run.py +++ b/E04_Stab3D/E04_Stab3D_Run.py @@ -33,7 +33,7 @@ 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-1" # ์—”์ง„ ํŒ โ€” ๋ฐ”๋€Œ๋ฉด ์ง€๋ฌธ์ด ๋‹ฌ๋ผ์ ธ ๋‹ค์‹œ ์…ˆ +ENGINE_VERSION = "e04-3" # ์—”์ง„ ํŒ โ€” ๋ฐ”๋€Œ๋ฉด ์ง€๋ฌธ์ด ๋‹ฌ๋ผ์ ธ ๋‹ค์‹œ ์…ˆ STAGE_DIR = "E04_Stab3D" GROUND_FILE = Path("E02_StabGround") / "ground.json" SECTION_LENGTH_M = 100.0 # ์ž๋™ ๊ตฌ๊ฐ„ ๊ธธ์ด @@ -127,13 +127,19 @@ def _engine_station(root: Path, station: dict[str, Any]) -> dict[str, Any]: def _node_mask( - root: Path, grid: dict[str, Any], start_m: float, end_m: float, half_width_m: float + stations: list[dict[str, Any]], + grid: dict[str, Any], + start_m: float, + end_m: float, + half_width_m: float, ) -> np.ndarray: - """ํƒ์ƒ‰ ๋…ธ๋“œ ์ž๋ฆฌ = ์ค‘์‹ฌ์„ ์—์„œ half_width ์•ˆ ยท ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด ์ค‘์‹ฌ์„  ์ ์˜ ๋ˆ„๊ฐ€๊ฐ€ ๊ตฌ๊ฐ„ ์•ˆ.""" - points = sorted( - source.db_snapshot(root).get("route_points") or [], key=lambda p: p["sequence_num"] + """ํƒ์ƒ‰ ๋…ธ๋“œ ์ž๋ฆฌ = ์ค‘์‹ฌ์„ ์—์„œ half_width ์•ˆ ยท ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด ์ค‘์‹ฌ์„  ์ ์˜ ๋ˆ„๊ฐ€๊ฐ€ ๊ตฌ๊ฐ„ ์•ˆ + ยท ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด ์ ์ด ์ค‘์‹ฌ์„  ์–‘ ๋์ ์ธ ์…€(์‹œ์  ์•ž ยท ์ข…์  ๋’ค)์€ ๋บŒ. + + ์ค‘์‹ฌ์„  = ์ธก์  ์ค‘์‹ฌ์„ ์ด์€ ์„ (B06 ๋ˆ„๊ฐ€ ๊ทธ๋Œ€๋กœ ยท route_points ๋Š” ์ ‘์„  ๊บพ์€์„ ์ด๋ผ ๋ˆ„๊ฐ€๊ฐ€ ๋‹ค๋ฆ„).""" + line = np.array( + [[float(s["center_x"]), float(s["center_y"]), float(s["chainage_m"])] for s in stations] ) - line = np.array([[p["model_x"], p["model_y"], p["chainage_m"]] for p in points], float) # 0.5 m ๋กœ ์ด˜์ด˜ํžˆ โ€” ๊บพ์ธ ์  ์‚ฌ์ด ์…€๋„ ๋ฐ”๋ฅธ ๋ˆ„๊ฐ€ dense = [line[:1]] for a, b in zip(line[:-1], line[1:]): @@ -145,6 +151,7 @@ def _node_mask( dist, at = cKDTree(line[:, :2]).query(np.column_stack([xx.ravel(), yy.ravel()])) chain = line[at, 2] keep = (dist <= half_width_m) & (chain >= start_m - 1e-6) & (chain <= end_m + 1e-6) + keep &= (at > 0) & (at < len(line) - 1) return keep.reshape(xx.shape) @@ -197,7 +204,7 @@ def run_engine( strata = grid_engine.strata_from_grid( grid, (ground or {}).get("materials"), water_depth_m=0.0 if wet else None ) - mask = _node_mask(root, grid, settings["start_m"], settings["end_m"], half) + 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"])) params = search_engine.SearchParams( node_mask=mask, diff --git a/E04_Stab3D/E04_Stab3D_UI_Map.ts b/E04_Stab3D/E04_Stab3D_UI_Map.ts index 85b8d5ebe..407511ee3 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Map.ts +++ b/E04_Stab3D/E04_Stab3D_UI_Map.ts @@ -7,7 +7,7 @@ import { el } from "@ui/ui_template_elements"; import { t } from "@ui/ui_template_locale"; -import { cutCells } from "./logic/E04_Stab3D_Mesh"; +import { cutCells, footprintCenter } from "./logic/E04_Stab3D_Mesh"; import { FS_STEPS, VOLUME_STEPS, @@ -40,6 +40,8 @@ interface View { scale: number; // px / m } +const RANGE_COLOR = "#2563eb"; // ๊ณ ๋ฅธ ๊ตฌ๊ฐ„ โ€” 3D ๋…ธ์„  ๊ด€๊ณผ ๊ฐ™์€ ์ƒ‰ ยท ์•ˆ์ „์œจ ์ƒ‰ ๋‹จ๊ณ„์™€ ๊ฒน์น˜์ง€ ์•Š์Œ + const shortLabel = (label: string): string => label.replace(/^STA\./, "").replace(/\.0+$/, ""); const css = (name: string, fallback: string): string => @@ -61,6 +63,7 @@ export function createPlanMap(): PlanMap { let layer: MapLayer = "fs"; let image: HTMLCanvasElement | null = null; let outline: Set = new Set(); + let marker: { x: number; y: number } | null = null; let view: View = { cx: 0, cy: 0, scale: 1 }; let width = 0; let height = 0; @@ -118,7 +121,7 @@ export function createPlanMap(): PlanMap { if (line.length < 2) return; ctx.lineJoin = ctx.lineCap = "round"; ctx.strokeStyle = text; - ctx.lineWidth = 2; + ctx.lineWidth = 1.5; ctx.beginPath(); line.forEach((p, i) => i ? ctx.lineTo(...toScreen(p[0], p[1])) : ctx.moveTo(...toScreen(p[0], p[1])), @@ -184,9 +187,9 @@ export function createPlanMap(): PlanMap { } function drawMarker(text: string): void { - if (!result) return; + if (!result || !marker) return; const { best } = result; - const [px, py] = toScreen(best.x, best.y); + const [px, py] = toScreen(marker.x, marker.y); ctx.lineWidth = 2; ctx.strokeStyle = "#ffffff"; ctx.beginPath(); @@ -245,7 +248,7 @@ export function createPlanMap(): PlanMap { ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, width, height); const text = css("--color-text", "#1f2937"); - const accent = css("--color-primary", "#6d28d9"); + const accent = RANGE_COLOR; if (result && image) { const g = result.grid; const [left, top] = toScreen(g.x0 - g.dx / 2, g.y0 + (g.ny - 0.5) * g.dx); @@ -335,6 +338,8 @@ export function createPlanMap(): PlanMap { return { x: rect.left + px, y: rect.top + py }; }, result: () => result, + marker: () => marker, + outline: () => [...outline], layer: () => layer, }; (window as unknown as { __e04Map?: typeof probe }).__e04Map = probe; @@ -354,7 +359,9 @@ export function createPlanMap(): PlanMap { }, setResult(next) { result = next; - outline = new Set(next ? cutCells(next.grid, next.z, next.best).keys() : []); + const cut = next ? cutCells(next.grid, next.z, next.best) : new Map(); + outline = new Set(cut.keys()); + marker = next ? footprintCenter(next.grid, cut, next.best) : null; buildImage(); fit(); draw(); diff --git a/E04_Stab3D/E04_Stab3D_UI_Style.css b/E04_Stab3D/E04_Stab3D_UI_Style.css index 7b0c9b7ca..f215ec07a 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Style.css +++ b/E04_Stab3D/E04_Stab3D_UI_Style.css @@ -86,7 +86,7 @@ .e04-map__empty { position: absolute; - inset: auto 0 45% 0; + inset: var(--spacing-16) 0 auto 0; margin: 0; text-align: center; color: var(--color-text-muted); diff --git a/E04_Stab3D/logic/E04_Stab3D_Mesh.ts b/E04_Stab3D/logic/E04_Stab3D_Mesh.ts index b191f4e79..66361007a 100644 --- a/E04_Stab3D/logic/E04_Stab3D_Mesh.ts +++ b/E04_Stab3D/logic/E04_Stab3D_Mesh.ts @@ -90,6 +90,22 @@ export function cutCells( return out; } +/** ์ž„๊ณ„ ํ™œ๋™์ฒด ํ‰๋ฉด ์ค‘์‹ฌ = ์ž˜๋ฆฐ ์…€ ์ค‘์‹ฌ์˜ ํ‰๊ท (์—†์œผ๋ฉด ๊ตฌ ์ค‘์‹ฌ) โ€” ์ง€๋„ ์ตœ์†Œ ํ‘œ์‹œ ์ž๋ฆฌ */ +export function footprintCenter( + grid: Grid, + cut: ReadonlyMap, + best: SlipBest, +): { x: number; y: number } { + if (!cut.size) return { x: best.x, y: best.y }; + let sx = 0; + let sy = 0; + for (const index of cut.keys()) { + sx += grid.x0 + (index % grid.nx) * grid.dx; + sy += grid.y0 + Math.floor(index / grid.nx) * grid.dx; + } + return { x: sx / cut.size, y: sy / cut.size }; +} + /** ์ž˜๋ฆฐ ์…€์˜ 8 ์ด์›ƒ ์ค‘ ์ž˜๋ฆฌ์ง€ ์•Š์€ ์…€(๊ฐ’ ์žˆ์Œ) โ€” ํ™œ๋™๋ฉด ํ…Œ๋‘๋ฆฌ */ export function rimCells( grid: Grid, diff --git a/E05_StabReport/E05_StabReport_Area.py b/E05_StabReport/E05_StabReport_Area.py new file mode 100644 index 000000000..f62bb196e --- /dev/null +++ b/E05_StabReport/E05_StabReport_Area.py @@ -0,0 +1,78 @@ +"""E05 ๋ณด๊ณ ์„œ s7 ๊ด‘์—ญ์š”์•ฝ โ€” E07 ๊ด‘์—ญ ์ž์—ฐ์‚ฌ๋ฉด ์œ„ํ—˜ ์ €์žฅ๋ณธ โ†’ ์‹œํŠธ (PLAN 536-2). + +์นธ ์ž๋ฆฌ = `resources/master_template/report/stab_report_slots.md` s7. +E07 ์ €์žฅ๋ณธ์ด ์—†์œผ๋ฉด ใ€Œ๊ด‘์—ญ ํ•ด์„ ์ „ใ€. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from E07_StabArea.E07_StabArea_Engine import CLASS_KEYS, RISK_CLASS +from E07_StabArea.E07_StabArea_Router import RESULT, load + +CLASS_NAMES = ("", "์•ˆ์ •", "๋ณดํ†ต ์•ˆ์ •", "์ค€์•ˆ์ •", "์œ„ํ—˜ ํ•˜ํ•œ", "์œ„ํ—˜ ์ƒํ•œ", "๋ถˆ์•ˆ์ •") + + +def stamp(project_root: Path) -> list[int] | None: + """ํ•ด์„ ์ง€๋ฌธ์šฉ โ€” ์ €์žฅ๋ณธ ํฌ๊ธฐ ยท ๊ณ ์นœ ๋•Œ.""" + path = Path(project_root) / RESULT + return [path.stat().st_size, path.stat().st_mtime_ns] if path.is_file() else None + + +def read(project_root: Path) -> dict[str, Any] | None: + """์ €์žฅ๋ณธ์—์„œ ๋ณด๊ณ ์„œ์— ์“ฐ๋Š” ๊ฒƒ๋งŒ(๊ฒฉ์ž ๋ฐฐ์—ด ๋บŒ).""" + doc = load(project_root) + if not doc: + return None + return {k: doc[k] for k in ("settings", "computed_at", "stations", "summary")} + + +def _set(sheet: dict[str, Any], a1: str, value: Any, style: int | None = None) -> None: + cell = dict(sheet["์นธ"].get(a1) or {}) + cell["๊ฐ’"] = round(value, 6) if isinstance(value, float) else value + if style is not None: + cell["์„œ์‹"] = style + sheet["์นธ"][a1] = cell + + +def fill_area(sheet: dict[str, Any], area: dict[str, Any] | None, labels: dict[float, str]) -> None: + """s7 โ€” ์กฐ๊ฑด ๋„ท ยท ๋“ฑ๊ธ‰๋ณ„ ๋ฉด์  ์—ฌ์„ฏ ยท ์œ„ํ—˜ ์ธก์ (22 ํ–‰๋ถ€ํ„ฐ ํ•œ ์ค„์”ฉ).""" + if not area: + _set(sheet, "B5", "๊ด‘์—ญ ํ•ด์„ ์ „ โ€” E07 ๊ด‘์—ญ์—์„œ [ํ•ด์„] ๋’ค [๋ณด๊ณ ์„œ ๊ฐฑ์‹ ]") + return + s, summary = area["settings"], area["summary"] + _set(sheet, "B5", f"์ค‘์‹ฌ์„  ยฑ {s['band_m']:g} m ยท ๊ฒฉ์ž {s['cell_m']:g} m") + _set( + sheet, + "B6", + f"ฮณt {s['gamma_t']:g} ยท ฮณsat {s['gamma_sat']:g} kN/mยณ ยท c {s['c']:g}~{s['c_hi']:g} kPa ยท " + f"ฯ† {s['phi']:g}~{s['phi_hi']:g}ยฐ ยท ๋ฟŒ๋ฆฌ ์ ์ฐฉ {s['c_root']:g} kPa ยท " + f"ํ† ์ธต {s['soil_depth_m']:g} m", + ) + rain = ( + "์ง€ํ‘œ ํฌํ™”(m = 1)" + if s["scenario"] == "saturated" + else f"๊ฐ•์šฐ {s['rain_mm_h']:g} mm/h ยท ํˆฌ์ˆ˜๊ณ„์ˆ˜ {s['k_m_s']:g} m/s(์ •์ƒ๋ฅ˜ ํฌํ™”๋น„)" + ) + _set(sheet, "B7", rain) + _set(sheet, "B8", str(area["computed_at"]).replace("T", " ")) + for k in range(1, 7): + _set(sheet, f"C{11 + k}", float(summary["areas_m2"][CLASS_KEYS[k]])) + rows = [r for r in area["stations"] if r.get("worst_class", 0) >= RISK_CLASS] + template = {col: dict(sheet["์นธ"].get(f"{col}22") or {}) for col in "ABCDEFG"} + if not rows: + _set(sheet, "A22", "์œ„ํ—˜ ์ธก์  ์—†์Œ") + for i, row in enumerate(rows): + r = 22 + i + for col, cell in template.items(): + sheet["์นธ"][f"{col}{r}"] = dict(cell) + ch = float(row["chainage_m"]) + _set(sheet, f"A{r}", row.get("label") or labels.get(round(ch, 3)) or f"{ch:.2f}") + _set(sheet, f"B{r}", ch) + _set(sheet, f"C{r}", float(row["fs_min"])) + _set(sheet, f"D{r}", float(row["si_min"])) + _set(sheet, f"E{r}", CLASS_NAMES[int(row["worst_class"])]) + _set(sheet, f"F{r}", float(row["risk_area_m2"])) + _set(sheet, f"G{r}", float(row["slope_max_deg"])) diff --git a/E05_StabReport/E05_StabReport_Build.py b/E05_StabReport/E05_StabReport_Build.py index d924938cf..d0be14542 100644 --- a/E05_StabReport/E05_StabReport_Build.py +++ b/E05_StabReport/E05_StabReport_Build.py @@ -1,12 +1,13 @@ """E05 ์•ˆ์ •์„ฑํ‰๊ฐ€ ๋ณด๊ณ ์„œ ๋นŒ๋”(์š”๊ตฌ 347-5 ยท 351 ยท ์š”๊ตฌ์„œ 4-3 ยท PLAN 534-3). -์ž…๋ ฅ = E02 ์ง€๋ฐ˜ ์กฐ๊ฑด(`ground.json`) ยท E03 2D ๊ฒฐ๊ณผ ์š”์•ฝ(`_derived/result2d.json`) ยท ์ธก์ ๋ณ„ ์ €์žฅ๋ณธ ยท -๋Œ€ํ‘œ ๋‹จ๋ฉด(`representative.json`) ยท E04 3D ์ €์žฅ๋ณธ ์š”์•ฝ(์žˆ์œผ๋ฉด) ยท E01 ๋ณต์‚ฌ๋ณธ(์ธก์  ยท ํšก๋‹จ ยท ๋…ธ์„ ). +์ž…๋ ฅ = E02 ์ง€๋ฐ˜ ์กฐ๊ฑด(`ground.json`) ยท E03 2D ํŒŒ์ƒ ์ €์žฅ๋ณธ(`stab2d_summary` ๊ฐ€์žฅ ์ตœ๊ทผ ยท ์ธก์ ๋ณ„ +`stab2d_station`) ยท ๋Œ€ํ‘œ ๋‹จ๋ฉด(E03 ์ž๋™ + `representative.json` ์ผฌ/๋”) ยท E04 3D ์ €์žฅ๋ณธ ์š”์•ฝ(์žˆ์œผ๋ฉด) ยท E07 ๊ด‘์—ญ ์ €์žฅ๋ณธ(์žˆ์œผ๋ฉด) ยท +E01 ๋ณต์‚ฌ๋ณธ(์ธก์  ยท ํšก๋‹จ ยท ๋…ธ์„ ). ์–‘์‹ = `resources/master_template/report/stab_report.json` ์นธ ์ž๋ฆฌ = ๊ฐ™์€ ํด๋” `stab_report_slots.md`. ๋ชซ: ์‚ฌ์šฉ์ž(ํ‘œ์ง€ s1 ยท ๊ฐœ์š” s2) = ์ฒ˜์Œ ๋งŒ๋“ค ๋•Œ๋งŒ ์ฑ„์›€ ยท -ํ”„๋กœ๊ทธ๋žจ(์ž…๋ ฅ์กฐ๊ฑด s3 ยท ์ธก์ ํ‘œ s4 ยท ๋Œ€ํ‘œ๋‹จ๋ฉด s5 ยท 3D์š”์•ฝ s6) +ํ”„๋กœ๊ทธ๋žจ(์ž…๋ ฅ์กฐ๊ฑด s3 ยท ์ธก์ ํ‘œ s4 ยท ๋Œ€ํ‘œ๋‹จ๋ฉด s5 ยท 3D์š”์•ฝ s6 ยท ๊ด‘์—ญ์š”์•ฝ s7) = [๋ณด๊ณ ์„œ ๊ฐฑ์‹ ] ๋•Œ ์–‘์‹์—์„œ ํ†ต์งธ๋กœ ๋‹ค์‹œ ์”€. ํŒ์ • ๊ธ€ ์นธ = ์—”์ง„ ํŒ์ • ๊ฐ’ + N.G ์„œ์‹ 10 ยท O.K ์„œ์‹ 11 ์„ ์ง์ ‘ ๋ถ™์ž„ (์ธ์‡„ HTML ยท xlsx ๋Š” ์กฐ๊ฑด๋ถ€ ์„œ์‹์„ ์•ˆ ๋จน์Œ). @@ -26,10 +27,14 @@ from datetime import date from pathlib import Path from typing import Any +from common_util.common_util_derived_store import read_derived from E01_StabRegister import E01_StabRegister_Source as source from E02_StabGround import E02_StabGround_Model as ground_model +from E03_StabSection import E03_StabSection_View as view2d from E03_StabSection.E03_StabSection_Engine_Model import final_surface +from E03_StabSection.E03_StabSection_Router import KIND_STATION, _latest_summary, _overrides from E04_Stab3D import E04_Stab3D_Run as run3d +from E05_StabReport import E05_StabReport_Area as area from E05_StabReport import E05_StabReport_Figure as figure ROOT = Path(__file__).resolve().parents[1] @@ -37,14 +42,11 @@ FORM_PATH = ROOT / "resources" / "master_template" / "report" / "stab_report.jso STAGE = "E05_StabReport" REPORT, META = "report.json", "report_meta.json" USER_SHEETS = ("s1", "s2") -PROGRAM_SHEETS = ("s3", "s4", "s5", "s6") +PROGRAM_SHEETS = ("s3", "s4", "s5", "s6", "s7") BLOCK = 28 # ๋Œ€ํ‘œ๋‹จ๋ฉด ํ•œ ์ชฝ = 28 ํ–‰ -E03 = Path("E03_StabSection") -RESULT2D = E03 / "_derived" / "result2d.json" -STATION_RESULTS = E03 / "_derived" / "results" -REPRESENTATIVE = E03 / "representative.json" +REPRESENTATIVE = Path("E03_StabSection") / "representative.json" BAND_KEY = "fig/3d_band.png" -LOWEST_N = 5 # representative.json ์ด ์—†์„ ๋•Œ โ€” ์ตœ์†Œ ์•ˆ์ „์œจ ํ•˜์œ„ N +LOWEST_N = view2d.REPRESENTATIVE_LOWEST CASES = ("dry", "wet", "seismic") SIDE_NAMES = {"cut": "๊นŽ๊ธฐ", "fill": "์Œ“๊ธฐ"} METHOD_NAMES = { @@ -55,9 +57,9 @@ METHOD_NAMES = { "spencer": "Spencer", } REASONS = { - "fill_10m": "์Œ“๊ธฐ ๋†’์ด 10 m ์ด์ƒ", - "cut_15m": "๊นŽ๊ธฐ ๋†’์ด 15 m ์ด์ƒ", - "lowest_fs": "์ตœ์†Œ ์•ˆ์ „์œจ ํ•˜์œ„", + "fill_height": "์Œ“๊ธฐ ๋†’์ด 10 m ์ด์ƒ", + "cut_height": "๊นŽ๊ธฐ ๋†’์ด 15 m ์ด์ƒ", + "lowest": "์ตœ์†Œ ์•ˆ์ „์œจ ํ•˜์œ„", "user": "์‚ฌ์šฉ์ž ์ง€์ •", } GROUND_NAMES = {"soil": "ํ† ์‚ฌ", "ripping_rock": "๋ฆฌํ•‘์•”", "blasting_rock": "๋ฐœํŒŒ์•”", "fill": "์„ฑํ† "} @@ -104,23 +106,63 @@ def _ground(project_root: Path) -> dict[str, Any]: ) -def _station_result(project_root: Path, key: str) -> dict[str, Any] | None: - folder = Path(project_root) / STATION_RESULTS - for name in (key, re.sub(r"[^0-9A-Za-z._+-]", "_", key)): - found = _read(folder / f"{name}.json") - if found: - return found - return None +def _station_result(project_root: Path, fp: str) -> dict[str, Any] | None: + saved = read_derived(Path(project_root), KIND_STATION, fp) + return (saved or {}).get("value", {}).get("out") + + +def _row2d(row: dict[str, Any], method: str) -> dict[str, Any]: + """E03 ์š”์•ฝ ์ค„ โ†’ ๋ณด๊ณ ์„œ ์ค„ โ€” ๋น„ํƒˆ = ๊ธฐ์ค€ ๋Œ€๋น„ ๊ฐ€์žฅ ๋‚ฎ์€ ์ชฝ(fs / ๊ธฐ์ค€) ยท ํŒ์ • = ์กฐ๊ฑด ํ•˜๋‚˜๋ผ๋„ + N.G ๋ฉด ng.""" + roles = {s["side"]: s["role"] for s in row.get("sides") or []} + cases = row.get("cases") or {} + worst = min( + ( + (c["fs"] / c["required"], side) + for sides in cases.values() + for side, c in sides.items() + if c.get("fs") is not None and c.get("required") + ), + default=None, + ) + side = worst[1] if worst else None + verdicts = set((row.get("verdict") or {}).values()) + values = [v for v in (row.get("min") or {}).values() if v is not None] + return { + "station_key": row["key"], + "chainage_m": row["chainage_m"], + "fp": row.get("fp"), + "side": roles.get(side), + "fs_by_condition": { + cond: sides[side]["fs"] for cond, sides in cases.items() if side in sides + }, + "fs_min": min(values) if values else None, + "min_by_condition": row.get("min") or {}, + "method": method, + "verdict": "ng" if "ng" in verdicts else "ok" if verdicts == {"ok"} else None, + "representative": row.get("representative") or {}, + } + + +def _result2d(project_root: Path) -> dict[str, Any] | None: + """E03 ๊ฐ€์žฅ ์ตœ๊ทผ ์š”์•ฝ(์ง€๊ธˆ ์ž…๋ ฅ๊ณผ ๋‹ฌ๋ผ๋„ ยท E03 ํ™”๋ฉด๊ณผ ๊ฐ™์€ ํŒ) + ๋Œ€ํ‘œ ๋‹จ๋ฉด ํ‘œ์‹œ.""" + summary, _ = _latest_summary(project_root, "") + if not summary: + return None + rows = [dict(r) for r in summary.get("stations") or []] + view2d.mark_representative(rows, _overrides(project_root)) + method = summary.get("method") or view2d.PRIMARY + return {**summary, "stations": [_row2d(r, method) for r in rows]} def analysis_fingerprint(project_root: Path) -> str: """ํ•ด์„ ์ง€๋ฌธ โ€” 2D ์š”์•ฝ ์ง€๋ฌธ ยท ๋Œ€ํ‘œ ๋‹จ๋ฉด ยท ์ง€๋ฐ˜ ์กฐ๊ฑด ยท 3D ์ €์žฅ๋ณธ ยท ์–‘์‹์ด ๊ฐ™์œผ๋ฉด ๊ฐ™์Œ (๊ฐ€๋ฒผ์šด ์ฝ๊ธฐ๋งŒ).""" project_root = Path(project_root) - r2 = _read(project_root / RESULT2D) + r2, _ = _latest_summary(project_root, "") folder = run3d.results_dir(project_root) body = { - "2d": [r2.get("input_fingerprint"), r2.get("computed_at")] if r2 else None, + "2d": [r2.get("fingerprint"), r2.get("computed_at")] if r2 else None, "rep": _read(project_root / REPRESENTATIVE), "ground": _ground(project_root), "3d": [ @@ -128,42 +170,38 @@ def analysis_fingerprint(project_root: Path) -> str: ] if folder.is_dir() else [], + "area": area.stamp(project_root), "form": hashlib.sha1(FORM_PATH.read_bytes()).hexdigest(), } text = json.dumps(body, ensure_ascii=False, sort_keys=True, default=str) return hashlib.sha1(text.encode("utf-8")).hexdigest() -def _representatives(r2: dict[str, Any], rep: dict[str, Any] | None) -> list[dict[str, Any]]: - by_key = {s["station_key"]: s for s in r2.get("stations") or []} - if rep and isinstance(rep.get("stations"), list): - picked = [s for s in rep["stations"] if s.get("enabled", True)] - else: - ranked = sorted( - (s for s in by_key.values() if s.get("fs_min") is not None), key=lambda s: s["fs_min"] - ) - picked = [{"station_key": s["station_key"], "reason": "lowest_fs"} for s in ranked][ - :LOWEST_N - ] - return [ - {**p, "summary": by_key[p["station_key"]]} for p in picked if p["station_key"] in by_key - ] +def _representatives(r2: dict[str, Any]) -> list[dict[str, Any]]: + """E03 ๋Œ€ํ‘œ ๋‹จ๋ฉด ์ผฌ โ€” ๊นŒ๋‹ญ = ์‚ฌ์šฉ์ž ์ผฌ์ด๋ฉด user ยท ์•„๋‹ˆ๋ฉด ์ž๋™ ์ฒซ ๊นŒ๋‹ญ.""" + out = [] + for s in r2.get("stations") or []: + mark = s["representative"] + if mark.get("on"): + reason = "user" if mark.get("user") else (mark.get("auto") or ["user"])[0] + out.append({"station_key": s["station_key"], "reason": reason, "summary": s}) + return out def gather(project_root: Path) -> dict[str, Any]: """๋นŒ๋” ์ž…๋ ฅ ํ•œ ๋ฒŒ โ€” 2D ๊ฒฐ๊ณผ๊ฐ€ ์—†์œผ๋ฉด `MissingInput`.""" project_root = Path(project_root) - r2 = _read(project_root / RESULT2D) + r2 = _result2d(project_root) if not r2 or not r2.get("stations"): raise MissingInput("2D ํ•ด์„ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค โ€” E03 ์—์„œ [ํ•ด์„] ๋’ค ๋งŒ๋“œ์„ธ์š”.") root = source.source_dir(project_root) db = source.db_snapshot(root) ground = _ground(project_root) resolved = {round(float(s["chainage_m"]), 3): s for s in ground_model.resolve_stations(ground)} - reps = _representatives(r2, _read(project_root / REPRESENTATIVE)) + reps = _representatives(r2) for item in reps: chainage = float(item["summary"]["chainage_m"]) - item["result"] = _station_result(project_root, item["station_key"]) + item["result"] = _station_result(project_root, item["summary"]["fp"]) try: item["cross"] = source.cross_section(root, chainage) except KeyError: @@ -181,6 +219,7 @@ def gather(project_root: Path) -> dict[str, Any]: "representatives": reps, "ground": ground, "sections3d": sorted(sections3d, key=lambda s: s["start_m"]), + "area": area.read(project_root), "fingerprint": analysis_fingerprint(project_root), } @@ -304,14 +343,19 @@ def fill_conditions(sheet: dict[str, Any], inputs: dict[str, Any]) -> None: _set(sheet, "B22", float(ground.get("road_surcharge_kpa") or 0)) defaults = {m["id"]: m for m in ground_model.load_defaults()["materials"]} names = {m["id"]: m["name"] for m in ground.get("materials") or []} - rows: list[tuple[dict[str, Any], str]] = [(m, "์ „์ฒด") for m in ground.get("materials") or []] + mapped = set((ground.get("label_map") or {}).values()) # ์ง€๋ฐ˜ ๋ผ๋ฒจ์— ์ด์€ ์ง€์ธต๋งŒ ใ€Œ์ „์ฒดใ€ + rows: list[tuple[dict[str, Any], str]] = [ + (m, "์ „์ฒด" if m["id"] in mapped else "-") for m in ground.get("materials") or [] + ] for zone in ground.get("zones") or []: span = f"{float(zone['from_m']):g} ~ {float(zone['to_m']):g} m" for layer, where in (("soil", "์œ„"), ("rock", "์•„๋ž˜")): mid = zone.get(layer) match = next((m for m in ground["materials"] if m["id"] == mid), None) if match: - rows.append((match, f"{span} {where} ์ง€์ธต")) + depth = zone.get("soil_thickness_m") if layer == "soil" else None + more = f" ยท ๋‘๊ป˜ {float(depth):g} m" if depth is not None else "" + rows.append((match, f"{span} {where} ์ง€์ธต{more}")) if zone.get("soil_thickness_m") is not None and not (zone.get("soil") or zone.get("rock")): rows.append(({"name": "ํ† ์ธต ๋‘๊ป˜"}, f"{span} ยท {zone['soil_thickness_m']:g} m")) for i, (m, span) in enumerate(rows): @@ -426,7 +470,7 @@ def fill_sections(sheet: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[s f"๋Œ€ํ‘œ ๋‹จ๋ฉด {mark} โ€” {label} ยท {SIDE_NAMES.get(side, side or '-')} ยท H {height:.1f} m", ) reason = REASONS.get(rep.get("reason"), rep.get("reason") or "") - if rep.get("reason") == "lowest_fs": + if rep.get("reason") == "lowest": reason = f"{reason} {LOWEST_N}" _set(sheet, f"B{2 + dr}", reason) key = fig_key(rep["station_key"]) @@ -513,19 +557,23 @@ def fill_3d(sheet: dict[str, Any], inputs: dict[str, Any]) -> tuple[str, str]: ground = inputs["ground"] labels = _labels(inputs["stations"]) rows2d = inputs["result2d"]["stations"] - crit = _criterion(ground, "dry") sections = [] for i, sec in enumerate(inputs["sections3d"]): r = 20 + i _copy_row(sheet, 20, r, "ABCDEGH") start, end = float(sec["start_m"]), float(sec["end_m"]) + cond = sec.get("condition") or "dry" # 2D ๋Š” 3D ์™€ ๊ฐ™์€ ์กฐ๊ฑด๋ผ๋ฆฌ ๊ฒฌ์คŒ inside = [ - float(s["fs_min"]) + float(s["min_by_condition"][cond]) for s in rows2d - if s.get("fs_min") is not None and start - 1e-6 <= float(s["chainage_m"]) <= end + 1e-6 + if s["min_by_condition"].get(cond) is not None + and start - 1e-6 <= float(s["chainage_m"]) <= end + 1e-6 ] f3 = float(sec["f_min"]) - ok = f3 >= crit - 1e-9 + if sec.get("verdict") in ("ok", "ng"): # ํŒ์ • = E04 ์—”์ง„ ๊ฐ’(๊ธฐ์ค€์•ˆ์ „์œจ๋„ E04) + ok = sec["verdict"] == "ok" + else: + ok = f3 >= _criterion(ground, cond) - 1e-9 _set(sheet, f"A{r}", f"{i + 1}๊ตฌ๊ฐ„" + (" (๋ฏธ๋ฆฌ๋ณด๊ธฐ)" if sec.get("preview") else "")) _set(sheet, f"B{r}", _label_at(labels, start)) _set(sheet, f"C{r}", _label_at(labels, end)) @@ -571,6 +619,7 @@ def build_program(book: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[st fill_stations(by["s4"], inputs) jobs = fill_sections(by["s5"], inputs) jobs.append(fill_3d(by["s6"], inputs)) + area.fill_area(by["s7"], inputs["area"], _labels(inputs["stations"])) return jobs diff --git a/E06_StabRock/E06_StabRock_Engine.py b/E06_StabRock/E06_StabRock_Engine.py new file mode 100644 index 000000000..a69b468b3 --- /dev/null +++ b/E06_StabRock/E06_StabRock_Engine.py @@ -0,0 +1,444 @@ +"""E06 ์•”๋ฐ˜ ๋น„ํƒˆ๋ฉด โ€” Markland ํŒ์ • ยท Hoek-Bray ํ‰๋ฉด ยท ์๊ธฐ ์•ˆ์ „์œจ (์š”๊ตฌ 345 ยท 351 ยท PLAN 536-1). + +๊ทผ๊ฑฐ = ์ง€์‹DB `original/์•ˆ์ •์„ฑํ‰๊ฐ€/03`(Markland ๋ถ€๋“ฑ์‹ ยท Hoek-Bray ํ‰๋ฉด ยท ์๊ธฐ) ยท 06(์ƒ์šฉ SW) ยท +Rocscience RocPlane ยท SWedge ์ด๋ก ์„œ(ํž˜ ๋ฒกํ„ฐ ยท ๋ฏธ๋„๋Ÿผ ๋ฐฉํ–ฅ 8 ๊ฒฝ์šฐ ยท ์ˆ˜์ง๋ ฅ ยท ์ง€์ง€๋ ฅ์€ ์ˆ˜๋™๋ ฅ). +์ฝ”๋“œ๋Š” ์ง์ ‘ ์งฌ(Dips ยท RocPlane ยท SWedge ์ฝ”๋“œ ์•ˆ ์”€) ยท NumPy ยท SciPy ๋งŒ. + +์ขŒํ‘œ = ๋™(+x) ยท ๋ถ(+y) ยท ์œ„(+z). ๋ฉด = (๊ฒฝ์‚ฌ๋ฐฉํ–ฅ ฮฑ ยท ๊ฒฝ์‚ฌ ฯˆ) ยท ์„  = (์ถ”ํ–ฅ ยท ์„ ์ฃผ๊ฐ). +๋‹จ์œ„ = ๊ธธ์ด m ยท ๋‹จ์œ„์ค‘๋Ÿ‰ kN/mยณ ยท ์ ์ฐฉ๋ ฅ kPa ยท ๊ฐ ยฐ ยท ํž˜ kN(ํ‰๋ฉด = ๋‹จ์œ„ ํญ๋‹น kN/m). + +โš  ์ง€์‹DB 03 ์˜ ํ‰๋ฉด ๋ณผํŠธ ํ•ญ(์ˆ˜์ง T cos(ฮธ+ฯˆp) ยท ํ™œ๋™ โˆ’T sin(ฮธ+ฯˆp))์€ ฮธ = ๋ณผํŠธ์™€ ๋ฉด ๋ฒ•์„  ์‚ฌ์ด ๊ฐ์ผ +๋•Œ ๋งž์Œ โ€” ์—ฌ๊ธฐ๋Š” ฮธ = ๋ณผํŠธ ๊ฒฝ์‚ฌ(์ˆ˜ํ‰ ์•„๋ž˜)๋กœ ๋ฐ›์•„ ํž˜ ๋ฒกํ„ฐ๋กœ ํ’‚(์ˆ˜์ง T sin(ฮธ+ฯˆp) ยท ํ™œ๋™ โˆ’T cos(ฮธ+ฯˆp)). +""" + +from __future__ import annotations + +import math +from typing import Any + +import numpy as np +from scipy.optimize import minimize_scalar + +GAMMA_W = 9.81 # ๋ฌผ ๋‹จ์œ„์ค‘๋Ÿ‰ kN/mยณ (E03 Types ์™€ ๊ฐ™์Œ) +LATERAL_PLANAR_DEG = 20.0 # ํ‰๋ฉด ์ธก๋ฐฉ ํ•œ๊ณ„(Dips ๊ธฐ๋ณธ ยท Wyllie & Mah) +LATERAL_TOPPLING_DEG = 20.0 # ์ „๋„ ์ธก๋ฐฉ ํ•œ๊ณ„(Dips ๊ธฐ๋ณธ ยท ์ง€์‹DB 03 ์€ 30 โ€” ํ™”๋ฉด์—์„œ ๋ฐ”๊ฟˆ) + + +# ---- ๋ฐฉํ–ฅ ---------------------------------------------------------------------- +def ang_diff(a: float, b: float) -> float: + """๋‘ ๋ฐฉ์œ„๊ฐ ์‚ฌ์ด ์ตœ์†Œ ๊ฐ๊ฑฐ๋ฆฌ 0~180.""" + d = abs((a - b) % 360.0) + return min(d, 360.0 - d) + + +def plane_normal(dip_dir: float, dip: float) -> np.ndarray: + """๋ฉด์˜ ์œ„์ชฝ ๋‹จ์œ„ ๋ฒ•์„ .""" + a, p = math.radians(dip_dir), math.radians(dip) + return np.array([math.sin(a) * math.sin(p), math.cos(a) * math.sin(p), math.cos(p)]) + + +def line_vector(trend: float, plunge: float) -> np.ndarray: + """์„ (์ถ”ํ–ฅ ยท ์„ ์ฃผ๊ฐ) โ†’ ์•„๋ž˜๋กœ ํ–ฅํ•œ ๋‹จ์œ„ ๋ฒกํ„ฐ.""" + t, p = math.radians(trend), math.radians(plunge) + return np.array([math.sin(t) * math.cos(p), math.cos(t) * math.cos(p), -math.sin(p)]) + + +def vector_line(v: np.ndarray) -> tuple[float, float]: + """๋ฒกํ„ฐ โ†’ (์ถ”ํ–ฅ ยท ์„ ์ฃผ๊ฐ) ํ•˜๋ฐ˜๊ตฌ(์œ„๋กœ ํ–ฅํ•˜๋ฉด ๋’ค์ง‘์Œ).""" + v = np.asarray(v, float) + if v[2] > 0: + v = -v + v = v / np.linalg.norm(v) + trend = math.degrees(math.atan2(v[0], v[1])) % 360.0 + return trend, math.degrees(math.asin(max(-1.0, min(1.0, -v[2])))) + + +def pole(dip_dir: float, dip: float) -> tuple[float, float]: + """๋ฉด์˜ ๊ทน์ (ํ•˜๋ฐ˜๊ตฌ) = (ฮฑ + 180 ยท 90 โˆ’ ฯˆ).""" + return (dip_dir + 180.0) % 360.0, 90.0 - dip + + +def intersection(a: dict, b: dict) -> tuple[float, float] | None: + """๋‘ ๋ฉด ๊ต์„ (์ถ”ํ–ฅ ยท ์„ ์ฃผ๊ฐ) โ€” ๋‚˜๋ž€ํ•˜๋ฉด None.""" + v = np.cross( + plane_normal(a["dip_direction"], a["dip"]), plane_normal(b["dip_direction"], b["dip"]) + ) + if np.linalg.norm(v) < 1e-9: + return None + return vector_line(v) + + +def apparent_dip(dip: float, dip_dir: float, trend: float) -> float: + """๋ฉด (ฮฑ ยท ฯˆ) ์˜ trend ๋ฐฉํ–ฅ ๊ฒ‰๋ณด๊ธฐ ๊ฒฝ์‚ฌ โ€” ๋ฐ˜๋Œ€์ชฝ(90ยฐ ๋„˜์Œ)์ด๋ฉด ์Œ์ˆ˜.""" + return math.degrees( + math.atan(math.tan(math.radians(dip)) * math.cos(math.radians(trend - dip_dir))) + ) + + +def strike_to_dip_direction(strike: float) -> float: + """์˜ค๋ฅธ์† ๋ฒ•์น™ ์ฃผํ–ฅ โ†’ ๊ฒฝ์‚ฌ๋ฐฉํ–ฅ.""" + return (strike + 90.0) % 360.0 + + +# ---- Markland ํŒ์ • ------------------------------------------------------------- +def kinematic( + slope: dict, + joints: list[dict], + lateral_planar: float = LATERAL_PLANAR_DEG, + lateral_toppling: float = LATERAL_TOPPLING_DEG, +) -> dict[str, list]: + """slope = {dip_direction, dip} ยท joints = [{id, dip_direction, dip, phi}, โ€ฆ]. + + ํ‰๋ฉด = ฮ”(ฮฑf, ฮฑp) โ‰ค ์ธก๋ฐฉ ยท ฯ† < ฯˆp < ๊ฒ‰๋ณด๊ธฐ ์‚ฌ๋ฉด ๊ฒฝ์‚ฌ / ์๊ธฐ = ๊ต์„  ฮ” < 90 ยท ฯ† < ์„ ์ฃผ๊ฐ < + ๊ฒ‰๋ณด๊ธฐ ์‚ฌ๋ฉด ๊ฒฝ์‚ฌ(ฯ† = ๋‘ ๋ฉด ์ค‘ ์ž‘์€ ๊ฐ’) / + ์ „๋„ = ฮ”(ฮฑf + 180, ฮฑp) โ‰ค ์ธก๋ฐฉ ยท ฯˆp > 90 โˆ’ ฯˆf + ฯ† (Goodman). + """ + af, pf = float(slope["dip_direction"]), float(slope["dip"]) + planar, toppling, wedge = [], [], [] + for j in joints: + aj, pj, phi = float(j["dip_direction"]), float(j["dip"]), float(j["phi"]) + if ang_diff(af, aj) <= lateral_planar and phi < pj < apparent_dip(pf, af, aj): + planar.append(j["id"]) + if ang_diff(af + 180.0, aj) <= lateral_toppling and pj > 90.0 - pf + phi: + toppling.append(j["id"]) + for i, a in enumerate(joints): + for b in joints[i + 1 :]: + line = intersection(a, b) + if line is None: + continue + trend, plunge = line + phi = min(float(a["phi"]), float(b["phi"])) + if ang_diff(af, trend) < 90.0 and phi < plunge < apparent_dip(pf, af, trend): + wedge.append( + { + "ids": [a["id"], b["id"]], + "trend": round(trend, 2), + "plunge": round(plunge, 2), + } + ) + return {"planar": planar, "wedge": wedge, "toppling": toppling} + + +# ---- ํ‰๋ฉด ํŒŒ๊ดด (Hoek-Bray ยท 2D ๋‹จ์œ„ ํญ) ----------------------------------------- +def _plane_block(H: float, pf: float, pp: float, ps: float, crack_x: float | None): + """์‚ฌ๋ฉด ๋(0,0) ยท x = ์‚ฐ ์ชฝ ์ˆ˜ํ‰ ยท z = ์œ„. ๋ฉ์ด ๋‹ค๊ฐํ˜• ยท ํ™œ๋™๋ฉด ๋ x ยท ๊ท ์—ด ๊นŠ์ด. + + ์œ—๋ฉด = ๋งˆ๋ฃจ(H cotฯˆf, H)์—์„œ ฯˆs ๋กœ ์˜ค๋ฆ„. ๊ท ์—ด ์—†์Œ = ํ™œ๋™๋ฉด์ด ์œ—๋ฉด๊ณผ ๋งŒ๋‚˜๋Š” ์ ๊นŒ์ง€.""" + tf, tp, ts = (math.tan(math.radians(v)) for v in (pf, pp, ps)) + xc = H / tf # ๋งˆ๋ฃจ + + def top(x: float) -> float: + return x * tf if x <= xc else H + (x - xc) * ts + + if crack_x is None: + if ts >= tp - 1e-12: + return None # ํ™œ๋™๋ฉด์ด ์œ—๋ฉด๊ณผ ์•ˆ ๋งŒ๋‚จ = ๋ฉ์ด ์•ˆ ์ƒ๊น€ + xe = (H - xc * ts) / (tp - ts) + poly = [(0.0, 0.0), (xc, H), (xe, xe * tp)] + return poly, xe, 0.0 + if crack_x < xc or top(crack_x) - crack_x * tp <= 1e-9: + return None + poly = [(0.0, 0.0), (xc, H), (crack_x, top(crack_x)), (crack_x, crack_x * tp)] + return poly, crack_x, top(crack_x) - crack_x * tp + + +def _area(poly) -> float: + xs, zs = np.array(poly).T + return 0.5 * abs(float(np.dot(xs, np.roll(zs, -1)) - np.dot(zs, np.roll(xs, -1)))) + + +def plane_fs( + *, + H: float, + slope_dip: float, + plane_dip: float, + c: float, + phi: float, + gamma: float, + upper_dip: float = 0.0, + crack: dict | None = None, + water_fill: float = 0.0, + kh: float = 0.0, + bolt_t: float = 0.0, + bolt_plunge: float = 0.0, + gamma_w: float = GAMMA_W, +) -> dict[str, Any] | None: + """ํ‰๋ฉด ํŒŒ๊ดด ์•ˆ์ „์œจ โ€” ๋ฉ์ด๊ฐ€ ์•ˆ ์ƒ๊ธฐ๋ฉด None. + + crack = None | {"mode": "depth", "z": m} | {"mode": "critical"}(์•ˆ์ „์œจ ์ตœ์†Œ ์ž๋ฆฌ ์ฐพ์Œ) ยท + ๋ฌผ = ๊ท ์—ด ์žˆ์Œ: zw = fillยทz ยท U = ยฝฮณw zw A ยท V = ยฝฮณw zwยฒ / ๊ท ์—ด ์—†์Œ: ๊ฐ€์šด๋ฐ ์ตœ๋Œ€ ์ˆ˜๋‘ + fillยทh/2(h = ํ™œ๋™๋ฉด ์˜ค๋ฆ„) ยท U = ยฝฮณw (fillยทh/2) A (Hoek ยท RocPlane) ยท + kh = ๋ฐ”๊นฅ ์ˆ˜ํ‰ ยท ๋ณผํŠธ = ์ˆ˜๋™๋ ฅ(์‚ฐ ์ชฝ ยท ์ˆ˜ํ‰ ์•„๋ž˜ bolt_plungeยฐ).""" + if not 0 < plane_dip < slope_dip < 90 or H <= 0 or upper_dip >= slope_dip: + return None + mode = (crack or {}).get("mode", "none") + if mode == "critical": + return _critical_crack(locals()) + tp = math.tan(math.radians(plane_dip)) + xc = H / math.tan(math.radians(slope_dip)) + crack_x = None + if mode == "depth": + z = float(crack["z"]) + crack_x = _crack_x_for_depth(H, slope_dip, plane_dip, upper_dip, z) + if crack_x is None: + return None + elif mode == "x": + crack_x = xc + float(crack["b"]) + block = _plane_block(H, slope_dip, plane_dip, upper_dip, crack_x) + if block is None: + return None + poly, x_end, z = block + psi = math.radians(plane_dip) + A = x_end / math.cos(psi) + W = gamma * _area(poly) + if crack_x is not None: + zw = water_fill * z + U, V = 0.5 * gamma_w * zw * A, 0.5 * gamma_w * zw * zw + else: + zw = water_fill * (x_end * tp) / 2.0 + U, V = 0.5 * gamma_w * zw * A, 0.0 + d = np.array([-math.cos(psi), -math.sin(psi)]) # ์•„๋ž˜๋กœ ๋ฏธ๋„๋Ÿฌ์ง(๋ฐ”๊นฅ = โˆ’x) + n = np.array([-math.sin(psi), math.cos(psi)]) # ๋ฉด์—์„œ ๋ฉ์ด ์ชฝ + active = np.array([-kh * W - V, -W]) + U * n + drive = float(active @ d) + if drive <= 0: + return None + bolt = bolt_t * np.array( + [math.cos(math.radians(bolt_plunge)), -math.sin(math.radians(bolt_plunge))] + ) + N = max(0.0, -float((active + bolt) @ n)) + resist = c * A + N * math.tan(math.radians(phi)) - float(bolt @ d) + return { + "F": resist / drive, + "W": W, + "A": A, + "U": U, + "V": V, + "z": z, + "zw": zw, + "crack_b": None if crack_x is None else crack_x - xc, + "N": N, + "drive": drive, + "resist": resist, + "polygon": [[round(x, 3), round(y, 3)] for x, y in poly], + } + + +def _crack_x_for_depth(H, pf, pp, ps, z) -> float | None: + """์œ—๋ฉด ์•„๋ž˜ ๊ท ์—ด ๊นŠ์ด z ๊ฐ€ ๋˜๋Š” ์ž๋ฆฌ(๋งˆ๋ฃจ ๋’ค) โ€” ๊นŠ์ด๊ฐ€ ๋งˆ๋ฃจ ํ‹ˆ๋ณด๋‹ค ํฌ๋ฉด None.""" + tf, tp, ts = (math.tan(math.radians(v)) for v in (pf, pp, ps)) + xc = H / tf + gap = H - xc * tp # ๋งˆ๋ฃจ์—์„œ ์œ—๋ฉด โˆ’ ํ™œ๋™๋ฉด + if z <= 0 or z >= gap: + return None + if ts >= tp: # ํ‹ˆ์ด ์ปค์ง€๊ธฐ๋งŒ ํ•จ = ๋’ค๋กœ ๊ฐˆ์ˆ˜๋ก ๊นŠ์–ด์ง + return xc + (z - gap) / (ts - tp) if ts > tp else None + return xc + (gap - z) / (tp - ts) + + +def plane_critical_depth_hoek(H: float, slope_dip: float, plane_dip: float) -> float: + """Hoek-Bray ์ž„๊ณ„ ์ธ์žฅ๊ท ์—ด ๊นŠ์ด(์œ—๋ฉด ์ˆ˜ํ‰ ยท ๊ฑด์กฐ) z = H(1 โˆ’ โˆš(cotฯˆf tanฯˆp)).""" + return H * ( + 1.0 - math.sqrt(math.tan(math.radians(plane_dip)) / math.tan(math.radians(slope_dip))) + ) + + +def _critical_crack(kw: dict) -> dict | None: + """๊ท ์—ด ์ž๋ฆฌ๋ฅผ ๋งˆ๋ฃจ ๋’ค๋กœ ์˜ฎ๊ฒจ ์•ˆ์ „์œจ ์ตœ์†Œ โ€” ๊ท ์—ด ์—†์Œ๋ณด๋‹ค ํฌ๋ฉด ๊ท ์—ด ์—†์Œ.""" + args = {k: kw[k] for k in kw if k not in ("crack", "mode")} + tf, tp, ts = (math.tan(math.radians(kw[k])) for k in ("slope_dip", "plane_dip", "upper_dip")) + xc = kw["H"] / tf + x_far = (kw["H"] - xc * ts) / (tp - ts) if ts < tp else xc + 10 * kw["H"] + span = x_far - xc + + def fs_at(b: float) -> float: + r = plane_fs(**args, crack={"mode": "x", "b": b}) + return r["F"] if r else math.inf + + best = minimize_scalar(fs_at, bounds=(span * 1e-3, span * (1 - 1e-3)), method="bounded") + found = plane_fs(**args, crack={"mode": "x", "b": float(best.x)}) + plain = plane_fs(**args, crack=None) + if found is None or (plain is not None and plain["F"] <= found["F"]): + return plain + return found + + +# ---- ์๊ธฐ ํŒŒ๊ดด (Hoek-Bray ยท SWedge ๊ผด ์‚ฌ๋ฉด์ฒด) ------------------------------------ +def _meet(planes: list[tuple[np.ndarray, float]]) -> np.ndarray | None: + """์„ธ ๋ฉด nยทx = d ์˜ ๊ต์ .""" + m = np.array([p[0] for p in planes]) + if abs(np.linalg.det(m)) < 1e-10: + return None + return np.linalg.solve(m, np.array([p[1] for p in planes])) + + +def _tri_area(a, b, c) -> float: + return 0.5 * float(np.linalg.norm(np.cross(b - a, c - a))) + + +def wedge_geometry(j1: dict, j2: dict, slope: dict, upper: dict, H: float) -> dict | None: + """์‚ฌ๋ฉด์ฒด ์๊ธฐ โ€” ๊ผญ์ง“์  O(๋‘ ์ ˆ๋ฆฌ ยท ์‚ฌ๋ฉด = ์‚ฌ๋ฉด ๋) ยท A(1 ยท ์‚ฌ๋ฉด ยท ์œ—๋ฉด) ยท B(2 ยท ์‚ฌ๋ฉด ยท ์œ—๋ฉด) ยท + C(1 ยท 2 ยท ์œ—๋ฉด). ์‚ฌ๋ฉด ๋†’์ด H = ์‚ฌ๋ฉด ์œ„ ๊ผญ์ง“์  A ยท B ์ค‘ ๋†’์€ ์ชฝ์˜ O ์œ„ ๋†’์ด(SWedge ์™€ ๊ฐ™์Œ โ€” + Priest ์˜ˆ์ œ ๋ถ€ํ”ผ 45.201 mยณ ์žฌํ˜„). ๊ต์„ ์ด ์‚ฌ๋ฉด ยท ์œ—๋ฉด์œผ๋กœ ์•ˆ ๋‚˜์˜ค๋ฉด None(์๊ธฐ ์•ˆ ์ƒ๊น€).""" + n1 = plane_normal(j1["dip_direction"], j1["dip"]) + n2 = plane_normal(j2["dip_direction"], j2["dip"]) + nf = plane_normal(slope["dip_direction"], slope["dip"]) + nu = plane_normal(upper["dip_direction"], upper["dip"]) + # ์œ—๋ฉด = ์‚ฌ๋ฉด ์œ„๋กœ H ์˜ค๋ฅธ ์ ์„ ์ง€๋‚จ(์‚ฌ๋ฉด ๊ฒฝ์‚ฌ๋ฐฉํ–ฅ ๋ฐ˜๋Œ€๋กœ H cotฯˆf ๋“ค์–ด๊ฐ„ ์ ) + back = -line_vector(slope["dip_direction"], 0.0) * (H / math.tan(math.radians(slope["dip"]))) + crest = back + np.array([0.0, 0.0, H]) + du = float(nu @ crest) + O = np.zeros(3) # noqa: E741 โ€” ๊ผญ์ง“์  ์ด๋ฆ„(SWedge ๊ทธ๋ฆผ๊ณผ ๊ฐ™์Œ) + A = _meet([(n1, 0.0), (nf, 0.0), (nu, du)]) + B = _meet([(n2, 0.0), (nf, 0.0), (nu, du)]) + C = _meet([(n1, 0.0), (n2, 0.0), (nu, du)]) + if A is None or B is None or C is None: + return None + # C ๋Š” ์‚ฌ๋ฉด ๋’ค(์‚ฐ ์ชฝ) ยท ์œ„ โ€” ๊ต์„ ์ด ์‚ฌ๋ฉด์œผ๋กœ ๋‚˜์™€ ์œ—๋ฉด์— ๋‹ฟ์Œ + if C[2] <= 1e-9 or float(nf @ C) >= -1e-9 or A[2] <= 1e-9 or B[2] <= 1e-9: + return None + scale = H / max(A[2], B[2]) # ๋ฉด์ด ๋ชจ๋‘ O ๋ฅผ ์ง€๋‚˜ ๋‹ฎ์€๊ผด โ€” ๋†’์ด๋งŒ ๋งž์ถค + A, B, C = A * scale, B * scale, C * scale + volume = abs(float(np.linalg.det(np.array([A - O, B - O, C - O])))) / 6.0 + if volume <= 1e-9: + return None + centroid = (O + A + B + C) / 4.0 + inward = [n if float(n @ centroid) > 0 else -n for n in (n1, n2)] + return { + "vertices": {"O": O, "A": A, "B": B, "C": C}, + "volume": volume, + "areas": [_tri_area(O, A, C), _tri_area(O, B, C)], + "face_area": _tri_area(O, A, B), + "upper_area": _tri_area(A, B, C), + "normals": inward, + "Hw": float(C[2]), + "line_length": float(np.linalg.norm(C)), + } + + +def _unit(v: np.ndarray) -> np.ndarray: + n = float(np.linalg.norm(v)) + return v / n if n > 1e-12 else v * 0.0 + + +def _sliding(A: np.ndarray, n1: np.ndarray, n2: np.ndarray, W: np.ndarray): + """SWedge ๋ฏธ๋„๋Ÿผ ๋ฐฉํ–ฅ โ€” (๊ฐˆ๋ž˜, ๋ฐฉํ–ฅ). ๊ฐˆ๋ž˜ = lift ยท 1 ยท 2 ยท 12 ยท stable.""" + s1 = _unit(np.cross(np.cross(n1, A), n1)) + s2 = _unit(np.cross(np.cross(n2, A), n2)) + c12 = np.cross(n1, n2) + s12 = _unit(c12) * np.sign(float(c12 @ A)) + if A @ n1 > 0 and A @ n2 > 0: + return "lift", _unit(A) + if A @ n1 <= 0 and s1 @ n2 > 0: + return "1", s1 + if A @ n2 <= 0 and s2 @ n1 > 0: + return "2", s2 + if s1 @ n2 <= 0 and s2 @ n1 <= 0: + return "12", s12 + return "stable", None + + +def _normals(mode: str, F: np.ndarray, n1: np.ndarray, n2: np.ndarray) -> tuple[float, float]: + if mode == "1": + return -float(F @ n1), 0.0 + if mode == "2": + return 0.0, -float(F @ n2) + if mode == "12": + c12, c21 = np.cross(n1, n2), np.cross(n2, n1) + return ( + -float(np.cross(F, n2) @ c12) / float(c12 @ c12), + -float(np.cross(F, n1) @ c21) / float(c21 @ c21), + ) + return 0.0, 0.0 + + +def wedge_fs( + *, + j1: dict, + j2: dict, + slope: dict, + upper: dict, + H: float, + gamma: float, + water: dict | None = None, + kh: float = 0.0, + seismic_trend: float | None = None, + external: list[dict] | None = None, + bolt: dict | None = None, + gamma_w: float = GAMMA_W, +) -> dict[str, Any] | None: + """์๊ธฐ ์•ˆ์ „์œจ(SWedge ์ด๋ก ์„œ ๊ผด) โ€” ์๊ธฐ๊ฐ€ ์•ˆ ์ƒ๊ธฐ๋ฉด None. + + j = {dip_direction, dip, c, phi} ยท water = {"fill": p}(์ค‘๋ ฅ์‹ U = pยณฮณw Hw a/6) | + {"pressure": [u1, u2]}(ํ‰๊ท  ์ˆ˜์•• kPa) ยท kh = ์ˆ˜ํ‰(๊ธฐ๋ณธ ๊ต์„  ์ถ”ํ–ฅ) ยท external = [{force, trend, + plunge}] ยท bolt = {force, trend, plunge}(์ˆ˜๋™๋ ฅ). FS = max(์ง€์ง€ ์ „ ยท ์ง€์ง€ ๋’ค ยท ๋“ค๋ฆผ).""" + geo = wedge_geometry(j1, j2, slope, upper, H) + if geo is None: + return None + n1, n2 = geo["normals"] + a1, a2 = geo["areas"] + weight = gamma * geo["volume"] + Wv = np.array([0.0, 0.0, -weight]) + active = Wv.copy() + water = water or {} + if "pressure" in water: + u1, u2 = (float(u) for u in water["pressure"]) + else: + p = float(water.get("fill", 0.0)) + u1 = u2 = p**3 * gamma_w * geo["Hw"] / 6.0 + Uf = [u1 * a1 * n1, u2 * a2 * n2] + active += Uf[0] + Uf[1] + line = vector_line(np.cross(n1, n2)) + if kh: + trend = line[0] if seismic_trend is None else seismic_trend + active += kh * weight * line_vector(trend, 0.0) + for item in external or []: + active += float(item["force"]) * line_vector(item["trend"], item["plunge"]) + passive = np.zeros(3) + if bolt and bolt.get("force"): + passive = float(bolt["force"]) * line_vector(bolt["trend"], bolt["plunge"]) + mode, s = _sliding(active, n1, n2, Wv) + out = { + "volume": geo["volume"], + "W": weight, + "areas": [a1, a2], + "face_area": geo["face_area"], + "upper_area": geo["upper_area"], + "Hw": geo["Hw"], + "line_length": geo["line_length"], + "line": [round(line[0], 2), round(line[1], 2)], + "water_force": [float(np.linalg.norm(Uf[0])), float(np.linalg.norm(Uf[1]))], + "mode": mode, + "vertices": {k: [round(float(x), 3) for x in v] for k, v in geo["vertices"].items()}, + } + if mode == "stable": + return {**out, "F": math.inf, "N": [0.0, 0.0], "drive": 0.0, "resist": 0.0} + drive = float(active @ s) + if mode == "lift": + fs = -float(passive @ s) / drive if drive > 0 else math.inf + return {**out, "F": fs, "N": [0.0, 0.0], "drive": drive, "resist": -float(passive @ s)} + joints = (j1, j2) + + def resisting(F: np.ndarray) -> tuple[float, list[float]]: + N = _normals(mode, F, n1, n2) + total = 0.0 + for k, (n_k, a_k) in enumerate(zip(N, (a1, a2))): + if str(k + 1) not in mode or n_k <= 0: + continue # ๋–จ์–ด์ง„ ๋ฉด = ์ €ํ•ญ ์—†์Œ(SWedge ์™€ ๊ฐ™์Œ) + tau = float(joints[k]["c"]) + n_k / a_k * math.tan( + math.radians(float(joints[k]["phi"])) + ) + total += tau * a_k + return total, [max(0.0, n) for n in N] + + j_u, n_u = resisting(active) + fs_u = j_u / drive if drive > 0 else math.inf + fs, n_used, resist = fs_u, n_u, j_u + if np.any(passive): + j_s, n_s = resisting(active + passive) + fs_s = (-float(passive @ s) + j_s) / drive if drive > 0 else math.inf + fs_lift = -float(passive @ _unit(active)) / float(np.linalg.norm(active)) + if fs_s > fs: + fs, n_used, resist = fs_s, n_s, -float(passive @ s) + j_s + fs = max(fs, fs_lift) + return {**out, "F": fs, "N": n_used, "drive": drive, "resist": resist} diff --git a/E06_StabRock/E06_StabRock_Model.py b/E06_StabRock/E06_StabRock_Model.py new file mode 100644 index 000000000..2cfe7d1ae --- /dev/null +++ b/E06_StabRock/E06_StabRock_Model.py @@ -0,0 +1,446 @@ +"""E06 ์ ˆ๋ฆฌ ์ •๋ณธ ๊ผด ยท ๊ฒ€์‚ฌ ยท ์ธก์  ํ•ด์„ ๋ฌถ์Œ (์š”๊ตฌ 345 ยท 351 ยท PLAN 536-1). + +์ •๋ณธ = `E06_StabRock/joints.json` ยท ์ดˆ๊ธฐ๊ฐ’ = `initial_snapshot/E06_StabRock/joints.json` +(์ ˆ๋ฆฌ ์—†์Œ + ๊ธฐ๋ณธ ์„ค์ • ยท ์ฒ˜์Œ GET ๋•Œ ํ•œ ๋ฒˆ). ์ ˆ๋ฆฌ๊ตฐ ํ•œ ์ค„ = ๊ตฌ๊ฐ„(from_m ~ to_m) ยท ๊ฒฝ์‚ฌ๋ฐฉํ–ฅ ยท ๊ฒฝ์‚ฌ ยท +ฯ†j ยท cj ยท ์ง€์†์„ฑ(์„ ํƒ). ์ธก์  = ๋ณต์‚ฌ๋ณธ ์•” ์ธก์ (๋ฆฌํ•‘์•” ยท ๋ฐœํŒŒ์•”)์˜ ๊นŽ๊ธฐ ์ชฝ๋งŒ. + +์‚ฌ๋ฉด = ์ธก์  frame ๋ฐฉํ–ฅ + ๊นŽ๊ธฐ ์ชฝ(๊ฒฝ์‚ฌ๋ฐฉํ–ฅ = ๋…ธ๋ฉด ์ชฝ) ยท ๊ฒฝ์‚ฌ = ๊นŽ๊ธฐ(์•”) ๊ฒฝ์‚ฌ๋น„ ยท ๋†’์ด = E03 ๊ณผ ๊ฐ™์€ +`side_cases` ๋†’์ด ยท ์œ—๋ฉด = ๋น„ํƒˆ ๋ ๋ฐ”๊นฅ ์›์ง€๋ฐ˜ ๊ฒฝ์‚ฌ. ๋ฌผ์„ฑ ยท ์ง€์ง„ ยท ๊ธฐ์ค€์•ˆ์ „์œจ = E02 ์ง€๋ฐ˜ ์กฐ๊ฑด. +""" + +from __future__ import annotations + +import copy +import hashlib +import math +from functools import lru_cache +from pathlib import Path +from typing import Any + +import numpy as np + +from common_util.common_util_station_key import chainage_key +from E01_StabRegister import E01_StabRegister_Source as src +from E02_StabGround import E02_StabGround_Model as ground_model +from E03_StabSection.E03_StabSection_Engine_Model import ground_from_cross, side_cases +from E06_StabRock import E06_StabRock_Engine as engine +from E06_StabRock.E06_StabRock_Stereonet import PROJECTIONS, stereonet + +STAGE_DIR = "E06_StabRock" +JOINTS_NAME = "joints.json" +VERSION = 1 +ENGINE_FILES = ("E06_StabRock_Engine.py", "E06_StabRock_Stereonet.py", "E06_StabRock_Model.py") +DEFAULT_GAMMA_R = 25.0 # ์•” ๋‹จ์œ„์ค‘๋Ÿ‰ โ€” E02 ์•” ๋ฌผ์„ฑ์ด ์—†์„ ๋•Œ(์ง€์‹DB 07 Hoek & Bray 25~27) +MIN_HEIGHT_M = 1.0 # ์ด๋ณด๋‹ค ๋‚ฎ์€ ๊นŽ๊ธฐ๋Š” ์•”๋ฐ˜ ๋ธ”๋ก ๊ฒ€ํ†  ์•ˆ ํ•จ +CRACK_MODES = ("none", "critical", "depth") +CONDITIONS = ("dry", "wet", "seismic") + +DEFAULT_SETTINGS: dict[str, Any] = { + "projection": "equal_angle", + "lateral_planar_deg": engine.LATERAL_PLANAR_DEG, + "lateral_toppling_deg": engine.LATERAL_TOPPLING_DEG, + "gamma_r": None, # None = E02 ์ธก์  ์•” ๋ฌผ์„ฑ ฮณt + "water_fill_wet": 0.5, # ์šฐ๊ธฐ ์ˆ˜์œ„ = ๋น„ํƒˆ ๋†’์ด ยฝ(KDS 11 70 05 ํ‘œ 4.3-3 ์•”๋ฐ˜) + "crack": {"mode": "none", "z": None}, + "bolt": {"force": 0.0, "plunge": 15.0}, +} + +# ์นธ ๋ฒ”์œ„ โ€” ํ™”๋ฉด ์ž…๋ ฅ ยท ์„œ๋ฒ„ ๊ฒ€์‚ฌ ๊ฐ™์€ ๊ฐ’ +LIMITS: dict[str, list[float]] = { + "chainage_m": [0.0, 100000.0], + "dip_direction": [0.0, 360.0], + "dip": [0.0, 90.0], + "phi": [0.0, 60.0], + "c": [0.0, 1000.0], + "persistence_m": [0.0, 1000.0], + "lateral_deg": [5.0, 45.0], + "gamma_r": [15.0, 35.0], + "water_fill_wet": [0.0, 1.0], + "crack_z": [0.1, 100.0], + "bolt_force": [0.0, 5000.0], + "bolt_plunge": [-30.0, 60.0], +} + +# ๊ธฐ๋ณธ ์ ˆ๋ฆฌ๋ฉด ๊ฐ’(์ƒˆ ์ค„) โ€” ์ง€์‹DB 07 ํ‘œ 7: cj 0~30 ยท ฯ†j 25~35(Barton ๊ธฐ๋ณธ๋งˆ์ฐฐ๊ฐ) ๋ณด์ˆ˜ ์ชฝ +NEW_SET = {"phi": 30.0, "c": 0.0} + + +def joints_path(project_root: Path) -> Path: + return Path(project_root) / STAGE_DIR / JOINTS_NAME + + +def initial_path(project_root: Path) -> Path: + return Path(project_root) / "initial_snapshot" / STAGE_DIR / JOINTS_NAME + + +def build_initial() -> dict[str, Any]: + return {"version": VERSION, "sets": [], "settings": copy.deepcopy(DEFAULT_SETTINGS)} + + +def upgrade(doc: dict[str, Any]) -> dict[str, Any]: + """๋น ์ง„ ์„ค์ • ์นธ๋งŒ ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ์ฑ„์šด ์‚ฌ๋ณธ.""" + out = copy.deepcopy(doc) + settings = out.setdefault("settings", {}) + for key, value in DEFAULT_SETTINGS.items(): + settings.setdefault(key, copy.deepcopy(value)) + out.setdefault("sets", []) + return out + + +# ---- ๊ฒ€์‚ฌ ---------------------------------------------------------------------- +def _num(value: Any) -> bool: + return isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(value) + + +def _in(value: Any, key: str) -> bool: + lo, hi = LIMITS[key] + return _num(value) and lo <= value <= hi + + +def check_joints(doc: dict[str, Any]) -> list[dict[str, str]]: + """ํ‹€๋ฆฐ ์นธ [{path, code}] โ€” code = range ยท order ยท name ยท id_dup ยท enum ยท type.""" + errors: list[dict[str, str]] = [] + + def bad(path: str, code: str = "range") -> None: + errors.append({"path": path, "code": code}) + + sets = doc.get("sets") + if not isinstance(sets, list): + return [{"path": "sets", "code": "type"}] + seen: set[str] = set() + for k, row in enumerate(sets): + base = f"sets.{k}" + if not isinstance(row, dict): + bad(base, "type") + continue + ident = str(row.get("id") or "") + if not ident or ident in seen: + bad(f"{base}.id", "id_dup") + seen.add(ident) + if not str(row.get("name") or "").strip(): + bad(f"{base}.name", "name") + for key, limit in ( + ("from_m", "chainage_m"), + ("to_m", "chainage_m"), + ("dip_direction", "dip_direction"), + ("dip", "dip"), + ("phi", "phi"), + ("c", "c"), + ): + if not _in(row.get(key), limit): + bad(f"{base}.{key}") + if _num(row.get("from_m")) and _num(row.get("to_m")) and row["to_m"] < row["from_m"]: + bad(f"{base}.to_m", "order") + if row.get("persistence_m") is not None and not _in(row["persistence_m"], "persistence_m"): + bad(f"{base}.persistence_m") + settings = doc.get("settings") + if not isinstance(settings, dict): + return errors + [{"path": "settings", "code": "type"}] + if settings.get("projection") not in PROJECTIONS: + bad("settings.projection", "enum") + for key in ("lateral_planar_deg", "lateral_toppling_deg"): + if not _in(settings.get(key), "lateral_deg"): + bad(f"settings.{key}") + if settings.get("gamma_r") is not None and not _in(settings["gamma_r"], "gamma_r"): + bad("settings.gamma_r") + if not _in(settings.get("water_fill_wet"), "water_fill_wet"): + bad("settings.water_fill_wet") + crack = settings.get("crack") if isinstance(settings.get("crack"), dict) else {} + if crack.get("mode") not in CRACK_MODES: + bad("settings.crack.mode", "enum") + elif crack["mode"] == "depth" and not _in(crack.get("z"), "crack_z"): + bad("settings.crack.z") + bolt = settings.get("bolt") if isinstance(settings.get("bolt"), dict) else {} + if not _in(bolt.get("force"), "bolt_force"): + bad("settings.bolt.force") + if not _in(bolt.get("plunge"), "bolt_plunge"): + bad("settings.bolt.plunge") + return errors + + +def clean(doc: dict[str, Any]) -> dict[str, Any]: + """์ •๋ณธ ์นธ๋งŒ ยท ์ˆซ์ž๋Š” float.""" + sets = [] + for row in doc["sets"]: + item = { + "id": str(row["id"]), + "name": str(row["name"]).strip(), + **{ + key: float(row[key]) + for key in ("from_m", "to_m", "dip_direction", "dip", "phi", "c") + }, + "persistence_m": None + if row.get("persistence_m") is None + else float(row["persistence_m"]), + } + item["dip_direction"] %= 360.0 + sets.append(item) + s = doc["settings"] + crack = s["crack"] + return { + "version": VERSION, + "sets": sets, + "settings": { + "projection": s["projection"], + "lateral_planar_deg": float(s["lateral_planar_deg"]), + "lateral_toppling_deg": float(s["lateral_toppling_deg"]), + "gamma_r": None if s.get("gamma_r") is None else float(s["gamma_r"]), + "water_fill_wet": float(s["water_fill_wet"]), + "crack": { + "mode": crack["mode"], + "z": float(crack["z"]) if crack["mode"] == "depth" else None, + }, + "bolt": {"force": float(s["bolt"]["force"]), "plunge": float(s["bolt"]["plunge"])}, + }, + } + + +# ---- ์ธก์  ์‚ฌ๋ฉด ------------------------------------------------------------------ +def rock_stations(source: Path) -> list[dict[str, Any]]: + """์•” ์ธก์ (๋ฆฌํ•‘์•” ยท ๋ฐœํŒŒ์•”).""" + return [s for s in src.stations(source) if s.get("ground_type") in ground_model.ROCK_LABELS] + + +def _upper_dip(ground: list, start: float, sign: float, H: float, face_dip: float) -> float: + """๋น„ํƒˆ ๋ ๋ฐ”๊นฅ max(H, 5 m) ์›์ง€๋ฐ˜ ํ‰๊ท  ๊ฒฝ์‚ฌ(์˜ค๋ฅด๋ง‰๋งŒ ยท ์‚ฌ๋ฉด ๊ฒฝ์‚ฌ โˆ’ 1ยฐ ์•„๋ž˜).""" + gx = np.array([p[0] for p in ground]) + gz = np.array([p[1] for p in ground]) + end = start + sign * max(H, 5.0) + end = min(max(end, gx[0]), gx[-1]) + run = abs(end - start) + if run < 0.5: + return 0.0 + rise = float(np.interp(end, gx, gz) - np.interp(start, gx, gz)) + return float(min(max(0.0, math.degrees(math.atan2(rise, run))), face_dip - 1.0)) + + +def station_slopes(cross: dict, design: dict) -> list[dict[str, Any]]: + """์ธก์ ์˜ ๊นŽ๊ธฐ ์ชฝ๋งˆ๋‹ค ์‚ฌ๋ฉด โ€” {side, dip_direction, dip, height_m, upper_dip}.""" + ground = ground_from_cross(cross) + line = sorted( + (float(p["offset_m"]), float(p["elevation_m"])) for p in design.get("design_line") or [] + ) + ratio = design.get("cut_slope_ratio") + if len(ground) < 2 or len(line) < 2 or not ratio: + return [] + face_dip = math.degrees(math.atan2(1.0, float(ratio))) + left = np.array([float(v) for v in cross["frame"]["left_xy"]]) + out = [] + for case in side_cases(design, ground, line): + if case["role"] != "cut" or case["height_m"] < MIN_HEIGHT_M: + continue + side = case["side"] + sign = 1.0 if side == "left" else -1.0 + # ๋น„ํƒˆ ๋ = ํƒ์ƒ‰ ๋์—์„œ ๋˜์งš์Œ(side_cases: reach = ๋น„ํƒˆ ๋ + sign ยท max(5, 2H)) + day = case["limits"][1 if sign > 0 else 0] - sign * max(5.0, 2.0 * case["height_m"]) + face = -sign * left # ๋…ธ๋ฉด ์ชฝ์œผ๋กœ ๊ธฐ์›€ + dip_dir = math.degrees(math.atan2(face[0], face[1])) % 360.0 + out.append( + { + "side": side, + "dip_direction": round(dip_dir, 2), + "dip": round(face_dip, 2), + "height_m": round(case["height_m"], 3), + "upper_dip": round(_upper_dip(ground, day, sign, case["height_m"], face_dip), 2), + } + ) + return out + + +# ---- ํ•ด์„ ---------------------------------------------------------------------- +@lru_cache(maxsize=1) +def engine_hash() -> str: + digest = hashlib.sha1() + for name in ENGINE_FILES: + digest.update((Path(__file__).parent / name).read_bytes()) + return digest.hexdigest()[:12] + + +def _ground_inputs(ground: dict[str, Any]) -> dict[str, Any]: + """E02 ์—์„œ E06 ์ด ์“ฐ๋Š” ์นธ โ€” ์ง€๋ฌธ์—๋„ ์ด๊ฒƒ๋งŒ.""" + return { + k: ground.get(k) + for k in ("materials", "label_map", "stations", "zones", "safety_factors", "seismic") + } + + +def _gamma_for(resolved: dict | None, materials: dict, override: float | None) -> float: + if override: + return float(override) + mat = materials.get((resolved or {}).get("lower") or (resolved or {}).get("upper")) + return float(mat["gamma_t"]) if mat else DEFAULT_GAMMA_R + + +def _fmt(value: float | None) -> float | None: + if value is None or not math.isfinite(value): + return None + return round(value, 4) + + +def _cases_for(slope: dict, joints: list[dict], kin: dict, settings: dict, gamma: float, conds): + """์œ„ํ—˜ ๋ชจ๋“œ๋งŒ ์•ˆ์ „์œจ โ€” ์กฐ๊ฑด = [(์ด๋ฆ„, ์ˆ˜์œ„ ๋น„, kh)].""" + upper = {"dip_direction": slope["dip_direction"], "dip": slope["upper_dip"]} + by_id = {j["id"]: j for j in joints} + bolt = settings["bolt"] + crack = settings["crack"] if settings["crack"]["mode"] != "none" else None + out = [] + for jid in kin["planar"]: + j = by_id[jid] + for name, fill, kh in conds: + r = engine.plane_fs( + H=slope["height_m"], + slope_dip=slope["dip"], + plane_dip=j["dip"], + c=j["c"], + phi=j["phi"], + gamma=gamma, + upper_dip=slope["upper_dip"], + crack=crack, + water_fill=fill, + kh=kh, + bolt_t=bolt["force"], + bolt_plunge=bolt["plunge"], + ) + short = ( + r is not None and j.get("persistence_m") is not None and j["persistence_m"] < r["A"] + ) + out.append( + { + "mode": "planar", + "ids": [jid], + "condition": name, + "F": _fmt(r["F"]) if r else None, + "formed": r is not None, + "persistence_short": short, + "W": _fmt(r["W"]) if r else None, + "A": _fmt(r["A"]) if r else None, + "z": _fmt(r["z"]) if r else None, + } + ) + for item in kin["wedge"]: + a, b = (by_id[k] for k in item["ids"]) + for name, fill, kh in conds: + r = engine.wedge_fs( + j1=a, + j2=b, + slope=slope, + upper=upper, + H=slope["height_m"], + gamma=gamma, + water={"fill": fill}, + kh=kh, + bolt={ + "force": bolt["force"], + "trend": (slope["dip_direction"] + 180.0) % 360.0, + "plunge": bolt["plunge"], + }, + ) + length = r["line_length"] if r else None + short = any( + j.get("persistence_m") is not None + and length is not None + and j["persistence_m"] < length + for j in (a, b) + ) + out.append( + { + "mode": "wedge", + "ids": list(item["ids"]), + "condition": name, + "F": _fmt(r["F"]) if r else None, + "formed": r is not None, + "persistence_short": short, + "sliding": r["mode"] if r else None, + "W": _fmt(r["W"]) if r else None, + "volume": _fmt(r["volume"]) if r else None, + } + ) + return out + + +def analyze(source: Path, joints_doc: dict, ground: dict) -> list[dict[str, Any]]: + """์•” ์ธก์  ร— ๊นŽ๊ธฐ ์ชฝ โ€” ์‚ฌ๋ฉด ยท ์ ˆ๋ฆฌ ยท Markland ยท ์•ˆ์ „์œจ ยท ํŒ์ •.""" + settings = joints_doc["settings"] + sets = joints_doc["sets"] + materials = {m["id"]: m for m in ground.get("materials") or []} + resolved = {chainage_key(r["chainage_m"]): r for r in ground_model.resolve_stations(ground)} + seismic = ground.get("seismic") or {} + factors = ground.get("safety_factors") or {} + conds = [("dry", 0.0, 0.0), ("wet", float(settings["water_fill_wet"]), 0.0)] + if seismic.get("enabled") and seismic.get("kh"): + conds.append(("seismic", 0.0, float(seismic["kh"]))) + out = [] + for station in rock_stations(source): + ch = float(station["chainage_m"]) + key = chainage_key(ch) + joints = [j for j in sets if j["from_m"] <= ch <= j["to_m"]] + cross = src.cross_section(source, ch) + gamma = _gamma_for(resolved.get(key), materials, settings.get("gamma_r")) + for slope in station_slopes(cross, cross["design"]): + kin = engine.kinematic( + slope, joints, settings["lateral_planar_deg"], settings["lateral_toppling_deg"] + ) + cases = _cases_for(slope, joints, kin, settings, gamma, conds) + for case in cases: + need = (factors.get(case["condition"]) or {}).get("cut") + case["required"] = need + case["ok"] = None if case["F"] is None or need is None else case["F"] >= need + out.append( + { + "key": key, + "chainage_m": ch, + "label": station.get("label"), + "ground_type": station.get("ground_type"), + "slope": slope, + "gamma_r": gamma, + "joints": [ + {k: j[k] for k in ("id", "name", "dip_direction", "dip", "phi", "c")} + for j in joints + ], + "kinematic": kin, + "cases": cases, + **_verdict(kin, cases), + } + ) + return out + + +def _verdict(kin: dict, cases: list[dict]) -> dict[str, Any]: + """ํŒ์ • โ€” NG(๊ธฐ์ค€ ๋ฏธ๋‹ฌ ํ•˜๋‚˜๋ผ๋„) ยท check(์ „๋„๋งŒ ยท ๋ธ”๋ก ์•ˆ ์ƒ๊น€) ยท OK ยท none(์œ„ํ—˜ ๋ชจ๋“œ ์—†์Œ).""" + values = [c["F"] for c in cases if c["F"] is not None] + min_f = min(values) if values else None + if any(c["ok"] is False for c in cases): + verdict = "NG" + elif kin["toppling"] or (cases and not values): + verdict = "check" + elif cases: + verdict = "OK" + else: + verdict = "none" + return { + "counts": { + "planar": len(kin["planar"]), + "wedge": len(kin["wedge"]), + "toppling": len(kin["toppling"]), + }, + "min_F": min_f, + "verdict": verdict, + } + + +def station_net(entry: dict, settings: dict) -> dict[str, Any]: + """ํ•œ ์ธก์  ์ชฝ ์Šคํ…Œ๋ ˆ์˜ค๋„ท(ํ•ด์„ ๋•Œ ์ ˆ๋ฆฌ ๊ทธ๋Œ€๋กœ) โ€” ๋‘ ํˆฌ์˜ ๋ชจ๋‘(ํ™”๋ฉด์ด ๊ณ ๋ฆ„).""" + joints = entry["joints"] + phi = min((j["phi"] for j in joints), default=NEW_SET["phi"]) + nets = { + kind: stereonet( + entry["slope"], + joints, + phi, + kind, + settings["lateral_planar_deg"], + settings["lateral_toppling_deg"], + ) + for kind in PROJECTIONS + } + return {**nets, "phi": phi} diff --git a/E06_StabRock/E06_StabRock_Router.py b/E06_StabRock/E06_StabRock_Router.py new file mode 100644 index 000000000..559e41d2d --- /dev/null +++ b/E06_StabRock/E06_StabRock_Router.py @@ -0,0 +1,204 @@ +"""E06 ์•”๋ฐ˜ ํ‰์‚ฌํˆฌ์˜ ํ•ด์„ API (์š”๊ตฌ 345 ยท 351 ยท ์š”๊ตฌ์„œ 4-2 2์ฐจ ยท PLAN 536-1). + +- `GET /api/projects/{id}/stab/rock` โ€” ์ ˆ๋ฆฌ ์ž‘์—…๋ณธ(์—†์œผ๋ฉด ์ดˆ๊ธฐ๊ฐ’) ยท ๋ฒ”์œ„ ยท ์•” ์ธก์  ยท ๊ณ ์น  ์ˆ˜ ์žˆ๋Š”์ง€ +- `PUT โ€ฆ/stab/rock` โ€” [์ €์žฅ] ๊ฒ€์‚ฌ ๋’ค ์ž‘์—…๋ณธ `E06_StabRock/joints.json` +- `POST โ€ฆ/stab/rock/reset` โ€” [์ดˆ๊ธฐํ™”] ์ดˆ๊ธฐ๊ฐ’์„ ์ž‘์—…๋ณธ์— ๋ฎ์–ด์”€(์žฌ๊ณ„์‚ฐ ์•„๋‹˜) +- `POST โ€ฆ/stab/rock/analyze` โ€” [ํ•ด์„] ์•” ์ธก์  ร— ๊นŽ๊ธฐ ์ชฝ ยท ์ง€๋ฌธ์ด ๊ฐ™์œผ๋ฉด ์•ˆ ์…ˆ(`fresh`) +- `GET โ€ฆ/stab/rock/result` โ€” ์ธก์ ๋ณ„ ๋ชจ๋“œ ยท ์•ˆ์ „์œจ ยท ํŒ์ •(+ ์ง€๋ฌธ์ด ์ง€๊ธˆ๊ณผ ๋‹ค๋ฅธ์ง€) +- `GET โ€ฆ/stab/rock/result?station=&side=` โ€” ๊ทธ ์ธก์  ์ชฝ + ์Šคํ…Œ๋ ˆ์˜ค๋„ท(๋“ฑ๊ฐ ยท ๋“ฑ๋ฉด์ ) + +์ž…๋ ฅ = E01 ๋ณต์‚ฌ๋ณธ + E02 ์ง€๋ฐ˜ ์กฐ๊ฑด(๋ฌผ์„ฑ ยท ์ง€์ง„ ยท ๊ธฐ์ค€์•ˆ์ „์œจ) + ์ ˆ๋ฆฌ ์ž‘์—…๋ณธ(์ €์žฅ ์•ˆ ํ•œ ์บ์‹œ๋Š” ์•ˆ ์”€). +๊ณ„์‚ฐ ์ž๋ฆฌ = โ‘ข ์„œ๋ฒ„ ๋‹จ๋…. ๊ฒฐ๊ณผ = ํŒŒ์ƒ ์ €์žฅ๋ณธ `_derived/stab_rock/<์ง€๋ฌธ>`. +๊ณต์œ  ํšŒ์‚ฌ(๋ณด๊ธฐ๋งŒ)๋Š” `require_project_access` ๊ฐ€ PUT ยท POST ๋ฅผ ๋ง‰์Œ. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from common_util.common_util_auth import verify_session +from common_util.common_util_derived_store import ( + derived_dir, + derived_key, + read_derived, + write_derived, +) +from common_util.common_util_json import atomic_write_json +from E01_StabRegister import E01_StabRegister_Source as src +from E02_StabGround import E02_StabGround_Router as e02 +from E06_StabRock import E06_StabRock_Model as model + +router = APIRouter(prefix="/api/projects", tags=["E06 Stab Rock"]) + +KIND = "stab_rock" + + +class JointsRequest(BaseModel): + joints: dict[str, Any] + + +def ensure_initial(project_root: Path) -> dict[str, Any]: + """์ดˆ๊ธฐ๊ฐ’์ด ์—†์„ ๋•Œ๋งŒ ํ•œ ๋ฒˆ โ€” ์ ˆ๋ฆฌ ์—†์Œ + ๊ธฐ๋ณธ ์„ค์ •.""" + path = model.initial_path(project_root) + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + doc = model.build_initial() + atomic_write_json(path, doc) + return doc + + +def _working(project_root: Path) -> dict[str, Any] | None: + path = model.joints_path(project_root) + return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None + + +def _joints(project_root: Path) -> dict[str, Any]: + return model.upgrade(_working(project_root) or ensure_initial(project_root)) + + +def _source(project_root: Path) -> Path: + root = src.source_dir(project_root) + if not (root / src.MANIFEST_NAME).is_file(): + raise HTTPException(status_code=409, detail="์›๋ณธ ๋ณต์‚ฌ๋ณธ์ด ์—†์Šต๋‹ˆ๋‹ค.") + return root + + +def _fingerprint(project_root: Path, source: Path) -> tuple[str, dict, dict]: + joints = _joints(project_root) + ground = e02._read_working(project_root) or e02.ensure_initial(project_root) + fp = derived_key( + model.engine_hash(), + src.manifest(source)["source_fingerprint"], + { + "sets": joints["sets"], + "settings": {k: v for k, v in joints["settings"].items() if k != "projection"}, + }, + model._ground_inputs(ground), + ) + return fp, joints, ground + + +def _latest(project_root: Path) -> dict[str, Any] | None: + """๊ฐ€์žฅ ์ตœ๊ทผ ์ €์žฅ๋ณธ(์ง€๋ฌธ์ด ๋‹ฌ๋ผ๋„ โ€” ํ™”๋ฉด์ด ใ€Œ๋‹ค์‹œ ํ•ด์„ใ€ ๋ ๋ฅผ ๋„์›€).""" + folder = derived_dir(project_root, KIND) + files = sorted(folder.glob("*.json"), key=lambda p: p.stat().st_mtime_ns, reverse=True) + for path in files: + saved = read_derived(project_root, KIND, path.stem) + if saved: + return saved + return None + + +@router.get("/{project_id}/stab/rock") +async def get_rock( + project_id: UUID, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + root, project = await e02._project(project_id) + initial = ensure_initial(root) + working = _working(root) + try: + stations = [ + {"chainage_m": s["chainage_m"], "label": s["label"], "ground_type": s["ground_type"]} + for s in model.rock_stations(_source(root)) + ] + except HTTPException: + stations = [] + return { + "joints": model.upgrade(working or initial), + "saved": working is not None, + "defaults": { + "settings": model.DEFAULT_SETTINGS, + "new_set": model.NEW_SET, + "limits": model.LIMITS, + }, + "stations": stations, + "editable": e02._editable(session, project), + } + + +@router.put("/{project_id}/stab/rock") +async def put_rock(project_id: UUID, payload: JointsRequest) -> dict[str, Any]: + root, _ = await e02._project(project_id) + errors = model.check_joints(payload.joints) + if errors: + raise HTTPException(status_code=422, detail={"errors": errors}) + doc = model.clean(payload.joints) + atomic_write_json(model.joints_path(root), doc) + return {"joints": doc} + + +@router.post("/{project_id}/stab/rock/reset") +async def reset_rock(project_id: UUID) -> dict[str, Any]: + root, _ = await e02._project(project_id) + initial = ensure_initial(root) + atomic_write_json(model.joints_path(root), initial) + return {"joints": model.upgrade(initial)} + + +def _summary(stations: list[dict[str, Any]]) -> dict[str, Any]: + verdicts: dict[str, int] = {} + for item in stations: + verdicts[item["verdict"]] = verdicts.get(item["verdict"], 0) + 1 + return { + "slopes": len(stations), + "verdicts": verdicts, + "modes": { + mode: sum(item["counts"][mode] for item in stations) + for mode in ("planar", "wedge", "toppling") + }, + } + + +def _run(root: Path) -> dict[str, Any]: + source = _source(root) + fp, joints, ground = _fingerprint(root, source) + if read_derived(root, KIND, fp): + return {"status": "fresh", "fingerprint": fp} + stations = model.analyze(source, joints, ground) + value = { + "fingerprint": fp, + "settings": joints["settings"], + "summary": _summary(stations), + "stations": stations, + } + write_derived(root, KIND, fp, value) + return {"status": "done", "fingerprint": fp, "summary": value["summary"]} + + +@router.post("/{project_id}/stab/rock/analyze") +async def analyze_rock(project_id: UUID) -> dict[str, Any]: + """[ํ•ด์„] โ€” ์•” ์ธก์  ์ˆ˜์‹ญ ๊ฐœ ยท 1 ์ดˆ ์•ˆ์ชฝ์ด๋ผ ๋ฐ”๋กœ ์…ˆ(์ง„ํ–‰๋ฅ  ์—†์Œ).""" + root, _ = await e02._project(project_id) + return await asyncio.to_thread(_run, root) + + +def _result(root: Path, station: str | None, side: str | None) -> dict[str, Any]: + source = _source(root) + fp, _, _ = _fingerprint(root, source) + saved = read_derived(root, KIND, fp) or _latest(root) + if not saved: + return {"status": "empty", "stale": False, "result": None} + value = saved["value"] + stale = value["fingerprint"] != fp + if station is None: + return {"status": "success", "stale": stale, "result": value} + for entry in value["stations"]: + if entry["key"] == station and (side is None or entry["slope"]["side"] == side): + net = model.station_net(entry, value["settings"]) + return {"status": "success", "stale": stale, "entry": entry, "stereonet": net} + raise HTTPException(status_code=404, detail="๊ทธ ์ธก์ ์˜ ์•”๋ฐ˜ ํ•ด์„ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.") + + +@router.get("/{project_id}/stab/rock/result") +async def result_rock( + project_id: UUID, station: str | None = None, side: str | None = None +) -> dict[str, Any]: + root, _ = await e02._project(project_id) + return await asyncio.to_thread(_result, root, station, side) diff --git a/E06_StabRock/E06_StabRock_Stereonet.py b/E06_StabRock/E06_StabRock_Stereonet.py new file mode 100644 index 000000000..fb0b3cd3e --- /dev/null +++ b/E06_StabRock/E06_StabRock_Stereonet.py @@ -0,0 +1,137 @@ +"""E06 ์Šคํ…Œ๋ ˆ์˜ค๋„ท ํˆฌ์˜ ์ขŒํ‘œ โ€” ํ•˜๋ฐ˜๊ตฌ ยท ๋“ฑ๊ฐ(Wulff) ยท ๋“ฑ๋ฉด์ (Schmidt) (PLAN 536-1). + +ํ™”๋ฉด(SVG)์€ ์ด ์ขŒํ‘œ๋ฅผ ๊ทธ๋ฆฌ๊ธฐ๋งŒ ํ•จ โ€” ๋ฐ˜์ง€๋ฆ„ 1 ์› ยท x = ๋™ ยท y = ๋ถ(ํ™”๋ฉด์€ y ๋ฅผ ๋’ค์ง‘์Œ). +๊ทน์  ยท ๋Œ€์› ยท ๊ต์„  ยท ๋งˆ์ฐฐ์› ยท ์ผ๊ด‘ ์—”๋ฒจ๋กœํ”„ ยท ์œ„ํ—˜ ์˜์—ญ(Markland ๋ถ€๋“ฑ์‹๊ณผ ๊ฐ™์€ ๊ฒฝ๊ณ„ โ€” +`E06_StabRock_Engine.kinematic`)์„ ๋ƒ„. ํˆฌ์˜์‹ = ํ‘œ์ค€ ๊ตฌ๋ฉด ํˆฌ์˜(mplstereonet ๋“ฑ์€ ๋Œ€์กฐ๋งŒ). +""" + +from __future__ import annotations + +import math +from typing import Any + +from E06_StabRock.E06_StabRock_Engine import ( + LATERAL_PLANAR_DEG, + LATERAL_TOPPLING_DEG, + apparent_dip, + intersection, + pole, +) + +PROJECTIONS = ("equal_angle", "equal_area") +STEP_DEG = 2.0 # ๊ณก์„  ํ‘œ๋ณธ ๊ฐ„๊ฒฉ + + +def radius(plunge: float, kind: str) -> float: + """์„ ์ฃผ๊ฐ โ†’ ์ค‘์‹ฌ์—์„œ ๊ฑฐ๋ฆฌ(0 = ์—ฐ์ง ยท 1 = ์ˆ˜ํ‰).""" + half = math.radians(90.0 - plunge) / 2.0 + return math.tan(half) if kind == "equal_angle" else math.sqrt(2.0) * math.sin(half) + + +def project(trend: float, plunge: float, kind: str) -> list[float]: + r = radius(plunge, kind) + t = math.radians(trend) + return [round(r * math.sin(t), 5), round(r * math.cos(t), 5)] + + +def inverse(x: float, y: float, kind: str) -> tuple[float, float]: + """ํˆฌ์˜ ์ขŒํ‘œ โ†’ (์ถ”ํ–ฅ ยท ์„ ์ฃผ๊ฐ) โ€” ์™•๋ณต ์‹œํ—˜ ยท ํ™”๋ฉด ๋งˆ์šฐ์Šค ์ž๋ฆฌ ์ฝ๊ธฐ.""" + r = math.hypot(x, y) + half = math.atan(r) if kind == "equal_angle" else math.asin(min(1.0, r / math.sqrt(2.0))) + return math.degrees(math.atan2(x, y)) % 360.0, 90.0 - math.degrees(2.0 * half) + + +def _samples(lo: float, hi: float) -> list[float]: + n = max(2, int(math.ceil((hi - lo) / STEP_DEG)) + 1) + return [lo + (hi - lo) * k / (n - 1) for k in range(n)] + + +def great_circle(dip_dir: float, dip: float, kind: str) -> list[list[float]]: + """๋ฉด์˜ ๋Œ€์› โ€” ์ฃผํ–ฅ ํ•œ๋ โ†’ ๊ฒฝ์‚ฌ ๋ฐฉํ–ฅ โ†’ ๋‹ค๋ฅธ ๋.""" + return [ + project(dip_dir + b, max(0.0, apparent_dip(dip, dip_dir, dip_dir + b)), kind) + for b in _samples(-90.0, 90.0) + ] + + +def _zone(trend_lo, trend_hi, plunge_lo, plunge_hi, kind) -> list[list[float]] | None: + """(์ถ”ํ–ฅ ยท ์„ ์ฃผ๊ฐ) ๋  โ†’ ๋‹ซํžŒ ๋‹ค๊ฐํ˜• โ€” plunge_lo/hi ๋Š” ์ถ”ํ–ฅ์˜ ํ•จ์ˆ˜ ยท ๋น„๋ฉด None.""" + ts = _samples(trend_lo, trend_hi) + lows = [plunge_lo(t) for t in ts] + highs = [plunge_hi(t) for t in ts] + keep = [k for k in range(len(ts)) if highs[k] > lows[k]] + if len(keep) < 2: + return None + outer = [project(ts[k], lows[k], kind) for k in keep] + inner = [project(ts[k], highs[k], kind) for k in reversed(keep)] + return outer + inner + + +def stereonet( + slope: dict, + joints: list[dict], + phi: float, + kind: str = "equal_angle", + lateral_planar: float = LATERAL_PLANAR_DEG, + lateral_toppling: float = LATERAL_TOPPLING_DEG, +) -> dict[str, Any]: + """slope = {dip_direction, dip} ยท joints = [{id, dip_direction, dip}] ยท phi = ์˜์—ญ ๋งˆ์ฐฐ๊ฐ.""" + af, pf = float(slope["dip_direction"]), float(slope["dip"]) + + def face_app(t: float) -> float: + return apparent_dip(pf, af, t) + + planes = [] + for j in joints: + tr, pl = pole(j["dip_direction"], j["dip"]) + planes.append( + { + "id": j["id"], + "pole": project(tr, pl, kind), + "great_circle": great_circle(j["dip_direction"], j["dip"], kind), + } + ) + lines = [] + for i, a in enumerate(joints): + for b in joints[i + 1 :]: + line = intersection(a, b) + if line is not None: + lines.append({"ids": [a["id"], b["id"]], "point": project(*line, kind)}) + # ์ผ๊ด‘ ์—”๋ฒจ๋กœํ”„(๊ทน์  ๊ผด) = ์‚ฌ๋ฉด ๊ฒ‰๋ณด๊ธฐ ๊ฒฝ์‚ฌ๋กœ ๊ธฐ์šด ๋ฉด๋“ค์˜ ๊ทน์  + envelope = [ + project(af + b + 180.0, 90.0 - face_app(af + b), kind) for b in _samples(-90.0, 90.0) + ] + zones = { + # ํ‰๋ฉด(๊ทน์ ) โ€” ๊ทน์  ์ถ”ํ–ฅ ฮฑf + 180 ยฑ ์ธก๋ฐฉ ยท ์„ ์ฃผ๊ฐ 90 โˆ’ ฯˆapp ~ 90 โˆ’ ฯ† + "planar": _zone( + af + 180.0 - lateral_planar, + af + 180.0 + lateral_planar, + lambda t: 90.0 - face_app(t - 180.0), + lambda t: 90.0 - phi, + kind, + ), + # ์๊ธฐ(๊ต์„ ) โ€” ์ถ”ํ–ฅ ฮฑf ยฑ 90 ยท ์„ ์ฃผ๊ฐ ฯ† ~ ฯˆapp + "wedge": _zone(af - 90.0, af + 90.0, lambda t: phi, face_app, kind), + # ์ „๋„(๊ทน์ ) โ€” ๊ทน์  ์ถ”ํ–ฅ ฮฑf ยฑ ์ธก๋ฐฉ ยท ์„ ์ฃผ๊ฐ 0 ~ ฯˆf โˆ’ ฯ† + "toppling": _zone( + af - lateral_toppling, + af + lateral_toppling, + lambda t: 0.0, + lambda t: pf - phi, + kind, + ), + } + return { + "projection": kind, + "slope": { + "pole": project(*pole(af, pf), kind), + "great_circle": great_circle(af, pf, kind), + }, + "planes": planes, + "intersections": lines, + # ๋งˆ์ฐฐ์› โ€” ๊ทน์  ๊ผด = ์ค‘์‹ฌ์—์„œ ฯ†(์„ ์ฃผ๊ฐ 90 โˆ’ ฯ†) ยท ๊ต์„  ๊ผด = ์„ ์ฃผ๊ฐ ฯ† + "friction_pole_r": round(radius(90.0 - phi, kind), 5), + "friction_line_r": round(radius(phi, kind), 5), + "daylight_envelope": envelope, + "zones": zones, + } diff --git a/E06_StabRock/E06_StabRock_UI_Joints.ts b/E06_StabRock/E06_StabRock_UI_Joints.ts new file mode 100644 index 000000000..6350ae45f --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Joints.ts @@ -0,0 +1,304 @@ +/* ============================================================================= + * E06_StabRock_UI_Joints.ts + * E06 ์ขŒ์ธก โ€” ์ ˆ๋ฆฌ๊ตฐ ํ‘œ(๊ตฌ๊ฐ„ ยท ๊ฒฝ์‚ฌ๋ฐฉํ–ฅ ยท ๊ฒฝ์‚ฌ ยท ฯ†j ยท cj ยท ์ง€์†์„ฑ ยท [์ถ”๊ฐ€] [์ง€์›€]) + ํ•ด์„ ์„ค์ • + * (์ธก๋ฐฉ ํ•œ๊ณ„ ยท ์•” ๋‹จ์œ„์ค‘๋Ÿ‰ ยท ์šฐ๊ธฐ ์ˆ˜์œ„ ยท ์ธ์žฅ๊ท ์—ด ยท ๋ณผํŠธ). ์นธ์„ ๋ฐ”๊พธ๋ฉด doc ์„ ๊ณ ์น˜๊ณ  onChange โ€” + * ์บ์‹œ ยท ์ €์žฅ์€ ํŽ˜์ด์ง€ ๋ชซ. ๋ฒ”์œ„ = ์„œ๋ฒ„ LIMITS(๋ฒ—์–ด๋‚˜๋ฉด ์นธ ๋นจ๊ฐ• ยท ๋งˆ์šฐ์Šค ๊ธ€). + * ========================================================================== */ + +import { createPanelCard } from "@ui/ui_template_collapsible"; +import { createButton, el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; + +export interface JointSet { + id: string; + name: string; + from_m: number; + to_m: number; + dip_direction: number; + dip: number; + phi: number; + c: number; + persistence_m: number | null; +} + +export type Projection = "equal_angle" | "equal_area"; + +export interface RockSettings { + projection: Projection; + lateral_planar_deg: number; + lateral_toppling_deg: number; + gamma_r: number | null; + water_fill_wet: number; + crack: { mode: "none" | "critical" | "depth"; z: number | null }; + bolt: { force: number; plunge: number }; +} + +export interface JointsDoc { + version: number; + sets: JointSet[]; + settings: RockSettings; +} + +export interface RockDefaults { + settings: RockSettings; + new_set: { phi: number; c: number }; + limits: Record; +} + +export interface JointsView { + doc: JointsDoc; + defaults: RockDefaults; + editable: boolean; + endM: number; // ๋งˆ์ง€๋ง‰ ์•” ์ธก์ (์ƒˆ ์ค„ ๊ตฌ๊ฐ„ ๋) + change: () => void; // ๊ฐ’๋งŒ ๋ฐ”๋€œ + rebuild: () => void; // ์ค„ ์ˆ˜๊ฐ€ ๋ฐ”๋€œ +} + +/** ์ˆซ์ž ์นธ โ€” ๋ฒ”์œ„ ๋ฐ– ยท ๋นˆ ์นธ(ํ•„์ˆ˜)์ด๋ฉด is-invalid ยท ๋ฐ”๊พธ๋ฉด set ๋ถ€๋ฅด๊ณ  change */ +function numberInput( + view: JointsView, + value: number | null, + limit: string, + set: (v: number | null) => void, + opts: { optional?: boolean; step?: string; path: string }, +): HTMLInputElement { + const [lo, hi] = view.defaults.limits[limit] ?? [-Infinity, Infinity]; + const input = el("input", { + className: "e06-input", + attrs: { type: "number", step: opts.step ?? "any", "data-path": opts.path }, + }) as HTMLInputElement; + input.value = value === null ? "" : String(value); + input.disabled = !view.editable; + const check = (): boolean => { + const raw = input.value.trim(); + const num = Number(raw); + const ok = raw === "" ? Boolean(opts.optional) : Number.isFinite(num) && num >= lo && num <= hi; + input.classList.toggle("is-invalid", !ok); + input.title = ok ? "" : `${t("E06_Err_Range")} (${lo} ~ ${hi})`; + return ok; + }; + input.addEventListener("input", () => { + const raw = input.value.trim(); + set(raw === "" ? null : Number(raw)); + check(); + view.change(); + }); + check(); + return input; +} + +function nextId(sets: JointSet[]): string { + let k = sets.length + 1; + while (sets.some((s) => s.id === `J${k}`)) k += 1; + return `J${k}`; +} + +const COLUMNS: LocaleKey[] = [ + "E06_Col_Name", + "E06_Col_From", + "E06_Col_To", + "E06_Col_DipDir", + "E06_Col_Dip", + "E06_Col_Phi", + "E06_Col_C", + "E06_Col_Persist", +]; + +function jointTable(view: JointsView): HTMLElement { + const wrap = el("div", { className: "e06-table-wrap" }); + const table = el("table", { className: "e06-table" }); + const head = el("tr"); + for (const key of COLUMNS) head.append(el("th", { text: t(key) })); + head.append(el("th")); + table.append(el("thead"), el("tbody")); + table.tHead!.append(head); + view.doc.sets.forEach((row, k) => { + const tr = el("tr", { attrs: { "data-id": row.id } }); + const name = el("input", { + className: "e06-input e06-input--name", + attrs: { type: "text", "data-path": `sets.${k}.name` }, + }) as HTMLInputElement; + name.value = row.name; + name.disabled = !view.editable; + name.addEventListener("input", () => { + row.name = name.value; + name.classList.toggle("is-invalid", !name.value.trim()); + view.change(); + }); + tr.append(el("td", { children: [name] })); + const fields: [keyof JointSet, string, boolean][] = [ + ["from_m", "chainage_m", false], + ["to_m", "chainage_m", false], + ["dip_direction", "dip_direction", false], + ["dip", "dip", false], + ["phi", "phi", false], + ["c", "c", false], + ["persistence_m", "persistence_m", true], + ]; + for (const [key, limit, optional] of fields) { + const input = numberInput( + view, + row[key] as number | null, + limit, + (v) => ((row as unknown as Record)[key] = v), + { optional, path: `sets.${k}.${key}` }, + ); + tr.append(el("td", { children: [input] })); + } + const remove = createButton({ + label: "ร—", + variant: "danger-ghost", + size: "sm", + onClick: () => { + view.doc.sets.splice(k, 1); + view.rebuild(); + }, + }); + remove.title = t("E06_Remove"); + remove.disabled = !view.editable; + tr.append(el("td", { children: [remove] })); + table.tBodies[0].append(tr); + }); + wrap.append(table); + if (!view.doc.sets.length) wrap.append(el("p", { className: "e06-hint", text: t("E06_NoSets") })); + return wrap; +} + +function settingRow(label: LocaleKey, control: HTMLElement): HTMLElement { + const row = el("label", { className: "e06-setting" }); + row.append(el("span", { text: t(label) }), control); + return row; +} + +function select( + view: JointsView, + value: T, + options: [T, LocaleKey][], + set: (v: T) => void, + path: string, +): HTMLSelectElement { + const box = el("select", { + className: "e06-input", + attrs: { "data-path": path }, + }) as HTMLSelectElement; + for (const [v, key] of options) box.append(el("option", { text: t(key), attrs: { value: v } })); + box.value = value; + box.disabled = !view.editable; + box.addEventListener("change", () => { + set(box.value as T); + view.rebuild(); + }); + return box; +} + +export function buildJointCards(view: JointsView): HTMLElement[] { + const s = view.doc.settings; + const setsCard = createPanelCard(t("E06_Card_Joints"), false); + const add = createButton({ + label: t("E06_Add"), + variant: "ghost", + size: "sm", + onClick: () => { + const id = nextId(view.doc.sets); + view.doc.sets.push({ + id, + name: id, + from_m: 0, + to_m: Math.ceil(view.endM), + dip_direction: 0, + dip: 45, + phi: view.defaults.new_set.phi, + c: view.defaults.new_set.c, + persistence_m: null, + }); + view.rebuild(); + }, + }); + add.disabled = !view.editable; + setsCard.append( + el("p", { className: "e06-hint", text: t("E06_JointsHint") }), + jointTable(view), + el("div", { className: "e06-row-actions", children: [add] }), + ); + + const setCard = createPanelCard(t("E06_Card_Settings"), true); + const crack = s.crack; + setCard.append( + settingRow( + "E06_Set_LateralPlanar", + numberInput( + view, + s.lateral_planar_deg, + "lateral_deg", + (v) => (s.lateral_planar_deg = v ?? NaN), + { + path: "settings.lateral_planar_deg", + }, + ), + ), + settingRow( + "E06_Set_LateralTopple", + numberInput( + view, + s.lateral_toppling_deg, + "lateral_deg", + (v) => (s.lateral_toppling_deg = v ?? NaN), + { path: "settings.lateral_toppling_deg" }, + ), + ), + settingRow( + "E06_Set_Gamma", + numberInput(view, s.gamma_r, "gamma_r", (v) => (s.gamma_r = v), { + optional: true, + path: "settings.gamma_r", + }), + ), + settingRow( + "E06_Set_WaterWet", + numberInput(view, s.water_fill_wet, "water_fill_wet", (v) => (s.water_fill_wet = v ?? NaN), { + step: "0.05", + path: "settings.water_fill_wet", + }), + ), + settingRow( + "E06_Set_Crack", + select( + view, + crack.mode, + [ + ["none", "E06_Crack_None"], + ["critical", "E06_Crack_Critical"], + ["depth", "E06_Crack_Depth"], + ], + (v) => { + crack.mode = v; + crack.z = v === "depth" ? (crack.z ?? 1) : null; + }, + "settings.crack.mode", + ), + ), + ); + if (crack.mode === "depth") + setCard.append( + settingRow( + "E06_Set_CrackZ", + numberInput(view, crack.z, "crack_z", (v) => (crack.z = v), { path: "settings.crack.z" }), + ), + ); + setCard.append( + settingRow( + "E06_Set_BoltForce", + numberInput(view, s.bolt.force, "bolt_force", (v) => (s.bolt.force = v ?? NaN), { + path: "settings.bolt.force", + }), + ), + settingRow( + "E06_Set_BoltPlunge", + numberInput(view, s.bolt.plunge, "bolt_plunge", (v) => (s.bolt.plunge = v ?? NaN), { + path: "settings.bolt.plunge", + }), + ), + el("p", { className: "e06-hint", text: t("E06_SettingsHint") }), + ); + return [setsCard, setCard]; +} diff --git a/E06_StabRock/E06_StabRock_UI_Page.ts b/E06_StabRock/E06_StabRock_UI_Page.ts new file mode 100644 index 000000000..f96156bd7 --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Page.ts @@ -0,0 +1,397 @@ +/* ============================================================================= + * E06_StabRock_UI_Page.ts + * E06 ์•”๋ฐ˜ ํ‰์‚ฌํˆฌ์˜ ํ•ด์„(์š”๊ตฌ 345 ยท 351 ยท ์š”๊ตฌ์„œ 4-2 2์ฐจ ยท PLAN 536-1) โ€” B05~B09 ํ‹€(group_workflow). + * ์ขŒ = ์ ˆ๋ฆฌ๊ตฐ ํ‘œ ยท ํ•ด์„ ์„ค์ • ยท [์ดˆ๊ธฐํ™”] [์ €์žฅ] [ํ•ด์„] + * ๊ฐ€์šด๋ฐ = ์œ„ํ—˜ ๋  ยท ์Šคํ…Œ๋ ˆ์˜ค๋„ท(๋“ฑ๊ฐ ยท ๋“ฑ๋ฉด์  ยท ๋ณด๊ธฐ = ์ „์ฒด ยท ํ‰๋ฉด ยท ์๊ธฐ ยท ์ „๋„) + * ์šฐ = ์ธก์ ๋ณ„ ๋ชจ๋“œ ์ˆ˜ ยท ์ตœ์†Œ ์•ˆ์ „์œจ ยท ํŒ์ • ยท ๊ณ ๋ฅธ ์ธก์  ์•ˆ์ „์œจ ํ‘œ + * ์กฐ์ž‘ = sessionStorage ์บ์‹œ๋งŒ(์ž๋™์ €์žฅ ์—†์Œ) ยท [์ €์žฅ] = PUT ยท [์ดˆ๊ธฐํ™”] = POST โ€ฆ/reset ยท + * [ํ•ด์„] = ์ €์žฅ ์•ˆ ํ•œ ๊ณ ์นจ์ด ์žˆ์œผ๋ฉด ๋จผ์ € ์ €์žฅ โ†’ POST โ€ฆ/analyze(์„œ๋ฒ„ ๋‹จ๋…) โ†’ ๊ฒฐ๊ณผ GET. + * ========================================================================== */ + +import "./E06_StabRock_UI_Style.css"; +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { + createButton, + el, + hideLoadingOverlay, + showConfirmDialog, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { createSidePanels } from "@ui/ui_template_side_panels"; +import { mountGroupStep } from "../A00_Common/group_workflow"; +import { + buildJointCards, + type JointsDoc, + type JointsView, + type Projection, + type RockDefaults, +} from "./E06_StabRock_UI_Joints"; +import { + buildBand, + buildDetail, + buildList, + entryId, + type RockResult, + type StationEntry, +} from "./E06_StabRock_UI_Result"; +import { drawStereonet, netLegend, type NetData, type NetMode } from "./E06_StabRock_UI_Stereonet"; + +interface RockResponse { + joints: JointsDoc; + saved?: boolean; + defaults?: RockDefaults; + stations?: { chainage_m: number; label: string; ground_type: string }[]; + editable?: boolean; +} + +interface ResultResponse { + status: string; + stale: boolean; + result: RockResult | null; +} + +interface StationResponse { + entry: StationEntry; + stereonet: Record & { phi: number }; +} + +const cacheKey = (projectId: string): string => `e06_rock_cache:${projectId}`; + +async function api(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...init, + }); + const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown }; + if (!response.ok) { + const detail = body.detail; + if (typeof detail === "string") throw new Error(detail); + const errors = (detail as { errors?: unknown[] } | undefined)?.errors; + throw new Error(errors ? `${t("E06_Invalid")} ${errors.length}` : `HTTP ${response.status}`); + } + return body; +} + +function toggle(options: [LocaleKey, T][], value: T, onPick: (v: T) => void) { + const group = el("div", { className: "e06-toggle" }); + const buttons = options.map(([key, v]) => { + const button = createButton({ label: t(key), variant: "ghost", size: "sm" }); + button.dataset.value = v; + button.classList.toggle("is-on", v === value); + button.addEventListener("click", () => { + buttons.forEach((b) => b.classList.toggle("is-on", b === button)); + onPick(v); + }); + return button; + }); + group.append(...buttons); + return group; +} + +export function renderE06StabRock(root: HTMLElement): void { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const panel = el("div", { className: "ui-sidebar-panel e06-panel" }); + const main = el("div", { className: "e06-page" }); + mountGroupStep(root, ROUTES.E06_STAB_ROCK, { mainContent: main, leftPanel: panel }); + if (!projectId) + return void main.append(el("p", { className: "e06-empty", text: t("E06_NoProject") })); + + // ---- ์ขŒ ---- + const cards = el("div", { className: "e06-cards" }); + const status = el("p", { className: "e06-status" }); + const saveButton = createButton({ + label: t("Common_Btn_Save"), + variant: "ghost", + onClick: () => void save(), + }); + const resetButton = createButton({ + label: t("Common_Btn_Reset"), + variant: "danger-ghost", + onClick: () => void reset(), + }); + const analyzeButton = createButton({ + label: t("E06_Analyze"), + variant: "filled", + onClick: () => void analyze(), + }); + saveButton.disabled = resetButton.disabled = analyzeButton.disabled = true; + const actions = el("div", { className: "ui-sidebar-actions" }); + actions.append(resetButton, saveButton, analyzeButton); + panel.append(el("p", { className: "e06-hint", text: t("E06_Note") }), cards, status, actions); + attachCollapsible(cards); + + // ---- ๊ฐ€์šด๋ฐ ยท ์šฐ ---- + const staleBar = el("div", { className: "e06-stale", text: t("E06_Stale") }); + staleBar.hidden = true; + const bandSlot = el("div"); + const title = el("strong", { className: "e06-title" }); + const netSlot = el("div", { className: "e06-net-slot" }); + const legendSlot = el("div"); + let projection: Projection = "equal_angle"; + let netMode: NetMode = "all"; + const toolbar = el("div", { className: "e06-toolbar" }); + const projectionToggle = el("div"); + toolbar.append( + title, + projectionToggle, + toggle( + [ + ["E06_View_All", "all"], + ["E06_Mode_planar", "planar"], + ["E06_Mode_wedge", "wedge"], + ["E06_Mode_toppling", "toppling"], + ], + netMode, + (v) => { + netMode = v; + paintNet(); + }, + ), + ); + const stage = el("div", { className: "e06-stage" }); + stage.append(netSlot, legendSlot); + const center = el("div", { className: "e06-center" }); + center.append(staleBar, bandSlot, toolbar, stage); + const listSlot = el("div", { className: "e06-list-slot" }); + const detailSlot = el("div"); + const sideBody = el("div", { className: "e06-side" }); + sideBody.append(listSlot, detailSlot); + const side = createSidePanels({ + prefKey: "e06-stab-rock-result", + panels: [{ id: "result", title: t("E06_Result_Title"), body: sideBody }], + defaultOpen: "result", + defaultWidth: 380, + follow: false, + }); + const row = el("div", { className: "ui-side-panels-row e06-row" }); + row.append(center, side.root); + main.append(row); + + // ---- ์ƒํƒœ ---- + let view: JointsView | null = null; + let dirty = false; + let result: RockResult | null = null; + let active: string | null = null; + let shown: StationResponse | null = null; + + const invalidCount = (): number => cards.querySelectorAll(".is-invalid").length; + const paintStatus = (): void => { + const lines: string[] = []; + if (view && !view.editable) lines.push(t("E06_ReadOnly")); + if (dirty) lines.push(t("E06_Unsaved")); + const bad = invalidCount(); + if (bad) lines.push(`${t("E06_Invalid")} ${bad}`); + status.textContent = lines.join("\n"); + status.classList.toggle("is-error", bad > 0); + }; + + const renderCards = (): void => { + if (!view) return; + // ๋‹ค์‹œ ๊ทธ๋ ค๋„ ์ ‘ํž˜์€ ๊ทธ๋Œ€๋กœ + const folded = [...cards.children].map((c) => c.classList.contains("is-collapsed")); + const built = buildJointCards(view); + built.forEach( + (c, i) => folded[i] !== undefined && c.classList.toggle("is-collapsed", folded[i]), + ); + cards.replaceChildren(...built); + paintStatus(); + }; + + const remember = (): void => { + if (!view) return; + dirty = true; + sessionStorage.setItem(cacheKey(projectId), JSON.stringify(view.doc)); + }; + + function paintProjection(): void { + projectionToggle.replaceChildren( + toggle( + [ + ["E06_Proj_Angle", "equal_angle"], + ["E06_Proj_Area", "equal_area"], + ], + projection, + (v) => { + projection = v; // ๋ณด๊ธฐ ๊ณ ๋ฆ„ โ€” ์ •๋ณธ ์•„๋‹˜(ํ•ด์„ ์ง€๋ฌธ์—๋„ ์•ˆ ๋“ค์–ด๊ฐ) + paintNet(); + }, + ), + ); + } + + function paintNet(): void { + legendSlot.replaceChildren(netLegend(netMode)); + if (!shown) { + title.textContent = ""; + netSlot.replaceChildren(el("p", { className: "e06-empty", text: t("E06_PickStation") })); + return; + } + const e = shown.entry; + title.textContent = `${e.label ?? e.key} ยท ${t(e.slope.side === "left" ? "E06_Left" : "E06_Right")} ยท ฯ† ${shown.stereonet.phi}ยฐ`; + netSlot.replaceChildren( + drawStereonet(shown.stereonet[projection], netMode, { + planar: e.kinematic.planar, + toppling: e.kinematic.toppling, + wedge: e.kinematic.wedge.map((w) => w.ids.join("|")), + }), + ); + } + + function paintResult(): void { + const stations = result?.stations ?? []; + bandSlot.replaceChildren(buildBand(stations, active, (id) => void select(id))); + listSlot.replaceChildren( + stations.length + ? buildList(stations, active, (id) => void select(id)) + : el("p", { className: "e06-hint", text: t(result ? "E06_NoRock" : "E06_NoResult") }), + ); + detailSlot.replaceChildren(shown ? buildDetail(shown.entry) : el("span")); + paintNet(); + } + + async function select(id: string): Promise { + active = id; + const [key, sideName] = id.split("|"); + try { + shown = await api( + `/projects/${projectId}/stab/rock/result?station=${encodeURIComponent(key)}&side=${sideName}`, + ); + } catch (error) { + shown = null; + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } + paintResult(); + } + + async function loadResult(): Promise { + const body = await api(`/projects/${projectId}/stab/rock/result`); + result = body.result; + staleBar.hidden = !body.stale; + const stations = result?.stations ?? []; + const keep = stations.find((e) => entryId(e) === active); + const first = keep ?? stations.find((e) => e.verdict === "NG") ?? stations[0]; + if (first) await select(entryId(first)); + else { + active = null; + shown = null; + paintResult(); + } + } + + const adopt = (body: RockResponse, keepCache: boolean): void => { + if (!view) return; + const cached = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null; + if (!cached) sessionStorage.removeItem(cacheKey(projectId)); + view.doc = cached ? (JSON.parse(cached) as JointsDoc) : body.joints; + dirty = Boolean(cached); + saveButton.disabled = resetButton.disabled = analyzeButton.disabled = !view.editable; + renderCards(); + }; + + async function putJoints(): Promise { + if (!view?.editable) return false; + if (invalidCount()) { + showToast(`${t("E06_Invalid")} ${invalidCount()}`, "error"); + return false; + } + const body = await api(`/projects/${projectId}/stab/rock`, { + method: "PUT", + body: JSON.stringify({ joints: view.doc }), + }); + adopt(body, false); + return true; + } + + async function save(): Promise { + showLoadingOverlay(); + try { + if (await putJoints()) { + showToast(t("E06_Saved"), "success"); + await loadResult(); + } + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } finally { + hideLoadingOverlay(); + } + } + + async function reset(): Promise { + if (!view?.editable) return; + const ok = await showConfirmDialog( + t("E06_ResetConfirm"), + t("Common_Btn_Reset"), + t("Common_Btn_Cancel"), + ); + if (!ok) return; + showLoadingOverlay(); + try { + const body = await api(`/projects/${projectId}/stab/rock/reset`, { + method: "POST", + }); + adopt(body, false); + showToast(t("E06_ResetDone"), "success"); + await loadResult(); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } finally { + hideLoadingOverlay(); + } + } + + async function analyze(): Promise { + if (!view?.editable) return; + analyzeButton.disabled = true; + status.textContent = t("E06_Running"); + try { + if (dirty && !(await putJoints())) return; + const body = await api<{ status: string; summary?: RockResult["summary"] }>( + `/projects/${projectId}/stab/rock/analyze`, + { method: "POST" }, + ); + await loadResult(); + showToast(t(body.status === "fresh" ? "E06_Fresh" : "E06_Done"), "success"); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } finally { + analyzeButton.disabled = !view.editable; + paintStatus(); + } + } + + void (async () => { + try { + const body = await api(`/projects/${projectId}/stab/rock`); + const stations = body.stations ?? []; + projection = body.joints.settings.projection; + paintProjection(); + view = { + doc: body.joints, + defaults: body.defaults as RockDefaults, + editable: body.editable ?? false, + endM: stations.length ? stations[stations.length - 1].chainage_m : 0, + change: () => { + remember(); + paintStatus(); + }, + rebuild: () => { + remember(); + renderCards(); + }, + }; + adopt(body, true); + await loadResult(); + } catch (error) { + main.replaceChildren( + el("p", { + className: "e06-empty", + text: error instanceof Error ? error.message : t("Common_Status_Error"), + }), + ); + } + })(); +} diff --git a/E06_StabRock/E06_StabRock_UI_Result.ts b/E06_StabRock/E06_StabRock_UI_Result.ts new file mode 100644 index 000000000..a2d3352f8 --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Result.ts @@ -0,0 +1,219 @@ +/* ============================================================================= + * E06_StabRock_UI_Result.ts + * E06 ๊ฒฐ๊ณผ โ€” ์œ„ํ—˜ ๋ (๋ˆ„๊ฐ€ ์ถ• ยท ์ธก์  ํŒ์ • ์ƒ‰ ยท ๋ˆ„๋ฅด๋ฉด ๊ทธ ์ธก์ ) ยท ์šฐ์ธก ์ธก์  ๋ชฉ๋ก(์ชฝ ยท ๋ชจ๋“œ ์ˆ˜ ยท + * ์ตœ์†Œ ์•ˆ์ „์œจ ยท ํŒ์ •) ยท ๊ณ ๋ฅธ ์ธก์  ์‚ฌ๋ฉด ยท ๋ชจ๋“œ๋ณ„ ์•ˆ์ „์œจ ํ‘œ. ๊ฐ’์€ ์„œ๋ฒ„ ์ €์žฅ๋ณธ ๊ทธ๋Œ€๋กœ(๊ณ„์‚ฐ ์—†์Œ). + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; + +export type Verdict = "NG" | "check" | "OK" | "none"; + +export interface RockCase { + mode: "planar" | "wedge"; + ids: string[]; + condition: "dry" | "wet" | "seismic"; + F: number | null; + formed: boolean; + persistence_short: boolean; + required: number | null; + ok: boolean | null; + sliding?: string | null; +} + +export interface StationEntry { + key: string; + chainage_m: number; + label: string | null; + ground_type: string; + slope: { side: string; dip_direction: number; dip: number; height_m: number; upper_dip: number }; + gamma_r: number; + joints: { + id: string; + name: string; + dip_direction: number; + dip: number; + phi: number; + c: number; + }[]; + kinematic: { + planar: string[]; + toppling: string[]; + wedge: { ids: [string, string]; trend: number; plunge: number }[]; + }; + cases: RockCase[]; + counts: { planar: number; wedge: number; toppling: number }; + min_F: number | null; + verdict: Verdict; +} + +export interface RockResult { + fingerprint: string; + summary: { + slopes: number; + verdicts: Partial>; + modes: Record; + }; + stations: StationEntry[]; +} + +const ORDER: Verdict[] = ["NG", "check", "OK", "none"]; +export const entryId = (e: StationEntry): string => `${e.key}|${e.slope.side}`; +const fmt = (v: number | null | undefined, d = 2): string => + v === null || v === undefined ? "โ€“" : v.toFixed(d); + +function badge(verdict: Verdict): HTMLElement { + return el("span", { + className: `e06-verdict e06-verdict--${verdict.toLowerCase()}`, + text: t(`E06_V_${verdict}` as LocaleKey), + }); +} + +/** ์œ„ํ—˜ ๋  โ€” ์ธก์ ๋งˆ๋‹ค ๊ฐ€์žฅ ๋‚˜์œ ํŒ์ • ยท ์ด์›ƒ ์ธก์  ๊ฐ€์šด๋ฐ์„œ ๋Š์Œ */ +export function buildBand( + stations: StationEntry[], + active: string | null, + onSelect: (id: string) => void, +): HTMLElement { + const box = el("div", { className: "e06-band", attrs: { "data-role": "band" } }); + const worst = new Map(); + for (const e of stations) { + const prev = worst.get(e.chainage_m); + if (!prev || ORDER.indexOf(e.verdict) < ORDER.indexOf(prev.verdict)) worst.set(e.chainage_m, e); + } + const list = [...worst.values()].sort((a, b) => a.chainage_m - b.chainage_m); + const legend = el("div", { className: "e06-band__legend" }); + legend.append(el("strong", { text: t("E06_Band") })); + for (const v of ORDER) { + const n = list.filter((e) => e.verdict === v).length; + if (n) legend.append(badge(v), el("span", { className: "e06-band__count", text: String(n) })); + } + const strip = el("div", { className: "e06-band__strip" }); + if (list.length) { + const lo = list[0].chainage_m; + const hi = Math.max(list[list.length - 1].chainage_m, lo + 1); + list.forEach((e, i) => { + const a = i ? (list[i - 1].chainage_m + e.chainage_m) / 2 : lo; + const b = i < list.length - 1 ? (e.chainage_m + list[i + 1].chainage_m) / 2 : hi; + const seg = el("button", { + className: `e06-band__seg e06-band__seg--${e.verdict.toLowerCase()}${ + active?.startsWith(`${e.key}|`) ? " is-active" : "" + }`, + attrs: { + type: "button", + "data-key": e.key, + title: `${e.label ?? e.key} ยท ${t(`E06_V_${e.verdict}` as LocaleKey)}`, + }, + }); + seg.style.left = `${((a - lo) / (hi - lo)) * 100}%`; + seg.style.width = `${Math.max(0.4, ((b - a) / (hi - lo)) * 100)}%`; + seg.addEventListener("click", () => onSelect(entryId(e))); + strip.append(seg); + }); + const ticks = el("div", { className: "e06-band__ticks" }); + ticks.append( + el("span", { text: `${lo.toFixed(0)} m` }), + el("span", { text: `${hi.toFixed(0)} m` }), + ); + box.append(legend, strip, ticks); + } else box.append(legend, el("p", { className: "e06-hint", text: t("E06_NoRock") })); + return box; +} + +export function buildList( + stations: StationEntry[], + active: string | null, + onSelect: (id: string) => void, +): HTMLElement { + const table = el("table", { className: "e06-list", attrs: { "data-role": "station-list" } }); + const head = el("tr"); + for (const key of [ + "E06_L_Station", + "E06_L_Side", + "E06_L_Modes", + "E06_L_MinF", + "E06_L_Verdict", + ] as LocaleKey[]) + head.append(el("th", { text: t(key) })); + table.append(el("thead", { children: [head] }), el("tbody")); + for (const e of stations) { + const id = entryId(e); + const tr = el("tr", { + className: id === active ? "is-active" : "", + attrs: { "data-id": id, "data-verdict": e.verdict }, + }); + tr.append( + el("td", { text: e.label ?? e.key }), + el("td", { text: t(e.slope.side === "left" ? "E06_Left" : "E06_Right") }), + el("td", { text: `${e.counts.planar} ยท ${e.counts.wedge} ยท ${e.counts.toppling}` }), + el("td", { text: fmt(e.min_F) }), + el("td", { children: [badge(e.verdict)] }), + ); + tr.addEventListener("click", () => onSelect(id)); + table.tBodies[0].append(tr); + } + return table; +} + +export function buildDetail(e: StationEntry): HTMLElement { + const box = el("div", { className: "e06-detail", attrs: { "data-role": "detail" } }); + const s = e.slope; + box.append( + el("h4", { text: `${e.label ?? e.key} ยท ${t(s.side === "left" ? "E06_Left" : "E06_Right")}` }), + el("p", { + className: "e06-hint", + text: t("E06_SlopeLine") + .replace("{dd}", s.dip_direction.toFixed(0)) + .replace("{dip}", s.dip.toFixed(1)) + .replace("{h}", s.height_m.toFixed(2)) + .replace("{up}", s.upper_dip.toFixed(1)) + .replace("{g}", e.gamma_r.toFixed(1)), + }), + ); + const kin = e.kinematic; + const kinText = [ + `${t("E06_Mode_planar")} ${kin.planar.join(", ") || "โ€“"}`, + `${t("E06_Mode_wedge")} ${kin.wedge.map((w) => `${w.ids.join("ยท")}(${w.trend.toFixed(0)}/${w.plunge.toFixed(0)})`).join(", ") || "โ€“"}`, + `${t("E06_Mode_toppling")} ${kin.toppling.join(", ") || "โ€“"}`, + ]; + box.append(el("p", { className: "e06-kin", text: kinText.join("\n") })); + if (!e.cases.length) { + box.append( + el("p", { + className: "e06-hint", + text: t(e.joints.length ? "E06_NoRisk" : "E06_NoJointsHere"), + }), + ); + return box; + } + const table = el("table", { className: "e06-cases" }); + const head = el("tr"); + for (const key of [ + "E06_C_Mode", + "E06_C_Joints", + "E06_C_Cond", + "E06_C_F", + "E06_C_Req", + "E06_L_Verdict", + ] as LocaleKey[]) + head.append(el("th", { text: t(key) })); + table.append(el("thead", { children: [head] }), el("tbody")); + for (const c of e.cases) { + const verdict: Verdict = c.F === null ? "check" : c.ok === false ? "NG" : "OK"; + const note = !c.formed ? t("E06_NotFormed") : c.persistence_short ? t("E06_Persist") : ""; + const tr = el("tr", { attrs: { "data-mode": c.mode, "data-cond": c.condition } }); + tr.append( + el("td", { text: t(`E06_Mode_${c.mode}` as LocaleKey) }), + el("td", { text: c.ids.join("ยท") }), + el("td", { text: t(`E06_Cond_${c.condition}` as LocaleKey) }), + el("td", { text: `${fmt(c.F, 3)}${note ? ` ${note}` : ""}` }), + el("td", { text: fmt(c.required) }), + el("td", { children: [badge(verdict)] }), + ); + table.tBodies[0].append(tr); + } + box.append(table); + if (kin.toppling.length) + box.append(el("p", { className: "e06-hint", text: t("E06_ToppleNote") })); + return box; +} diff --git a/E06_StabRock/E06_StabRock_UI_Stereonet.ts b/E06_StabRock/E06_StabRock_UI_Stereonet.ts new file mode 100644 index 000000000..73e2b9e21 --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Stereonet.ts @@ -0,0 +1,193 @@ +/* ============================================================================= + * E06_StabRock_UI_Stereonet.ts + * E06 ๊ฐ€์šด๋ฐ ์Šคํ…Œ๋ ˆ์˜ค๋„ท(SVG ยท ํ•˜๋ฐ˜๊ตฌ) โ€” ์„œ๋ฒ„๊ฐ€ ๋‚ด๋ ค ์ค€ ํˆฌ์˜ ์ขŒํ‘œ(๋ฐ˜์ง€๋ฆ„ 1 ยท x = ๋™ ยท y = ๋ถ)๋ฅผ + * ๊ทธ๋ฆฌ๊ธฐ๋งŒ ํ•จ(๊ณ„์‚ฐ = E06_StabRock_Stereonet.py). ๋ณด๊ธฐ = ์ „์ฒด ยท ํ‰๋ฉด ยท ์๊ธฐ ยท ์ „๋„. + * ํ‰๋ฉด ยท ์ „๋„ = ๊ทน์  + ๊ทน์  ๋งˆ์ฐฐ์›(ฯ†) ยท ์๊ธฐ = ๊ต์„  + ๊ต์„  ๋งˆ์ฐฐ์› ยท ์œ„ํ—˜ ์˜์—ญ = ๋ชจ๋“œ ์ƒ‰ ยท + * ์ผ๊ด‘ ์—”๋ฒจ๋กœํ”„(ํ‰๋ฉด) ยท ์‚ฌ๋ฉด ๋Œ€์› = ๊ตต์€ ์„ . ์œ„ํ—˜ ํŒ์ •๋œ ์ ˆ๋ฆฌ ยท ๊ต์„  = ํ…Œ๋‘๋ฆฌ ๊ฐ•์กฐ. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +const SIZE = 440; +const R = 190; +const C = SIZE / 2; + +export type NetMode = "all" | "planar" | "wedge" | "toppling"; +type XY = [number, number]; + +export interface NetData { + projection: string; + slope: { pole: XY; great_circle: XY[] }; + planes: { id: string; pole: XY; great_circle: XY[] }[]; + intersections: { ids: [string, string]; point: XY }[]; + friction_pole_r: number; + friction_line_r: number; + daylight_envelope: XY[]; + zones: Record<"planar" | "wedge" | "toppling", XY[] | null>; +} + +export interface NetMarks { + planar: string[]; + toppling: string[]; + wedge: string[]; // "J1|J2" +} + +function svg( + tag: K, + attrs: Record = {}, + text?: string, +): SVGElementTagNameMap[K] { + const node = document.createElementNS(SVG_NS, tag); + for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v)); + if (text !== undefined) node.textContent = text; + return node; +} + +const sx = (p: XY): number => C + R * p[0]; +const sy = (p: XY): number => C - R * p[1]; +const path = (pts: XY[], close = false): string => + pts.map((p, i) => `${i ? "L" : "M"}${sx(p).toFixed(1)},${sy(p).toFixed(1)}`).join("") + + (close ? "Z" : ""); + +/** ์ ˆ๋ฆฌ ์ƒ‰ ๋ฒˆํ˜ธ(0~5) โ€” ํ‘œ ยท ๊ทธ๋ฆผ ๊ฐ™์€ ์ฐจ๋ก€ */ +export const jointTone = (index: number): number => index % 6; + +export function drawStereonet(net: NetData, mode: NetMode, marks: NetMarks): SVGSVGElement { + const root = svg("svg", { + viewBox: `0 0 ${SIZE} ${SIZE}`, + class: "e06-net", + "data-role": "stereonet", + "data-projection": net.projection, + "data-mode": mode, + }); + const clip = svg("clipPath", { id: "e06-net-clip" }); + clip.append(svg("circle", { cx: C, cy: C, r: R })); + const defs = svg("defs"); + defs.append(clip); + root.append(defs, svg("circle", { cx: C, cy: C, r: R, class: "e06-net__rim" })); + const body = svg("g", { "clip-path": "url(#e06-net-clip)" }); + root.append(body); + + const show = (m: Exclude): boolean => mode === "all" || mode === m; + for (const m of ["planar", "wedge", "toppling"] as const) { + const zone = net.zones[m]; + if (zone && show(m)) + body.append( + svg("path", { + d: path(zone, true), + class: `e06-net__zone e06-net__zone--${m}`, + "data-zone": m, + }), + ); + } + // ๋งˆ์ฐฐ์› + if (show("planar") || show("toppling")) + body.append( + svg("circle", { + cx: C, + cy: C, + r: R * net.friction_pole_r, + class: "e06-net__friction", + "data-role": "friction-pole", + }), + ); + if (show("wedge")) + body.append( + svg("circle", { + cx: C, + cy: C, + r: R * net.friction_line_r, + class: "e06-net__friction e06-net__friction--line", + "data-role": "friction-line", + }), + ); + if (show("planar")) + body.append( + svg("path", { + d: path(net.daylight_envelope), + class: "e06-net__envelope", + "data-role": "daylight", + }), + ); + // ๋Œ€์› + net.planes.forEach((p, i) => + body.append( + svg("path", { + d: path(p.great_circle), + class: `e06-net__gc e06-tone-${jointTone(i)}`, + "data-id": p.id, + }), + ), + ); + body.append( + svg("path", { d: path(net.slope.great_circle), class: "e06-net__slope", "data-role": "slope" }), + ); + + // ๊ทน์ (ํ‰๋ฉด ยท ์ „๋„ ๋ณด๊ธฐ) ยท ๊ต์„ (์๊ธฐ ๋ณด๊ธฐ) + if (mode !== "wedge") + net.planes.forEach((p, i) => { + const risky = marks.planar.includes(p.id) || marks.toppling.includes(p.id); + const g = svg("g", { + class: `e06-net__pole e06-tone-${jointTone(i)}${risky ? " is-risk" : ""}`, + "data-pole": p.id, + }); + g.append(svg("circle", { cx: sx(p.pole), cy: sy(p.pole), r: 5 })); + g.append(svg("text", { x: sx(p.pole) + 7, y: sy(p.pole) - 6 }, p.id)); + root.append(g); + }); + if (mode === "all" || mode === "wedge") + for (const line of net.intersections) { + const key = line.ids.join("|"); + const risky = marks.wedge.includes(key); + const [x, y] = [sx(line.point), sy(line.point)]; + const g = svg("g", { class: `e06-net__line${risky ? " is-risk" : ""}`, "data-line": key }); + g.append(svg("path", { d: `M${x},${y - 6}L${x + 5.5},${y + 4}L${x - 5.5},${y + 4}Z` })); + g.append(svg("text", { x: x + 7, y: y + 12 }, line.ids.join("ยท"))); + root.append(g); + } + // ์‚ฌ๋ฉด ๊ทน์  ยท ๋ฐฉ์œ„ + root.append( + svg("rect", { + x: sx(net.slope.pole) - 4, + y: sy(net.slope.pole) - 4, + width: 8, + height: 8, + class: "e06-net__slope-pole", + }), + ); + for (const [label, x, y] of [ + ["N", C, C - R - 8], + ["E", C + R + 10, C + 4], + ["S", C, C + R + 18], + ["W", C - R - 10, C + 4], + ] as const) + root.append(svg("text", { x, y, class: "e06-net__dir", "text-anchor": "middle" }, label)); + root.append(svg("line", { x1: C - 6, y1: C, x2: C + 6, y2: C, class: "e06-net__tick" })); + root.append(svg("line", { x1: C, y1: C - 6, x2: C, y2: C + 6, class: "e06-net__tick" })); + return root; +} + +export function netLegend(mode: NetMode): HTMLElement { + const box = el("div", { className: "e06-legend" }); + const items: [string, LocaleKey][] = [ + ["slope", "E06_Lg_Slope"], + ["friction", "E06_Lg_Friction"], + ]; + if (mode === "all" || mode === "planar") + items.push(["planar", "E06_Lg_Planar"], ["daylight", "E06_Lg_Daylight"]); + if (mode === "all" || mode === "wedge") + items.push(["wedge", "E06_Lg_Wedge"], ["line", "E06_Lg_Line"]); + if (mode === "all" || mode === "toppling") items.push(["toppling", "E06_Lg_Toppling"]); + if (mode !== "wedge") items.push(["pole", "E06_Lg_Pole"]); + for (const [cls, key] of items) { + const row = el("span", { className: "e06-legend__row" }); + row.append( + el("i", { className: `e06-legend__mark e06-legend__mark--${cls}` }), + el("span", { text: t(key) }), + ); + box.append(row); + } + return box; +} diff --git a/E06_StabRock/E06_StabRock_UI_Style.css b/E06_StabRock/E06_StabRock_UI_Style.css new file mode 100644 index 000000000..47b4a8b49 --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Style.css @@ -0,0 +1,485 @@ +/* ============================================================================= + * E06_StabRock_UI_Style.css + * E06 ์•”๋ฐ˜ ํ•ด์„ โ€” ์ขŒ์ธก ์ ˆ๋ฆฌ ํ‘œ ยท ์„ค์ • / ๊ฐ€์šด๋ฐ ์œ„ํ—˜ ๋  ยท ์Šคํ…Œ๋ ˆ์˜ค๋„ท / ์šฐ์ธก ์ธก์  ๊ฒฐ๊ณผ + * ์ƒ‰์€ ํ…Œ๋งˆ ํ† ํฐ(๋ฐ์Œ ยท ์–ด๋‘์›€ ๊ฐ™์ด) ยท ํŒ์ • ยท ๋ชจ๋“œ ยท ์ ˆ๋ฆฌ ์ƒ‰๋งŒ ๊ณ ์ •์ƒ‰(E03 ํŒ์ •์ƒ‰๊ณผ ๊ฐ™์Œ) + * ========================================================================== */ + +.e06-page { + --e06-ng: #d03b3b; + --e06-ok: #0ca30c; + --e06-check: #e0a106; + --e06-none: #9a9a95; + --e06-planar: #d03b3b; + --e06-wedge: #f1963b; + --e06-toppling: #9b6dde; + height: 100dvh; + box-sizing: border-box; +} + +.e06-row { + height: 100%; +} + +.e06-center { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + height: 100%; + min-height: 0; + padding: var(--spacing-12) 0 var(--spacing-12) var(--spacing-12); + box-sizing: border-box; + overflow: auto; +} + +.e06-empty, +.e06-hint, +.e06-status, +.e06-kin { + margin: 0; + white-space: pre-line; + color: var(--color-text-muted); + font-size: 12px; +} + +.e06-empty { + padding: var(--spacing-16); +} + +.e06-status.is-error { + color: var(--color-danger); +} + +.e06-kin { + color: var(--color-text); +} + +.e06-panel .ui-sidebar-actions { + flex-wrap: wrap; +} + +.e06-stale { + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface)); + color: var(--color-text); +} + +.e06-stale[hidden] { + display: none; +} + +/* ---- ์ขŒ์ธก ํ‘œ ยท ์„ค์ • ---- */ +.e06-table-wrap { + overflow-x: auto; +} + +.e06-table { + border-collapse: collapse; + font-size: 12px; +} + +.e06-table th { + padding: 2px 3px; + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + white-space: nowrap; +} + +.e06-table td { + padding: 1px 2px; +} + +.e06-input { + width: 4.2em; + padding: 2px 4px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + font-size: 12px; + box-sizing: border-box; +} + +.e06-input--name { + width: 3.6em; +} + +select.e06-input { + width: auto; +} + +.e06-input.is-invalid { + border-color: var(--color-danger); + background: color-mix(in srgb, var(--color-danger) 12%, var(--color-surface)); +} + +.e06-row-actions { + display: flex; + justify-content: flex-end; + margin-top: var(--spacing-4); +} + +.e06-setting { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); + margin: 2px 0; + font-size: 12px; + color: var(--color-text); +} + +.e06-setting .e06-input { + width: 5.5em; +} + +/* ---- ๊ฐ€์šด๋ฐ ---- */ +.e06-toolbar { + display: flex; + align-items: center; + gap: var(--spacing-12); + flex-wrap: wrap; +} + +.e06-title { + color: var(--color-text); +} + +.e06-toggle { + display: inline-flex; + gap: var(--spacing-4); +} + +.e06-toggle .ui-btn.is-on { + border-color: var(--color-primary); + background: var(--color-primary); + color: var(--color-primary-text); +} + +.e06-stage { + display: flex; + gap: var(--spacing-12); + align-items: flex-start; + flex-wrap: wrap; + padding: var(--spacing-8); + border-radius: var(--radius-sm); + background: var(--color-surface-raised); +} + +.e06-net-slot { + flex: 1 1 360px; + max-width: 560px; +} + +.e06-net { + display: block; + width: 100%; + height: auto; + color: var(--color-text); + font-size: 11px; +} + +.e06-net__rim { + fill: var(--color-surface); + stroke: var(--color-text); + stroke-width: 1.4; +} + +.e06-net__zone { + stroke: none; + opacity: 0.28; +} + +.e06-net__zone--planar { + fill: var(--e06-planar); +} + +.e06-net__zone--wedge { + fill: var(--e06-wedge); +} + +.e06-net__zone--toppling { + fill: var(--e06-toppling); +} + +.e06-net__friction { + fill: none; + stroke: var(--color-text-secondary); + stroke-width: 1; + stroke-dasharray: 5 3; +} + +.e06-net__friction--line { + stroke-dasharray: 2 3; +} + +.e06-net__envelope { + fill: none; + stroke: var(--e06-planar); + stroke-width: 1; + stroke-dasharray: 6 3; +} + +.e06-net__gc { + fill: none; + stroke: currentColor; + stroke-width: 1.2; +} + +.e06-net__slope { + fill: none; + stroke: var(--color-text); + stroke-width: 2.6; +} + +.e06-net__slope-pole { + fill: var(--color-text); +} + +.e06-net__pole circle { + fill: currentColor; + stroke: var(--color-surface); + stroke-width: 1; +} + +.e06-net__pole.is-risk circle, +.e06-net__line.is-risk path { + stroke: var(--e06-ng); + stroke-width: 2.4; +} + +.e06-net__pole text, +.e06-net__line text, +.e06-net__dir { + fill: var(--color-text); +} + +.e06-net__line path { + fill: var(--e06-wedge); + stroke: var(--color-surface); +} + +.e06-net__tick { + stroke: var(--color-text-secondary); +} + +.e06-tone-0 { + color: #2f6fd6; +} +.e06-tone-1 { + color: #1f9d6a; +} +.e06-tone-2 { + color: #c2410c; +} +.e06-tone-3 { + color: #7c4dcc; +} +.e06-tone-4 { + color: #b8860b; +} +.e06-tone-5 { + color: #0e7490; +} + +.e06-legend { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: var(--color-text); +} + +.e06-legend__row { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.e06-legend__mark { + display: inline-block; + width: 16px; + height: 10px; + border-radius: 2px; +} + +.e06-legend__mark--slope { + height: 3px; + background: var(--color-text); +} +.e06-legend__mark--friction { + height: 0; + border-top: 1px dashed var(--color-text-secondary); +} +.e06-legend__mark--daylight { + height: 0; + border-top: 1px dashed var(--e06-planar); +} +.e06-legend__mark--planar { + background: color-mix(in srgb, var(--e06-planar) 35%, transparent); +} +.e06-legend__mark--wedge { + background: color-mix(in srgb, var(--e06-wedge) 35%, transparent); +} +.e06-legend__mark--toppling { + background: color-mix(in srgb, var(--e06-toppling) 35%, transparent); +} +.e06-legend__mark--line { + width: 10px; + background: var(--e06-wedge); + clip-path: polygon(50% 0, 100% 100%, 0 100%); +} +.e06-legend__mark--pole { + width: 10px; + border-radius: 50%; + background: #2f6fd6; +} + +/* ---- ์œ„ํ—˜ ๋  ---- */ +.e06-band { + display: flex; + flex-direction: column; + gap: 4px; +} + +.e06-band__legend { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + font-size: 12px; + color: var(--color-text); +} + +.e06-band__count { + margin-right: 6px; + color: var(--color-text-secondary); +} + +.e06-band__strip { + position: relative; + height: 18px; + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + overflow: hidden; +} + +.e06-band__seg { + position: absolute; + top: 0; + bottom: 0; + padding: 0; + border: none; + cursor: pointer; +} + +.e06-band__seg--ng { + background: var(--e06-ng); +} +.e06-band__seg--ok { + background: var(--e06-ok); +} +.e06-band__seg--check { + background: var(--e06-check); +} +.e06-band__seg--none { + background: var(--e06-none); +} + +.e06-band__seg.is-active { + outline: 2px solid var(--color-text); + outline-offset: -2px; + z-index: 1; +} + +.e06-band__ticks { + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--color-text-secondary); +} + +/* ---- ์šฐ์ธก ---- */ +.e06-side { + display: flex; + flex-direction: column; + gap: var(--spacing-12); +} + +.e06-list-slot { + max-height: 46vh; + overflow: auto; +} + +.e06-list, +.e06-cases { + width: 100%; + border-collapse: collapse; + font-size: 12px; + color: var(--color-text); +} + +.e06-list th, +.e06-cases th { + position: sticky; + top: 0; + padding: 3px 4px; + background: var(--color-surface); + color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); + text-align: left; +} + +.e06-list td, +.e06-cases td { + padding: 3px 4px; + border-top: 1px solid var(--color-border); +} + +.e06-list tbody tr { + cursor: pointer; +} + +.e06-list tbody tr:hover { + background: var(--color-surface-raised); +} + +.e06-list tr.is-active { + background: color-mix(in srgb, var(--color-primary) 16%, transparent); +} + +.e06-detail h4 { + margin: 0 0 4px; + color: var(--color-text); +} + +.e06-verdict { + display: inline-flex; + align-items: center; + gap: 4px; + font-weight: var(--font-weight-medium); + white-space: nowrap; +} + +.e06-verdict::before { + content: ""; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--e06-none); +} + +.e06-verdict--ng::before { + background: var(--e06-ng); + border-radius: 2px; + transform: rotate(45deg); +} +.e06-verdict--ok::before { + background: var(--e06-ok); +} +.e06-verdict--check::before { + background: var(--e06-check); + border-radius: 0; + clip-path: polygon(50% 0, 100% 100%, 0 100%); +} diff --git a/E06_StabRock/draft/E06_StabRock_Route.ts b/E06_StabRock/draft/E06_StabRock_Route.ts new file mode 100644 index 000000000..627bed897 --- /dev/null +++ b/E06_StabRock/draft/E06_StabRock_Route.ts @@ -0,0 +1,16 @@ +/* ============================================================================= + * E06_StabRock_Route.ts + * ๊ธธ ์„ ์–ธ(PLAN 536-1) โ€” ๋ผ์šฐํ„ฐ๊ฐ€ ํŽ˜์ด์ง€ ํด๋”๋งˆ๋‹ค draft/*_Route.ts ๋ฅผ glob ํ•ด ๋“ฑ๋ก. + * ๋กœ๊ทธ์ธ ยท ํšŒ์‚ฌ ยท ํ˜„์žฌ ํ”„๋กœ์ ํŠธ ํ•„์š” ยท ๊ผฌ๋ฆฌ๋ง ์—†์Œ ยท ๋ณธ๋ฌธ = E06 ์•”๋ฐ˜ ํ‰์‚ฌํˆฌ์˜ ํŽ˜์ด์ง€ + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.E06_STAB_ROCK, + workflow: true, + project: true, + footerless: true, + load: async () => (await import("../E06_StabRock_UI_Page")).renderE06StabRock, +}; diff --git a/E07_StabArea/E07_StabArea_Engine.py b/E07_StabArea/E07_StabArea_Engine.py new file mode 100644 index 000000000..add8f867a --- /dev/null +++ b/E07_StabArea/E07_StabArea_Engine.py @@ -0,0 +1,283 @@ +"""E07 ๊ด‘์—ญ ์ž์—ฐ์‚ฌ๋ฉด ์œ„ํ—˜ โ€” ๋ฌดํ•œ์‚ฌ๋ฉด ์•ˆ์ „์œจ ยท SINMAP ์•ˆ์ • ์ง€์ˆ˜ (์š”๊ตฌ 345 ยท 351 ยท PLAN 536-2). + +์ˆœ์ˆ˜ ๊ณ„์‚ฐ(storage ๋ชจ๋ฆ„) โ€” D ์˜ˆ์ •๋…ธ์„  ์žฌํ•ด ์•ˆ์ „์„ฑ๋„ ๊ฐ™์€ ์—”์ง„์„ ์”€(ํ•œ ๋ฒŒ). +๊ทผ๊ฑฐ = ์ง€์‹DB `original/์˜ˆ์ •๋…ธ์„ /02` ยง1.1 ยท ยง2 โ‘  ยท `original/์•ˆ์ •์„ฑํ‰๊ฐ€/04`(๋ถ•์ ํ†  ๋ฒ”์œ„). + + ์ง€ํ˜• DEM โ†’ ๊ณ ๋ฅธ ๊ฒฉ์ž(cell_m) ยท ๊ฒฝ์‚ฌ ฮฒ(Horn 3ร—3) ยท D8 ํ๋ฆ„(์›…๋ฉ์ด ์ฑ„์›€ ยท ์ˆ˜์‹  ์…€ = B04 ๋ฐฐ์ˆ˜์œ ์—ญ + ํ•จ์ˆ˜ ์žฌ์‚ฌ์šฉ) ยท ๋‹จ์œ„ ์ง‘์ˆ˜๋ฉด์  a = ์ƒ๋ฅ˜ ์…€ ์ˆ˜ ร— cellยฒ/cell(๋“ฑ๊ณ ์„  ํญ = cell) + ํฌํ™”๋น„ m = 1(์ง€ํ‘œ ํฌํ™” ยท ๊ธฐ๋ณธ) | min(Rยทa / (Tยทsinฮฒ), 1) ยท T = Kยทz(์ •์ƒ๋ฅ˜ ยท SINMAP ยท SHALSTAB) + ์•ˆ์ „์œจ Fs = (c + c_r + [ฮณt z(1โˆ’m) + (ฮณsatโˆ’ฮณw) m z] cosยฒฮฒ tanฯ†) + / ([ฮณt z(1โˆ’m) + ฮณsat m z] sinฮฒ cosฮฒ) + ์•ˆ์ • ์ง€์ˆ˜ SI(SINMAP ยท Pack 1998) โ€” c โˆˆ [c, c_hi] ยท tanฯ† โˆˆ [tanฯ†, tanฯ†_hi] ๊ณ ๋ฅธ ๋ถ„ํฌ + Fs_min โ‰ฅ 1 โ†’ SI = Fs_min ยท Fs_max < 1 โ†’ SI = 0 + ยท ๊ทธ ์‚ฌ์ด โ†’ SI = P(Fs > 1)(๋‘ ๊ณ ๋ฅธ ๋ถ„ํฌ ํ•ฉ ยท ๋‹ซํžŒ ๊ผด) + ๋“ฑ๊ธ‰ 1 ์•ˆ์ •(SI โ‰ฅ 1.5) ยท 2 ๋ณดํ†ต ์•ˆ์ •(โ‰ฅ 1.25) ยท 3 ์ค€์•ˆ์ •(โ‰ฅ 1.0) ยท 4 ์œ„ํ—˜ ํ•˜ํ•œ(โ‰ฅ 0.5) + ยท 5 ์œ„ํ—˜ ์ƒํ•œ(> 0) ยท 6 ๋ถˆ์•ˆ์ •(SI = 0) ยท 0 = ์ž๋ฃŒ ์—†์Œ + +๊ฒฉ์ž ๊ผด = ํ–‰ 0 ์ด ๋‚จ์ชฝ(y0) ยท ์…€ (iy, ix) ์ค‘์‹ฌ = (x0 + ixยทdx, y0 + iyยทdx) โ€” E04 ๋ž˜์Šคํ„ฐ์™€ ๊ฐ™์Œ. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from types import SimpleNamespace +from typing import Any + +import numpy as np +from scipy.interpolate import RegularGridInterpolator +from scipy.spatial import cKDTree + +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ( + compute_receivers, + condition_surface, +) + +GAMMA_W = 9.81 # kN/mยณ +SI_EDGES = (1.5, 1.25, 1.0, 0.5) # ๋“ฑ๊ธ‰ 1~4 ์•„๋ž˜ ๊ฒฝ๊ณ„ ยท 5 = (0, 0.5) ยท 6 = 0 +CLASS_KEYS = ("", "stable", "moderate", "quasi", "lower", "upper", "defended") +RISK_CLASS = 4 # ์ด ๋“ฑ๊ธ‰๋ถ€ํ„ฐ ์œ„ํ—˜(SI < 1) +FLAT_SLOPE_RAD = 1e-4 # ์ด๋ณด๋‹ค ํ‰ํ‰ํ•˜๋ฉด ํ™œ๋™ ์—†์Œ(Fs = โˆž) + + +@dataclass(frozen=True) +class AreaParams: + """ํ•œ ๋ฒˆ ํ•ด์„ ์กฐ๊ฑด โ€” ๋‹จ์œ„ kN/mยณ ยท kPa ยท ยฐ ยท m ยท mm/h ยท m/s.""" + + gamma_t: float + gamma_sat: float + c: float # ์ ์ฐฉ๋ ฅ ์•„๋ž˜(๊ฒฐ์ • Fs ยท SI ๋ฒ”์œ„ ์•„๋ž˜) + c_hi: float # SI ๋ฒ”์œ„ ์œ„ + phi: float + phi_hi: float + c_root: float = 0.0 # ๋ฟŒ๋ฆฌ ์ ์ฐฉ(๋‚˜์ง€ = 0) + soil_depth_m: float = 1.5 + scenario: str = "saturated" # saturated = ์ง€ํ‘œ ํฌํ™” m = 1 ยท rain = ์ •์ƒ๋ฅ˜ ๊ฐ•์šฐ + rain_mm_h: float = 30.0 + k_m_s: float = 1e-4 # ํฌํ™” ํˆฌ์ˆ˜๊ณ„์ˆ˜ + + +# --------------------------------------------------------------------------- +# ์ง€ํ˜• +# --------------------------------------------------------------------------- + + +def resample_dem(dem: dict[str, Any], cell_m: float) -> tuple[np.ndarray, float, float]: + """DTM npz(x ยท y ์˜ค๋ฆ„ ์ถ• ยท z (ny, nx) ยท valid_mask) โ†’ ๊ณ ๋ฅธ ๊ฒฉ์ž (z, x0, y0) ยท ๋ฐ– = NaN.""" + x = np.asarray(dem["x"], float) + y = np.asarray(dem["y"], float) + z = np.where(np.asarray(dem["valid_mask"], bool), np.asarray(dem["z"], float), np.nan) + nx = int(math.floor((x[-1] - x[0]) / cell_m)) + 1 + ny = int(math.floor((y[-1] - y[0]) / cell_m)) + 1 + gx = x[0] + np.arange(nx) * cell_m + gy = y[0] + np.arange(ny) * cell_m + yy, xx = np.meshgrid(gy, gx, indexing="ij") + f = RegularGridInterpolator((y, x), z, bounds_error=False, fill_value=np.nan) + return f(np.column_stack([yy.ravel(), xx.ravel()])).reshape(ny, nx), float(x[0]), float(y[0]) + + +def slope_rad(z: np.ndarray, cell_m: float) -> np.ndarray: + """Horn 3ร—3 ๊ฒฝ์‚ฌ(rad) โ€” ์ด์›ƒ์— NaN ์ด ์žˆ๊ฑฐ๋‚˜ ๊ฒฉ์ž ๋์ด๋ฉด NaN.""" + p = np.pad(z, 1, constant_values=np.nan) + + def at(dy: int, dx: int) -> np.ndarray: + return p[1 + dy : 1 + dy + z.shape[0], 1 + dx : 1 + dx + z.shape[1]] + + gx = ((at(-1, 1) + 2 * at(0, 1) + at(1, 1)) - (at(-1, -1) + 2 * at(0, -1) + at(1, -1))) / ( + 8 * cell_m + ) + gy = ((at(1, -1) + 2 * at(1, 0) + at(1, 1)) - (at(-1, -1) + 2 * at(-1, 0) + at(-1, 1))) / ( + 8 * cell_m + ) + return np.arctan(np.hypot(gx, gy)) + + +def catchment_area(z: np.ndarray, cell_m: float) -> np.ndarray: + """D8 ๋‹จ์œ„ ์ง‘์ˆ˜๋ฉด์  a(m) = ์ž๊ธฐ ํฌํ•จ ์ƒ๋ฅ˜ ์…€ ์ˆ˜ ร— cell. ๋ฐ– = NaN. + + ponytail: ์ง€ํ˜• ๊ฒฉ์ž ๋ ๋ฐ– ์ƒ๋ฅ˜๋Š” ๋ชจ๋ฆ„(๋ณต์‚ฌ DTM = ๋…ธ์„  ๋ ) โ€” ๋  ๋ ์…€์€ a ๊ฐ€ ์ž‘๊ฒŒ ๋‚˜์˜ด ยท + ๋„“์€ DEM(D ์˜ˆ์ •๋…ธ์„  ๋“ฑ)์ด๋ฉด ๊ทธ๋Œ€๋กœ ๋งž์Œ. + """ + filled, valid = condition_surface(z.astype(np.float32)) + spec = SimpleNamespace(n_rows=z.shape[0], n_cols=z.shape[1], cell_m=cell_m) + receiver, _ = compute_receivers(spec, filled, valid) + flat = filled.ravel() + order = np.flatnonzero(valid.ravel()) + order = order[np.argsort(-flat[order], kind="stable")].tolist() # ๋†’์€ ๋ฐ์„œ ๋‚ฎ์€ ๋ฐ๋กœ + acc = valid.ravel().astype(float).tolist() + recv = receiver.tolist() + for i in order: + r = recv[i] + if r != i: + acc[r] += acc[i] + out = np.asarray(acc).reshape(z.shape) * cell_m + return np.where(valid, out, np.nan) + + +# --------------------------------------------------------------------------- +# ๋ฌดํ•œ์‚ฌ๋ฉด ยท SINMAP +# --------------------------------------------------------------------------- + + +def wetness(beta: np.ndarray, a: np.ndarray, p: AreaParams) -> np.ndarray: + """ํฌํ™”๋น„ m(0~1) โ€” ์ง€ํ‘œ ํฌํ™” = 1 ยท ๊ฐ•์šฐ = min(Rยทa/(T sinฮฒ), 1).""" + if p.scenario == "saturated": + return np.ones_like(beta) + recharge = p.rain_mm_h / 1000.0 / 3600.0 # m/s + transmissivity = p.k_m_s * p.soil_depth_m # mยฒ/s + with np.errstate(divide="ignore", invalid="ignore"): + m = recharge * a / (transmissivity * np.sin(beta)) + return np.clip(np.nan_to_num(m, nan=1.0, posinf=1.0), 0.0, 1.0) + + +def _terms(beta: np.ndarray, m: np.ndarray, p: AreaParams) -> tuple[np.ndarray, np.ndarray]: + """Fs = (c + c_r)ยทA + tanฯ†ยทB ์˜ A ยท B(ํ‰ํ‰ํ•œ ์…€ = โˆž).""" + z = p.soil_depth_m + cos, sin = np.cos(beta), np.sin(beta) + normal = (p.gamma_t * z * (1 - m) + (p.gamma_sat - GAMMA_W) * m * z) * cos**2 + shear = (p.gamma_t * z * (1 - m) + p.gamma_sat * m * z) * sin * cos + flat = beta < FLAT_SLOPE_RAD + with np.errstate(divide="ignore", invalid="ignore"): + a = np.where(flat, np.inf, 1.0 / shear) + b = np.where(flat, np.inf, normal / shear) + return a, b + + +def infinite_slope_fs(beta: np.ndarray, m: np.ndarray, p: AreaParams) -> np.ndarray: + """๊ฒฐ์ • ์•ˆ์ „์œจ(c ยท ฯ† = ๋ฒ”์œ„ ์•„๋ž˜ = ๋ณด์ˆ˜) โ€” ์ง€์‹DB 02 ยง1.1 ์‹.""" + a, b = _terms(beta, m, p) + with np.errstate(invalid="ignore"): + return np.where( + np.isinf(a), np.inf, (p.c + p.c_root) * a + math.tan(math.radians(p.phi)) * b + ) + + +def _cdf_sum_uniform( + s: np.ndarray, a1: np.ndarray, a2: np.ndarray, b1: np.ndarray, b2: np.ndarray +) -> np.ndarray: + """P(X + Y โ‰ค s) ยท X ~ U[a1, a2] ยท Y ~ U[b1, b2] ๋…๋ฆฝ โ€” ํญ 0 ์ด๋ฉด ๊ทธ ๋ณ€์ˆ˜๋Š” ์ƒ์ˆ˜.""" + wa, wb = a2 - a1, b2 - b1 + eps = 1e-12 + + def g(u: np.ndarray) -> np.ndarray: + return np.maximum(u, 0.0) ** 2 / 2 + + with np.errstate(divide="ignore", invalid="ignore"): + both = (g(s - a1 - b1) - g(s - a2 - b1) - g(s - a1 - b2) + g(s - a2 - b2)) / (wa * wb) + only_a = (s - a1 - b1) / wa + only_b = (s - a1 - b1) / wb + step = (a1 + b1 <= s).astype(float) + out = np.where( + (wa > eps) & (wb > eps), both, np.where(wa > eps, only_a, np.where(wb > eps, only_b, step)) + ) + return np.clip(np.nan_to_num(out, nan=0.0), 0.0, 1.0) + + +def stability_index(beta: np.ndarray, m: np.ndarray, p: AreaParams) -> np.ndarray: + """SINMAP ์•ˆ์ • ์ง€์ˆ˜ โ€” Fs_min โ‰ฅ 1 โ†’ Fs_min ยท Fs_max < 1 โ†’ 0 ยท ๊ทธ ์‚ฌ์ด โ†’ P(Fs > 1).""" + a, b = _terms(beta, m, p) + t1, t2 = math.tan(math.radians(p.phi)), math.tan(math.radians(max(p.phi_hi, p.phi))) + c1, c2 = p.c + p.c_root, max(p.c_hi, p.c) + p.c_root + flat = np.isinf(a) + a = np.where(flat, 0.0, a) + b = np.where(flat, 0.0, b) + lo, hi = c1 * a + t1 * b, c2 * a + t2 * b + prob = 1.0 - _cdf_sum_uniform(np.ones_like(a), c1 * a, c2 * a, t1 * b, t2 * b) + si = np.where(lo >= 1.0, lo, np.where(hi < 1.0, 0.0, prob)) + return np.where(flat, np.inf, si) + + +def classify(si: np.ndarray) -> np.ndarray: + """SI โ†’ ๋“ฑ๊ธ‰ 1~6(0 = ์ž๋ฃŒ ์—†์Œ ยท NaN).""" + cls = np.full(si.shape, 6, dtype=np.uint8) + cls[si > 0] = 5 + for k, edge in enumerate(reversed(SI_EDGES)): # 0.5 โ†’ 4 ยท 1.0 โ†’ 3 ยท 1.25 โ†’ 2 ยท 1.5 โ†’ 1 + cls[si >= edge] = 4 - k + cls[np.isnan(si)] = 0 + return cls + + +def analyze_grid(z: np.ndarray, cell_m: float, p: AreaParams) -> dict[str, np.ndarray]: + """๊ณ ๋ฅธ ๊ฒฉ์ž ์ง€ํ˜• โ†’ {slope(rad) ยท a ยท m ยท fs ยท si ยท cls} ยท ๋ฐ– = NaN / 0.""" + beta = slope_rad(z, cell_m) + a = catchment_area(z, cell_m) if p.scenario == "rain" else np.full(z.shape, np.nan) + m = wetness(beta, a, p) + fs = infinite_slope_fs(beta, m, p) + si = stability_index(beta, m, p) + nodata = np.isnan(beta) + fs[nodata] = np.nan + si[nodata] = np.nan + m = np.where(nodata, np.nan, m) + return {"slope": beta, "a": a, "m": m, "fs": fs, "si": si, "cls": classify(si)} + + +# --------------------------------------------------------------------------- +# ๋…ธ์„  ๋  ยท ์ธก์  +# --------------------------------------------------------------------------- + + +def dense_line(line: np.ndarray, step_m: float = 0.5) -> np.ndarray: + """์ค‘์‹ฌ์„  [x, y, ๋ˆ„๊ฐ€] ๋ฅผ step ๊ฐ„๊ฒฉ์œผ๋กœ ์ด˜์ด˜ํžˆ(๊บพ์ธ ์  ์‚ฌ์ด ์…€๋„ ๋ฐ”๋ฅธ ๋ˆ„๊ฐ€).""" + out = [line[:1]] + for a, b in zip(line[:-1], line[1:]): + n = max(1, int(math.ceil(math.hypot(*(b[:2] - a[:2])) / step_m))) + out.append(a + (b - a) * np.linspace(0, 1, n + 1)[1:, None]) + return np.vstack(out) + + +def band_cells( + shape: tuple[int, int], x0: float, y0: float, cell_m: float, line: np.ndarray, half_m: float +) -> tuple[np.ndarray, np.ndarray]: + """๋…ธ์„  ๋ (์ค‘์‹ฌ์„  ยฑ half_m) ์…€ โ†’ (๋  ์•ˆ, ์…€์˜ ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด ์ค‘์‹ฌ์„  ๋ˆ„๊ฐ€).""" + dense = dense_line(np.asarray(line, float)) + yy, xx = np.meshgrid( + y0 + np.arange(shape[0]) * cell_m, x0 + np.arange(shape[1]) * cell_m, indexing="ij" + ) + dist, at = cKDTree(dense[:, :2]).query(np.column_stack([xx.ravel(), yy.ravel()])) + return (dist <= half_m).reshape(shape), dense[at, 2].reshape(shape) + + +def station_table( + chainages: list[float], + cell_chain: np.ndarray, + inside: np.ndarray, + out: dict[str, np.ndarray], + cell_m: float, +) -> list[dict[str, Any]]: + """์ธก์ ๋งˆ๋‹ค ๋งก์€ ๋  ์…€(์ด์›ƒ ์ธก์ ๊ณผ ์ค‘๊ฐ„ ๋ˆ„๊ฐ€๋กœ ๋‚˜๋ˆ”) โ€” + ์ตœ์†Œ Fs ยท SI ยท ๊ฐ€์žฅ ๋‚˜์œ ๋“ฑ๊ธ‰ ยท ์œ„ํ—˜ ๋ฉด์ .""" + chs = np.asarray(sorted(chainages), float) + if not len(chs): + return [] + mids = (chs[:-1] + chs[1:]) / 2 + owner = np.searchsorted(mids, cell_chain) + have = inside & (out["cls"] > 0) + rows = [] + for k, ch in enumerate(chs): + sel = have & (owner == k) + n = int(sel.sum()) + if not n: + rows.append({"chainage_m": float(ch), "cells": 0}) + continue + cls = out["cls"][sel] + fs = out["fs"][sel] + si = out["si"][sel] + rows.append( + { + "chainage_m": float(ch), + "cells": n, + "fs_min": round(float(np.min(fs)), 3), + "si_min": round(float(np.min(si)), 3), + "worst_class": int(cls.max()), + "risk_area_m2": round(float((cls >= RISK_CLASS).sum()) * cell_m**2, 1), + "slope_max_deg": round(float(np.degrees(np.nanmax(out["slope"][sel]))), 1), + } + ) + return rows + + +def class_areas(cls: np.ndarray, inside: np.ndarray, cell_m: float) -> dict[str, float]: + """๋  ์•ˆ ๋“ฑ๊ธ‰๋ณ„ ๋ฉด์  mยฒ.""" + return { + CLASS_KEYS[k]: round(float(((cls == k) & inside).sum()) * cell_m**2, 1) for k in range(1, 7) + } diff --git a/E07_StabArea/E07_StabArea_Router.py b/E07_StabArea/E07_StabArea_Router.py new file mode 100644 index 000000000..dfae623ff --- /dev/null +++ b/E07_StabArea/E07_StabArea_Router.py @@ -0,0 +1,337 @@ +"""E07 ๊ด‘์—ญ ์ž์—ฐ์‚ฌ๋ฉด ์œ„ํ—˜ ์ง€๋„ API (์š”๊ตฌ 345 ยท 351 ยท ์š”๊ตฌ์„œ 4-2 2์ฐจ ยท PLAN 536-2). + +- `POST /api/projects/{id}/stab/area/analyze` โ€” ํ•ด์„(๋’ค์—์„œ) ยท ์ž…๋ ฅ ์ง€๋ฌธ์ด ์ €์žฅ๋ณธ๊ณผ ๊ฐ™์œผ๋ฉด + `cached` ยท force = ๋‹ค์‹œ +- `GET โ€ฆ/stab/area/progress` โ€” ์ง„ํ–‰๋ฅ  +- `GET โ€ฆ/stab/area` โ€” ๊ธฐ๋ณธ๊ฐ’(E02 ๋ถ•์ ํ† ) ยท ๋ฌผ์„ฑ ๋ชฉ๋ก ยท ๋…ธ์„  ยท ์ธก์  ยท ์ €์žฅ๋ณธ(๊ฒฉ์ž ยท ์ธก์ ํ‘œ ยท ์š”์•ฝ) + +๊ณ„์‚ฐ ์ž๋ฆฌ = โ‘ข ์„œ๋ฒ„ ๋‹จ๋…(์—”์ง„ `E07_StabArea_Engine` ยท ์ˆœ์ˆ˜). ์ง€ํ˜• = ๋ณต์‚ฌ๋ณธ ์›์ง€ํ˜• DTM +(๋…ธ์„ ๋ฉด ํ•ฉ์„ฑ ์•ˆ ํ•จ โ€” ๋…ธ์„  ์œ„ ยท ์•„๋ž˜ ์ž์—ฐ์‚ฌ๋ฉด). +ํŒŒ์ƒ ์ €์žฅ๋ณธ = `E07_StabArea/_derived/area.json`(์ง€๋ฌธ์ด ๊ฐ™์œผ๋ฉด ์•ˆ ์…ˆ). +๊ถŒํ•œ = main.py `protected_with_company`(์ง€์ •๋ฐ›์€ ํšŒ์‚ฌ = GET ๋งŒ). + +๊ฒฉ์ž ๊ผด(ํ™”๋ฉด `logic/E07_StabArea_Raster.ts` ์™€ ๊ฐ™์€ ์—ด์‡ ) โ€” grid = {x0, y0, dx, nx, ny} +(ํ–‰ 0 = ๋‚จ์ชฝ) ยท fs ยท si = uint16 ร—1000(65535 = ์ž๋ฃŒ ์—†์Œ ยท 65.534 ์œ„๋กœ ์ž๋ฆ„) ยท +cls = uint8 ๋“ฑ๊ธ‰(0 ์—†์Œ ยท 1~6) ยท slope = uint16 ร—10 ยฐ ยท m = uint8 ร—100(255 ์—†์Œ) +โ€” ๋ชจ๋‘ ์ค„ ์šฐ์„  ยท ์ž‘์€ ๋ base64 ยท ๋  ๋ฐ– ์…€ = ์ž๋ฃŒ ์—†์Œ. +""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import json +import logging +import time +from collections.abc import Callable +from datetime import datetime +from pathlib import Path +from typing import Any, Literal +from uuid import UUID + +import aiomysql +import numpy as np +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field, model_validator + +from common_util.common_util_auth import require_company +from common_util.common_util_json import atomic_write_json +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool +from E01_StabRegister import E01_StabRegister_Source as source +from E02_StabGround import E02_StabGround_Model as ground_model +from E07_StabArea import E07_StabArea_Engine as engine + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["E07 Stab Area"]) + +ENGINE_VERSION = "e07-1" # ์—”์ง„ ํŒ โ€” ๋ฐ”๋€Œ๋ฉด ์ง€๋ฌธ์ด ๋‹ฌ๋ผ์ ธ ๋‹ค์‹œ ์…ˆ +RESULT = Path("E07_StabArea") / "_derived" / "area.json" +DEFAULT_MATERIAL = "colluvium" # ํ‘œ์ธต ๋ถ•๊ดด์— ๊ฐ€์žฅ ์ทจ์•ฝ(์ง€์‹DB ์•ˆ์ •์„ฑํ‰๊ฐ€/04 ๋ถ•์ ํ† ) +RANGE_C_KPA, RANGE_PHI_DEG = ( + 5.0, + 3.0, +) # SI ๋ฒ”์œ„ ์œ„ = ์•„๋ž˜ + ์ด ํญ(04 ๋ถ•์ ํ†  ๋ฒ”์œ„ c 0~10 ยท ฯ† 25~30 ํญ์˜ ๋ฐ˜) + +_JOBS: dict[str, dict[str, Any]] = {} +_TASKS: set[asyncio.Task] = set() +_IDLE = {"running": False, "percent": 0, "message": None, "error": None} + + +class AreaRequest(BaseModel): + band_m: float = Field(100.0, ge=10.0, le=500.0) # ๋…ธ์„  ๋  = ์ค‘์‹ฌ์„  ยฑ ์ด ํญ + cell_m: float = Field(2.0, ge=1.0, le=10.0) + material_id: str | None = None # ์ด๋ฆ„ํ‘œ๋งŒ(๊ฐ’์€ ์•„๋ž˜ ์นธ) + gamma_t: float = Field(..., gt=0.0, le=30.0) + gamma_sat: float = Field(..., gt=engine.GAMMA_W, le=30.0) + c: float = Field(..., ge=0.0, le=500.0) + c_hi: float = Field(..., ge=0.0, le=500.0) + phi: float = Field(..., ge=0.0, le=60.0) + phi_hi: float = Field(..., ge=0.0, le=60.0) + c_root: float = Field(0.0, ge=0.0, le=50.0) + soil_depth_m: float = Field(1.5, ge=0.1, le=10.0) + scenario: Literal["saturated", "rain"] = "saturated" + rain_mm_h: float = Field(30.0, ge=0.0, le=300.0) + k_m_s: float = Field(1e-4, ge=1e-8, le=1e-1) + force: bool = False + + @model_validator(mode="after") + def _ranges(self) -> AreaRequest: + if self.c_hi < self.c or self.phi_hi < self.phi: + raise ValueError("๋ฒ”์œ„ ์œ„๊ฐ€ ์•„๋ž˜๋ณด๋‹ค ์ž‘์Šต๋‹ˆ๋‹ค(c ยท ฯ†).") + return self + + +# --------------------------------------------------------------------------- +# ์ž…๋ ฅ ยท ์ง€๋ฌธ ยท ์ €์žฅ๋ณธ +# --------------------------------------------------------------------------- + + +def _ground(project_root: Path) -> dict[str, Any]: + 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 defaults(project_root: Path) -> dict[str, Any]: + """๊ธฐ๋ณธ ์กฐ๊ฑด โ€” E02 ๋ถ•์ ํ† (์—†์œผ๋ฉด ์ฒซ ๋ฌผ์„ฑ) ยท ๋ฒ”์œ„ ์œ„ = + 5 kPa ยท + 3ยฐ.""" + materials = _ground(project_root).get("materials") or [] + mat = next((m for m in materials if m.get("id") == DEFAULT_MATERIAL), None) or materials[0] + return { + **{ + k: f.default + for k, f in AreaRequest.model_fields.items() + if not f.is_required() and k != "force" + }, + "material_id": mat.get("id"), + "gamma_t": float(mat["gamma_t"]), + "gamma_sat": float(mat["gamma_sat"]), + "c": float(mat["c"]), + "c_hi": float(mat["c"]) + RANGE_C_KPA, + "phi": float(mat["phi"]), + "phi_hi": float(mat["phi"]) + RANGE_PHI_DEG, + } + + +def fingerprint(source_fingerprint: str, settings: dict[str, Any]) -> str: + body = {"source": source_fingerprint, "settings": settings, "engine": ENGINE_VERSION} + text = json.dumps(body, ensure_ascii=False, sort_keys=True) + return hashlib.sha1(text.encode("utf-8")).hexdigest() + + +def load(project_root: Path) -> dict[str, Any] | None: + path = Path(project_root) / RESULT + return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None + + +def _route_line(root: Path) -> np.ndarray: + points = sorted( + source.db_snapshot(root).get("route_points") or [], key=lambda p: p["sequence_num"] + ) + return np.array([[p["model_x"], p["model_y"], p["chainage_m"]] for p in points], float) + + +def _b64(values: np.ndarray, dtype: str) -> str: + return base64.b64encode(np.ascontiguousarray(values, dtype=dtype).tobytes()).decode("ascii") + + +def _u16(values: np.ndarray, scale: float, keep: np.ndarray) -> str: + v = np.round(np.clip(np.nan_to_num(values * scale, nan=-1.0, posinf=65534.0), -1, 65534)) + return _b64(np.where(keep & (v >= 0), v, 65535), " dict[str, Any]: + """ํ•ด์„ ํ•œ ๋ฒˆ โ†’ ์ €์žฅ๋ณธ ์“ฐ๊ณ  ์š”์•ฝ ๋Œ๋ ค์คŒ.""" + started = time.perf_counter() + cell = float(settings["cell_m"]) + report(5, "์ง€ํ˜• ๊ฒฉ์ž") + with np.load(source.terrain_grid_path(root)) as npz: + dem = {k: npz[k] for k in ("x", "y", "z", "valid_mask")} + z, x0, y0 = engine.resample_dem(dem, cell) + report(25, "๊ฒฝ์‚ฌ ยท ์ง‘์ˆ˜๋ฉด์  ยท ์•ˆ์ „์œจ") + keys = ( + "gamma_t", + "gamma_sat", + "c", + "c_hi", + "phi", + "phi_hi", + "c_root", + "soil_depth_m", + "scenario", + "rain_mm_h", + "k_m_s", + ) + out = engine.analyze_grid(z, cell, engine.AreaParams(**{k: settings[k] for k in keys})) + report(75, "๋…ธ์„  ๋  ยท ์ธก์ ") + stations = sorted(source.stations(root), key=lambda s: float(s["chainage_m"])) + inside, chain = engine.band_cells( + z.shape, x0, y0, cell, _route_line(root), float(settings["band_m"]) + ) + rows = engine.station_table([s["chainage_m"] for s in stations], chain, inside, out, cell) + labels = {round(float(s["chainage_m"]), 3): s.get("label") for s in stations} + for row in rows: + row["label"] = labels.get(round(row["chainage_m"], 3)) + # ๋  ์ƒ์ž๋กœ ์ž˜๋ผ ์ €์žฅ + iy, ix = np.nonzero(inside) + if not len(iy): + raise ValueError("๋…ธ์„  ๋ ๊ฐ€ ์ง€ํ˜• ๊ฒฉ์ž์™€ ๊ฒน์น˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.") + win = (slice(iy.min(), iy.max() + 1), slice(ix.min(), ix.max() + 1)) + keep = inside[win] & (out["cls"][win] > 0) + areas = engine.class_areas(out["cls"], inside, cell) + have = inside & (out["cls"] > 0) + summary = { + "areas_m2": areas, + "band_area_m2": round(float(inside.sum()) * cell**2, 1), + "covered_area_m2": round(float(have.sum()) * cell**2, 1), + "risk_area_m2": round( + sum(v for k, v in areas.items() if engine.CLASS_KEYS.index(k) >= engine.RISK_CLASS), 1 + ), + "fs_min": round(float(np.min(out["fs"][have])), 3) if have.any() else None, + "risk_stations": [ + r["chainage_m"] for r in rows if r.get("worst_class", 0) >= engine.RISK_CLASS + ], + "stations": len(rows), + } + doc = { + "fingerprint": finger, + "engine": ENGINE_VERSION, + "settings": settings, + "computed_at": datetime.now().isoformat(timespec="seconds"), + "elapsed_s": round(time.perf_counter() - started, 2), + "grid": { + "x0": x0 + int(ix.min()) * cell, + "y0": y0 + int(iy.min()) * cell, + "dx": cell, + "nx": int(keep.shape[1]), + "ny": int(keep.shape[0]), + }, + "fs": _u16(out["fs"][win], 1000, keep), + "si": _u16(out["si"][win], 1000, keep), + "cls": _b64(np.where(keep, out["cls"][win], 0), "u1"), + "slope": _u16(np.degrees(out["slope"][win]), 10, keep), + "m": _b64(np.where(keep, np.round(np.nan_to_num(out["m"][win], nan=0) * 100), 255), "u1"), + "stations": rows, + "summary": summary, + } + report(95, "์ €์žฅ") + atomic_write_json(Path(project_root) / RESULT, doc) + return summary + + +# --------------------------------------------------------------------------- +# ๊ธธ +# --------------------------------------------------------------------------- + + +async def _project_root(project_id: UUID) -> Path: + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute( + "SELECT storage_path FROM projects" + " WHERE id = %s AND project_kind = 'STABILITY' AND deleted_at IS NULL", + (str(project_id),), + ) + row = await cursor.fetchone() + if not row or not row["storage_path"]: + raise HTTPException(status_code=404, detail="์•ˆ์ •์„ฑํ‰๊ฐ€ ํ”„๋กœ์ ํŠธ๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค.") + return Path(resolve_stored_project_path(row["storage_path"])) + + +def _source_root(project_root: Path) -> Path: + root = source.source_dir(project_root) + if not (root / source.MANIFEST_NAME).is_file(): + raise HTTPException(status_code=409, detail="์›๋ณธ ๋ณต์‚ฌ๋ณธ์ด ์—†์Šต๋‹ˆ๋‹ค.") + return root + + +@router.post("/{project_id}/stab/area/analyze", response_model=None) +async def analyze(project_id: UUID, payload: AreaRequest) -> dict[str, Any]: + """[ํ•ด์„] โ€” ์ง€๋ฌธ์ด ๊ฐ™์œผ๋ฉด `cached`(์•ˆ ์…ˆ) ยท ๋‹ค๋ฅด๊ฑฐ๋‚˜ force ๋ฉด ๋’ค์—์„œ ์…ˆ.""" + pid = str(project_id) + project_root = await _project_root(project_id) + root = _source_root(project_root) + settings = payload.model_dump(exclude={"force"}) + finger = fingerprint(source.manifest(root)["source_fingerprint"], settings) + saved = await asyncio.to_thread(load, project_root) + if not payload.force and saved and saved["fingerprint"] == finger: + return {"status": "cached", "summary": saved["summary"]} + if (_JOBS.get(pid) or {}).get("running"): + raise HTTPException(status_code=409, detail="์ด๋ฏธ ๊ด‘์—ญ ํ•ด์„์ด ๋Œ๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.") + job = {**_IDLE, "running": True, "message": "์‹œ์ž‘"} + _JOBS[pid] = job + + def report(percent: int, message: str) -> None: + job.update(percent=max(0, min(99, int(percent))), message=message) + + async def work() -> None: + try: + await asyncio.to_thread(run, project_root, root, settings, finger, report) + job.update(percent=100, message="๋") + except Exception as error: # noqa: BLE001 โ€” ์ง„ํ–‰๋ฅ ์— ๊นŒ๋‹ญ์„ ๊ทธ๋Œ€๋กœ ์˜ฌ๋ฆผ + logger.exception("E07 ๊ด‘์—ญ ํ•ด์„ ์‹คํŒจ: project_id=%s", pid) + job["error"] = str(error) + finally: + job["running"] = False + + task = asyncio.create_task(work(), name=f"E07 area {pid}") + _TASKS.add(task) + task.add_done_callback(_TASKS.discard) + return {"status": "accepted"} + + +@router.get("/{project_id}/stab/area/progress", response_model=None) +async def progress(project_id: UUID) -> dict[str, Any]: + return {"status": "success", **dict(_JOBS.get(str(project_id)) or _IDLE)} + + +def _overview(project_root: Path, root: Path) -> dict[str, Any]: + saved = load(project_root) + if saved: + current = fingerprint(source.manifest(root)["source_fingerprint"], saved["settings"]) + saved["stale"] = saved["fingerprint"] != current + return { + "defaults": defaults(project_root), + "materials": _ground(project_root).get("materials") or [], + "line": _route_line(root).round(3).tolist(), + "stations": [ + { + "chainage_m": s["chainage_m"], + "label": s["label"], + "x": s["center_x"], + "y": s["center_y"], + } + for s in sorted(source.stations(root), key=lambda s: float(s["chainage_m"])) + ], + "result": saved, + } + + +async def _editable(project_id: UUID, session: dict[str, Any]) -> bool: + """[ํ•ด์„] ๊ฐ€๋Šฅ = ์‹œ์Šคํ…œ ๊ด€๋ฆฌ์ž ยท ๊ฐ™์€ ํšŒ์‚ฌ(์ง€์ •๋ฐ›์€ ํšŒ์‚ฌ๋Š” ๋ณด๊ธฐ๋งŒ).""" + if session.get("role") == "SYSTEM_ADMIN": + return True + async with get_db_pool().acquire() as connection, connection.cursor() as cursor: + await cursor.execute("SELECT company_id FROM projects WHERE id = %s", (str(project_id),)) + row = await cursor.fetchone() + return bool(row) and row[0] == session.get("company_id") + + +@router.get("/{project_id}/stab/area", response_model=None) +async def area( + project_id: UUID, session: dict[str, Any] = Depends(require_company) +) -> dict[str, Any]: + project_root = await _project_root(project_id) + root = _source_root(project_root) + overview = await asyncio.to_thread(_overview, project_root, root) + return {"status": "success", "editable": await _editable(project_id, session), **overview} diff --git a/E07_StabArea/E07_StabArea_UI_Map.ts b/E07_StabArea/E07_StabArea_UI_Map.ts new file mode 100644 index 000000000..b2a77b581 --- /dev/null +++ b/E07_StabArea/E07_StabArea_UI_Map.ts @@ -0,0 +1,296 @@ +/* ============================================================================= + * E07_StabArea_UI_Map.ts + * E07 ๊ฐ€์šด๋ฐ ํ‰๋ฉด ์ง€๋„(PLAN 536-2) โ€” ๋“ฑ๊ธ‰(SI) ยท ์•ˆ์ „์œจ ์ƒ‰ ๊ฒฉ์ž ยท ๋…ธ์„  ๋  ยท ๋…ธ์„  ยท ์ธก์  ยท ๊ณ ๋ฅธ ์ธก์  ยท + * ๋ฐฉ์œ„ ยท ์ถ•์ฒ™ ยท ์…€ ํˆดํŒ. ํœ  = ํ™•๋Œ€ ยท ๋Œ๊ธฐ = ์˜ฎ๊น€ ยท ๋‘ ๋ฒˆ ๋ˆ„๋ฆ„ = ๋งž์ถค. ์ƒ‰ ยท ํ’€๊ธฐ = logic/E07_StabArea_Raster. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { CLASS_KEYS, cellIndex, rasterRgba, type Decoded } from "./logic/E07_StabArea_Raster"; + +export type MapLayer = "cls" | "fs"; + +export interface StationPoint { + chainage_m: number; + label: string | null; + x: number; + y: number; +} + +export interface AreaMap { + root: HTMLElement; + setRoute: (line: number[][], stations: StationPoint[]) => void; + setBand: (halfWidthM: number) => void; + setResult: (data: Decoded | null) => void; + setLayer: (layer: MapLayer) => void; + focus: (chainage: number) => void; + dispose: () => void; +} + +interface View { + cx: number; + cy: number; + scale: number; // px / m +} + +const shortLabel = (label: string): string => label.replace(/^STA\./, "").replace(/\.0+$/, ""); + +const css = (name: string, fallback: string): string => + getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; + +export function createAreaMap(): AreaMap { + const root = el("div", { className: "e07-map" }); + const canvas = el("canvas", { className: "e07-map__canvas" }); + const tip = el("div", { className: "e07-map__tip" }); + const empty = el("p", { className: "e07-map__empty", text: t("E07_Map_Empty") }); + tip.hidden = true; + root.append(canvas, tip, empty); + const ctx = canvas.getContext("2d")!; + + let line: number[][] = []; + let stations: StationPoint[] = []; + let band = 100; + let data: Decoded | null = null; + let layer: MapLayer = "cls"; + let image: HTMLCanvasElement | null = null; + let picked: number | null = null; + let view: View = { cx: 0, cy: 0, scale: 1 }; + let width = 0; + let height = 0; + + const toScreen = (x: number, y: number): [number, number] => [ + width / 2 + (x - view.cx) * view.scale, + height / 2 - (y - view.cy) * view.scale, + ]; + const toModel = (px: number, py: number): [number, number] => [ + view.cx + (px - width / 2) / view.scale, + view.cy - (py - height / 2) / view.scale, + ]; + + function buildImage(): void { + image = null; + if (!data) return; + const { nx, ny } = data.grid; + const off = document.createElement("canvas"); + off.width = nx; + off.height = ny; + off.getContext("2d")!.putImageData(new ImageData(rasterRgba(data, layer), nx, ny), 0, 0); + image = off; + } + + function fit(): void { + const xs = line.map((p) => p[0]); + const ys = line.map((p) => p[1]); + if (!xs.length || !width || !height) return; + const pad = band; + const [x0, x1] = [Math.min(...xs) - pad, Math.max(...xs) + pad]; + const [y0, y1] = [Math.min(...ys) - pad, Math.max(...ys) + pad]; + const scale = Math.min(width / Math.max(x1 - x0, 1), height / Math.max(y1 - y0, 1)) * 0.92; + view = { cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, scale }; + } + + function path(): void { + ctx.beginPath(); + line.forEach((p, i) => + i ? ctx.lineTo(...toScreen(p[0], p[1])) : ctx.moveTo(...toScreen(p[0], p[1])), + ); + } + + function drawRoute(text: string, accent: string): void { + if (line.length < 2) return; + ctx.lineJoin = ctx.lineCap = "round"; + // ๋…ธ์„  ๋  โ€” ์ค‘์‹ฌ์„  ยฑ band(์˜…์€ ๋ ) + ctx.globalAlpha = 0.12; + ctx.strokeStyle = accent; + ctx.lineWidth = Math.max(2 * band * view.scale, 1); + path(); + ctx.stroke(); + ctx.globalAlpha = 1; + ctx.strokeStyle = text; + ctx.lineWidth = 2; + path(); + ctx.stroke(); + const gap = stations.length > 1 ? 20 * view.scale : 999; + const every = [1, 2, 5, 10, 25, 50, 100].find((n) => n * gap >= 70) ?? 100; + ctx.font = "11px sans-serif"; + ctx.textBaseline = "middle"; + stations.forEach((s, i) => { + const [px, py] = toScreen(s.x, s.y); + if (px < -50 || py < -50 || px > width + 50 || py > height + 50) return; + const on = picked !== null && Math.abs(s.chainage_m - picked) < 1e-6; + ctx.fillStyle = on ? accent : text; + ctx.beginPath(); + ctx.arc(px, py, on ? 6 : i % every === 0 ? 3 : 1.5, 0, Math.PI * 2); + ctx.fill(); + if ((on || i % every === 0) && s.label) ctx.fillText(shortLabel(s.label), px + 7, py - 9); + }); + } + + function drawFurniture(text: string): void { + ctx.fillStyle = text; + ctx.strokeStyle = text; + ctx.beginPath(); + ctx.moveTo(width - 24, 14); + ctx.lineTo(width - 30, 34); + ctx.lineTo(width - 18, 34); + ctx.closePath(); + ctx.fill(); + ctx.font = "bold 12px sans-serif"; + ctx.textAlign = "center"; + ctx.fillText("N", width - 24, 46); + const target = 100 / view.scale; + const pow = 10 ** Math.floor(Math.log10(target)); + const meters = [1, 2, 5, 10].map((k) => k * pow).find((m) => m >= target) ?? target; + const px = meters * view.scale; + ctx.fillStyle = css("--color-surface", "#ffffff"); + ctx.globalAlpha = 0.85; + ctx.fillRect(width - 24 - px, height - 42, px + 16, 34); + ctx.globalAlpha = 1; + ctx.fillStyle = text; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(width - 16 - px, height - 16); + ctx.lineTo(width - 16, height - 16); + ctx.stroke(); + ctx.fillText(`${meters} m`, width - 16 - px / 2, height - 26); + ctx.textAlign = "start"; + } + + function draw(): void { + const dpr = window.devicePixelRatio || 1; + width = root.clientWidth; + height = root.clientHeight; + if (!width || !height) return; + if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) { + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + const text = css("--color-text", "#1f2937"); + const accent = css("--color-primary", "#6d28d9"); + if (data && image) { + const g = data.grid; + const [left, top] = toScreen(g.x0 - g.dx / 2, g.y0 + (g.ny - 0.5) * g.dx); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(image, left, top, g.nx * g.dx * view.scale, g.ny * g.dx * view.scale); + } + drawRoute(text, accent); + drawFurniture(text); + empty.hidden = Boolean(data); + } + + let drag: { x: number; y: number; view: View } | null = null; + canvas.addEventListener("wheel", (event) => { + event.preventDefault(); + const rect = canvas.getBoundingClientRect(); + const [mx, my] = toModel(event.clientX - rect.left, event.clientY - rect.top); + const k = Math.exp(-event.deltaY * 0.0015); + view = { scale: view.scale * k, cx: mx - (mx - view.cx) / k, cy: my - (my - view.cy) / k }; + draw(); + }); + canvas.addEventListener("pointerdown", (event) => { + drag = { x: event.clientX, y: event.clientY, view: { ...view } }; + canvas.setPointerCapture(event.pointerId); + }); + canvas.addEventListener("pointerup", () => (drag = null)); + canvas.addEventListener("dblclick", () => { + fit(); + draw(); + }); + canvas.addEventListener("pointerleave", () => (tip.hidden = true)); + canvas.addEventListener("pointermove", (event) => { + if (drag) { + view = { + ...drag.view, + cx: drag.view.cx - (event.clientX - drag.x) / view.scale, + cy: drag.view.cy + (event.clientY - drag.y) / view.scale, + }; + draw(); + return; + } + const rect = canvas.getBoundingClientRect(); + const [mx, my] = toModel(event.clientX - rect.left, event.clientY - rect.top); + const i = data ? cellIndex(data.grid, mx, my) : -1; + if (i < 0 || !data!.cls[i]) return void (tip.hidden = true); + const near = stations.reduce( + (best, s) => + !best || (s.x - mx) ** 2 + (s.y - my) ** 2 < (best.x - mx) ** 2 + (best.y - my) ** 2 + ? s + : best, + null, + ); + const m = data!.m[i]; + tip.textContent = [ + t(`E07_Class_${CLASS_KEYS[data!.cls[i] - 1]}` as LocaleKey), + `SI ${data!.si[i].toFixed(3)} ยท Fs ${data!.fs[i].toFixed(3)}`, + `${t("E07_Tip_Slope")} ${data!.slope[i].toFixed(1)}ยฐ${Number.isNaN(m) ? "" : ` ยท m ${m.toFixed(2)}`}`, + near ? `${t("E07_Tip_Station")} ${near.label ?? near.chainage_m.toFixed(1)}` : "", + ] + .filter(Boolean) + .join("\n"); + tip.style.left = `${event.clientX - rect.left + 14}px`; + tip.style.top = `${event.clientY - rect.top + 14}px`; + tip.hidden = false; + }); + + const resize = new ResizeObserver(() => { + const first = !width; + draw(); + if (first) { + fit(); + draw(); + } + }); + resize.observe(root); + const theme = new MutationObserver(draw); + theme.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); + + // ๊ฒ€์ฆ ์ฐฝ๊ตฌ โ€” ๋ชจ๋ธ ์ขŒํ‘œ โ†’ ํ™”๋ฉด(client) ์ขŒํ‘œ ยท ์ง€๊ธˆ ๊ฒฉ์ž ยท ์ธต + const probe = { + project: (x: number, y: number) => { + const rect = canvas.getBoundingClientRect(); + const [px, py] = toScreen(x, y); + return { x: rect.left + px, y: rect.top + py }; + }, + data: () => data, + layer: () => layer, + }; + (window as unknown as { __e07Map?: typeof probe }).__e07Map = probe; + + return { + root, + setRoute(nextLine, nextStations) { + line = nextLine; + stations = nextStations; + fit(); + draw(); + }, + setBand(halfWidthM) { + band = halfWidthM; + draw(); + }, + setResult(next) { + data = next; + buildImage(); + draw(); + }, + setLayer(next) { + layer = next; + buildImage(); + draw(); + }, + focus(chainage) { + picked = chainage; + const s = stations.find((x) => Math.abs(x.chainage_m - chainage) < 1e-6); + if (s) view = { ...view, cx: s.x, cy: s.y }; + draw(); + }, + dispose() { + resize.disconnect(); + theme.disconnect(); + if ((window as unknown as { __e07Map?: unknown }).__e07Map === probe) + delete (window as unknown as { __e07Map?: unknown }).__e07Map; + }, + }; +} diff --git a/E07_StabArea/E07_StabArea_UI_Page.ts b/E07_StabArea/E07_StabArea_UI_Page.ts new file mode 100644 index 000000000..f31216547 --- /dev/null +++ b/E07_StabArea/E07_StabArea_UI_Page.ts @@ -0,0 +1,478 @@ +/* ============================================================================= + * E07_StabArea_UI_Page.ts + * E07 ๊ด‘์—ญ ์ž์—ฐ์‚ฌ๋ฉด ์œ„ํ—˜ ์ง€๋„(์š”๊ตฌ 345 ยท 351 ยท ์š”๊ตฌ์„œ 4-2 2์ฐจ ยท PLAN 536-2) โ€” B05~B09 ํ‹€(group_workflow). + * ์ขŒ = ํ† ์ธต ๋ฌผ์„ฑ(E02 ๋ฌผ์„ฑ ๊ณ ๋ฆ„ ยท ๋ฒ”์œ„) ยท ๊ฐ•์šฐ(์ง€ํ‘œ ํฌํ™” ยท ๊ฐ•์šฐ ๊ฐ•๋„) ยท ๋…ธ์„  ๋  ยท [ํ•ด์„] ยท ์ง„ํ–‰๋ฅ  + * ๊ฐ€์šด๋ฐ = ํ‰๋ฉด ์ง€๋„(SINMAP ๋“ฑ๊ธ‰ ยท ์•ˆ์ „์œจ) ยท ๋ฒ”๋ก€ + * ์šฐ = ์š”์•ฝ(๋“ฑ๊ธ‰๋ณ„ ๋ฉด์  ยท ์œ„ํ—˜ ๋ฉด์ ) ยท ์ธก์ ๋ณ„ ์œ„ํ—˜ ํ‘œ(๋ˆ„๋ฆ„ = ์ง€๋„ ๊ทธ ์ธก์ ) + * ๊ณ„์‚ฐ = ์„œ๋ฒ„ ๋‹จ๋…(POST โ€ฆ/stab/area/analyze ยท ๋’ค์—์„œ) ยท ํ™”๋ฉด์€ ๊ฐ’ ยท ๊ทธ๋ฆผ๋งŒ. ์กฐ๊ฑด = sessionStorage. + * ========================================================================== */ + +import "./E07_StabArea_UI_Style.css"; +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, + type InputFieldHandle, +} from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { createSidePanels } from "@ui/ui_template_side_panels"; +import { mountGroupStep } from "../A00_Common/group_workflow"; +import { createAreaMap, type MapLayer, type StationPoint } from "./E07_StabArea_UI_Map"; +import { + CLASS_COLORS, + CLASS_KEYS, + FS_STEPS, + RISK_CLASS, + decode, + type AreaResult, +} from "./logic/E07_StabArea_Raster"; + +interface Material { + id: string; + name: string; + gamma_t: number; + gamma_sat: number; + c: number; + phi: number; +} + +type Settings = Record; + +interface Overview { + editable: boolean; + defaults: Settings; + materials: Material[]; + line: number[][]; + stations: StationPoint[]; + result: AreaResult | null; +} + +interface Progress { + running: boolean; + percent: number; + message: string | null; + error: string | null; +} + +const NUMBER_FIELDS: [string, LocaleKey, "soil" | "rain" | "band"][] = [ + ["gamma_t", "E07_GammaT", "soil"], + ["gamma_sat", "E07_GammaSat", "soil"], + ["c", "E07_C", "soil"], + ["c_hi", "E07_CHi", "soil"], + ["phi", "E07_Phi", "soil"], + ["phi_hi", "E07_PhiHi", "soil"], + ["c_root", "E07_CRoot", "soil"], + ["soil_depth_m", "E07_Depth", "soil"], + ["rain_mm_h", "E07_Rain", "rain"], + ["k_m_s", "E07_K", "rain"], + ["band_m", "E07_Band", "band"], + ["cell_m", "E07_Cell", "band"], +]; + +const cacheKey = (projectId: string): string => `e07_settings:${projectId}`; +const shortLabel = (label: string | null, ch: number): string => + (label ?? `${ch.toFixed(1)} m`).replace(/^STA\./, "").replace(/\.0+$/, ""); + +async function api(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...init, + }); + const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown }; + if (!response.ok) { + const detail = body.detail; + const text = Array.isArray(detail) + ? detail.map((d) => (d as { msg?: string }).msg ?? "").join(" ยท ") + : detail; + throw new Error(typeof text === "string" && text ? text : `HTTP ${response.status}`); + } + return body; +} + +function swatch(color: string): HTMLElement { + const chip = el("i", { className: "e07-chip" }); + chip.style.background = color; + return chip; +} + +function legend(layer: MapLayer): HTMLElement { + const box = el("div", { className: "e07-legend" }); + box.append(el("strong", { text: t(layer === "cls" ? "E07_Legend_Class" : "E07_Legend_FS") })); + const rows = + layer === "cls" + ? CLASS_KEYS.map((k, i) => ({ + color: CLASS_COLORS[i], + label: t(`E07_Class_${k}` as LocaleKey), + })) + : FS_STEPS.map((s, i) => ({ + color: s.color, + label: + i === 0 + ? `< ${s.below.toFixed(1)}` + : s.below === Infinity + ? `โ‰ฅ ${FS_STEPS[i - 1].below.toFixed(1)}` + : `${FS_STEPS[i - 1].below.toFixed(1)} โ€“ ${s.below.toFixed(1)}`, + })); + for (const row of rows) { + const item = el("span", { className: "e07-legend__row" }); + item.append(swatch(row.color), el("span", { text: row.label })); + box.append(item); + } + return box; +} + +export function renderE07StabArea(root: HTMLElement): void { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const panel = el("div", { className: "ui-sidebar-panel e07-panel" }); + const main = el("div", { className: "e07-page" }); + mountGroupStep(root, ROUTES.E07_STAB_AREA, { mainContent: main, leftPanel: panel }); + if (!projectId) + return void main.append(el("p", { className: "e07-empty", text: t("E07_NoProject") })); + + // ---- ๊ฐ€์šด๋ฐ ---- + const map = createAreaMap(); + let layer: MapLayer = "cls"; + const legendSlot = el("div", { className: "e07-legend-slot" }); + const paintLegend = (): void => legendSlot.replaceChildren(legend(layer)); + const toggle = el("div", { className: "e07-toggle" }); + const layerButtons = ( + [ + ["E07_Layer_Class", "cls"], + ["E07_Layer_FS", "fs"], + ] as [LocaleKey, MapLayer][] + ).map(([key, value], i) => { + const button = createButton({ label: t(key), variant: "ghost", size: "sm" }); + button.dataset.value = value; + button.classList.toggle("is-on", i === 0); + button.addEventListener("click", () => { + layerButtons.forEach((b) => b.classList.toggle("is-on", b === button)); + layer = value; + map.setLayer(layer); + paintLegend(); + }); + return button; + }); + toggle.append(...layerButtons); + const stage = el("div", { className: "e07-stage" }); + stage.append(map.root, legendSlot); + const center = el("div", { className: "e07-center" }); + center.append(toggle, stage); + paintLegend(); + + // ---- ์šฐ ---- + const resultBody = el("div", { className: "e07-result" }); + const side = createSidePanels({ + prefKey: "e07-stab-area-result", + panels: [{ id: "result", title: t("E07_Result_Title"), body: resultBody }], + defaultOpen: "result", + defaultWidth: 380, + follow: false, + }); + const row = el("div", { className: "ui-side-panels-row e07-row" }); + row.append(center, side.root); + main.append(row); + + // ---- ์ขŒ ---- + const fields = new Map(); + const cards = { + soil: createPanelCard(t("E07_Card_Soil"), false), + rain: createPanelCard(t("E07_Card_Rain"), false), + band: createPanelCard(t("E07_Card_Band"), false), + }; + const material = createSelectField({ label: t("E07_Material"), options: [], compact: true }); + const scenario = createSelectField({ + label: t("E07_Scenario"), + options: [ + { value: "saturated", text: t("E07_Scenario_saturated") }, + { value: "rain", text: t("E07_Scenario_rain") }, + ], + compact: true, + }); + cards.soil.append(material.root); + cards.rain.append(scenario.root); + for (const [key, label, card] of NUMBER_FIELDS) { + const field = createInputField({ label: t(label), type: "number", compact: true }); + field.input.step = "any"; + field.input.dataset.key = key; + fields.set(key, field); + cards[card].append(field.root); + } + const progressBar = el("progress", { className: "e07-progress", attrs: { max: "100" } }); + const progressText = el("p", { className: "e07-progress__text" }); + const progressBox = el("div", { className: "e07-progress-box" }); + progressBox.append(progressBar, progressText); + progressBox.hidden = true; + const defaultsButton = createButton({ + label: t("E07_Defaults"), + variant: "ghost", + onClick: () => overview && write(overview.defaults), + }); + const analyzeButton = createButton({ + label: t("E07_Analyze"), + variant: "filled", + onClick: () => void analyze(false), + }); + const reanalyzeButton = createButton({ + label: t("E07_Reanalyze"), + variant: "ghost", + onClick: () => void analyze(true), + }); + const actions = el("div", { className: "ui-sidebar-actions" }); + actions.append(defaultsButton, reanalyzeButton, analyzeButton); + const readOnly = el("p", { className: "e07-hint", text: t("E07_ReadOnly") }); + readOnly.hidden = true; + panel.append( + el("p", { className: "e07-hint", text: t("E07_Hint") }), + readOnly, + cards.soil, + cards.rain, + cards.band, + progressBox, + actions, + ); + attachCollapsible(panel); + + // ---- ์ƒํƒœ ---- + let overview: Overview | null = null; + let running = false; + + function read(): Settings { + const out: Settings = { material_id: material.select.value || null }; + out.scenario = scenario.select.value; + for (const [key, field] of fields) out[key] = Number(field.input.value); + return out; + } + + function write(values: Settings): void { + for (const [key, field] of fields) field.input.value = String(values[key] ?? ""); + material.select.value = String(values.material_id ?? ""); + scenario.select.value = String(values.scenario ?? "saturated"); + paintScenario(); + remember(); + } + + function paintScenario(): void { + const rain = scenario.select.value === "rain"; + for (const key of ["rain_mm_h", "k_m_s"]) fields.get(key)!.root.hidden = !rain; + map.setBand(Number(fields.get("band_m")!.input.value) || 100); + } + + const remember = (): void => sessionStorage.setItem(cacheKey(projectId), JSON.stringify(read())); + panel.addEventListener("input", remember); + scenario.select.addEventListener("change", () => { + paintScenario(); + remember(); + }); + fields.get("band_m")!.input.addEventListener("input", paintScenario); + material.select.addEventListener("change", () => { + const m = overview?.materials.find((x) => x.id === material.select.value); + if (!m) return; + const set = (key: string, v: number) => (fields.get(key)!.input.value = String(v)); + const rangeC = Number(overview!.defaults.c_hi) - Number(overview!.defaults.c); + const rangePhi = Number(overview!.defaults.phi_hi) - Number(overview!.defaults.phi); + set("gamma_t", m.gamma_t); + set("gamma_sat", m.gamma_sat); + set("c", m.c); + set("c_hi", m.c + rangeC); + set("phi", m.phi); + set("phi_hi", m.phi + rangePhi); + remember(); + }); + + function paintButtons(): void { + const editable = Boolean(overview?.editable); + analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview; + readOnly.hidden = !overview || editable; + } + + function paintResult(): void { + const result = overview?.result; + if (!result) { + resultBody.replaceChildren(el("p", { className: "e07-empty", text: t("E07_Result_None") })); + return; + } + const s = result.summary; + const list = el("dl", { className: "e07-summary" }); + const pairs: [LocaleKey, string][] = [ + ["E07_Sum_FsMin", s.fs_min === null ? "-" : s.fs_min.toFixed(3)], + ["E07_Sum_Risk", `${Math.round(s.risk_area_m2).toLocaleString()} mยฒ`], + ["E07_Sum_RiskStations", `${s.risk_stations.length} / ${s.stations}`], + [ + "E07_Sum_Covered", + `${Math.round(s.covered_area_m2).toLocaleString()} / ${Math.round(s.band_area_m2).toLocaleString()} mยฒ`, + ], + [ + "E07_Sum_Time", + `${result.elapsed_s.toFixed(1)} s ยท ${result.computed_at.replace("T", " ")}`, + ], + ]; + for (const [key, value] of pairs) + list.append( + el("dt", { text: t(key) }), + el("dd", { text: value, attrs: { "data-field": key } }), + ); + const areas = el("table", { className: "e07-table e07-areas" }); + const total = Object.values(s.areas_m2).reduce((a, b) => a + b, 0) || 1; + areas.append( + el("tr", { + children: [ + el("th", { text: t("E07_Col_Class") }), + el("th", { text: t("E07_Col_Area") }), + el("th", { text: "%" }), + ], + }), + ...CLASS_KEYS.map((k, i) => { + const name = el("td"); + name.append( + swatch(CLASS_COLORS[i]), + el("span", { text: t(`E07_Class_${k}` as LocaleKey) }), + ); + return el("tr", { + attrs: { "data-class": k }, + children: [ + name, + el("td", { text: Math.round(s.areas_m2[k] ?? 0).toLocaleString() }), + el("td", { text: (((s.areas_m2[k] ?? 0) / total) * 100).toFixed(1) }), + ], + }); + }), + ); + const table = el("table", { className: "e07-table e07-stations" }); + table.append( + el("tr", { + children: [ + el("th", { text: t("E07_Col_Station") }), + el("th", { text: t("E07_Col_FsMin") }), + el("th", { text: t("E07_Col_SiMin") }), + el("th", { text: t("E07_Col_Worst") }), + el("th", { text: t("E07_Col_RiskArea") }), + ], + }), + ...result.stations.map((r) => { + const worst = el("td"); + if (r.worst_class) + worst.append( + swatch(CLASS_COLORS[r.worst_class - 1]), + el("span", { text: t(`E07_Class_${CLASS_KEYS[r.worst_class - 1]}` as LocaleKey) }), + ); + const tr = el("tr", { + className: (r.worst_class ?? 0) >= RISK_CLASS ? "is-risk" : "", + attrs: { "data-chainage": String(r.chainage_m) }, + children: [ + el("td", { text: shortLabel(r.label, r.chainage_m) }), + el("td", { text: r.fs_min === undefined ? "-" : r.fs_min.toFixed(2) }), + el("td", { text: r.si_min === undefined ? "-" : r.si_min.toFixed(2) }), + worst, + el("td", { + text: + r.risk_area_m2 === undefined ? "-" : Math.round(r.risk_area_m2).toLocaleString(), + }), + ], + }); + tr.addEventListener("click", () => { + table.querySelectorAll("tr.is-on").forEach((x) => x.classList.remove("is-on")); + tr.classList.add("is-on"); + map.focus(r.chainage_m); + }); + return tr; + }), + ); + const notes: HTMLElement[] = []; + if (result.stale) notes.push(el("p", { className: "e07-warn", text: t("E07_Result_Stale") })); + notes.push(el("p", { className: "e07-note", text: t("E07_Result_Note") })); + resultBody.replaceChildren( + list, + el("h4", { text: t("E07_Areas_Title") }), + areas, + el("h4", { text: t("E07_Stations_Title") }), + table, + ...notes, + ); + } + + async function refresh(): Promise { + overview = await api(`/projects/${projectId}/stab/area`); + material.setOptions( + overview.materials.map((m) => ({ value: m.id, text: m.name })), + String(overview.defaults.material_id ?? ""), + ); + map.setResult(overview.result ? decode(overview.result) : null); + paintButtons(); + paintResult(); + } + + async function poll(): Promise { + while (root.isConnected) { + const p = await api(`/projects/${projectId}/stab/area/progress`).catch(() => null); + if (!p) break; + running = p.running; + progressBox.hidden = !p.running; + progressBar.value = p.percent; + progressText.textContent = `${t("E07_Running")} ${p.percent}% ยท ${p.message ?? ""}`; + paintButtons(); + if (!p.running) { + if (p.error) showToast(p.error, "error"); + else showToast(t("E07_Done"), "success"); + await refresh(); + return; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + + async function analyze(force: boolean): Promise { + try { + const body = await api<{ status: string }>(`/projects/${projectId}/stab/area/analyze`, { + method: "POST", + body: JSON.stringify({ ...read(), force }), + }); + if (body.status === "cached") { + showToast(t("E07_Cached"), "info"); + await refresh(); + return; + } + running = true; + paintButtons(); + progressBox.hidden = false; + void poll(); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), "error"); + } + } + + // ---- ์ฒ˜์Œ ---- + paintButtons(); + void (async () => { + try { + await refresh(); + } catch (error) { + main.replaceChildren( + el("p", { + className: "e07-empty", + text: error instanceof Error ? error.message : String(error), + }), + ); + return; + } + const ov = overview!; + const cached = JSON.parse( + sessionStorage.getItem(cacheKey(projectId)) ?? "null", + ) as Settings | null; + write(cached ?? (ov.result?.settings as Settings | undefined) ?? ov.defaults); + map.setRoute(ov.line, ov.stations); + const p = await api(`/projects/${projectId}/stab/area/progress`).catch(() => null); + if (p?.running) void poll(); + })(); + + const leave = new MutationObserver(() => { + if (root.isConnected && main.isConnected) return; + leave.disconnect(); + map.dispose(); + }); + leave.observe(document.body, { childList: true, subtree: true }); +} diff --git a/E07_StabArea/E07_StabArea_UI_Style.css b/E07_StabArea/E07_StabArea_UI_Style.css new file mode 100644 index 000000000..ee82afbc2 --- /dev/null +++ b/E07_StabArea/E07_StabArea_UI_Style.css @@ -0,0 +1,231 @@ +/* ============================================================================= + * E07_StabArea_UI_Style.css + * E07 ๊ด‘์—ญ ์œ„ํ—˜ ์ง€๋„ โ€” ์ขŒ์ธก ์กฐ๊ฑด ยท ์ง„ํ–‰ / ๊ฐ€์šด๋ฐ ์ง€๋„ ยท ๋ฒ”๋ก€ / ์šฐ์ธก ์š”์•ฝ ยท ์ธก์  ํ‘œ + * ์ƒ‰์€ ํ…Œ๋งˆ ํ† ํฐ๋งŒ(๋ฐ์Œ ยท ์–ด๋‘์›€ ๊ฐ™์ด) ยท ๋“ฑ๊ธ‰ ยท ์•ˆ์ „์œจ ์ƒ‰๋งŒ logic ๊ณ ์ •์ƒ‰ + * ========================================================================== */ + +.e07-page { + height: 100dvh; + box-sizing: border-box; +} + +.e07-row { + height: 100%; +} + +.e07-center { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + height: 100%; + min-height: 0; + padding: var(--spacing-12) 0 var(--spacing-12) var(--spacing-12); + box-sizing: border-box; +} + +.e07-toggle { + display: inline-flex; + gap: var(--spacing-4); +} + +.e07-toggle .ui-btn.is-on { + border-color: var(--color-primary); + background: var(--color-primary); + color: var(--color-primary-text); +} + +.e07-stage { + position: relative; + flex: 1 1 auto; + min-height: 320px; + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + overflow: hidden; +} + +.e07-map { + position: absolute; + inset: 0; +} + +.e07-map__canvas { + display: block; + width: 100%; + height: 100%; + touch-action: none; + cursor: grab; +} + +.e07-map__tip { + position: absolute; + padding: var(--spacing-4) var(--spacing-8); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-border); + font-size: 12px; + white-space: pre; + pointer-events: none; +} + +.e07-map__empty { + position: absolute; + inset: auto 0 45% 0; + margin: 0; + text-align: center; + color: var(--color-text-muted); + pointer-events: none; +} + +.e07-legend-slot { + position: absolute; + left: var(--spacing-8); + bottom: var(--spacing-8); + pointer-events: none; +} + +.e07-legend { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--spacing-8); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-border); + font-size: 12px; +} + +.e07-legend__row { + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +.e07-chip { + display: inline-block; + width: 14px; + height: 10px; + flex: none; + margin-right: var(--spacing-4); + border-radius: 2px; + border: 1px solid var(--color-border); + vertical-align: middle; +} + +/* ์ขŒ์ธก */ +.e07-hint, +.e07-note { + margin: 0 0 var(--spacing-8); + color: var(--color-text-muted); + font-size: 12px; + line-height: 1.5; +} + +.e07-panel .ui-field[hidden] { + display: none; +} + +.e07-progress-box { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin: var(--spacing-8) 0; +} + +.e07-progress-box[hidden] { + display: none; +} + +.e07-progress { + width: 100%; +} + +.e07-progress__text { + margin: 0; + color: var(--color-text); + font-size: 12px; +} + +/* ์šฐ์ธก */ +.e07-result { + padding: var(--spacing-12); + color: var(--color-text); +} + +.e07-result h4 { + margin: var(--spacing-16) 0 var(--spacing-8); + font-size: 13px; +} + +.e07-summary { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--spacing-8) var(--spacing-12); + margin: 0; + font-size: 13px; +} + +.e07-summary dt { + color: var(--color-text-muted); +} + +.e07-summary dd { + margin: 0; + font-weight: 600; + text-align: right; +} + +.e07-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.e07-table th, +.e07-table td { + padding: var(--spacing-4) var(--spacing-8); + border-bottom: 1px solid var(--color-border); + text-align: right; + white-space: nowrap; +} + +.e07-table th:first-child, +.e07-table td:first-child, +.e07-stations td:nth-child(4) { + text-align: left; +} + +.e07-table th { + color: var(--color-text-muted); + font-weight: 600; +} + +.e07-stations tr[data-chainage] { + cursor: pointer; +} + +.e07-stations tr[data-chainage]:hover { + background: var(--color-surface-raised); +} + +.e07-stations tr.is-risk td { + color: var(--color-danger); + font-weight: 600; +} + +.e07-stations tr.is-on { + outline: 2px solid var(--color-primary); + outline-offset: -2px; +} + +.e07-warn { + margin: var(--spacing-12) 0 0; + color: var(--color-danger); + font-size: 12px; +} + +.e07-empty { + margin: var(--spacing-16); + color: var(--color-text-muted); +} diff --git a/E07_StabArea/draft/E07_StabArea_Route.ts b/E07_StabArea/draft/E07_StabArea_Route.ts new file mode 100644 index 000000000..08b2db164 --- /dev/null +++ b/E07_StabArea/draft/E07_StabArea_Route.ts @@ -0,0 +1,16 @@ +/* ============================================================================= + * E07_StabArea_Route.ts + * ๊ธธ ์„ ์–ธ(PLAN 536-2) โ€” ๋ผ์šฐํ„ฐ๊ฐ€ ํŽ˜์ด์ง€ ํด๋”๋งˆ๋‹ค draft/*_Route.ts ๋ฅผ glob ํ•ด ๋“ฑ๋ก. + * ๋กœ๊ทธ์ธ ยท ํšŒ์‚ฌ ยท ํ˜„์žฌ ํ”„๋กœ์ ํŠธ ํ•„์š” ยท ๊ผฌ๋ฆฌ๋ง ์—†์Œ ยท ๋ณธ๋ฌธ = E07 ๊ด‘์—ญ ์œ„ํ—˜ ์ง€๋„ ํŽ˜์ด์ง€ + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.E07_STAB_AREA, + workflow: true, + project: true, + footerless: true, + load: async () => (await import("../E07_StabArea_UI_Page")).renderE07StabArea, +}; diff --git a/E07_StabArea/logic/E07_StabArea_Raster.ts b/E07_StabArea/logic/E07_StabArea_Raster.ts new file mode 100644 index 000000000..a9a00b8e7 --- /dev/null +++ b/E07_StabArea/logic/E07_StabArea_Raster.ts @@ -0,0 +1,153 @@ +/* ============================================================================= + * E07_StabArea_Raster.ts + * E07 ๊ด‘์—ญ ์œ„ํ—˜ ์ง€๋„ ํ™”๋ฉด ๊ณ„์‚ฐ(PLAN 536-2) โ€” ์ €์žฅ๋ณธ ๊ฒฉ์ž ํ’€๊ธฐ ยท ๋“ฑ๊ธ‰ ยท ์•ˆ์ „์œจ ์ƒ‰ ยท RGBA ยท ์…€ ์ฐพ๊ธฐ. + * ๊ฒฉ์ž ๊ผด = ์„œ๋ฒ„ `E07_StabArea_Router.py` ๋จธ๋ฆฌ ์ฃผ์„(fs ยท si uint16 ร—1000 ยท cls uint8 ยท slope ร—10 ยท + * m ร—100 ยท ์ž‘์€ ๋ base64 ยท ํ–‰ 0 = ๋‚จ์ชฝ). ์ˆœ์ˆ˜ ํ•จ์ˆ˜๋งŒ(DOM ยท fetch ๊ธˆ์ง€). + * ========================================================================== */ + +export interface Grid { + x0: number; + y0: number; + dx: number; + nx: number; + ny: number; +} + +export interface StationRow { + chainage_m: number; + label: string | null; + cells: number; + fs_min?: number; + si_min?: number; + worst_class?: number; + risk_area_m2?: number; + slope_max_deg?: number; +} + +export interface AreaSummary { + areas_m2: Record; + band_area_m2: number; + covered_area_m2: number; + risk_area_m2: number; + fs_min: number | null; + risk_stations: number[]; + stations: number; +} + +export interface AreaResult { + stale?: boolean; + computed_at: string; + elapsed_s: number; + settings: Record; + grid: Grid; + fs: string; + si: string; + cls: string; + slope: string; + m: string; + stations: StationRow[]; + summary: AreaSummary; +} + +/** ํ’€์–ด ๋‘” ๊ฒฉ์ž โ€” ์ž๋ฃŒ ์—†์Œ = NaN(fs ยท si ยท slope ยท m) ยท 0(cls) */ +export interface Decoded { + grid: Grid; + fs: Float32Array; + si: Float32Array; + cls: Uint8Array; + slope: Float32Array; + m: Float32Array; +} + +/** ๋“ฑ๊ธ‰ ์—ด์‡ (์„œ๋ฒ„ CLASS_KEYS ์ฐจ๋ก€ ยท 1~6) */ +export const CLASS_KEYS = ["stable", "moderate", "quasi", "lower", "upper", "defended"] as const; +export const RISK_CLASS = 4; + +/** ๋“ฑ๊ธ‰ ์ƒ‰ 1~6 โ€” SINMAP ๊ด€๋ก€(์ดˆ๋ก โ†’ ๋นจ๊ฐ•) */ +export const CLASS_COLORS = ["#1a9850", "#91cf60", "#d9ef8b", "#fee08b", "#fc8d59", "#d73027"]; + +/** ์•ˆ์ „์œจ ์ƒ‰ ๋‹จ๊ณ„ โ€” 1.0 ยท 1.2 ยท 1.5 ๊ฒฝ๊ณ„ */ +export const FS_STEPS: readonly { below: number; color: string }[] = [ + { below: 1.0, color: "#a50026" }, + { below: 1.2, color: "#f46d43" }, + { below: 1.5, color: "#fee08b" }, + { below: 2.0, color: "#a6d96a" }, + { below: Infinity, color: "#1a9850" }, +]; + +function bytes(text: string): Uint8Array { + const raw = atob(text); + const out = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i += 1) out[i] = raw.charCodeAt(i); + return out; +} + +function u16(text: string, scale: number): Float32Array { + const b = bytes(text); + const view = new DataView(b.buffer); + const out = new Float32Array(b.length / 2); + for (let i = 0; i < out.length; i += 1) { + const v = view.getUint16(i * 2, true); + out[i] = v === 65535 ? NaN : v / scale; + } + return out; +} + +export function decode(result: AreaResult): Decoded { + const m = bytes(result.m); + return { + grid: result.grid, + fs: u16(result.fs, 1000), + si: u16(result.si, 1000), + cls: bytes(result.cls), + slope: u16(result.slope, 10), + m: Float32Array.from(m, (v) => (v === 255 ? NaN : v / 100)), + }; +} + +function rgb(hex: string): [number, number, number] { + const n = Number.parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +export function fsColor(value: number): string { + return (FS_STEPS.find((s) => value < s.below) ?? FS_STEPS[FS_STEPS.length - 1]).color; +} + +/** ๊ฒฉ์ž โ†’ RGBA(์ค„ 0 = ๋ถ์ชฝ ๋ ยท ์บ”๋ฒ„์Šค ๊ทธ๋ฆผ์šฉ) ยท ์ž๋ฃŒ ์—†์Œ = ํˆฌ๋ช… */ +export function rasterRgba(data: Decoded, layer: "cls" | "fs"): Uint8ClampedArray { + const { nx, ny } = data.grid; + const out = new Uint8ClampedArray(nx * ny * 4); + const classRgb = CLASS_COLORS.map(rgb); + const fsRgb = FS_STEPS.map((s) => rgb(s.color)); + for (let iy = 0; iy < ny; iy += 1) { + const row = ny - 1 - iy; + for (let ix = 0; ix < nx; ix += 1) { + const i = iy * nx + ix; + const k = data.cls[i]; + if (!k) continue; + const j = FS_STEPS.findIndex((s) => data.fs[i] < s.below); + const color = layer === "cls" ? classRgb[k - 1] : fsRgb[j < 0 ? fsRgb.length - 1 : j]; + const at = (row * nx + ix) * 4; + out[at] = color[0]; + out[at + 1] = color[1]; + out[at + 2] = color[2]; + out[at + 3] = 255; + } + } + return out; +} + +/** ๋ชจ๋ธ ์ขŒํ‘œ โ†’ ์…€ ๋ฒˆํ˜ธ(๊ฒฉ์ž ๋ฐ– = -1) */ +export function cellIndex(grid: Grid, x: number, y: number): number { + const ix = Math.round((x - grid.x0) / grid.dx); + const iy = Math.round((y - grid.y0) / grid.dx); + return ix < 0 || iy < 0 || ix >= grid.nx || iy >= grid.ny ? -1 : iy * grid.nx + ix; +} + +/** ๋“ฑ๊ธ‰๋ณ„ ์…€ ์ˆ˜(1~6) โ€” ๋ฒ”๋ก€ ยท ๊ฒ€์ฆ์šฉ */ +export function classCounts(data: Decoded): number[] { + const out = [0, 0, 0, 0, 0, 0]; + for (const k of data.cls) if (k) out[k - 1] += 1; + return out; +} diff --git a/common_util/common_util_project_kind.py b/common_util/common_util_project_kind.py index d4d4e997d..2ebca164c 100644 --- a/common_util/common_util_project_kind.py +++ b/common_util/common_util_project_kind.py @@ -20,7 +20,14 @@ from config.config_db import get_db_pool KIND_STAGE_DIRS = { "PLAN": ("D02_PlanBase", "D03_PlanAnalysis", "D04_PlanConfirm"), - "STABILITY": ("E02_StabGround", "E03_StabSection", "E04_Stab3D", "E05_StabReport"), + "STABILITY": ( + "E02_StabGround", + "E03_StabSection", + "E04_Stab3D", + "E05_StabReport", + "E06_StabRock", + "E07_StabArea", + ), } diff --git a/common_util/common_util_workflow_state.py b/common_util/common_util_workflow_state.py index 3c0c4c7d5..b6bb4f882 100644 --- a/common_util/common_util_workflow_state.py +++ b/common_util/common_util_workflow_state.py @@ -20,11 +20,11 @@ STAGE_KEYS = [ ] -# ์ข…๋ฅ˜๋ณ„ ๋‹จ๊ณ„(์š”๊ตฌ 345) โ€” ROAD = B03~B09 ยท PLAN = D02~D04 ยท STABILITY = E02~E05. +# ์ข…๋ฅ˜๋ณ„ ๋‹จ๊ณ„(์š”๊ตฌ 345) โ€” ROAD = B03~B09 ยท PLAN = D02~D04 ยท STABILITY = E02~E06. STAGE_KEYS_BY_KIND = { "ROAD": STAGE_KEYS, "PLAN": ["PLAN_BASE", "PLAN_ANALYSIS", "PLAN_CONFIRM"], - "STABILITY": ["STAB_GROUND", "STAB_SECTION", "STAB_3D", "STAB_REPORT"], + "STABILITY": ["STAB_GROUND", "STAB_SECTION", "STAB_3D", "STAB_REPORT", "STAB_ROCK"], } diff --git a/config/config_frontend.ts b/config/config_frontend.ts index 9d295ec1c..e256ad5e0 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -130,6 +130,8 @@ export const ROUTES = { E03_STAB_SECTION: "e03-stab-section", E04_STAB_3D: "e04-stab-3d", E05_STAB_REPORT: "e05-stab-report", + E06_STAB_ROCK: "e06-stab-rock", + E07_STAB_AREA: "e07-stab-area", } as const; /** D ์˜ˆ์ •๋…ธ์„  ๋‹จ๊ณ„ ์ฃผ์†Œ(D02 โ†’ D04 ยท ๋“ฑ๋ก D01 ์€ ๋‹จ๊ณ„ ๋ฐ–) */ @@ -139,12 +141,14 @@ export const PLAN_STEP_ROUTES = [ ROUTES.D04_PLAN_CONFIRM, ] as const; -/** E ์•ˆ์ •์„ฑํ‰๊ฐ€ ๋‹จ๊ณ„ ์ฃผ์†Œ(E02 โ†’ E05 ยท ์ƒ์„ฑ E01 ์€ ๋‹จ๊ณ„ ๋ฐ–) */ +/** E ์•ˆ์ •์„ฑํ‰๊ฐ€ ๋‹จ๊ณ„ ์ฃผ์†Œ(E02 โ†’ E06 ยท ๊ด‘์—ญ E07 ยท ์ƒ์„ฑ E01 ์€ ๋‹จ๊ณ„ ๋ฐ–) */ export const STAB_STEP_ROUTES = [ ROUTES.E02_STAB_GROUND, ROUTES.E03_STAB_SECTION, ROUTES.E04_STAB_3D, ROUTES.E05_STAB_REPORT, + ROUTES.E06_STAB_ROCK, + ROUTES.E07_STAB_AREA, ] as const; export type RouteKey = keyof typeof ROUTES; diff --git a/config/config_system.py b/config/config_system.py index 2fa29bb33..4c49991d3 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -220,6 +220,8 @@ PROJECT_STORAGE_STAGE_DIRS = frozenset( "E03_StabSection", "E04_Stab3D", "E05_StabReport", + "E06_StabRock", + "E07_StabArea", } ) diff --git a/docs/raw/์š”๊ตฌ_1025/์‚ฌ์šฉ์ž์š”๊ตฌ_1025.md b/docs/raw/์š”๊ตฌ_1025/์‚ฌ์šฉ์ž์š”๊ตฌ_1025.md index b1976b7da..63c5bca75 100644 --- a/docs/raw/์š”๊ตฌ_1025/์‚ฌ์šฉ์ž์š”๊ตฌ_1025.md +++ b/docs/raw/์š”๊ตฌ_1025/์‚ฌ์šฉ์ž์š”๊ตฌ_1025.md @@ -141,3 +141,24 @@ PDF ๋ฌด๋ฃŒ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์ค‘์— ์‚ฌ์šฉ ๊ฐ€๋Šฅ ํ•œ๊ฒƒ์ค‘์— ํ’ˆ์งˆ์ด ์ข‹์€ ๋„ˆ ๋ธŒ๋ผ์šฐ์ ธ๋ฅผ ๋ฐ˜๋ณต์ ์œผ๋กœ ๋„์šฐ๊ณ  ์žˆ๋‹ค๊ณ  ๋ฒŒ์จ 2๋ฒˆ์งธ์•ผ + +## ์š”๊ตฌ 354 (10-25 ยท ์‚ฌ์šฉ์ž) + +์˜ค๋А์นด ๋ธŒ๋ผ์šฐ์ ธ๋Š” ๋ฒŒ์จ ๊ธฐ๋Šฅ์ด์žˆ๊ฑฐ๋‚˜ ์Šคํฌ๋ฆฝํŠธ๋กœ ์šด์šฉํ•˜๋Š”๊ฒŒ ์ข‹์•„๋ณด์ž„ + +## ์š”๊ตฌ 355 (10-25 ยท ์‚ฌ์šฉ์ž) + +ํด๋กœ๋“œ 5์‹œ๊ฐ„ ํ† ํฐ ์‚ฌ์šฉ ์ œํ•œ์— ๊ฑธ๋ฆด๊ฒƒ์œผ๋กœ ์˜ˆ์ƒ๋จ. ํ† ํฐ ํ’€๋ฆฐ ํ›„ ๋ฐ”๋กœ ๋„ˆ๋ฅผ ๊นจ์›Œ์ฃผ๋Š” ์Šคํฌ๋ฆฝํŠธ๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ. + +1์‹œ๊ฐ„์— ํ•œ๋ฒˆ์ด๋ฉด ๋จ + +## ์š”๊ตฌ 356 (10-25 ยท ์‚ฌ์šฉ์ž) + +ํ† ํฌ ํ•œ๋„ ๋ฆฌ๋ฐ‹ ๊ด€๋ จ ์Šคํฌ๋ฆฝํŠธ ์‹คํ–‰์€ ๋‚ด๊ฐ€ on/off๋Š” ๋‚ด๊ฐ€ ์ง€์‹œํ•˜๊ฒ ์Œ. ํ˜„์žฌ๋Š” o. ๋ฉ”๋ชจ์—์„œ ์‚ญ์ œ. + +## ์š”๊ตฌ 357 (10-25 ยท ์‚ฌ์šฉ์ž) + +on ์ด ๋งž์Œ. ๊ทธ๋Ÿฐ๋ฐ shell command๊ฐ€ ์•„๋‹˜? +์‰˜๋กœ ๋ฐ”๊ฟ”์ค˜ ๋ญ๊ฐ€ ์‹คํ–‰์ค‘์ธ์ง€ ์˜ค๋ฅด์นด ide์— ํ‘œ์‹œ๋˜๋Š”๊ฒŒ ์ข‹๊ฒ ์–ด + +๊ทธ๋ง์ด ์•„๋‹ˆ๋ผ ์ง€๊ธˆ ๋„ˆ์™€์˜ ๋Œ€ํ™”์ฐฝ์—๋Š” ๊ฐ์‹œ ์Šคํฌ๋ฆฝํŠธ๊ฐ€ ๋Œ๋ฉด wait for worker reports ๋ผ๊ณ  shell command ํ•ญ๋ชฉ์ด ๋ณด์—ฌ ์ด๊ฑธ๋กœ ์‹คํ–‰์—ฌ๋ถ€๋ฅผ ํŒ๋‹จํ•˜๊ณ  ์‹ถ๋‹ค๊ณ  diff --git a/main.py b/main.py index f9d6e072d..f36ca04af 100644 --- a/main.py +++ b/main.py @@ -83,7 +83,9 @@ from E01_StabRegister.E01_StabRegister_Router import router as e01_stab_register from E02_StabGround.E02_StabGround_Router import router as e02_stab_ground_router from E04_Stab3D.E04_Stab3D_Router import router as e04_stab_3d_router from E05_StabReport.E05_StabReport_Router import router as e05_stab_report_router +from E07_StabArea.E07_StabArea_Router import router as e07_stab_area_router from E03_StabSection.E03_StabSection_Router import router as e03_stab_section_router +from E06_StabRock.E06_StabRock_Router import router as e06_stab_rock_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router @@ -493,7 +495,9 @@ app.include_router(d04_plan_link_router, dependencies=protected_with_company) app.include_router(e02_stab_ground_router, dependencies=protected_with_company) app.include_router(e04_stab_3d_router, dependencies=protected_with_company) app.include_router(e05_stab_report_router, dependencies=protected_with_company) +app.include_router(e07_stab_area_router, dependencies=protected_with_company) app.include_router(e03_stab_section_router, dependencies=protected_with_company) +app.include_router(e06_stab_rock_router, dependencies=protected_with_company) app.include_router(b05_corridor_router, dependencies=protected_with_company) app.include_router(b05_route_replan_router, dependencies=protected_with_company) app.include_router(b05_route_terrain_router, dependencies=protected_with_company) diff --git a/resources/master_template/report/stab_report.json b/resources/master_template/report/stab_report.json index 9ded03dad..42a350b7e 100644 --- a/resources/master_template/report/stab_report.json +++ b/resources/master_template/report/stab_report.json @@ -2600,6 +2600,484 @@ } } ] + }, + { + "id": "s7", + "์ด๋ฆ„": "๊ด‘์—ญ์š”์•ฝ", + "์นธ": { + "A1": { + "๊ฐ’": "๊ด‘์—ญ ์ž์—ฐ์‚ฌ๋ฉด ์œ„ํ—˜ ์š”์•ฝ", + "์„œ์‹": 1 + }, + "B1": { + "์„œ์‹": 1 + }, + "C1": { + "์„œ์‹": 1 + }, + "D1": { + "์„œ์‹": 1 + }, + "E1": { + "์„œ์‹": 1 + }, + "F1": { + "์„œ์‹": 1 + }, + "G1": { + "์„œ์‹": 1 + }, + "H1": { + "์„œ์‹": 1 + }, + "A2": { + "๊ฐ’": "๋…ธ์„  ๋  ๋ฌดํ•œ์‚ฌ๋ฉด ยท SINMAP ์•ˆ์ • ์ง€์ˆ˜ โ€” ๋…ธ์„  ์œ„ ยท ์•„๋ž˜ ์ž์—ฐ์‚ฌ๋ฉด ํ‘œ์ธต ๋ถ•๊ดด ์œ„ํ—˜ ยท ๋ฌธํ—Œ ์ถ”์ •์น˜", + "์„œ์‹": 2 + }, + "B2": { + "์„œ์‹": 2 + }, + "C2": { + "์„œ์‹": 2 + }, + "D2": { + "์„œ์‹": 2 + }, + "E2": { + "์„œ์‹": 2 + }, + "F2": { + "์„œ์‹": 2 + }, + "G2": { + "์„œ์‹": 2 + }, + "H2": { + "์„œ์‹": 2 + }, + "A4": { + "๊ฐ’": "1. ํ•ด์„ ์กฐ๊ฑด", + "์„œ์‹": 3 + }, + "B4": { + "์„œ์‹": 3 + }, + "C4": { + "์„œ์‹": 3 + }, + "D4": { + "์„œ์‹": 3 + }, + "E4": { + "์„œ์‹": 3 + }, + "F4": { + "์„œ์‹": 3 + }, + "G4": { + "์„œ์‹": 3 + }, + "H4": { + "์„œ์‹": 3 + }, + "A5": { + "๊ฐ’": "๋…ธ์„  ๋ ", + "์„œ์‹": 4 + }, + "B5": { + "์„œ์‹": 5 + }, + "C5": { + "์„œ์‹": 5 + }, + "D5": { + "์„œ์‹": 5 + }, + "E5": { + "์„œ์‹": 5 + }, + "F5": { + "์„œ์‹": 5 + }, + "G5": { + "์„œ์‹": 5 + }, + "H5": { + "์„œ์‹": 5 + }, + "A6": { + "๊ฐ’": "ํ† ์ธต ๋ฌผ์„ฑ", + "์„œ์‹": 4 + }, + "B6": { + "์„œ์‹": 5 + }, + "C6": { + "์„œ์‹": 5 + }, + "D6": { + "์„œ์‹": 5 + }, + "E6": { + "์„œ์‹": 5 + }, + "F6": { + "์„œ์‹": 5 + }, + "G6": { + "์„œ์‹": 5 + }, + "H6": { + "์„œ์‹": 5 + }, + "A7": { + "๊ฐ’": "๊ฐ•์šฐ ยท ํฌํ™”", + "์„œ์‹": 4 + }, + "B7": { + "์„œ์‹": 5 + }, + "C7": { + "์„œ์‹": 5 + }, + "D7": { + "์„œ์‹": 5 + }, + "E7": { + "์„œ์‹": 5 + }, + "F7": { + "์„œ์‹": 5 + }, + "G7": { + "์„œ์‹": 5 + }, + "H7": { + "์„œ์‹": 5 + }, + "A8": { + "๊ฐ’": "ํ•ด์„ ๋•Œ", + "์„œ์‹": 4 + }, + "B8": { + "์„œ์‹": 5 + }, + "C8": { + "์„œ์‹": 5 + }, + "D8": { + "์„œ์‹": 5 + }, + "E8": { + "์„œ์‹": 5 + }, + "F8": { + "์„œ์‹": 5 + }, + "G8": { + "์„œ์‹": 5 + }, + "H8": { + "์„œ์‹": 5 + }, + "A10": { + "๊ฐ’": "2. ๋“ฑ๊ธ‰๋ณ„ ๋ฉด์ ", + "์„œ์‹": 3 + }, + "B10": { + "์„œ์‹": 3 + }, + "C10": { + "์„œ์‹": 3 + }, + "D10": { + "์„œ์‹": 3 + }, + "E10": { + "์„œ์‹": 3 + }, + "F10": { + "์„œ์‹": 3 + }, + "G10": { + "์„œ์‹": 3 + }, + "H10": { + "์„œ์‹": 3 + }, + "A11": { + "๊ฐ’": "๋“ฑ๊ธ‰", + "์„œ์‹": 6 + }, + "B11": { + "๊ฐ’": "์•ˆ์ • ์ง€์ˆ˜ SI", + "์„œ์‹": 6 + }, + "C11": { + "๊ฐ’": "๋ฉด์  (mยฒ)", + "์„œ์‹": 6 + }, + "D11": { + "๊ฐ’": "๋น„์œจ (%)", + "์„œ์‹": 6 + }, + "A12": { + "๊ฐ’": "์•ˆ์ •", + "์„œ์‹": 7 + }, + "B12": { + "๊ฐ’": "SI โ‰ฅ 1.5", + "์„œ์‹": 7 + }, + "C12": { + "์„œ์‹": 14 + }, + "D12": { + "์‹": "IF(SUM(C$12:C$17)>0,C12/SUM(C$12:C$17)*100,\"\")", + "์„œ์‹": 16 + }, + "A13": { + "๊ฐ’": "๋ณดํ†ต ์•ˆ์ •", + "์„œ์‹": 7 + }, + "B13": { + "๊ฐ’": "1.25 โ‰ค SI < 1.5", + "์„œ์‹": 7 + }, + "C13": { + "์„œ์‹": 14 + }, + "D13": { + "์‹": "IF(SUM(C$12:C$17)>0,C13/SUM(C$12:C$17)*100,\"\")", + "์„œ์‹": 16 + }, + "A14": { + "๊ฐ’": "์ค€์•ˆ์ •", + "์„œ์‹": 7 + }, + "B14": { + "๊ฐ’": "1.0 โ‰ค SI < 1.25", + "์„œ์‹": 7 + }, + "C14": { + "์„œ์‹": 14 + }, + "D14": { + "์‹": "IF(SUM(C$12:C$17)>0,C14/SUM(C$12:C$17)*100,\"\")", + "์„œ์‹": 16 + }, + "A15": { + "๊ฐ’": "์œ„ํ—˜ ํ•˜ํ•œ", + "์„œ์‹": 7 + }, + "B15": { + "๊ฐ’": "0.5 โ‰ค SI < 1.0", + "์„œ์‹": 7 + }, + "C15": { + "์„œ์‹": 14 + }, + "D15": { + "์‹": "IF(SUM(C$12:C$17)>0,C15/SUM(C$12:C$17)*100,\"\")", + "์„œ์‹": 16 + }, + "A16": { + "๊ฐ’": "์œ„ํ—˜ ์ƒํ•œ", + "์„œ์‹": 7 + }, + "B16": { + "๊ฐ’": "0 < SI < 0.5", + "์„œ์‹": 7 + }, + "C16": { + "์„œ์‹": 14 + }, + "D16": { + "์‹": "IF(SUM(C$12:C$17)>0,C16/SUM(C$12:C$17)*100,\"\")", + "์„œ์‹": 16 + }, + "A17": { + "๊ฐ’": "๋ถˆ์•ˆ์ •", + "์„œ์‹": 7 + }, + "B17": { + "๊ฐ’": "SI = 0", + "์„œ์‹": 7 + }, + "C17": { + "์„œ์‹": 14 + }, + "D17": { + "์‹": "IF(SUM(C$12:C$17)>0,C17/SUM(C$12:C$17)*100,\"\")", + "์„œ์‹": 16 + }, + "A18": { + "๊ฐ’": "ํ•ฉ๊ณ„", + "์„œ์‹": 4 + }, + "B18": { + "์„œ์‹": 4 + }, + "C18": { + "์‹": "SUM(C12:C17)", + "์„œ์‹": 14 + }, + "D18": { + "์„œ์‹": 16 + }, + "A20": { + "๊ฐ’": "3. ์œ„ํ—˜ ์ธก์  (SI < 1 ์…€์ด ์žˆ๋Š” ์ธก์ )", + "์„œ์‹": 3 + }, + "B20": { + "์„œ์‹": 3 + }, + "C20": { + "์„œ์‹": 3 + }, + "D20": { + "์„œ์‹": 3 + }, + "E20": { + "์„œ์‹": 3 + }, + "F20": { + "์„œ์‹": 3 + }, + "G20": { + "์„œ์‹": 3 + }, + "H20": { + "์„œ์‹": 3 + }, + "A21": { + "๊ฐ’": "์ธก์ ", + "์„œ์‹": 6 + }, + "B21": { + "๊ฐ’": "๋ˆ„๊ฐ€๊ฑฐ๋ฆฌ (m)", + "์„œ์‹": 6 + }, + "C21": { + "๊ฐ’": "์ตœ์†Œ Fs", + "์„œ์‹": 6 + }, + "D21": { + "๊ฐ’": "์ตœ์†Œ SI", + "์„œ์‹": 6 + }, + "E21": { + "๊ฐ’": "๊ฐ€์žฅ ๋‚˜์œ ๋“ฑ๊ธ‰", + "์„œ์‹": 6 + }, + "F21": { + "๊ฐ’": "์œ„ํ—˜ ๋ฉด์  (mยฒ)", + "์„œ์‹": 6 + }, + "G21": { + "๊ฐ’": "์ตœ๋Œ€ ๊ฒฝ์‚ฌ (ยฐ)", + "์„œ์‹": 6 + }, + "A22": { + "์„œ์‹": 7 + }, + "B22": { + "์„œ์‹": 17 + }, + "C22": { + "์„œ์‹": 8 + }, + "D22": { + "์„œ์‹": 8 + }, + "E22": { + "์„œ์‹": 7 + }, + "F22": { + "์„œ์‹": 14 + }, + "G22": { + "์„œ์‹": 16 + } + }, + "๋ณ‘ํ•ฉ": [ + "A1:H1", + "A2:H2", + "A4:H4", + "B5:H5", + "B6:H6", + "B7:H7", + "B8:H8", + "A10:H10", + "A20:H20" + ], + "์—ด": { + "A": { + "ํญ": 14 + }, + "B": { + "ํญ": 15 + }, + "C": { + "ํญ": 11 + }, + "D": { + "ํญ": 11 + }, + "E": { + "ํญ": 13 + }, + "F": { + "ํญ": 13 + }, + "G": { + "ํญ": 12 + }, + "H": { + "ํญ": 6 + } + }, + "ํ–‰": { + "1": { + "๋†’์ด": 36 + }, + "2": { + "๋†’์ด": 20 + }, + "4": { + "๋†’์ด": 22 + }, + "10": { + "๋†’์ด": 22 + }, + "20": { + "๋†’์ด": 22 + }, + "21": { + "๋†’์ด": 32 + } + }, + "๋ณด๊ธฐ": { + "๋ˆˆ๊ธˆ์„ ": false + }, + "์ธ์‡„": { + "์šฉ์ง€": "A4", + "๋ฐฉํ–ฅ": "portrait", + "๋งž์ถค": { + "ํญ": 1, + "๋†’์ด": 0 + }, + "์—ฌ๋ฐฑ": { + "์œ„": 15, + "์•„๋ž˜": 15, + "์™ผ": 10, + "์˜ค๋ฅธ": 10 + }, + "๋จธ๋ฆฌํ–‰": [ + 21, + 21 + ] + } } ], "ํ™œ์„ฑ": "s1" diff --git a/resources/master_template/report/stab_report_slots.md b/resources/master_template/report/stab_report_slots.md index 3fb11f4c7..b9bafb828 100644 --- a/resources/master_template/report/stab_report_slots.md +++ b/resources/master_template/report/stab_report_slots.md @@ -79,3 +79,13 @@ | 20 ํ–‰๋ถ€ํ„ฐ ๊ตฌ๊ฐ„ ํ•˜๋‚˜ = ํ•œ ์ค„ | A ๊ตฌ๊ฐ„ ยท B ์‹œ์ž‘ ์ธก์  ยท C ๋ ์ธก์ (์„œ์‹ 7) ยท D 3D ์ตœ์†Œ Fs ยท E 2D ์ตœ์†Œ Fs(์„œ์‹ 8) ยท **F 3D/2D ๋น„ = ์ˆ˜์‹ `IF(E{r}>0,D{r}/E{r},"")`**(์„œ์‹ 8) ยท G ์ž„๊ณ„ ์ฒด์  mยณ(์„œ์‹ 14) ยท H ํŒ์ •(์„œ์‹ 7 โ†’ N.G 10 ยท O.K 11) | - 20 ํ–‰ ์„œ์‹์„ ์ค„๋งˆ๋‹ค ๋ณต์‚ฌ. + +## s7 ๊ด‘์—ญ์š”์•ฝ (ํ”„๋กœ๊ทธ๋žจ ๋ชซ ยท ๋จธ๋ฆฌ ์ค„ 21 ํ–‰ ๋ฐ˜๋ณต ยท E07 ๊ด‘์—ญ ์ €์žฅ๋ณธ ยท ์—†์œผ๋ฉด `B5` ์— ใ€Œ๊ด‘์—ญ ํ•ด์„ ์ „ใ€) + +| ์นธ | ๋‚ด์šฉ | +| --- | --- | +| `B5` `B6` `B7` `B8` (๊ฐ B:H ๋ณ‘ํ•ฉ ยท ์„œ์‹ 5) | ๋…ธ์„  ๋  ยท ํ† ์ธต ๋ฌผ์„ฑ ยท ๊ฐ•์šฐ ยท ํฌํ™” ยท ํ•ด์„ ๋•Œ ๊ธ€ | +| `C12`~`C17` | ๋“ฑ๊ธ‰ 1~6(์•ˆ์ • ยท ๋ณดํ†ต ์•ˆ์ • ยท ์ค€์•ˆ์ • ยท ์œ„ํ—˜ ํ•˜ํ•œ ยท ์œ„ํ—˜ ์ƒํ•œ ยท ๋ถˆ์•ˆ์ •) ๋ฉด์  mยฒ(์„œ์‹ 14) ยท `D` ๋น„์œจ = ์–‘์‹ ์ˆ˜์‹ ยท `C18` ํ•ฉ๊ณ„ = ์–‘์‹ ์ˆ˜์‹ | +| 22 ํ–‰๋ถ€ํ„ฐ ์œ„ํ—˜ ์ธก์  ํ•˜๋‚˜ = ํ•œ ์ค„ | A ์ธก์ (์„œ์‹ 7) ยท B ๋ˆ„๊ฐ€๊ฑฐ๋ฆฌ(17) ยท C ์ตœ์†Œ Fs ยท D ์ตœ์†Œ SI(8) ยท E ๊ฐ€์žฅ ๋‚˜์œ ๋“ฑ๊ธ‰ ๊ธ€(7) ยท F ์œ„ํ—˜ ๋ฉด์  mยฒ(14) ยท G ์ตœ๋Œ€ ๊ฒฝ์‚ฌ ยฐ(16) ยท ์—†์œผ๋ฉด `A22` ใ€Œ์œ„ํ—˜ ์ธก์  ์—†์Œใ€ | + +- 22 ํ–‰ ์„œ์‹์„ ์ค„๋งˆ๋‹ค ๋ณต์‚ฌ. ์œ„ํ—˜ ์ธก์  = ๋งก์€ ๋  ์…€์— SI < 1(๋“ฑ๊ธ‰ 4 ์ด์ƒ)์ด ์žˆ๋Š” ์ธก์ . diff --git a/resources/tester/test_528_project_groups.py b/resources/tester/test_528_project_groups.py index e63553e50..aedfd6fe7 100644 --- a/resources/tester/test_528_project_groups.py +++ b/resources/tester/test_528_project_groups.py @@ -165,7 +165,7 @@ def test_e01_requires_b06(monkeypatch): call() assert e.value.status_code == 404 seen, call = _e01(monkeypatch, [{**src, "project_year": 2026, "section_complete": 1}]) - assert call() == {"project_id": "stab-id", "source_fingerprint": "f" * 40} + assert call() == {"project_id": "stab-id", "source_fingerprint": "f" * 40, "warnings": []} assert seen["copied"] == ("stab-id", "s" * 36) assert seen["kind"] == "STABILITY" and seen["source_project_id"] == "s" * 36 assert seen["name"] == "์ž„๋„ ์•ˆ์ •์„ฑํ‰๊ฐ€" @@ -257,7 +257,7 @@ def test_register_ignores_sent_company(): assert "company_name" not in data -@pytest.mark.parametrize("kind,count", [("ROAD", 7), ("PLAN", 3), ("STABILITY", 4)]) +@pytest.mark.parametrize("kind,count", [("ROAD", 7), ("PLAN", 3), ("STABILITY", 5)]) def test_stage_seed_by_kind(kind, count): log = [] asyncio.run(initialize_project_stages(FakeCursor({}, log), "p1", kind)) diff --git a/resources/tester/test_533_3_e04_engine.py b/resources/tester/test_533_3_e04_engine.py index 9e3497618..011197fd3 100644 --- a/resources/tester/test_533_3_e04_engine.py +++ b/resources/tester/test_533_3_e04_engine.py @@ -83,16 +83,6 @@ def root(tmp_path, monkeypatch): z = _ground(yy) np.savez(models / "dtm_classification.npz", x=xs, y=ys, z=z, valid_mask=np.ones_like(z, bool)) monkeypatch.setattr(run.source, "cross_section", lambda r, ch: _cross(float(ch))) - monkeypatch.setattr( - run.source, - "db_snapshot", - lambda r: { - "route_points": [ - {"model_x": ch, "model_y": 0.0, "chainage_m": ch, "sequence_num": i} - for i, ch in enumerate(CHAINAGES) - ] - }, - ) return tmp_path @@ -178,3 +168,17 @@ def test_hooks_do_not_change_search(): with pytest.raises(InterruptedError): search(strata, SearchParams(**base, should_stop=lambda: True)) assert math.isfinite(plain.best["governing"]) + + +def test_node_mask_section_and_route_ends(root): + """๋…ธ๋“œ ์ž๋ฆฌ = ๊ตฌ๊ฐ„ ๋ˆ„๊ฐ€ ์•ˆ ยท ํƒ์ƒ‰ ํญ ์•ˆ ยท ๋…ธ์„  ์‹œ์  ์•ž(x < 0) ยท ์ข…์  ๋’ค(x > 100)๋Š” ๋บŒ.""" + x = np.arange(-30.0, 131.0, 1.0) + y = np.arange(-20.0, 21.0, 1.0) + grid = {"x": x, "y": y} + mask = run._node_mask(STATIONS, grid, 0.0, 100.0, 12.0) + xx, yy = np.meshgrid(x, y) + assert not mask[xx < -0.5].any() and not mask[xx > 100.5].any() + assert not mask[np.abs(yy) > 12.0].any() + 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() diff --git a/resources/tester/test_533_3_e04_logic.py b/resources/tester/test_533_3_e04_logic.py index 380ba706a..b26ff6e6a 100644 --- a/resources/tester/test_533_3_e04_logic.py +++ b/resources/tester/test_533_3_e04_logic.py @@ -60,6 +60,8 @@ writeFileSync(process.argv[3], JSON.stringify({ sphere: mesh(M.slipMesh(c.grid, c.z, c.sphere)), ellipsoid: mesh(M.slipMesh(c.grid, c.z, c.ellipsoid)), cut: Array.from(M.cutCells(c.grid, c.z, c.sphere).keys()), + center: M.footprintCenter(c.grid, M.cutCells(c.grid, c.z, c.sphere), c.sphere), + centerEmpty: M.footprintCenter(c.grid, new Map(), c.sphere), floored: Object.fromEntries(M.cutCells(c.grid, c.z, { ...c.sphere, floor: [[7 * 21 + 10, 53.0], [7 * 21 + 11, 60.0]] }).entries()), holed: mesh(M.terrainWithout(c.grid, c.z, M.cutCells(c.grid, c.z, c.sphere))), @@ -180,6 +182,15 @@ def test_slip_mesh_vertices(ts, name): assert all(z < Z[i] for i, z in cut.items()) +def test_footprint_center(ts): + """์ง€๋„ ์ตœ์†Œ ํ‘œ์‹œ = ์ž˜๋ฆฐ ์…€ ์ค‘์‹ฌ ํ‰๊ท  ยท ์ž˜๋ฆฐ ์…€ ์—†์œผ๋ฉด ๊ตฌ ์ค‘์‹ฌ.""" + cut = [i for i, _ in _py_cut(SPHERE)] + x = sum(GRID["x0"] + i % GRID["nx"] for i in cut) / len(cut) + y = sum(GRID["y0"] + i // GRID["nx"] for i in cut) / len(cut) + assert ts["center"] == pytest.approx({"x": x, "y": y}) + assert ts["centerEmpty"] == {"x": SPHERE["x"], "y": SPHERE["y"]} + + def test_floor_truncates_slip(ts): """๋ชป ๋šซ๋Š” ์ธต โ€” ํ™œ๋™๋ฉด์ด ์ธต ์œ—๋ฉด ์•„๋ž˜๋กœ ์•ˆ ๋‚ด๋ ค๊ฐ ยท ์ธต์ด ์ง€ํ‘œ๋ณด๋‹ค ๋†’์œผ๋ฉด ์ž˜๋ฆฐ ์…€ ์•„๋‹˜.""" center = 7 * 21 + 10 diff --git a/resources/tester/test_533_3_e04_router.py b/resources/tester/test_533_3_e04_router.py index aac24b02b..0e7793b5d 100644 --- a/resources/tester/test_533_3_e04_router.py +++ b/resources/tester/test_533_3_e04_router.py @@ -57,21 +57,6 @@ def project(tmp_path, monkeypatch): z = 500.0 + 0.4 * (yy - 200.0) + 0.002 * (xx - 200.0) ** 2 # ๋…ธ์„  ์˜† ๋น„ํƒˆ np.savez(models / "dtm_classification.npz", x=xs, y=ys, z=z, valid_mask=np.ones_like(z, bool)) monkeypatch.setattr(run.source, "stations", lambda root: STATIONS) - monkeypatch.setattr( - run.source, - "db_snapshot", - lambda root: { - "route_points": [ - { - "model_x": s["center_x"], - "model_y": 200.0, - "chainage_m": s["chainage_m"], - "sequence_num": i, - } - for i, s in enumerate(STATIONS) - ] - }, - ) async def fake_root(project_id): return tmp_path diff --git a/resources/tester/test_534_3_e05_report.py b/resources/tester/test_534_3_e05_report.py index e4dfc66f6..9d34d49fe 100644 --- a/resources/tester/test_534_3_e05_report.py +++ b/resources/tester/test_534_3_e05_report.py @@ -1,8 +1,8 @@ """534-3(๋นŒ๋” ยท storage ยท ๊ธธ) โ€” E05 ์•ˆ์ •์„ฑํ‰๊ฐ€ ๋ณด๊ณ ์„œ `E05_StabReport_Build` ยท `_Store` ยท `_Router`. -๊ฐ€์งœ E ํ”„๋กœ์ ํŠธ(2D ์š”์•ฝ ยท ์ธก์ ๋ณ„ ์ €์žฅ๋ณธ ยท ๋Œ€ํ‘œ ๋‹จ๋ฉด ยท ์ง€๋ฐ˜ ์กฐ๊ฑด ยท 3D ์ €์žฅ๋ณธ ํ•˜๋‚˜ ยท -๋ณต์‚ฌ๋ณธ ์ฝ๊ธฐ๋Š” ๋ฐ”๊ฟ” ๋ผ์›€)๋กœ ์ธก์  ์ˆ˜ = ์ค„ ์ˆ˜ ยท N.G ์ˆ˜ ยท ๊ทธ๋ฆผ ํŒŒ์ผ ์ˆ˜ ยท ๊ฐฑ์‹  ๋’ค ์‚ฌ์šฉ์ž ๋ชซ ๋ถˆ๋ณ€ ยท -๋ฎ์ž„ ๋ชฉ๋ก ยท ์ดˆ๊ธฐํ™” ยท ํŒ 409 ยท xlsx ๋˜์ฝ๊ธฐ ยท PDF ์ชฝ ์ˆ˜. +๊ฐ€์งœ E ํ”„๋กœ์ ํŠธ(E03 ํŒŒ์ƒ ์ €์žฅ๋ณธ ๊ผด 2D ์š”์•ฝ ยท ์ธก์ ๋ณ„ ์ €์žฅ๋ณธ ยท ๋Œ€ํ‘œ ๋‹จ๋ฉด ์ผฌ/๋” ยท ์ง€๋ฐ˜ ์กฐ๊ฑด ยท +3D ์ €์žฅ๋ณธ ํ•˜๋‚˜ ยท ๋ณต์‚ฌ๋ณธ ์ฝ๊ธฐ๋Š” ๋ฐ”๊ฟ” ๋ผ์›€)๋กœ ์ธก์  ์ˆ˜ = ์ค„ ์ˆ˜ ยท N.G ์ˆ˜ ยท ๊ทธ๋ฆผ ํŒŒ์ผ ์ˆ˜ ยท +๊ฐฑ์‹  ๋’ค ์‚ฌ์šฉ์ž ๋ชซ ๋ถˆ๋ณ€ ยท ๋ฎ์ž„ ๋ชฉ๋ก ยท ์ดˆ๊ธฐํ™” ยท ํŒ 409 ยท xlsx ๋˜์ฝ๊ธฐ ยท PDF ์ชฝ ์ˆ˜. """ import io @@ -13,6 +13,7 @@ from pathlib import Path import pytest from openpyxl import load_workbook +from common_util.common_util_derived_store import derived_dir, write_derived from common_util.common_util_print_pdf import PrintError from common_util.common_util_spreadsheet import workbook_pdf from common_util.common_util_workbook_xlsx import workbook_to_xlsx @@ -24,7 +25,11 @@ from E05_StabReport import E05_StabReport_Store as store pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node ๊ฐ€ ์—†์Œ") N = 30 # ์ธก์  ์ˆ˜ NG_AT = {3, 10, 17, 24} # N.G ์ธก์  -REPS = [("grid:3", "lowest_fs"), ("grid:12", "cut_15m"), ("grid:20", "fill_10m")] +REPS = [("grid:3", "lowest"), ("grid:12", "cut_height"), ("grid:20", "fill_height")] +# ์ž๋™ ๋Œ€ํ‘œ(๋†’์ด ยท ํ•˜์œ„ 5) ์ค‘ REPS ๋งŒ ๋‚จ๊ธฐ๋Š” ์‚ฌ์šฉ์ž ๋” +OFF = {"grid:0": False, "grid:5": False, "grid:10": False, "grid:17": False, "grid:24": False} +KIND_SUMMARY, KIND_STATION = "stab2d_summary", "stab2d_station" +REQUIRED = {"dry": {"cut": 1.5, "fill": 1.5}, "wet": {"cut": 1.2, "fill": 1.3}} def _key(i: int) -> str: @@ -32,17 +37,49 @@ def _key(i: int) -> str: def _summary(i: int, bump: float = 0.0) -> dict: - ng = i in NG_AT + """E03 ์š”์•ฝ ์ค„ ๊ผด โ€” ์ง€๋ฐฐ ๋น„ํƒˆ(ํ™€์ˆ˜ = ๊นŽ๊ธฐ ์™ผ์ชฝ ยท ์ง์ˆ˜ = ์Œ“๊ธฐ ์˜ค๋ฅธ์ชฝ)๋งŒ ๋‚ฎ์€ ์•ˆ์ „์œจ.""" + ng = i in NG_AT and not bump dry = 1.31 if ng else 1.7 + i / 100 + bump - return { - "station_key": _key(i), - "chainage_m": 20.0 * i, - "side": "cut" if i % 2 else "fill", - "fs_min": dry - 0.2, - "fs_by_condition": {"dry": dry, "wet": dry - 0.2}, - "method": "bishop", - "verdict": "ng" if ng else "ok", + role = "cut" if i % 2 else "fill" + side = "left" if role == "cut" else "right" + other = "right" if side == "left" else "left" + heights = {"cut": 15.0 if i == 12 else 6.0, "fill": 10.0 if i == 20 else 6.0} + fs = {"dry": dry, "wet": dry - 0.2} + cases = { + cond: { + side: {"fs": fs[cond], "required": REQUIRED[cond][role]}, + other: {"fs": 9.0, "required": REQUIRED[cond]["fill" if role == "cut" else "cut"]}, + } + for cond in fs } + return { + "key": _key(i), + "chainage_m": 20.0 * i, + "label": f"No.{i}", + "fp": f"st{i}", + "sides": [ + {"side": "left", "role": "cut", "height_m": heights["cut"]}, + {"side": "right", "role": "fill", "height_m": heights["fill"]}, + ], + "cases": cases, + "min": {c: min(v["fs"] for v in cases[c].values()) for c in cases}, + "verdict": { + c: "ok" if all(v["fs"] >= v["required"] for v in cases[c].values()) else "ng" + for c in cases + }, + "error": None, + } + + +def _write_summary(root: Path, fp: str, rows: list[dict]) -> None: + value = { + "fingerprint": fp, + "computed_at": "2026-10-25T10:00:00", + "conditions": ["dry", "wet"], + "method": "bishop", + "stations": rows, + } + write_derived(root, KIND_SUMMARY, fp, value) def _slip(F: float) -> dict: @@ -54,7 +91,7 @@ def _station_doc(i: int) -> dict: s = _summary(i) cases = [] for cond in ("dry", "wet"): - F = s["fs_by_condition"][cond] + F = s["min"][cond] for side, role in (("left", "cut"), ("right", "fill")): cases.append( { @@ -94,32 +131,13 @@ def _cross(chainage: float) -> dict: @pytest.fixture def project(tmp_path, monkeypatch) -> Path: root = tmp_path / "e_project" - d2 = root / "E03_StabSection" / "_derived" - (d2 / "results").mkdir(parents=True) - (d2 / "result2d.json").write_text( - json.dumps( - { - "input_fingerprint": "fp-1", - "computed_at": "2026-10-25T10:00:00", - "stations": [_summary(i) for i in range(N)], - } - ), - encoding="utf-8", - ) + _write_summary(root, "fp-1", [_summary(i) for i in range(N)]) for key, _ in REPS: i = int(key.split(":")[1]) - (d2 / "results" / f"grid_{i}.json").write_text( - json.dumps(_station_doc(i)), encoding="utf-8" - ) + write_derived(root, KIND_STATION, f"st{i}", {"out": _station_doc(i), "error": None}, keep=N) + (root / "E03_StabSection").mkdir() (root / "E03_StabSection" / "representative.json").write_text( - json.dumps( - { - "rule": {"fill_h_m": 10, "cut_h_m": 15, "lowest_n": 5}, - "stations": [{"station_key": k, "reason": r, "enabled": True} for k, r in REPS] - + [{"station_key": _key(5), "reason": "user", "enabled": False}], - } - ), - encoding="utf-8", + json.dumps({"overrides": OFF}), encoding="utf-8" ) ground = ground_model.pick(ground_model.load_defaults()) ground["stations"] = [ @@ -139,7 +157,15 @@ def project(tmp_path, monkeypatch) -> Path: { "fingerprint": "3d-1", "settings": {}, - "summary": {"start_m": 0.0, "end_m": 200.0, "f_min": 1.42, "volume": 830.0}, + "summary": { + "start_m": 0.0, + "end_m": 200.0, + "f_min": 1.42, + "volume": 830.0, + "condition": "wet", + "reference_fs": 1.3, + "verdict": "ok", + }, } ), encoding="utf-8", @@ -197,12 +223,85 @@ def test_์ฒ˜์Œ_๋งŒ๋“ฆ_์ธก์ ์ˆ˜_NG_๊ทธ๋ฆผ(project): assert s3["A26"]["๊ฐ’"] == "ํ’ํ™”ํ† " and s3["B32"]["๊ฐ’"].startswith("0 ~ 100 m ์œ„ ์ง€์ธต") assert s3["A32"]["๊ฐ’"] == "๋ถ•์ ํ† " and s3["B13"]["๊ฐ’"] == 1.3 s6 = by["3D์š”์•ฝ"]["์นธ"] - assert s6["D20"]["๊ฐ’"] == 1.42 and s6["H20"] == {"๊ฐ’": "N.G", "์„œ์‹": build.ST_NG} - assert s6["E20"]["๊ฐ’"] == pytest.approx(1.11) # 0~200 m ์•ˆ 2D ์ตœ์†Œ + assert s6["D20"]["๊ฐ’"] == 1.42 and s6["H20"] == {"๊ฐ’": "O.K", "์„œ์‹": build.ST_OK} # E04 ํŒ์ • + assert s6["E20"]["๊ฐ’"] == pytest.approx(1.11) # 0~200 m ์•ˆ 2D ์šฐ๊ธฐ ์ตœ์†Œ(3D ์™€ ๊ฐ™์€ ์กฐ๊ฑด) assert (build.initial_dir(project) / build.REPORT).is_file() assert saved["fingerprint"] == build.analysis_fingerprint(project) +def test_๊ด‘์—ญ์š”์•ฝ_E07_์ €์žฅ๋ณธ(project): + """E07 ์ €์žฅ๋ณธ ์—†์Œ = ใ€Œ๊ด‘์—ญ ํ•ด์„ ์ „ใ€ ยท ์ƒ๊น€ = ์ง€๋ฌธ ๋ฐ”๋€œ โ†’ ๊ฐฑ์‹  ๋’ค ๋“ฑ๊ธ‰ ๋ฉด์  ยท ์œ„ํ—˜ ์ธก์  ์ค„.""" + _create(project) + book, meta = store.load(project) + assert _sheets(book)["๊ด‘์—ญ์š”์•ฝ"]["์นธ"]["B5"]["๊ฐ’"].startswith("๊ด‘์—ญ ํ•ด์„ ์ „") + keys = ("stable", "moderate", "quasi", "lower", "upper", "defended") + area = { + "settings": { + "band_m": 100.0, + "cell_m": 2.0, + "gamma_t": 17.5, + "gamma_sat": 18.5, + "c": 5.0, + "c_hi": 10.0, + "phi": 27.0, + "phi_hi": 30.0, + "c_root": 0.0, + "soil_depth_m": 1.5, + "scenario": "saturated", + "rain_mm_h": 30.0, + "k_m_s": 1e-4, + }, + "computed_at": "2026-10-25T21:00:00", + "stations": [ + { + "chainage_m": 0.0, + "cells": 9, + "fs_min": 1.6, + "si_min": 1.6, + "worst_class": 1, + "risk_area_m2": 0.0, + "slope_max_deg": 12.0, + "label": "No.0", + }, + { + "chainage_m": 20.0, + "cells": 9, + "fs_min": 0.8, + "si_min": 0.0, + "worst_class": 6, + "risk_area_m2": 36.0, + "slope_max_deg": 41.5, + "label": "No.1", + }, + { + "chainage_m": 40.0, + "cells": 9, + "fs_min": 0.95, + "si_min": 0.7, + "worst_class": 4, + "risk_area_m2": 8.0, + "slope_max_deg": 33.0, + "label": "No.2", + }, + ], + "summary": {"areas_m2": dict(zip(keys, (100.0, 20.0, 12.0, 8.0, 0.0, 36.0)))}, + } + path = project / "E07_StabArea" / "_derived" / "area.json" + path.parent.mkdir(parents=True) + path.write_text(json.dumps(area, ensure_ascii=False), encoding="utf-8") + assert build.analysis_fingerprint(project) != meta["fingerprint"] + store.refresh(project) + book, _ = store.load(project) + s7 = _sheets(book)["๊ด‘์—ญ์š”์•ฝ"]["์นธ"] + assert [s7[f"C{r}"]["๊ฐ’"] for r in range(12, 18)] == [100.0, 20.0, 12.0, 8.0, 0.0, 36.0] + assert [s7[f"A{r}"]["๊ฐ’"] for r in (22, 23)] == ["No.1", "No.2"] and "A24" not in s7 + assert ( + s7["E22"]["๊ฐ’"] == "๋ถˆ์•ˆ์ •" and s7["F23"]["๊ฐ’"] == 8.0 and s7["B7"]["๊ฐ’"].startswith("์ง€ํ‘œ") + ) + vals = book["๊ณ„์‚ฐ๊ฐ’"]["s7"] + assert vals["C18"] == {"์ˆ˜": "176"} and vals["D17"]["์ˆ˜"].startswith("20.45") + + def test_๊ฐฑ์‹ _์‚ฌ์šฉ์ž๋ชซ_๋ถˆ๋ณ€_๋ฎ์ž„(project): _create(project) book, _ = store.load(project) @@ -216,11 +315,9 @@ def test_๊ฐฑ์‹ _์‚ฌ์šฉ์ž๋ชซ_๋ถˆ๋ณ€_๋ฎ์ž„(project): by = _sheets(book) by["์ธก์ ํ‘œ"]["์นธ"]["A6"]["๊ฐ’"] = "๊ณ ์นœ ์ธก์ " store.save(project, book) - r2 = project / build.RESULT2D - doc = json.loads(r2.read_text(encoding="utf-8")) - doc["stations"][3] = _summary(4, 0.5) | {"station_key": _key(3), "chainage_m": 60.0} - doc["input_fingerprint"] = "fp-2" - r2.write_text(json.dumps(doc), encoding="utf-8") + rows = [_summary(i) for i in range(N)] + rows[3] = _summary(3, 0.5) + _write_summary(project, "fp-2", rows) assert build.analysis_fingerprint(project) != store.load(project)[1]["fingerprint"] try: out = store.refresh(project) @@ -251,7 +348,7 @@ def test_3D_์—†์Œ_2D_์—†์Œ(project): by = _sheets(book) assert by["3D์š”์•ฝ"]["์นธ"]["A20"]["๊ฐ’"] == "3D ํ•ด์„ ์ „" assert by["์ž…๋ ฅ์กฐ๊ฑด"]["์นธ"]["B7"]["๊ฐ’"] == "3D ํ•ด์„ ์ „" - (project / build.RESULT2D).unlink() + shutil.rmtree(derived_dir(project, KIND_SUMMARY)) with pytest.raises(build.MissingInput): build.gather(project) @@ -262,7 +359,7 @@ def test_xlsx_๋˜์ฝ๊ธฐ_pdf_์ชฝ์ˆ˜(project): figs = store.figures_of(project, book) assert len(figs) == len(REPS) + 1 wb = load_workbook(io.BytesIO(workbook_to_xlsx(book, figs))) - assert wb.sheetnames == ["ํ‘œ์ง€", "๊ฐœ์š”", "์ž…๋ ฅ์กฐ๊ฑด", "์ธก์ ํ‘œ", "๋Œ€ํ‘œ๋‹จ๋ฉด", "3D์š”์•ฝ"] + assert wb.sheetnames == ["ํ‘œ์ง€", "๊ฐœ์š”", "์ž…๋ ฅ์กฐ๊ฑด", "์ธก์ ํ‘œ", "๋Œ€ํ‘œ๋‹จ๋ฉด", "3D์š”์•ฝ", "๊ด‘์—ญ์š”์•ฝ"] assert len(wb["๋Œ€ํ‘œ๋‹จ๋ฉด"]._images) == len(REPS) and len(wb["3D์š”์•ฝ"]._images) == 1 assert [b.id for b in wb["๋Œ€ํ‘œ๋‹จ๋ฉด"].row_breaks.brk] == [28, 56, 84] assert wb["์ธก์ ํ‘œ"]["J9"].value == "N.G" and wb["์ธก์ ํ‘œ"]["J9"].fill.fgColor.rgb.endswith( @@ -278,8 +375,8 @@ def test_xlsx_๋˜์ฝ๊ธฐ_pdf_์ชฝ์ˆ˜(project): texts = [p.extract_text() for p in PdfReader(io.BytesIO(pdf)).pages] stab = [i for i, t in enumerate(texts) if "์‚ฐ์ • ์•ˆ์ „์œจ" in t] assert stab and all("์‚ฐ์ • ์•ˆ์ „์œจ" in texts[i] for i in stab) - # ํ‘œ์ง€ 1 + ๊ฐœ์š” 1 + ์ž…๋ ฅ์กฐ๊ฑด 1 + ์ธก์ ํ‘œ n + ๋Œ€ํ‘œ๋‹จ๋ฉด 3 + 3D 1 - assert len(texts) == 3 + len(stab) + len(REPS) + 1 + # ํ‘œ์ง€ 1 + ๊ฐœ์š” 1 + ์ž…๋ ฅ์กฐ๊ฑด 1 + ์ธก์ ํ‘œ n + ๋Œ€ํ‘œ๋‹จ๋ฉด 3 + 3D 1 + ๊ด‘์—ญ 1 + assert len(texts) == 3 + len(stab) + len(REPS) + 1 + 1 def test_๋‹จ๋ฉด_๊ทธ๋ฆผ_SVG(): @@ -317,7 +414,7 @@ def test_๊ธธ_์ €์žฅ_409_๊ฐฑ์‹ _์ดˆ๊ธฐํ™”_๋‚ด๋ณด๋‚ด๊ธฐ(project, monkeypatch): got = client.get(url).json() except PrintError as error: pytest.skip(f"๋ธŒ๋ผ์šฐ์ € ์—†์Œ โ€” {error}") - assert got["editable"] and got["stale"] is False and len(got["book"]["์‹œํŠธ"]) == 6 + assert got["editable"] and got["stale"] is False and len(got["book"]["์‹œํŠธ"]) == 7 book = got["book"] _sheets(book)["ํ‘œ์ง€"]["์นธ"]["C10"] = {"๊ฐ’": "์‚ฐ๋ฆผ์ฒญ", "์„œ์‹": 5} assert client.put(url, json={"rev": "์˜›ํŒ", "book": book}).status_code == 409 diff --git a/resources/tester/test_534_3_stab_report_form.py b/resources/tester/test_534_3_stab_report_form.py index 89ac63e32..8eee5c646 100644 --- a/resources/tester/test_534_3_stab_report_form.py +++ b/resources/tester/test_534_3_stab_report_form.py @@ -36,7 +36,7 @@ STYLE_KEYS = { } # fmt: skip PRINT_KEYS = {"์šฉ์ง€", "๋ฐฉํ–ฅ", "๋ฐฐ์œจ", "๋งž์ถค", "์—ฌ๋ฐฑ", "๋จธ๋ฆฌํ–‰", "์ชฝ๋‚˜๋ˆ”"} A1 = re.compile(r"^([A-Z]{1,3})(\d+)$") -NAMES = ["ํ‘œ์ง€", "๊ฐœ์š”", "์ž…๋ ฅ์กฐ๊ฑด", "์ธก์ ํ‘œ", "๋Œ€ํ‘œ๋‹จ๋ฉด", "3D์š”์•ฝ"] +NAMES = ["ํ‘œ์ง€", "๊ฐœ์š”", "์ž…๋ ฅ์กฐ๊ฑด", "์ธก์ ํ‘œ", "๋Œ€ํ‘œ๋‹จ๋ฉด", "3D์š”์•ฝ", "๊ด‘์—ญ์š”์•ฝ"] needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node ๊ฐ€ ์—†์Œ") @@ -148,7 +148,7 @@ def test_์–‘์‹_๊ณ„์•ฝ(): book = _form() _contract(book) assert [s["์ด๋ฆ„"] for s in book["์‹œํŠธ"]] == NAMES - assert [s["id"] for s in book["์‹œํŠธ"]] == ["s1", "s2", "s3", "s4", "s5", "s6"] + assert [s["id"] for s in book["์‹œํŠธ"]] == ["s1", "s2", "s3", "s4", "s5", "s6", "s7"] def test_์–‘์‹_์ธ์‡„_์ชฝ๋‚˜๋ˆ”_๋จธ๋ฆฌํ–‰_๊ทธ๋ฆผ(): @@ -159,7 +159,15 @@ def test_์–‘์‹_์ธ์‡„_์ชฝ๋‚˜๋ˆ”_๋จธ๋ฆฌํ–‰_๊ทธ๋ฆผ(): assert by["์ธก์ ํ‘œ"]["์ธ์‡„"]["๋ฐฉํ–ฅ"] == "landscape" and by["์ธก์ ํ‘œ"]["์ธ์‡„"]["๋จธ๋ฆฌํ–‰"] == [4, 5] assert by["๋Œ€ํ‘œ๋‹จ๋ฉด"]["์ธ์‡„"]["์ชฝ๋‚˜๋ˆ”"] == [BLOCK] figs = {n: len(s.get("๊ทธ๋ฆผ", [])) for n, s in by.items()} - assert figs == {"ํ‘œ์ง€": 0, "๊ฐœ์š”": 0, "์ž…๋ ฅ์กฐ๊ฑด": 0, "์ธก์ ํ‘œ": 0, "๋Œ€ํ‘œ๋‹จ๋ฉด": 1, "3D์š”์•ฝ": 1} + assert figs == { + "ํ‘œ์ง€": 0, + "๊ฐœ์š”": 0, + "์ž…๋ ฅ์กฐ๊ฑด": 0, + "์ธก์ ํ‘œ": 0, + "๋Œ€ํ‘œ๋‹จ๋ฉด": 1, + "3D์š”์•ฝ": 1, + "๊ด‘์—ญ์š”์•ฝ": 0, + } # N.G ๋นจ๊ฐ• ์กฐ๊ฑด๋ถ€ ์„œ์‹ โ€” ์ธก์ ํ‘œ ํŒ์ • ์—ด ยท ์„œ์‹ ํ‘œ์— N.G ์นธ ์„œ์‹(๋นŒ๋”๊ฐ€ ์ธ์‡„ ยท xlsx ์šฉ์œผ๋กœ ์ง์ ‘ ์”€) assert by["์ธก์ ํ‘œ"]["์กฐ๊ฑด๋ถ€์„œ์‹"][0]["๋ฒ”์œ„"][0]["c0"] == 9 assert any(st.get("๊ธ€์ž์ƒ‰") == "#C00000" and st.get("์ฑ„์›€") for st in _form()["์„œ์‹"]) @@ -222,8 +230,8 @@ def test_๊ฒฌ๋ณธ_pdf_์ชฝ์ˆ˜_๋จธ๋ฆฌํ–‰(png): ) sections = [i for i, t in enumerate(texts) if "๋Œ€ํ‘œ ๋‹จ๋ฉด" in t and "์•ˆ์ „์œจ Fs" in t] assert len(sections) == 2 # ๋‹จ๋ฉด๋งˆ๋‹ค ํ•œ ์ชฝ - # ํ‘œ์ง€ 1 + ๊ฐœ์š” 1 + ์ž…๋ ฅ์กฐ๊ฑด 1 + ์ธก์ ํ‘œ n + ๋Œ€ํ‘œ๋‹จ๋ฉด 2 + 3D 1 - assert len(pages) == 1 + 1 + 1 + len(stab) + 2 + 1 + # ํ‘œ์ง€ 1 + ๊ฐœ์š” 1 + ์ž…๋ ฅ์กฐ๊ฑด 1 + ์ธก์ ํ‘œ n + ๋Œ€ํ‘œ๋‹จ๋ฉด 2 + 3D 1 + ๊ด‘์—ญ 1 + assert len(pages) == 1 + 1 + 1 + len(stab) + 2 + 1 + 1 assert all(float(pages[i].mediabox.width) > float(pages[i].mediabox.height) for i in stab) html = spreadsheet.print_htmls([{"book": book, "figures": {k: png for k in figs}}])[0] assert html.count(" dry["F"] + + +def test_wedge_not_formed(): + # ๊ต์„ ์ด ์‚ฌ๋ฉด๋ณด๋‹ค ๊ฐ€ํŒŒ๋ฆ„ โ†’ ์‚ฌ๋ฉด์œผ๋กœ ์•ˆ ๋‚˜์˜ด + assert _wedge((141, 75), (219, 75), (180, 50), 35) is None + + +# ---- Markland ๊ฒฝ๊ณ„ ------------------------------------------------------------- +SLOPE = {"dip_direction": 90.0, "dip": 70.0} + + +def _j(dd, dip, phi=30.0, jid="J"): + return {"id": jid, "dip_direction": dd, "dip": dip, "phi": phi} + + +@pytest.mark.parametrize( + "dd,dip,hit", + [ + (109.9, 45, True), + (110.1, 45, False), + (70.1, 45, True), + (90, 30.1, True), + (90, 29.9, False), + (90, 69.9, True), + (90, 70.1, False), + ], +) +def test_markland_planar(dd, dip, hit): + assert (eng.kinematic(SLOPE, [_j(dd, dip)])["planar"] == ["J"]) is hit + + +def test_markland_planar_apparent_daylight(): + """์ธก๋ฐฉ 15ยฐ โ€” ๊ฒ‰๋ณด๊ธฐ ์‚ฌ๋ฉด ๊ฒฝ์‚ฌ(tanฯˆf cos15ยฐ)๋ฅผ ๋„˜๋Š” ์ ˆ๋ฆฌ๋Š” ์•ˆ ๋‚˜์˜ด.""" + app = eng.apparent_dip(70, 90, 105) + assert eng.kinematic(SLOPE, [_j(105, app - 0.1)])["planar"] == ["J"] + assert eng.kinematic(SLOPE, [_j(105, app + 0.1)])["planar"] == [] + + +@pytest.mark.parametrize( + "dd,dip,hit", [(270, 50.1, True), (270, 49.9, False), (289.9, 80, True), (290.1, 80, False)] +) +def test_markland_toppling(dd, dip, hit): + """ฯˆp > 90 โˆ’ ฯˆf + ฯ† = 50 ยท ์—ญ๋ฐฉํ–ฅ ยฑ20.""" + assert (eng.kinematic(SLOPE, [_j(dd, dip)])["toppling"] == ["J"]) is hit + + +def test_markland_wedge_plunge_bounds(): + a, b = _j(50, 60, jid="A"), _j(130, 60, jid="B") + trend, plunge = eng.intersection(a, b) + assert trend == pytest.approx(90.0, abs=1e-6) + hit = eng.kinematic(SLOPE, [a, b])["wedge"] + assert hit and hit[0]["ids"] == ["A", "B"] + # ฯ† ๋ฅผ ์„ ์ฃผ๊ฐ ์œ„๋กœ ์˜ฌ๋ฆฌ๋ฉด ์‚ฌ๋ผ์ง + hi = [dict(a, phi=plunge + 0.1), dict(b, phi=plunge + 0.1)] + assert eng.kinematic(SLOPE, hi)["wedge"] == [] + # ์‚ฌ๋ฉด์„ ๊ต์„ ๋ณด๋‹ค ์™„๋งŒํ•˜๊ฒŒ ํ•˜๋ฉด ์•ˆ ๋‚˜์˜ด + assert eng.kinematic({"dip_direction": 90, "dip": plunge - 0.1}, [a, b])["wedge"] == [] + + +# ---- ํˆฌ์˜ ---------------------------------------------------------------------- +@pytest.mark.parametrize("kind", net.PROJECTIONS) +def test_projection_round_trip(kind): + rng = np.random.default_rng(536) + for trend, plunge in zip(rng.uniform(0, 360, 200), rng.uniform(0, 89.9, 200)): + x, y = net.project(trend, plunge, kind) + t2, p2 = net.inverse(x, y, kind) + assert p2 == pytest.approx(plunge, abs=2e-3) + # ์ขŒํ‘œ๋Š” ์†Œ์ˆ˜ ๋‹ค์„ฏ์งธ ์ž๋ฆฌ โ€” ์ค‘์‹ฌ ๊ฐ€๊นŒ์ด๋Š” ์ถ”ํ–ฅ ์˜ค์ฐจ๊ฐ€ 1e-5 / r rad ๊นŒ์ง€ + assert eng.ang_diff(t2, trend) <= math.degrees(1e-5 / math.hypot(x, y)) + 1e-6 + + +def test_projection_known_radii(): + assert net.radius(0, "equal_angle") == pytest.approx(1.0) + assert net.radius(0, "equal_area") == pytest.approx(1.0) + assert net.radius(90, "equal_angle") == pytest.approx(0.0) + assert net.radius(45, "equal_angle") == pytest.approx(math.tan(math.radians(22.5))) + assert net.radius(45, "equal_area") == pytest.approx( + math.sqrt(2) * math.sin(math.radians(22.5)) + ) + # ๋™์ชฝ ์ˆ˜ํ‰์„  = (1, 0) ยท ๋ถ์ชฝ = (0, 1) + assert net.project(90, 0, "equal_angle") == pytest.approx([1.0, 0.0], abs=1e-5) + assert net.project(0, 0, "equal_area") == pytest.approx([0.0, 1.0], abs=1e-5) + + +def _inside(pt, poly): + x, y = pt + hit = False + for (x1, y1), (x2, y2) in zip(poly, poly[1:] + poly[:1]): + if (y1 > y) != (y2 > y) and x < x1 + (y - y1) * (x2 - x1) / (y2 - y1): + hit = not hit + return hit + + +@pytest.mark.parametrize("kind", net.PROJECTIONS) +def test_zones_match_kinematic(kind): + """์œ„ํ—˜ ์˜์—ญ ๋‹ค๊ฐํ˜• ์•ˆ ๊ทน์  ยท ๊ต์„  = Markland ํŒ์ •(๊ฒฝ๊ณ„ 1ยฐ ๋  ๋ฐ– ํ‘œ๋ณธ).""" + rng = np.random.default_rng(7) + phi = 30.0 + data = net.stereonet(SLOPE, [], phi, kind) + checked = 0 + for dd, dip in zip(rng.uniform(0, 360, 1500), rng.uniform(1, 89, 1500)): + kin = eng.kinematic(SLOPE, [_j(dd, dip, phi)]) + near = ( + abs(eng.ang_diff(dd, 90) - 20) < 1 + or abs(eng.ang_diff(dd, 270) - 20) < 1 + or abs(dip - phi) < 1 + or abs(dip - eng.apparent_dip(70, 90, dd)) < 1 + or abs(dip - 50) < 1 + ) + if near: + continue + pole = net.project(*eng.pole(dd, dip), kind) + assert _inside(pole, data["zones"]["planar"]) == bool(kin["planar"]), (dd, dip) + assert _inside(pole, data["zones"]["toppling"]) == bool(kin["toppling"]), (dd, dip) + checked += 1 + assert checked > 1000 + + +# ---- ์ •๋ณธ ยท ์ธก์  ์‚ฌ๋ฉด ------------------------------------------------------------ +def _doc(): + doc = model.build_initial() + doc["sets"] = [ + { + "id": "J1", + "name": "J1", + "from_m": 0, + "to_m": 100, + "dip_direction": 90, + "dip": 45, + "phi": 30, + "c": 5, + "persistence_m": None, + } + ] + return doc + + +def test_check_and_clean(): + assert model.check_joints(_doc()) == [] + bad = _doc() + bad["sets"][0].update(dip=95, to_m=-1, name=" ") + bad["sets"].append(dict(bad["sets"][0])) + bad["settings"]["crack"] = {"mode": "depth", "z": None} + paths = {e["path"] for e in model.check_joints(bad)} + assert {"sets.0.dip", "sets.0.to_m", "sets.0.name", "sets.1.id", "settings.crack.z"} <= paths + clean = model.clean(dict(_doc(), extra=1)) + assert set(clean) == {"version", "sets", "settings"} + assert clean["sets"][0]["dip"] == 45.0 + + +def _cross(side_cut: str): + """์ขŒ(+offset) ๋˜๋Š” ์šฐ ๊นŽ๊ธฐ โ€” ๋…ธ์„  ๋ถ์ชฝ ์ง„ํ–‰(left = ์„œ์ชฝ โˆ’x) ยท ์›์ง€๋ฐ˜ ๊นŽ๊ธฐ ์ชฝ์œผ๋กœ 0.6 ์˜ค๋ฆ„ ยท + ๋…ธ๋ฉด 99 ยท ๊นŽ๊ธฐ 1:0.4 ยท ์Œ“๊ธฐ 1:1.5.""" + sign = 1.0 if side_cut == "left" else -1.0 + offs = np.arange(-20, 20.5, 0.5) + ground = [[float(o), 100 + 0.6 * sign * o] for o in offs] + design = [] + for o, z in ground: + d = sign * o + if abs(o) <= 2: + z = 99.0 + elif d > 2: + z = min(z, 99 + 2.5 * (d - 2)) + else: + z = max(z, 99 - (abs(d) - 2) / 1.5) + design.append([o, z]) + return ( + { + "frame": {"left_xy": [-1.0, 0.0]}, + "samples": [{"offset_m": o, "elevation_m": z} for o, z in ground], + }, + { + "section_mode": f"{side_cut}_cut", + "cut_slope_ratio": 0.4, + "road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}}, + "design_line": [{"offset_m": o, "elevation_m": z} for o, z in design], + }, + ) + + +@pytest.mark.parametrize("side,dd", [("left", 90.0), ("right", 270.0)]) +def test_station_slope_orientation(side, dd): + """์ขŒ = ์„œ์ชฝ ์‚ฐ ยท ์‚ฌ๋ฉด์ด ๋™(๋…ธ๋ฉด)์œผ๋กœ ๊ธฐ์šบ = ๊ฒฝ์‚ฌ๋ฐฉํ–ฅ 90 ยท ์šฐ๋Š” 270 ยท ๊ฒฝ์‚ฌ = atan(1/0.4).""" + cross, design = _cross(side) + slopes = model.station_slopes(cross, design) + assert len(slopes) == 1 and slopes[0]["side"] == side + assert slopes[0]["dip_direction"] == pytest.approx(dd) + assert slopes[0]["dip"] == pytest.approx(math.degrees(math.atan(2.5)), abs=0.01) + assert slopes[0]["height_m"] > 1 + assert slopes[0]["upper_dip"] == pytest.approx(math.degrees(math.atan(0.6)), abs=0.5) + + +def test_analyze_station(monkeypatch): + cross, design = _cross("left") + monkeypatch.setattr( + model, + "rock_stations", + lambda _s: [{"chainage_m": 20.0, "label": "STA", "ground_type": "ripping_rock"}], + ) + monkeypatch.setattr(model.src, "cross_section", lambda _s, _c: {**cross, "design": design}) + ground = { + "materials": [], + "stations": [], + "safety_factors": {"dry": {"cut": 1.5}, "wet": {"cut": 1.2}}, + "seismic": {"enabled": False}, + } + out = model.analyze(None, _doc(), ground) + assert len(out) == 1 + e = out[0] + assert e["kinematic"]["planar"] == ["J1"] + assert {c["condition"] for c in e["cases"]} == {"dry", "wet"} + dry = next(c for c in e["cases"] if c["condition"] == "dry") + assert dry["required"] == 1.5 and dry["ok"] == (dry["F"] >= 1.5) + assert e["verdict"] in ("OK", "NG") + assert e["gamma_r"] == model.DEFAULT_GAMMA_R + nets = model.station_net(e, _doc()["settings"]) + assert set(nets) == {"equal_angle", "equal_area", "phi"} + assert nets["equal_area"]["zones"]["planar"] diff --git a/resources/tester/test_536_2_e07_area.py b/resources/tester/test_536_2_e07_area.py new file mode 100644 index 000000000..b72bf11c4 --- /dev/null +++ b/resources/tester/test_536_2_e07_area.py @@ -0,0 +1,158 @@ +"""536-2 โ€” E07 ๊ด‘์—ญ ์ž์—ฐ์‚ฌ๋ฉด ์œ„ํ—˜ `E07_StabArea_Engine` ยท `_Router`. + +๋ฌดํ•œ์‚ฌ๋ฉด ํ•ด์„ํ•ด(๊ฑด์กฐ ยท ํฌํ™” ยท ์ ์ฐฉ) ยท SINMAP ์•ˆ์ • ์ง€์ˆ˜(๋‹ซํžŒ ๊ผด = ํ‘œ๋ณธ ยท ๋“ฑ๊ธ‰ ๊ฒฝ๊ณ„) ยท +D8 ์ง‘์ˆ˜๋ฉด์  ๊ฒฌ๋ณธ(๊ธฐ์šด ํ‰๋ฉด ยท V ๊ณจ์งœ๊ธฐ) ยท ๊ฐ•์šฐ ํฌํ™”๋น„ ยท ๊ฐ€์งœ ๋ณต์‚ฌ๋ณธ์œผ๋กœ ๋  ยท ์ธก์ ํ‘œ ยท ์ €์žฅ๋ณธ ยท ์ง€๋ฌธ. +""" + +import base64 +import json +import math + +import numpy as np +import pytest + +from E07_StabArea import E07_StabArea_Engine as engine +from E07_StabArea import E07_StabArea_Router as router_mod + +SOIL = dict(gamma_t=18.0, gamma_sat=20.0, c=0.0, c_hi=0.0, phi=35.0, phi_hi=35.0) + + +def _fs(beta_deg: float, m: float, **kw) -> float: + p = engine.AreaParams(**{**SOIL, **kw}) + return float(engine.infinite_slope_fs(np.array([math.radians(beta_deg)]), np.array([m]), p)[0]) + + +def test_๋ฌดํ•œ์‚ฌ๋ฉด_ํ•ด์„ํ•ด_๊ฑด์กฐ_ํฌํ™”_์ ์ฐฉ(): + t = math.tan(math.radians(35)) / math.tan(math.radians(30)) + assert _fs(30, 0.0) == pytest.approx(t, rel=1e-12) # ๊ฑด์กฐ c = 0 โ†’ tanฯ† / tanฮฒ + assert _fs(30, 1.0) == pytest.approx((20 - 9.81) / 20 * t, rel=1e-12) # ํฌํ™” โ†’ ฮณ' / ฮณsat ๋ฐฐ + # ์†๊ณ„์‚ฐ โ€” c 5 ยท ๋ฟŒ๋ฆฌ 2 ยท z 1.5 ยท ฮฒ 35ยฐ ยท m 0.5 + b, z = math.radians(35), 1.5 + w = 18 * z * 0.5 + 20 * z * 0.5 + n = (18 * z * 0.5 + (20 - 9.81) * z * 0.5) * math.cos(b) ** 2 + hand = (7 + n * math.tan(math.radians(35))) / (w * math.sin(b) * math.cos(b)) + assert _fs(35, 0.5, c=5.0, c_root=2.0) == pytest.approx(hand, rel=1e-12) + assert math.isinf(_fs(0, 1.0)) # ํ‰ํ‰ = ํ™œ๋™ ์—†์Œ + + +def test_SINMAP_์•ˆ์ •์ง€์ˆ˜_๋‹ซํžŒ๊ผด_ํ‘œ๋ณธ(): + p = engine.AreaParams(gamma_t=17.5, gamma_sat=18.5, c=0.0, c_hi=8.0, phi=25.0, phi_hi=38.0) + beta = np.radians(np.array([10.0, 30.0, 35.0, 40.0, 60.0])) + m = np.ones_like(beta) + si = engine.stability_index(beta, m, p) + a, b = engine._terms(beta, m, p) + rng = np.random.default_rng(1) + c = rng.uniform(0, 8, 400_000) + t = rng.uniform(math.tan(math.radians(25)), math.tan(math.radians(38)), 400_000) + for k in range(len(beta)): + lo = 0 * a[k] + math.tan(math.radians(25)) * b[k] + fs = c * a[k] + t * b[k] + if lo >= 1: + assert si[k] == pytest.approx(lo) # Fs_min โ‰ฅ 1 โ†’ SI = Fs_min + elif fs.max() < 1: + assert si[k] == 0.0 + else: + assert si[k] == pytest.approx((fs > 1).mean(), abs=3e-3) # P(Fs > 1) + assert si[0] >= 1 and 0 < si[1] < 1 and si[4] == 0 # ์„ธ ๊ฐˆ๋ž˜ ๋ชจ๋‘ ์ง€๋‚จ + # ํญ 0(๋ฒ”์œ„ ์—†์Œ) โ€” ๊ฒฐ์ • Fs ์™€ ๊ฐ™๊ฑฐ๋‚˜ 0 + q = engine.AreaParams(**SOIL) + fs = engine.infinite_slope_fs(beta, m, q) + si0 = engine.stability_index(beta, m, q) + assert np.allclose(si0, np.where(fs >= 1, fs, 0.0)) + + +def test_SINMAP_๋“ฑ๊ธ‰_๊ฒฝ๊ณ„(): + si = np.array([np.inf, 1.5, 1.4999, 1.25, 1.2499, 1.0, 0.9999, 0.5, 0.4999, 1e-9, 0.0, np.nan]) + assert engine.classify(si).tolist() == [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 0] + + +def test_D8_์ง‘์ˆ˜๋ฉด์ _๊ธฐ์šดํ‰๋ฉด_V๊ณจ์งœ๊ธฐ(): + cell = 2.0 + ny, nx = 5, 7 + xx = np.arange(nx)[None, :] * cell + plane = np.repeat(100.0 - 0.5 * xx, ny, axis=0) # ๋™์ชฝ์œผ๋กœ ๊ธฐ์›€ โ†’ ํ–‰๋งˆ๋‹ค ๋™์ชฝ์œผ๋กœ ํ๋ฆ„ + a = engine.catchment_area(plane, cell) + assert np.allclose(a, (np.arange(nx)[None, :] + 1) * cell) + yy = np.arange(9)[:, None] * cell + valley = 100.0 + np.abs(yy - 8.0) * 1.0 - 0.1 * np.arange(nx)[None, :] * cell + a = engine.catchment_area(valley, cell) + assert a[4, -1] == pytest.approx(9 * nx * cell) # ์ „๋ถ€๊ฐ€ ๊ณจ ์ถœ๊ตฌ๋กœ + assert np.all(a[4, :-1] < a[4, 1:]) # ๊ณจ ๋”ฐ๋ผ ๋Š˜์–ด๋‚จ + + +def test_๊ฐ•์šฐ_ํฌํ™”๋น„(): + p = engine.AreaParams(**SOIL, scenario="rain", rain_mm_h=36.0, k_m_s=1e-4, soil_depth_m=2.0) + beta = np.radians(np.array([30.0, 30.0])) + a = np.array([10.0, 1e6]) + m = engine.wetness(beta, a, p) + assert m[0] == pytest.approx(1e-5 * 10 / (2e-4 * 0.5)) # R a / (T sinฮฒ) + assert m[1] == 1.0 + assert engine.wetness(beta, a, engine.AreaParams(**SOIL)).tolist() == [1.0, 1.0] + + +def _decode(text: str, dtype: str, grid: dict) -> np.ndarray: + return np.frombuffer(base64.b64decode(text), dtype=dtype).reshape(grid["ny"], grid["nx"]) + + +@pytest.fixture() +def project(tmp_path, monkeypatch): + """๊ฐ€์งœ E ๋ณต์‚ฌ๋ณธ โ€” ์„œ์ชฝ์ด ๋‚ฎ์€ 30ยฐ ๋น„ํƒˆ(๋™์„œ 200 m ยท ๋‚จ๋ถ 100 m) ยท ๋…ธ์„  = ๋‚จ๋ถ ์ค‘์‹ฌ์„ .""" + x = np.arange(0.0, 200.5, 0.5) + y = np.arange(0.0, 100.5, 0.5) + z = np.repeat((x * math.tan(math.radians(30)))[None, :], len(y), axis=0) + valid = np.ones_like(z, bool) + valid[:, x > 180] = False # ๋™์ชฝ ๋ = ์ง€ํ˜• ์—†์Œ + dem = tmp_path / "dtm.npz" + np.savez( + dem, + x=x.astype(np.float32), + y=y.astype(np.float32), + z=z.astype(np.float32), + valid_mask=valid, + ) + stations = [ + {"chainage_m": float(c), "label": f"No.{i}", "center_x": 100.0, "center_y": float(c)} + for i, c in enumerate(range(0, 101, 20)) + ] + route = [ + {"sequence_num": i, "model_x": 100.0, "model_y": float(c), "chainage_m": float(c)} + for i, c in enumerate((0, 100)) + ] + src = router_mod.source + monkeypatch.setattr(src, "terrain_grid_path", lambda _r: dem) + monkeypatch.setattr(src, "stations", lambda _r: stations) + monkeypatch.setattr(src, "db_snapshot", lambda _r: {"route_points": route}) + return tmp_path + + +def test_๊ธธ_๋ _์ธก์ ํ‘œ_์ €์žฅ๋ณธ(project): + settings = {**router_mod.defaults(project), "band_m": 30.0, "cell_m": 2.0} + assert settings["material_id"] == "colluvium" and settings["c_hi"] == settings["c"] + 5 + steps = [] + summary = router_mod.run(project, project, settings, "fp", lambda p, m: steps.append(p)) + doc = router_mod.load(project) + assert steps == sorted(steps) and doc["fingerprint"] == "fp" + g = doc["grid"] + cls = _decode(doc["cls"], "u1", g) + fs = _decode(doc["fs"], " 0] + assert np.median(inner) / 1000 == pytest.approx(hand, abs=2e-3) + assert summary["fs_min"] == pytest.approx(hand, abs=5e-3) + # ๋  = ์ค‘์‹ฌ์„  ยฑ 30 m(๊ฒฉ์ž ์ƒ์ž๋„ ๋ ๋งŒํผ) ยท ๋“ฑ๊ธ‰ ๋ฉด์  ํ•ฉ = ๋ฎ์ธ ๋ฉด์  + assert g["nx"] * g["dx"] <= 62 + 1e-9 and g["ny"] * g["dx"] <= 102 + 1e-9 + assert sum(summary["areas_m2"].values()) == pytest.approx(summary["covered_area_m2"]) + assert summary["covered_area_m2"] == pytest.approx((cls > 0).sum() * 4.0) + rows = doc["stations"] + assert [r["chainage_m"] for r in rows] == [0, 20, 40, 60, 80, 100] + assert all(r["label"] == f"No.{i}" and r["worst_class"] >= 4 for i, r in enumerate(rows)) + assert summary["risk_stations"] == [0, 20, 40, 60, 80, 100] + assert sum(r["cells"] for r in rows) == (cls > 0).sum() + # ์ง€๋ฌธ = ์„ค์ •์— ๋”ฐ๋ผ ๋ฐ”๋€œ + assert router_mod.fingerprint("s", settings) != router_mod.fingerprint( + "s", {**settings, "c": 6} + ) + json.dumps(doc) # ๊ทธ๋Œ€๋กœ JSON diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index a5309b16a..3d8827e53 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -16,6 +16,7 @@ * - ui_template_locale_b5.ts : B ๊ทธ๋ฃน ๋ (B08~B11) * - ui_template_locale_de.ts : D ์˜ˆ์ •๋…ธ์„  ยท E ์•ˆ์ •์„ฑํ‰๊ฐ€ ยท ๋Œ€์‹œ๋ณด๋“œ [+] ๊ณ ๋ฅด๊ธฐ * - ui_template_locale_e04.ts : E04 ์•ˆ์ •์„ฑํ‰๊ฐ€ 3D ํ•ด์„ + * - ui_template_locale_e07.ts : E07 ์•ˆ์ •์„ฑํ‰๊ฐ€ ๊ด‘์—ญ ์œ„ํ—˜ ์ง€๋„ * * ์ด ํŒŒ์ผ์˜ export ์‹œ๊ทธ๋‹ˆ์ฒ˜๋Š” ๋ถ„ํ•  ์ด์ „๊ณผ ๋™์ผํ•˜๋ฏ€๋กœ ์†Œ๋น„์ฒ˜ import ์ˆ˜์ •์€ ๋ถˆํ•„์š”ํ•˜๋‹ค. * ========================================================================== */ @@ -35,6 +36,8 @@ import { ui_locales_m2 } from "./ui_template_locale_m2"; import { ui_locales_de } from "./ui_template_locale_de"; import { ui_locales_e } from "./ui_template_locale_e"; import { ui_locales_e05 } from "./ui_template_locale_e05"; +import { ui_locales_e06 } from "./ui_template_locale_e06"; +import { ui_locales_e07 } from "./ui_template_locale_e07"; /** ์ง€์› ์–ธ์–ด ์ธ๋ฑ์Šค: 0 = ํ•œ๊ตญ์–ด, 1 = ์˜์–ด */ export const LANGUAGES = ["ko", "en"] as const; @@ -77,6 +80,8 @@ export const ui_locales = { ...ui_locales_de, ...ui_locales_e, ...ui_locales_e05, + ...ui_locales_e06, + ...ui_locales_e07, } as const; export type LocaleKey = keyof typeof ui_locales; diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index a08abd22d..9f17c0750 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -17,6 +17,8 @@ export const ui_locales_de = { DE_Step_StabSection: ["2D ํ•ด์„", "2D Analysis"], DE_Step_Stab3D: ["3D ํ•ด์„", "3D Analysis"], DE_Step_StabReport: ["๋ณด๊ณ ์„œ", "Report"], + DE_Step_StabRock: ["์•”๋ฐ˜ ํ•ด์„", "Rock Analysis"], + DE_Step_StabArea: ["๊ด‘์—ญ ์œ„ํ—˜", "Area Hazard"], DE_Step_Empty: ["์ค€๋น„ ์ค‘์ธ ๋‹จ๊ณ„์ž…๋‹ˆ๋‹ค.", "This step is not ready yet."], /* --- D04 ํ™•์ • ยท ์—…์ฒด ยท ๋‚ด๋ณด๋‚ด๊ธฐ ยท B03 ์˜ˆ์ •๋…ธ์„  ๊ฐ€์ ธ์˜ค๊ธฐ(531-3) --- */ diff --git a/ui_template/ui_template_locale_e06.ts b/ui_template/ui_template_locale_e06.ts new file mode 100644 index 000000000..e604dca01 --- /dev/null +++ b/ui_template/ui_template_locale_e06.ts @@ -0,0 +1,122 @@ +/* ============================================================================= + * ui_template_locale_e06.ts + * ๋‹ค๊ตญ์–ด ์‚ฌ์ „ โ€” E06 ์•”๋ฐ˜ ํ‰์‚ฌํˆฌ์˜ ํ•ด์„(์š”๊ตฌ 345 ยท 351 ยท PLAN 536-1) + * + * ์‹ ๊ทœ ๋ฌธ๊ตฌ๋Š” ํ•ด๋‹น ์„น์…˜ ์ตœํ•˜๋‹จ์— ์ถ”๊ฐ€. + * ========================================================================== */ + +import type { LocaleEntry } from "./ui_template_locale_common"; + +export const ui_locales_e06 = { + /* --- E06 ์ขŒ์ธก --- */ + E06_Note: [ + "์•” ์ธก์ (๋ฆฌํ•‘์•” ยท ๋ฐœํŒŒ์•”) ๊นŽ๊ธฐ ๋น„ํƒˆ๋งŒ ๋ด…๋‹ˆ๋‹ค. ์ ˆ๋ฆฌ๊ตฐ์„ ๋„ฃ๊ณ  [์ €์žฅ] โ†’ [ํ•ด์„]. ์‚ฌ๋ฉด ๋ฐฉํ–ฅ ยท ๊ฒฝ์‚ฌ ยท ๋†’์ด๋Š” ๋ณต์‚ฌ๋ณธ ํšก๋‹จ, ๋ฌผ์„ฑ ยท ์ง€์ง„ ยท ๊ธฐ์ค€์•ˆ์ „์œจ์€ ์ง€๋ฐ˜ ์กฐ๊ฑด(E02)์„ ์”๋‹ˆ๋‹ค.", + "Only cut slopes at rock stations (rippable ยท blasting rock). Enter joint sets, [Save] โ†’ [Analyze]. Slope orientation, angle and height come from the copied sections; strength, seismic and required FS from Ground (E02).", + ], + E06_Card_Joints: ["์ ˆ๋ฆฌ๊ตฐ", "Joint sets"], + E06_JointsHint: [ + "ํ˜„์žฅ ์กฐ์‚ฌ๊ฐ’ โ€” ๊ฒฝ์‚ฌ๋ฐฉํ–ฅ ยท ๊ฒฝ์‚ฌ(ยฐ) ยท ์ ˆ๋ฆฌ๋ฉด ฯ†j(ยฐ) ยท cj(kPa) ยท ์ง€์†์„ฑ(m ยท ๋น„์šฐ๋ฉด ๋ฌดํ•œ). ๊ตฌ๊ฐ„(m) ์•ˆ ์ธก์ ์—๋งŒ ์”๋‹ˆ๋‹ค.", + "Field data โ€” dip direction ยท dip (ยฐ) ยท joint ฯ†j (ยฐ) ยท cj (kPa) ยท persistence (m, blank = unlimited). Applies to stations within the range (m).", + ], + E06_Col_Name: ["์ด๋ฆ„", "Name"], + E06_Col_From: ["์‹œ์ž‘", "From"], + E06_Col_To: ["๋", "To"], + E06_Col_DipDir: ["๊ฒฝ์‚ฌ๋ฐฉํ–ฅ", "Dip dir."], + E06_Col_Dip: ["๊ฒฝ์‚ฌ", "Dip"], + E06_Col_Phi: ["ฯ†j", "ฯ†j"], + E06_Col_C: ["cj", "cj"], + E06_Col_Persist: ["์ง€์†์„ฑ", "Persist."], + E06_Add: ["+ ์ ˆ๋ฆฌ๊ตฐ", "+ Joint set"], + E06_Remove: ["์ง€์›€", "Remove"], + E06_NoSets: [ + "์ ˆ๋ฆฌ๊ตฐ์ด ์—†์Šต๋‹ˆ๋‹ค โ€” [+ ์ ˆ๋ฆฌ๊ตฐ] ์œผ๋กœ ๋„ฃ์œผ์„ธ์š”.", + "No joint sets โ€” add one with [+ Joint set].", + ], + E06_Card_Settings: ["ํ•ด์„ ์„ค์ •", "Analysis settings"], + E06_Set_LateralPlanar: ["ํ‰๋ฉด ์ธก๋ฐฉ ํ•œ๊ณ„ (ยฐ)", "Planar lateral limit (ยฐ)"], + E06_Set_LateralTopple: ["์ „๋„ ์ธก๋ฐฉ ํ•œ๊ณ„ (ยฐ)", "Toppling lateral limit (ยฐ)"], + E06_Set_Gamma: ["์•” ๋‹จ์œ„์ค‘๋Ÿ‰ (kN/mยณ ยท ๋น„์šฐ๋ฉด E02)", "Rock unit weight (kN/mยณ, blank = E02)"], + E06_Set_WaterWet: ["์šฐ๊ธฐ ์ˆ˜์œ„ ๋น„ (0~1)", "Wet water ratio (0โ€“1)"], + E06_Set_Crack: ["์ธ์žฅ๊ท ์—ด", "Tension crack"], + E06_Crack_None: ["์—†์Œ", "None"], + E06_Crack_Critical: ["์ž„๊ณ„(์ตœ์†Œ ์•ˆ์ „์œจ ์ž๋ฆฌ)", "Critical (minimum FS)"], + E06_Crack_Depth: ["๊นŠ์ด ์ง€์ •", "Given depth"], + E06_Set_CrackZ: ["๊ท ์—ด ๊นŠ์ด (m)", "Crack depth (m)"], + E06_Set_BoltForce: ["๋ณผํŠธ ํž˜ (ํ‰๋ฉด kN/m ยท ์๊ธฐ kN)", "Bolt force (plane kN/m ยท wedge kN)"], + E06_Set_BoltPlunge: ["๋ณผํŠธ ๊ฒฝ์‚ฌ (์ˆ˜ํ‰ ์•„๋ž˜ ยฐ)", "Bolt plunge (below horizontal ยฐ)"], + E06_SettingsHint: [ + "์ธก๋ฐฉ ํ•œ๊ณ„ ๊ธฐ๋ณธ 20ยฐ (Dips ๊ธฐ๋ณธ ยท ์ง€์‹DB 03 ์ „๋„๋Š” 30ยฐ) ยท ์šฐ๊ธฐ = ๋น„ํƒˆ ๋†’์ด ยฝ ์ˆ˜์œ„(KDS 11 70 05) ยท ๋ณผํŠธ = ์ˆ˜๋™ ์ง€์ง€๋ ฅ.", + "Lateral limit default 20ยฐ (Dips default; knowledge DB uses 30ยฐ for toppling) ยท wet = water at half height (KDS 11 70 05) ยท bolts = passive support.", + ], + E06_Analyze: ["ํ•ด์„", "Analyze"], + E06_Running: ["ํ•ด์„ ์ค‘โ€ฆ", "Analyzingโ€ฆ"], + E06_Done: ["์•”๋ฐ˜ ํ•ด์„์„ ๋งˆ์ณค์Šต๋‹ˆ๋‹ค.", "Rock analysis done."], + E06_Fresh: ["์ž…๋ ฅ์ด ๊ฐ™์•„ ์ง€๋‚œ ๊ฒฐ๊ณผ๋ฅผ ์”๋‹ˆ๋‹ค.", "Inputs unchanged โ€” previous result kept."], + E06_Saved: ["์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค.", "Saved."], + E06_Unsaved: ["์ €์žฅ ์•ˆ ๋œ ๋ณ€๊ฒฝ์ด ์žˆ์Šต๋‹ˆ๋‹ค.", "There are unsaved changes."], + E06_ReadOnly: ["๋ณด๊ธฐ๋งŒ ํ—ˆ์šฉ๋œ ํ”„๋กœ์ ํŠธ์ž…๋‹ˆ๋‹ค.", "This project is view-only."], + E06_Invalid: ["ํ‹€๋ฆฐ ์นธ", "Invalid fields"], + E06_Err_Range: ["๋ฒ”์œ„ ๋ฐ–", "Out of range"], + E06_ResetConfirm: [ + "์ ˆ๋ฆฌ๊ตฐ ยท ์„ค์ •์„ ์ดˆ๊ธฐ๊ฐ’(์ ˆ๋ฆฌ ์—†์Œ)์œผ๋กœ ๋˜๋Œ๋ฆด๊นŒ์š”?", + "Reset joint sets and settings to the initial state (no joints)?", + ], + E06_ResetDone: ["์ดˆ๊ธฐ๊ฐ’์œผ๋กœ ๋˜๋Œ๋ ธ์Šต๋‹ˆ๋‹ค.", "Reset to initial values."], + E06_NoProject: ["์—ด๋ฆฐ ํ”„๋กœ์ ํŠธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.", "No project is open."], + + /* --- E06 ๊ฐ€์šด๋ฐ ยท ์šฐ์ธก --- */ + E06_Stale: [ + "์ ˆ๋ฆฌ ยท ์ง€๋ฐ˜ ์กฐ๊ฑด์ด ํ•ด์„ ๋’ค ๋ฐ”๋€Œ์—ˆ์Šต๋‹ˆ๋‹ค โ€” [ํ•ด์„] ์„ ๋‹ค์‹œ ๋ˆ„๋ฅด์„ธ์š”.", + "Joints or ground changed since the analysis โ€” press [Analyze] again.", + ], + E06_Band: ["์œ„ํ—˜ ๋ ", "Risk band"], + E06_View_All: ["์ „์ฒด", "All"], + E06_Mode_planar: ["ํ‰๋ฉด", "Planar"], + E06_Mode_wedge: ["์๊ธฐ", "Wedge"], + E06_Mode_toppling: ["์ „๋„", "Toppling"], + E06_Proj_Angle: ["๋“ฑ๊ฐ", "Equal angle"], + E06_Proj_Area: ["๋“ฑ๋ฉด์ ", "Equal area"], + E06_PickStation: ["์ธก์ ์„ ๊ณ ๋ฅด์„ธ์š”.", "Pick a station."], + E06_Lg_Slope: ["์‚ฌ๋ฉด ๋Œ€์› ยท ๊ทน์ ", "Slope plane ยท pole"], + E06_Lg_Friction: ["๋งˆ์ฐฐ์› ฯ†", "Friction circle ฯ†"], + E06_Lg_Planar: ["ํ‰๋ฉด ์œ„ํ—˜(๊ทน์ )", "Planar zone (poles)"], + E06_Lg_Daylight: ["์ผ๊ด‘ ์—”๋ฒจ๋กœํ”„", "Daylight envelope"], + E06_Lg_Wedge: ["์๊ธฐ ์œ„ํ—˜(๊ต์„ )", "Wedge zone (intersections)"], + E06_Lg_Line: ["๊ต์„ ", "Intersection"], + E06_Lg_Toppling: ["์ „๋„ ์œ„ํ—˜(๊ทน์ )", "Toppling zone (poles)"], + E06_Lg_Pole: ["์ ˆ๋ฆฌ ๊ทน์ ", "Joint pole"], + E06_Result_Title: ["์ธก์  ๊ฒฐ๊ณผ", "Station results"], + E06_NoResult: ["ํ•ด์„ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค โ€” [ํ•ด์„] ์„ ๋ˆ„๋ฅด์„ธ์š”.", "No results โ€” press [Analyze]."], + E06_NoRock: ["์•” ์ธก์  ๊นŽ๊ธฐ ๋น„ํƒˆ์ด ์—†์Šต๋‹ˆ๋‹ค.", "No rock cut slopes."], + E06_L_Station: ["์ธก์ ", "Station"], + E06_L_Side: ["์ชฝ", "Side"], + E06_L_Modes: ["ํ‰ ยท ์ ยท ์ „", "P ยท W ยท T"], + E06_L_MinF: ["์ตœ์†Œ Fs", "Min Fs"], + E06_L_Verdict: ["ํŒ์ •", "Verdict"], + E06_Left: ["์ขŒ", "L"], + E06_Right: ["์šฐ", "R"], + E06_V_NG: ["N.G", "N.G"], + E06_V_check: ["๊ฒ€ํ† ", "Check"], + E06_V_OK: ["O.K", "O.K"], + E06_V_none: ["์œ„ํ—˜ ์—†์Œ", "No risk"], + E06_SlopeLine: [ + "์‚ฌ๋ฉด {dd}ยฐ / {dip}ยฐ ยท ๋†’์ด {h} m ยท ์œ—๋ฉด {up}ยฐ ยท ์•” ฮณ {g} kN/mยณ", + "Slope {dd}ยฐ / {dip}ยฐ ยท height {h} m ยท upper {up}ยฐ ยท rock ฮณ {g} kN/mยณ", + ], + E06_NoRisk: ["์œ„ํ—˜ ๋ชจ๋“œ ์—†์Œ(Markland).", "No kinematic failure mode (Markland)."], + E06_NoJointsHere: ["์ด ์ธก์  ๊ตฌ๊ฐ„์— ์ ˆ๋ฆฌ๊ตฐ์ด ์—†์Šต๋‹ˆ๋‹ค.", "No joint sets cover this station."], + E06_C_Mode: ["๋ชจ๋“œ", "Mode"], + E06_C_Joints: ["์ ˆ๋ฆฌ", "Joints"], + E06_C_Cond: ["์กฐ๊ฑด", "Case"], + E06_C_F: ["Fs", "Fs"], + E06_C_Req: ["๊ธฐ์ค€", "Req."], + E06_Cond_dry: ["๊ฑด๊ธฐ", "Dry"], + E06_Cond_wet: ["์šฐ๊ธฐ", "Wet"], + E06_Cond_seismic: ["์ง€์ง„", "Seismic"], + E06_NotFormed: ["(๋ธ”๋ก ์•ˆ ์ƒ๊น€)", "(no block)"], + E06_Persist: ["(์ง€์†์„ฑ < ํ™œ๋™๋ฉด)", "(persistence < plane)"], + E06_ToppleNote: [ + "์ „๋„ = ๊ธฐํ•˜ ํŒ์ •๋งŒ(๋ธ”๋ก ์ „๋„ ์•ˆ์ „์œจ์€ Goodman-Bray ์ •๋ฐ€ ๊ฒ€ํ†  ๋ชซ).", + "Toppling = kinematic only (block toppling FS needs a Goodmanโ€“Bray check).", + ], +} as const satisfies Record; diff --git a/ui_template/ui_template_locale_e07.ts b/ui_template/ui_template_locale_e07.ts new file mode 100644 index 000000000..036187a13 --- /dev/null +++ b/ui_template/ui_template_locale_e07.ts @@ -0,0 +1,83 @@ +/* ============================================================================= + * ui_template_locale_e07.ts + * ๋‹ค๊ตญ์–ด ์‚ฌ์ „ โ€” E07 ์•ˆ์ •์„ฑํ‰๊ฐ€ ๊ด‘์—ญ ์ž์—ฐ์‚ฌ๋ฉด ์œ„ํ—˜ ์ง€๋„(์š”๊ตฌ 345 ยท 351 ยท PLAN 536-2) + * + * ์‹ ๊ทœ ๋ฌธ๊ตฌ๋Š” ํ•ด๋‹น ์„น์…˜ ์ตœํ•˜๋‹จ์— ์ถ”๊ฐ€. + * ========================================================================== */ + +import type { LocaleEntry } from "./ui_template_locale_common"; + +export const ui_locales_e07 = { + /* --- E07 ๊ด‘์—ญ ์œ„ํ—˜ ์ง€๋„ --- */ + E07_NoProject: ["ํ”„๋กœ์ ํŠธ๋ฅผ ๋จผ์ € ๊ณ ๋ฅด์„ธ์š”.", "Pick a project first."], + E07_Hint: [ + "๋…ธ์„  ๋  ์•ˆ ์ž์—ฐ์‚ฌ๋ฉด์˜ ํ‘œ์ธต ๋ถ•๊ดด ์œ„ํ—˜ โ€” ๋ฌดํ•œ์‚ฌ๋ฉด ์•ˆ์ „์œจ๊ณผ SINMAP ์•ˆ์ • ์ง€์ˆ˜(SI)๋กœ ๋“ฑ๊ธ‰์„ ๋งค๊น๋‹ˆ๋‹ค. ์ง€ํ˜• = ๋ณต์‚ฌํ•œ ์›์ง€ํ˜•.", + "Shallow-landslide hazard of natural slopes in the route band โ€” infinite-slope Fs and SINMAP stability index (SI). Terrain = copied original DTM.", + ], + E07_ReadOnly: ["๋ณด๊ธฐ๋งŒ ํ—ˆ์šฉ๋œ ํ”„๋กœ์ ํŠธ์ž…๋‹ˆ๋‹ค.", "This project is view-only."], + E07_Card_Soil: ["ํ† ์ธต ๋ฌผ์„ฑ", "Soil layer"], + E07_Card_Rain: ["๊ฐ•์šฐ ยท ํฌํ™”", "Rain ยท saturation"], + E07_Card_Band: ["๋…ธ์„  ๋  ยท ๊ฒฉ์ž", "Route band ยท grid"], + E07_Material: ["๋ฌผ์„ฑ(E02 ์ง€๋ฐ˜ ์กฐ๊ฑด)", "Material (E02 ground)"], + E07_GammaT: ["ฮณt (kN/mยณ)", "ฮณt (kN/mยณ)"], + E07_GammaSat: ["ฮณsat (kN/mยณ)", "ฮณsat (kN/mยณ)"], + E07_C: ["c ์•„๋ž˜ (kPa)", "c low (kPa)"], + E07_CHi: ["c ์œ„ (kPa)", "c high (kPa)"], + E07_Phi: ["ฯ† ์•„๋ž˜ (ยฐ)", "ฯ† low (ยฐ)"], + E07_PhiHi: ["ฯ† ์œ„ (ยฐ)", "ฯ† high (ยฐ)"], + E07_CRoot: ["๋ฟŒ๋ฆฌ ์ ์ฐฉ (kPa)", "Root cohesion (kPa)"], + E07_Depth: ["ํ† ์ธต ๋‘๊ป˜ (m)", "Soil depth (m)"], + E07_Scenario: ["์‹œ๋‚˜๋ฆฌ์˜ค", "Scenario"], + E07_Scenario_saturated: ["์ง€ํ‘œ ํฌํ™” (m = 1)", "Fully saturated (m = 1)"], + E07_Scenario_rain: ["๊ฐ•์šฐ ๊ฐ•๋„ (์ •์ƒ๋ฅ˜)", "Rainfall (steady state)"], + E07_Rain: ["๊ฐ•์šฐ ๊ฐ•๋„ (mm/h)", "Rainfall (mm/h)"], + E07_K: ["ํˆฌ์ˆ˜๊ณ„์ˆ˜ (m/s)", "Hydraulic conductivity (m/s)"], + E07_Band: ["๋  ํญ ยฑ (m)", "Band half-width (m)"], + E07_Cell: ["๊ฒฉ์ž (m)", "Cell (m)"], + E07_Defaults: ["๊ธฐ๋ณธ๊ฐ’", "Defaults"], + E07_Analyze: ["ํ•ด์„", "Analyze"], + E07_Reanalyze: ["๋‹ค์‹œ ํ•ด์„", "Re-analyze"], + E07_Running: ["ํ•ด์„ ์ค‘", "Analyzing"], + E07_Done: ["๊ด‘์—ญ ํ•ด์„์„ ๋งˆ์ณค์Šต๋‹ˆ๋‹ค.", "Area analysis finished."], + E07_Cached: ["๊ฐ™์€ ์กฐ๊ฑด์˜ ๊ฒฐ๊ณผ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.", "A result with the same inputs exists."], + E07_Layer_Class: ["๋“ฑ๊ธ‰ (SI)", "Class (SI)"], + E07_Layer_FS: ["์•ˆ์ „์œจ Fs", "Safety factor Fs"], + E07_Legend_Class: ["SINMAP ๋“ฑ๊ธ‰", "SINMAP class"], + E07_Legend_FS: ["๋ฌดํ•œ์‚ฌ๋ฉด Fs", "Infinite-slope Fs"], + E07_Class_stable: ["์•ˆ์ • (SI โ‰ฅ 1.5)", "Stable (SI โ‰ฅ 1.5)"], + E07_Class_moderate: ["๋ณดํ†ต ์•ˆ์ • (โ‰ฅ 1.25)", "Moderately stable (โ‰ฅ 1.25)"], + E07_Class_quasi: ["์ค€์•ˆ์ • (โ‰ฅ 1.0)", "Quasi-stable (โ‰ฅ 1.0)"], + E07_Class_lower: ["์œ„ํ—˜ ํ•˜ํ•œ (โ‰ฅ 0.5)", "Lower threshold (โ‰ฅ 0.5)"], + E07_Class_upper: ["์œ„ํ—˜ ์ƒํ•œ (> 0)", "Upper threshold (> 0)"], + E07_Class_defended: ["๋ถˆ์•ˆ์ • (SI = 0)", "Defended (SI = 0)"], + E07_Map_Empty: [ + "[ํ•ด์„] ์„ ๋ˆ„๋ฅด๋ฉด ์œ„ํ—˜ ์ง€๋„๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค.", + "Press [Analyze] to draw the hazard map.", + ], + E07_Tip_Slope: ["๊ฒฝ์‚ฌ", "Slope"], + E07_Tip_Station: ["๊ฐ€๊นŒ์šด ์ธก์ ", "Nearest station"], + E07_Result_Title: ["๊ด‘์—ญ ๊ฒฐ๊ณผ", "Area result"], + E07_Result_None: ["์•„์ง ํ•ด์„ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.", "No result yet."], + E07_Result_Stale: [ + "์—”์ง„์ด ๋ฐ”๋€Œ์–ด ๊ฒฐ๊ณผ๊ฐ€ ์˜›๊ฒƒ์ž…๋‹ˆ๋‹ค โ€” [๋‹ค์‹œ ํ•ด์„].", + "The engine changed โ€” re-analyze.", + ], + E07_Result_Note: [ + "๋ฌธํ—Œ ์ถ”์ •์น˜ ยท ํ˜„์žฅ ์‹œํ—˜ ์—†์Œ. SI = c ยท ฯ† ๋ฒ”์œ„ ์•ˆ์—์„œ Fs > 1 ์ผ ํ™•๋ฅ (SI โ‰ฅ 1 ์ด๋ฉด ๊ฐ€์žฅ ๋‚˜์œ ๊ฐ’์˜ Fs). ์ง€ํ˜• ๊ฒฉ์ž ๋ ๋ฐ– ์ƒ๋ฅ˜๋Š” ์ง‘์ˆ˜๋ฉด์ ์— ์•ˆ ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค.", + "Literature values, no field tests. SI = probability of Fs > 1 over the c ยท ฯ† ranges (worst-case Fs when SI โ‰ฅ 1). Upslope area beyond the terrain grid is not counted.", + ], + E07_Sum_FsMin: ["๋  ์•ˆ ์ตœ์†Œ Fs", "Min Fs in band"], + E07_Sum_Risk: ["์œ„ํ—˜ ๋ฉด์  (SI < 1)", "Hazard area (SI < 1)"], + E07_Sum_RiskStations: ["์œ„ํ—˜ ์ธก์ ", "Hazard stations"], + E07_Sum_Covered: ["์ง€ํ˜• ์žˆ๋Š” ๋  / ๋ ", "Band with terrain / band"], + E07_Sum_Time: ["ํ•ด์„", "Computed"], + E07_Areas_Title: ["๋“ฑ๊ธ‰๋ณ„ ๋ฉด์ ", "Area by class"], + E07_Stations_Title: ["์ธก์ ๋ณ„ ์œ„ํ—˜ (๋ˆ„๋ฅด๋ฉด ์ง€๋„๋กœ)", "Hazard by station (click to locate)"], + E07_Col_Class: ["๋“ฑ๊ธ‰", "Class"], + E07_Col_Area: ["๋ฉด์  (mยฒ)", "Area (mยฒ)"], + E07_Col_Station: ["์ธก์ ", "Station"], + E07_Col_FsMin: ["์ตœ์†Œ Fs", "Min Fs"], + E07_Col_SiMin: ["์ตœ์†Œ SI", "Min SI"], + E07_Col_Worst: ["๋‚˜์œ ๋“ฑ๊ธ‰", "Worst class"], + E07_Col_RiskArea: ["์œ„ํ—˜ mยฒ", "Hazard mยฒ"], +} as const satisfies Record;