/* ============================================================================= * E02_StabGround_Check.ts * E02 지반 조건 꼴 · 입력 검사 · 측점 적용(요구 347-2 · 3 · 4 · PLAN 532-3) — 순수 함수만. * * ⚠ 파이썬·TS 짝 — `checkGround` ↔ `E02_StabGround_Model.py` `check_ground` · * `resolveStations` ↔ `resolve_stations`. 한쪽을 고치면 다른 쪽도 · 거울 시험 * `resources/tester/test_532_3_e02_ground.py`. * ========================================================================== */ export type WaterGroup = "soil" | "rock"; export type WaterRule = "none" | "surface" | "half_height"; /** 우기 지하수 갈래 — kds = wet_soil · wet_rock 규칙(기본) · depth_ratio · ru · line(파이썬 `WET_MODES`) */ export type WetMode = "kds" | "depth_ratio" | "ru" | "line"; /** 사용자 수위선 한 구간 — 점 = [offset m(좌 +), 공사 뒤 지표 아래 깊이 m] · offset 오름차순 */ export interface WaterLine { from_m: number; to_m: number; points: [number, number][]; } export interface Groundwater { dry: WaterRule; wet_soil: WaterRule; wet_rock: WaterRule; wet_mode: WetMode; depth_ratio: number; ru: number; lines: WaterLine[]; } /** 물성 한 줄 = E03_StabSection_Types.Material + 지하수 무리 */ export interface Material { id: string; name: string; gamma_t: number; gamma_sat: number; c: number; phi: number; water: WaterGroup; } export interface Station { chainage_m: number; ground_type: string; soil_thickness_m: number | null; } export interface Zone { from_m: number; to_m: number; soil: string | null; rock: string | null; soil_thickness_m: number | null; } export type SlopeFactors = Record<(typeof SLOPES)[number], number>; export interface Ground { version: number; materials: Material[]; label_map: Record<(typeof LABELS)[number], string>; stations: Station[]; zones: Zone[]; safety_factors: Record<(typeof CASES)[number], SlopeFactors>; road_surcharge_kpa: number; groundwater: Groundwater; seismic: { enabled: boolean; kh: number }; } export type Limits = Record; export interface GroundDefaults extends Ground { note: string; limits: Limits; sources: Record; } export interface CheckError { path: string; code: string; } export interface ResolvedStation { chainage_m: number; ground_type: string; upper: string | null; lower: string | null; soil_thickness_m: number | null; zoned: boolean; } export const MATERIAL_FIELDS = ["gamma_t", "gamma_sat", "c", "phi"] as const; export const WATER_GROUPS: readonly WaterGroup[] = ["soil", "rock"]; export const LABELS = ["soil", "ripping_rock", "blasting_rock", "fill"] as const; const ROCK_LABELS = ["ripping_rock", "blasting_rock"]; export const CASES = ["dry", "wet", "seismic", "short_term"] as const; export const SLOPES = ["cut", "fill", "soft"] as const; export const WATER_KEYS = ["dry", "wet_soil", "wet_rock"] as const; export const WATER_RULES: readonly WaterRule[] = ["none", "surface", "half_height"]; export const WET_MODES: readonly WetMode[] = ["kds", "depth_ratio", "ru", "line"]; type Loose = Record; const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); const asList = (v: unknown): unknown[] => (Array.isArray(v) ? v : []); const isDict = (v: unknown): v is Loose => typeof v === "object" && v !== null && !Array.isArray(v); const asDict = (v: unknown): Loose => (isDict(v) ? v : {}); /** 틀린 칸 목록 — 빈 목록 = 통과(code 는 파이썬과 같은 낱말) */ export function checkGround(ground: unknown, limits: Limits): CheckError[] { const errors: CheckError[] = []; const bad = (path: string, code: string): void => void errors.push({ path, code }); const ranged = (path: string, value: unknown, key: string): void => { const [low, high] = limits[key]; if (!isNum(value)) bad(path, "number"); else if (!(low <= value && value <= high)) bad(path, "range"); }; const doc = asDict(ground); const materials = asList(doc.materials); if (!materials.length) bad("materials", "empty"); const ids: string[] = []; const names: string[] = []; materials.forEach((item, i) => { const base = `materials.${i}`; if (!isDict(item)) return bad(base, "type"); const mid = item.id; if (typeof mid !== "string" || !mid || ids.includes(mid)) bad(`${base}.id`, "id_dup"); ids.push(typeof mid === "string" ? mid : ""); const name = typeof item.name === "string" ? item.name.trim() : ""; const [low, high] = limits.name_length; if (!(low <= name.length && name.length <= high)) bad(`${base}.name`, "name"); else if (names.includes(name)) bad(`${base}.name`, "name_dup"); names.push(name); for (const field of MATERIAL_FIELDS) ranged(`${base}.${field}`, item[field], field); if (isNum(item.gamma_t) && isNum(item.gamma_sat) && item.gamma_sat < item.gamma_t) bad(`${base}.gamma_sat`, "sat_lt_t"); if (!WATER_GROUPS.includes(item.water as WaterGroup)) bad(`${base}.water`, "enum"); }); const labelMap = asDict(doc.label_map); for (const label of LABELS) { const ref = labelMap[label]; if (!ref || !ids.includes(ref as string)) bad(`label_map.${label}`, "ref"); } asList(doc.zones).forEach((zone, i) => { const base = `zones.${i}`; if (!isDict(zone)) return bad(base, "type"); ranged(`${base}.from_m`, zone.from_m, "chainage_m"); ranged(`${base}.to_m`, zone.to_m, "chainage_m"); if (isNum(zone.from_m) && isNum(zone.to_m) && zone.from_m >= zone.to_m) bad(`${base}.to_m`, "order"); for (const layer of ["soil", "rock"]) { const ref = zone[layer]; if (ref !== null && ref !== undefined && !ids.includes(ref as string)) bad(`${base}.${layer}`, "ref"); } if (zone.soil_thickness_m !== null && zone.soil_thickness_m !== undefined) ranged(`${base}.soil_thickness_m`, zone.soil_thickness_m, "soil_thickness_m"); }); const factors = asDict(doc.safety_factors); for (const c of CASES) for (const s of SLOPES) ranged(`safety_factors.${c}.${s}`, asDict(factors[c])[s], "safety_factor"); ranged("road_surcharge_kpa", doc.road_surcharge_kpa, "road_surcharge_kpa"); const water = asDict(doc.groundwater); for (const key of WATER_KEYS) if (!WATER_RULES.includes(water[key] as WaterRule)) bad(`groundwater.${key}`, "enum"); if (!WET_MODES.includes(water.wet_mode as WetMode)) bad("groundwater.wet_mode", "enum"); ranged("groundwater.depth_ratio", water.depth_ratio, "depth_ratio"); ranged("groundwater.ru", water.ru, "ru"); if (!Array.isArray(water.lines)) bad("groundwater.lines", "type"); asList(water.lines).forEach((line, i) => { const base = `groundwater.lines.${i}`; if (!isDict(line)) return bad(base, "type"); ranged(`${base}.from_m`, line.from_m, "chainage_m"); ranged(`${base}.to_m`, line.to_m, "chainage_m"); if (isNum(line.from_m) && isNum(line.to_m) && line.from_m >= line.to_m) bad(`${base}.to_m`, "order"); const points = asList(line.points); if (points.length < 2) bad(`${base}.points`, "few_points"); let last: number | null = null; points.forEach((point, k) => { if (!Array.isArray(point) || point.length !== 2) return bad(`${base}.points.${k}`, "type"); ranged(`${base}.points.${k}.0`, point[0], "offset_m"); ranged(`${base}.points.${k}.1`, point[1], "water_depth_m"); if (isNum(point[0])) { if (last !== null && point[0] <= last) bad(`${base}.points.${k}.0`, "order"); last = point[0]; } }); }); const seismic = asDict(doc.seismic); if (typeof seismic.enabled !== "boolean") bad("seismic.enabled", "enum"); ranged("seismic.kh", seismic.kh, "kh"); return errors; } /** 측점마다 적용 지층 — 위 = 토사 연결 · 아래 = 암 측점의 암 연결 · 두께 = B06 · 구간이 덮어씀 * (뒤 구간이 이김 · 양 끝 포함 · 빈 칸은 안 바꿈) · 두께가 없으면 아래 지층 없음 */ export function resolveStations(ground: Ground): ResolvedStation[] { return ground.stations.map((station) => { const chainage = station.chainage_m; let upper: string | null = ground.label_map.soil ?? null; let lower: string | null = ROCK_LABELS.includes(station.ground_type) ? (ground.label_map[station.ground_type as (typeof LABELS)[number]] ?? null) : null; let thickness = station.soil_thickness_m; let zoned = false; for (const zone of ground.zones) { if (!(zone.from_m <= chainage && chainage <= zone.to_m)) continue; zoned = true; if (zone.soil != null) upper = zone.soil; if (zone.rock != null) lower = zone.rock; if (zone.soil_thickness_m != null) thickness = zone.soil_thickness_m; } if (thickness === null) lower = null; // 경계 없음 = 전부 위 지층 return { chainage_m: chainage, ground_type: station.ground_type, upper, lower, soil_thickness_m: lower !== null ? thickness : null, zoned, }; }); }