"""E02 지반 조건 정본 꼴 · 검사 · 측점 적용(요구 347-2 · 3 · 4 · PLAN 532-3). 정본 = `E02_StabGround/ground.json` · 초기값 = `initial_snapshot/E02_StabGround/ground.json` (E 생성 직후 = 마스터 기본값 + B06 측점 암 경계) · 기본값 = `resources/master_template/stability/ ground_defaults.json`(M02 마스터 층). 물성 한 줄 = `E03_StabSection_Types.Material` 꼴 + `water`. ⚠ 파이썬·TS 짝 — `check_ground` ↔ `logic/E02_StabGround_Check.ts` `checkGround` · `resolve_stations` ↔ `resolveStations`. 한쪽을 고치면 다른 쪽도 · 거울 시험 `resources/tester/test_532_3_e02_ground.py`. 지하수 → 엔진 꼴(`station_groundwater` · 서버 단독 · 짝 없음) — 시험 `test_532_4_stab_walls_water.py`. """ from __future__ import annotations import copy import json import math from pathlib import Path from typing import Any import numpy as np PROJECT_ROOT = Path(__file__).resolve().parents[1] DEFAULTS_PATH = ( PROJECT_ROOT / "resources" / "master_template" / "stability" / "ground_defaults.json" ) STAGE_DIR = "E02_StabGround" GROUND_NAME = "ground.json" MATERIAL_FIELDS = ("gamma_t", "gamma_sat", "c", "phi") WATER_GROUPS = ("soil", "rock") LABELS = ("soil", "ripping_rock", "blasting_rock", "fill") ROCK_LABELS = ("ripping_rock", "blasting_rock") CASES = ("dry", "wet", "seismic", "short_term") SLOPES = ("cut", "fill", "soft") WATER_KEYS = ("dry", "wet_soil", "wet_rock") WATER_RULES = ("none", "surface", "half_height") # 우기 지하수 갈래 — kds = 위 규칙(wet_soil · wet_rock · 기본) · # depth_ratio = 지표 − 비탈 높이 × 비율 · ru = 간극수압비 · # line = 사용자 수위선(구간 · [offset, 지표 아래 깊이]) · 덮지 않은 측점은 kds WET_MODES = ("kds", "depth_ratio", "ru", "line") WATER_EXTRA_KEYS = ("wet_mode", "depth_ratio", "ru", "lines") # 정본에 남기는 칸 — 그 밖(limits · sources · note)은 기본값 파일에만 GROUND_KEYS = ( "version", "materials", "label_map", "stations", "zones", "safety_factors", "road_surcharge_kpa", "groundwater", "seismic", ) def load_defaults() -> dict[str, Any]: return json.loads(DEFAULTS_PATH.read_text(encoding="utf-8")) def ground_path(project_root: Path) -> Path: return Path(project_root) / STAGE_DIR / GROUND_NAME def initial_path(project_root: Path) -> Path: return Path(project_root) / "initial_snapshot" / STAGE_DIR / GROUND_NAME def pick(doc: dict[str, Any]) -> dict[str, Any]: """정본 칸만 떠 옴(깊은 복사).""" return copy.deepcopy({key: doc[key] for key in GROUND_KEYS if key in doc}) def upgrade(ground: dict[str, Any], defaults: dict[str, Any]) -> dict[str, Any]: """옛 정본(지하수 갈래 칸 전) = 빠진 지하수 칸만 기본값으로 채운 사본 · 있는 칸은 그대로.""" out = copy.deepcopy(ground) water = out.setdefault("groundwater", {}) if isinstance(water, dict): for key in WATER_EXTRA_KEYS: water.setdefault(key, copy.deepcopy(defaults["groundwater"][key])) return out def build_initial(defaults: dict[str, Any], stations: list[dict[str, Any]]) -> dict[str, Any]: """초기값 = 기본값 정본 칸 + B06 측점(암 경계).""" ground = pick(defaults) ground["stations"] = stations return ground def _num(value: Any) -> bool: return isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(value) def _in(value: Any, bound: list[float]) -> bool: return _num(value) and bound[0] <= value <= bound[1] def _list(value: Any) -> list: return value if isinstance(value, list) else [] def _dict(value: Any) -> dict: return value if isinstance(value, dict) else {} def check_ground(ground: dict[str, Any], limits: dict[str, list[float]]) -> list[dict[str, str]]: """틀린 칸 목록 [{path, code}] — 빈 목록 = 통과. code = number · range · order · sat_lt_t · name · name_dup · id_dup · enum · ref · empty · few_points · type.""" errors: list[dict[str, str]] = [] def bad(path: str, code: str) -> None: errors.append({"path": path, "code": code}) def ranged(path: str, value: Any, key: str) -> None: if not _num(value): bad(path, "number") elif not _in(value, limits[key]): bad(path, "range") materials = _list(ground.get("materials")) if not materials: bad("materials", "empty") ids: list[str] = [] names: list[str] = [] for i, item in enumerate(materials): base = f"materials.{i}" if not isinstance(item, dict): bad(base, "type") continue mid = item.get("id") if not isinstance(mid, str) or not mid or mid in ids: bad(f"{base}.id", "id_dup") ids.append(mid if isinstance(mid, str) else "") name = item["name"].strip() if isinstance(item.get("name"), str) else "" low, high = limits["name_length"] if not low <= len(name) <= high: bad(f"{base}.name", "name") elif name in names: bad(f"{base}.name", "name_dup") names.append(name) for field in MATERIAL_FIELDS: ranged(f"{base}.{field}", item.get(field), field) if _num(item.get("gamma_t")) and _num(item.get("gamma_sat")): if item["gamma_sat"] < item["gamma_t"]: bad(f"{base}.gamma_sat", "sat_lt_t") if item.get("water") not in WATER_GROUPS: bad(f"{base}.water", "enum") label_map = _dict(ground.get("label_map")) for label in LABELS: if not label_map.get(label) or label_map.get(label) not in ids: bad(f"label_map.{label}", "ref") for i, zone in enumerate(_list(ground.get("zones"))): base = f"zones.{i}" if not isinstance(zone, dict): bad(base, "type") continue ranged(f"{base}.from_m", zone.get("from_m"), "chainage_m") ranged(f"{base}.to_m", zone.get("to_m"), "chainage_m") if _num(zone.get("from_m")) and _num(zone.get("to_m")): if zone["from_m"] >= zone["to_m"]: bad(f"{base}.to_m", "order") for layer in ("soil", "rock"): if zone.get(layer) is not None and zone.get(layer) not in ids: bad(f"{base}.{layer}", "ref") if zone.get("soil_thickness_m") is not None: ranged(f"{base}.soil_thickness_m", zone.get("soil_thickness_m"), "soil_thickness_m") factors = _dict(ground.get("safety_factors")) for case in CASES: for slope in SLOPES: value = _dict(factors.get(case)).get(slope) ranged(f"safety_factors.{case}.{slope}", value, "safety_factor") ranged("road_surcharge_kpa", ground.get("road_surcharge_kpa"), "road_surcharge_kpa") water = _dict(ground.get("groundwater")) for key in WATER_KEYS: if water.get(key) not in WATER_RULES: bad(f"groundwater.{key}", "enum") if water.get("wet_mode") not in WET_MODES: bad("groundwater.wet_mode", "enum") ranged("groundwater.depth_ratio", water.get("depth_ratio"), "depth_ratio") ranged("groundwater.ru", water.get("ru"), "ru") if not isinstance(water.get("lines"), list): bad("groundwater.lines", "type") for i, line in enumerate(_list(water.get("lines"))): base = f"groundwater.lines.{i}" if not isinstance(line, dict): bad(base, "type") continue ranged(f"{base}.from_m", line.get("from_m"), "chainage_m") ranged(f"{base}.to_m", line.get("to_m"), "chainage_m") if _num(line.get("from_m")) and _num(line.get("to_m")): if line["from_m"] >= line["to_m"]: bad(f"{base}.to_m", "order") points = _list(line.get("points")) if len(points) < 2: bad(f"{base}.points", "few_points") last = None for k, point in enumerate(points): if not isinstance(point, list) or len(point) != 2: bad(f"{base}.points.{k}", "type") continue ranged(f"{base}.points.{k}.0", point[0], "offset_m") ranged(f"{base}.points.{k}.1", point[1], "water_depth_m") if _num(point[0]): if last is not None and point[0] <= last: bad(f"{base}.points.{k}.0", "order") last = point[0] seismic = _dict(ground.get("seismic")) if not isinstance(seismic.get("enabled"), bool): bad("seismic.enabled", "enum") ranged("seismic.kh", seismic.get("kh"), "kh") return errors def clean(ground: dict[str, Any]) -> dict[str, Any]: """검사를 지난 정본에서 알려진 칸만 남김 · 이름 앞뒤 빈칸 걷음.""" out = pick(ground) out["materials"] = [ { "id": m["id"], "name": m["name"].strip(), **{field: m[field] for field in MATERIAL_FIELDS}, "water": m["water"], } for m in out["materials"] ] out["label_map"] = {label: out["label_map"][label] for label in LABELS} out["zones"] = [ {key: z.get(key) for key in ("from_m", "to_m", "soil", "rock", "soil_thickness_m")} for z in _list(out.get("zones")) ] factors = out["safety_factors"] out["safety_factors"] = {c: {s: factors[c][s] for s in SLOPES} for c in CASES} water = out["groundwater"] out["groundwater"] = {key: water[key] for key in (*WATER_KEYS, *WATER_EXTRA_KEYS)} out["groundwater"]["lines"] = [ {"from_m": w["from_m"], "to_m": w["to_m"], "points": [list(p) for p in w["points"]]} for w in water["lines"] ] out["seismic"] = {"enabled": out["seismic"]["enabled"], "kh": out["seismic"]["kh"]} return out def resolve_stations(ground: dict[str, Any]) -> list[dict[str, Any]]: """측점마다 적용 지층 — 위 = 토사 연결 · 아래 = 암 측점의 암 연결 · 두께 = B06 · 구간이 덮어씀(뒤 구간이 이김 · 양 끝 포함 · 빈 칸은 안 바꿈) · 두께가 없으면 아래 지층 없음. 성토체는 측점과 상관없이 fill 연결.""" label_map = ground.get("label_map") or {} out: list[dict[str, Any]] = [] for station in ground.get("stations") or []: chainage = station["chainage_m"] ground_type = station.get("ground_type") upper = label_map.get("soil") lower = label_map.get(ground_type) if ground_type in ROCK_LABELS else None thickness = station.get("soil_thickness_m") zoned = False for zone in ground.get("zones") or []: if not zone["from_m"] <= chainage <= zone["to_m"]: continue zoned = True if zone.get("soil") is not None: upper = zone["soil"] if zone.get("rock") is not None: lower = zone["rock"] if zone.get("soil_thickness_m") is not None: thickness = zone["soil_thickness_m"] if thickness is None: # 경계 없음 = 전부 위 지층 lower = None out.append( { "chainage_m": chainage, "ground_type": ground_type, "upper": upper, "lower": lower, "soil_thickness_m": thickness if lower is not None else None, "zoned": zoned, } ) return out def _face_water(ground: dict[str, Any], chainage_m: float) -> str: """측점 비탈 겉 지층의 지하수 무리 — 토층 두께 0 = 아래(암) 지층 · 아니면 위 지층 · 측점 없음 = soil.""" names = {m["id"]: m for m in ground.get("materials") or []} for row in resolve_stations(ground): if abs(row["chainage_m"] - chainage_m) < 1e-3: face = row["lower"] if row["lower"] and row["soil_thickness_m"] == 0 else row["upper"] return (names.get(face) or {}).get("water", "soil") return "soil" def groundwater_for( ground: dict[str, Any], condition: str, *, chainage_m: float, surface: list, heights: dict[str, float], ) -> dict[str, Any]: """E02 지하수 규칙 → 엔진 꼴 `{mode: none | ru | line}`(E03 Types `Groundwater`) — 측점마다. surface = 공사 뒤 지표 [[x, z]](좌 = +x) · heights = 비탈 높이 {left, right} m(x ≥ 0 = 좌 높이). 건기 · 지진 · 단기 = `dry` 규칙 · 우기 = `wet_mode` 갈래 (kds = 겉 지층 무리로 wet_soil | wet_rock). """ water = ground.get("groundwater") or {} sx, sz = zip(*sorted((float(x), float(z)) for x, z in surface)) def below(depth_at, extra=()) -> dict[str, Any]: # 좌 · 우 높이가 달라 x = 0 에서 끊길 수 있음 — 0 양쪽 점을 넣어 계단으로 xs = sorted({*sx, *extra, -1e-6, 1e-6}) zs = np.interp(xs, sx, sz) - np.array([depth_at(x) for x in xs]) return {"mode": "line", "line": [[float(x), float(z)] for x, z in zip(xs, zs)]} def height(x: float) -> float: return float(heights.get("left" if x >= 0 else "right") or 0.0) def rule(name: str) -> dict[str, Any]: if name == "surface": return below(lambda x: 0.0) if name == "half_height": return below(lambda x: 0.5 * height(x)) return {"mode": "none"} if condition != "wet": return rule(water.get("dry", "none")) mode = water.get("wet_mode", "kds") if mode == "ru": return {"mode": "ru", "ru": float(water["ru"])} if mode == "depth_ratio": ratio = float(water["depth_ratio"]) return below(lambda x: ratio * height(x)) if mode == "line": hit = None for item in water.get("lines") or []: # 뒤 구간이 이김(지층 구간과 같은 규칙) if item["from_m"] <= chainage_m <= item["to_m"]: hit = item if hit is not None: ox, depth = zip(*sorted((float(o), float(d)) for o, d in hit["points"])) return below(lambda x: float(np.interp(x, ox, depth)), ox) group = _face_water(ground, chainage_m) return rule(water.get("wet_rock" if group == "rock" else "wet_soil", "none")) def station_groundwater(ground: dict[str, Any], condition: str, model: dict[str, Any]) -> dict: """E03 측점 모형(`section_model_from_b06` 결과) → 그 측점 지하수 — 부르는 쪽이 `model["groundwater"] = station_groundwater(ground, cond, model)` 로 갈아 끼움.""" heights = {side["side"]: side["height_m"] for side in model.get("sides") or []} return groundwater_for( ground, condition, chainage_m=float((model.get("station") or {}).get("chainage_m") or 0.0), surface=model["surface"], heights=heights, )