"""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`. """ from __future__ import annotations import copy import json import math from pathlib import Path from typing import Any 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") # 정본에 남기는 칸 — 그 밖(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 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 · 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") 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} out["groundwater"] = {key: out["groundwater"][key] for key in WATER_KEYS} 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