feat(532-3): E02 지하수 갈래 — 우기 규칙 넷(KDS 기본 · 지표 아래 깊이 비율 · r_u · 사용자 수위선 구간) · 엔진 꼴 바꿈 · 지하수 카드

- ground.json groundwater 칸 덧붙임(wet_mode · depth_ratio · ru · lines · 기존 칸 그대로) · 기본값 = KDS · 범위 칸 넷
- check_ground ↔ checkGround 짝 고침(갈래 · 비율 · r_u · 수위선 구간 · 점) · 거울 시험 판 넷 더함
- groundwater_for · station_groundwater — 측점 모형 → {mode: none | ru | line}(좌 · 우 비탈 높이 따로 · 덮지 않은 측점 = KDS)
- GET · [초기화] = 옛 정본 빠진 칸 기본값으로 채움 · 화면 옛 캐시도 서버 값으로 채움
- 화면 = 지하수 카드 따로(「KDS 기본 · 우기 기준안전율 1.2 와 짝」 안내) · 하중 카드 = 노면 하중 · 지진
- 시험 test_532_4_stab_walls_water(벽 · 지하수) · test_532_3 기본값 지하수 칸 대조 고침(칸 늘어남)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 20:44:00 +09:00
co-authored by Claude Opus 5.5
parent 8cc8122d3f
commit f4077fbcf6
11 changed files with 763 additions and 35 deletions
+133 -2
View File
@@ -7,6 +7,8 @@ ground_defaults.json`(M02 마스터 층). 물성 한 줄 = `E03_StabSection_Type
⚠ 파이썬·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
@@ -17,6 +19,8 @@ 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"
@@ -32,6 +36,11 @@ 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",
@@ -63,6 +72,16 @@ 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)
@@ -88,7 +107,7 @@ def _dict(value: Any) -> dict:
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."""
name · name_dup · id_dup · enum · ref · empty · few_points · type."""
errors: list[dict[str, str]] = []
def bad(path: str, code: str) -> None:
@@ -161,6 +180,36 @@ def check_ground(ground: dict[str, Any], limits: dict[str, list[float]]) -> list
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")
@@ -187,7 +236,12 @@ def clean(ground: dict[str, Any]) -> dict[str, Any]:
]
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}
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
@@ -228,3 +282,80 @@ def resolve_stations(ground: dict[str, Any]) -> list[dict[str, Any]]:
}
)
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,
)
+5 -3
View File
@@ -112,10 +112,12 @@ async def get_ground(
root, project = await _project(project_id)
initial = ensure_initial(root)
working = _read_working(root)
defaults = model.load_defaults()
return {
"ground": working or initial,
# 옛 정본(지하수 갈래 칸 전) = 빠진 칸만 기본값으로 채워 보냄(파일은 [저장] 때 씀)
"ground": model.upgrade(working or initial, defaults),
"saved": working is not None,
"defaults": model.load_defaults(),
"defaults": defaults,
"editable": _editable(session, project),
}
@@ -141,4 +143,4 @@ async def reset_ground(project_id: UUID) -> dict[str, Any]:
root, _ = await _project(project_id)
initial = ensure_initial(root)
atomic_write_json(model.ground_path(root), initial)
return {"ground": initial}
return {"ground": model.upgrade(initial, model.load_defaults())}
+4
View File
@@ -42,6 +42,8 @@ function limitKey(path: string): string {
const last = path.split(".").pop() ?? "";
if (path.startsWith("safety_factors.")) return "safety_factor";
if (last === "from_m" || last === "to_m") return "chainage_m";
if (/\.points\.\d+\.0$/.test(path)) return "offset_m";
if (/\.points\.\d+\.1$/.test(path)) return "water_depth_m";
return last;
}
@@ -141,6 +143,8 @@ export function renderE02StabGround(root: HTMLElement): void {
const cached = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null;
if (cached) ground = JSON.parse(cached) as Ground;
else sessionStorage.removeItem(cacheKey(projectId));
// 옛 캐시(지하수 갈래 칸 전) — 빠진 칸은 서버 값(서버가 기본값으로 채워 보냄)
ground.groundwater = { ...body.ground.groundwater, ...ground.groundwater };
dirty = Boolean(cached);
view.ground = ground;
view.defaults = body.defaults;
@@ -170,3 +170,17 @@
.e02-card--wide {
grid-column: 1 / -1;
}
/* 지하수 카드 — 사용자 수위선 점(offset · 깊이) 줄 */
.e02-points {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
align-items: center;
}
.e02-point {
display: inline-flex;
gap: 2px;
align-items: center;
}
+20 -26
View File
@@ -1,7 +1,7 @@
/* =============================================================================
* E02_StabGround_UI_Tables.ts
* E02 지반 조건 본문 카드 넷(요구 347-2 · 3 · 4) — 물성 표(지층 추가 · 지움 · B06 지반 연결) ·
* 층 두께 · 구간 덮어쓰기 + 측점별 적용 · 기준안전율 · 하중 · 지하수 · 지진. 카드마다 [기본값].
* 층 두께 · 구간 덮어쓰기 + 측점별 적용 · 기준안전율 · 하중 · 지진 · 지하수(`_UI_Water`). 카드마다 [기본값].
* 칸 = data-path(검사 열쇠 · 파이썬과 같음) · data-src(근거 열쇠) · data-def(기본값 글).
* 값 바꿈 = view.change()(캐시 · 검사) · 줄 더함 · 지움 · [기본값] = view.rebuild().
* ========================================================================== */
@@ -16,13 +16,12 @@ import {
MATERIAL_FIELDS,
SLOPES,
WATER_GROUPS,
WATER_KEYS,
WATER_RULES,
resolveStations,
type Ground,
type GroundDefaults,
type Material,
} from "./logic/E02_StabGround_Check";
import { waterCard } from "./E02_StabGround_UI_Water";
export interface E02View {
ground: Ground;
@@ -33,7 +32,7 @@ export interface E02View {
}
const L = (key: string): string => t(key as LocaleKey);
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
export const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
const FIELD_LABEL: Record<(typeof MATERIAL_FIELDS)[number], LocaleKey> = {
gamma_t: "E02_Col_GammaT",
gamma_sat: "E02_Col_GammaSat",
@@ -42,7 +41,7 @@ const FIELD_LABEL: Record<(typeof MATERIAL_FIELDS)[number], LocaleKey> = {
};
/** 숫자 칸 — 빈 칸 = null(nullable) · 아니면 숫자 아님으로 검사에 걸림 */
function numInput(
export function numInput(
view: E02View,
path: string,
value: number | null,
@@ -68,7 +67,7 @@ function numInput(
return input;
}
function selectInput(
export function selectInput(
view: E02View,
path: string,
options: { value: string; text: string }[],
@@ -92,7 +91,7 @@ function selectInput(
return select;
}
function card(
export function card(
title: LocaleKey,
...body: HTMLElement[]
): { section: HTMLElement; head: HTMLElement } {
@@ -103,14 +102,18 @@ function card(
return { section, head };
}
function headButtons(view: E02View, head: HTMLElement, ...buttons: HTMLButtonElement[]): void {
export function headButtons(
view: E02View,
head: HTMLElement,
...buttons: HTMLButtonElement[]
): void {
if (!view.editable) return;
const row = el("div", { className: "e02-card__actions" });
row.append(...buttons);
head.append(row);
}
function table(headers: string[], rows: HTMLElement[][]): HTMLTableElement {
export function table(headers: string[], rows: HTMLElement[][]): HTMLTableElement {
const node = el("table", { className: "e02-table" }) as HTMLTableElement;
const tr = node.createTHead().insertRow();
for (const text of headers) tr.append(el("th", { text }));
@@ -405,7 +408,7 @@ function safetyCard(view: E02View): HTMLElement {
return section;
}
/* --- 4. 노면 하중 · 지하수 · 지진 --- */
/* --- 4. 노면 하중 · 지진 (지하수 = `E02_StabGround_UI_Water`) --- */
function loadsCard(view: E02View): HTMLElement {
const g = view.ground;
const d = view.defaults;
@@ -429,19 +432,6 @@ function loadsCard(view: E02View): HTMLElement {
kh.disabled = !view.editable || !g.seismic.enabled;
view.change();
});
const water = WATER_KEYS.map((key) =>
field(
L(`E02_Water_${key}`),
selectInput(
view,
`groundwater.${key}`,
WATER_RULES.map((r) => ({ value: r, text: L(`E02_Rule_${r}`) })),
g.groundwater[key],
(v) => (g.groundwater[key] = v as (typeof WATER_RULES)[number]),
{ src: `groundwater.${key}`, def: L(`E02_Rule_${d.groundwater[key]}`) },
),
),
);
const grid = el("div", { className: "e02-grid" });
grid.append(
field(
@@ -457,7 +447,6 @@ function loadsCard(view: E02View): HTMLElement {
},
),
),
...water,
seismic.root,
field(t("E02_Kh"), kh),
);
@@ -471,7 +460,6 @@ function loadsCard(view: E02View): HTMLElement {
size: "sm",
onClick: () => {
g.road_surcharge_kpa = d.road_surcharge_kpa;
g.groundwater = clone(d.groundwater);
g.seismic = clone(d.seismic);
view.rebuild();
},
@@ -485,6 +473,12 @@ export function buildCards(view: E02View): { root: HTMLElement; refreshStations:
const root = el("div", { className: "e02-cards" });
const zones = zonesCard(view);
zones.section.classList.add("e02-card--wide");
root.append(materialsCard(view), safetyCard(view), loadsCard(view), zones.section);
root.append(
materialsCard(view),
safetyCard(view),
loadsCard(view),
waterCard(view),
zones.section,
);
return { root, refreshStations: zones.refresh };
}
+182
View File
@@ -0,0 +1,182 @@
/* =============================================================================
* E02_StabGround_UI_Water.ts
* E02 지하수 카드(요구 347 · 351 · PLAN 532-3) — 건기 규칙 · 우기 갈래 넷(KDS 기본 · 지표 아래 깊이 비율 ·
* r_u · 사용자 수위선 구간) · 「KDS 기본 · 우기 기준안전율 1.2 와 짝」 안내 · [기본값].
* 엔진 꼴로 바꾸기는 서버(`E02_StabGround_Model.station_groundwater`) — 여기는 값만 고침.
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { WATER_KEYS, WATER_RULES, WET_MODES, type WetMode } from "./logic/E02_StabGround_Check";
import {
card,
clone,
headButtons,
numInput,
selectInput,
table,
type E02View,
} from "./E02_StabGround_UI_Tables";
const L = (key: string): string => t(key as LocaleKey);
function field(label: string, input: HTMLElement): HTMLElement {
const node = el("label", { className: "e02-field" });
node.append(el("span", { text: label }), input);
return node;
}
function removeButton(view: E02View, onClick: () => void): HTMLButtonElement {
const button = createButton({
label: t("E02_Btn_Remove"),
variant: "danger-ghost",
size: "sm",
onClick,
});
button.hidden = !view.editable;
return button;
}
/** 사용자 수위선 구간 표 — 줄마다 시작 · 끝 · 점(offset · 깊이) · [점 추가] · [지움] */
function linesBody(view: E02View): HTMLElement {
const water = view.ground.groundwater;
if (!water.lines.length) return el("p", { className: "e02-empty", text: t("E02_Line_Empty") });
const rows = water.lines.map((line, i) => {
const base = `groundwater.lines.${i}`;
const points = el("div", { className: "e02-points" });
points.dataset.path = `${base}.points`;
line.points.forEach((point, k) => {
const pair = el("span", { className: "e02-point" });
pair.append(
numInput(view, `${base}.points.${k}.0`, point[0], (v) => (point[0] = v as number)),
numInput(view, `${base}.points.${k}.1`, point[1], (v) => (point[1] = v as number)),
removeButton(view, () => {
line.points.splice(k, 1);
view.rebuild();
}),
);
points.append(pair);
});
const addPoint = createButton({
label: t("E02_Btn_AddPoint"),
variant: "ghost",
size: "sm",
onClick: () => {
const last = line.points[line.points.length - 1];
line.points.push(last ? [last[0] + 5, last[1]] : [0, 1]);
view.rebuild();
},
});
addPoint.hidden = !view.editable;
points.append(addPoint);
return [
numInput(view, `${base}.from_m`, line.from_m, (v) => (line.from_m = v as number)),
numInput(view, `${base}.to_m`, line.to_m, (v) => (line.to_m = v as number)),
points,
removeButton(view, () => {
water.lines.splice(i, 1);
view.rebuild();
}),
];
});
return table([t("E02_Col_From"), t("E02_Col_To"), t("E02_Col_Points"), ""], rows);
}
export function waterCard(view: E02View): HTMLElement {
const water = view.ground.groundwater;
const d = view.defaults.groundwater;
const rule = (key: (typeof WATER_KEYS)[number]): HTMLElement =>
field(
L(`E02_Water_${key}`),
selectInput(
view,
`groundwater.${key}`,
WATER_RULES.map((r) => ({ value: r, text: L(`E02_Rule_${r}`) })),
water[key],
(v) => (water[key] = v as (typeof WATER_RULES)[number]),
{ src: `groundwater.${key}`, def: L(`E02_Rule_${d[key]}`) },
),
);
const mode = selectInput(
view,
"groundwater.wet_mode",
WET_MODES.map((m) => ({ value: m, text: L(`E02_WetMode_${m}`) })),
water.wet_mode,
(v) => {
water.wet_mode = v as WetMode;
view.rebuild();
},
{ src: "groundwater.wet_mode", def: L(`E02_WetMode_${d.wet_mode}`) },
);
const grid = el("div", { className: "e02-grid" });
grid.append(rule("dry"), field(t("E02_WetMode"), mode));
const body: HTMLElement[] = [];
if (water.wet_mode === "kds") grid.append(rule("wet_soil"), rule("wet_rock"));
if (water.wet_mode === "depth_ratio")
grid.append(
field(
t("E02_DepthRatio"),
numInput(
view,
"groundwater.depth_ratio",
water.depth_ratio,
(v) => (water.depth_ratio = v as number),
{ src: "groundwater.depth_ratio", def: d.depth_ratio },
),
),
);
if (water.wet_mode === "ru")
grid.append(
field(
t("E02_Ru"),
numInput(view, "groundwater.ru", water.ru, (v) => (water.ru = v as number), {
src: "groundwater.ru",
def: d.ru,
}),
),
);
if (water.wet_mode === "line")
body.push(el("p", { className: "e02-hint", text: t("E02_Line_Hint") }), linesBody(view));
const { section, head } = card(
"E02_Card_Water",
el("p", { className: "e02-hint", text: t("E02_Water_Hint") }),
grid,
...body,
);
const stations = view.ground.stations;
const first = stations[0]?.chainage_m ?? 0;
const last = stations[stations.length - 1]?.chainage_m ?? 100;
const buttons = [
createButton({
label: t("E02_Btn_Defaults"),
variant: "ghost",
size: "sm",
onClick: () => {
view.ground.groundwater = clone(d);
view.rebuild();
},
}),
];
if (water.wet_mode === "line")
buttons.unshift(
createButton({
label: t("E02_Btn_AddLine"),
variant: "ghost",
size: "sm",
onClick: () => {
water.lines.push({
from_m: first,
to_m: last > first ? last : first + 20,
points: [
[-10, 1],
[10, 1],
],
});
view.rebuild();
},
}),
);
headButtons(view, head, ...buttons);
return section;
}
+45 -1
View File
@@ -9,6 +9,25 @@
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 {
@@ -45,7 +64,7 @@ export interface Ground {
zones: Zone[];
safety_factors: Record<(typeof CASES)[number], SlopeFactors>;
road_surcharge_kpa: number;
groundwater: Record<(typeof WATER_KEYS)[number], WaterRule>;
groundwater: Groundwater;
seismic: { enabled: boolean; kh: number };
}
@@ -79,6 +98,7 @@ 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<string, unknown>;
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
@@ -149,6 +169,30 @@ export function checkGround(ground: unknown, limits: Limits): CheckError[] {
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");
@@ -19,13 +19,25 @@
"short_term": { "cut": 1.1, "fill": 1.1, "soft": 1.1 }
},
"road_surcharge_kpa": 10.0,
"groundwater": { "dry": "none", "wet_soil": "surface", "wet_rock": "half_height" },
"groundwater": {
"dry": "none",
"wet_soil": "surface",
"wet_rock": "half_height",
"wet_mode": "kds",
"depth_ratio": 0.5,
"ru": 0.2,
"lines": []
},
"seismic": { "enabled": false, "kh": 0.0 },
"limits": {
"gamma_t": [10.0, 30.0],
"gamma_sat": [10.0, 30.0],
"c": [0.0, 1000.0],
"phi": [0.0, 60.0],
"depth_ratio": [0.0, 1.0],
"ru": [0.0, 0.9],
"offset_m": [-200.0, 200.0],
"water_depth_m": [0.0, 50.0],
"safety_factor": [1.0, 3.0],
"road_surcharge_kpa": [0.0, 100.0],
"kh": [0.0, 0.5],
@@ -66,6 +78,10 @@
"groundwater.dry": "KDS 11 70 05 표 4.3-3 건기 = 지하수 없음",
"groundwater.wet_soil": "KDS 11 70 05 표 4.3-3 우기 토층 · 풍화암 = 지하수위 결정(지표 포화 가정)",
"groundwater.wet_rock": "KDS 11 70 05 표 4.3-3 우기 연암 · 경암 = 비탈면 높이 ½ 심도까지 포화",
"groundwater.wet_mode": "우기 지하수 규칙 — 기본 = KDS 11 70 05 표 4.3-3(위 두 칸 · 우기 기준안전율 깎기 1.2 와 짝) · 그 밖 갈래는 사용자 판단",
"groundwater.depth_ratio": "지표 아래 깊이 = 비탈 높이 × 비율 — 근거 없음 · 사용자 입력(0.5 = KDS 암반 ½ 와 같은 값)",
"groundwater.ru": "간극수압비 u = r_u · γ·h — 근거 없음 · 사용자 입력",
"groundwater.lines": "사용자 수위선 — 구간(누가 m) · 점(offset m · 공사 뒤 지표 아래 깊이 m) · 덮지 않은 측점은 KDS 규칙",
"seismic.enabled": "KDS 11 90 00 1.8.1 ② 영향 범위에 주구조물 없으면 비적용 · 임도는 발주자 협의 — 기본 끔",
"seismic.kh": "유사정적 수평지진계수 — 지반가속도의 비율로 사용자 입력(KDS 11 70 05 표 4.3 지진 시)",
"stations": "B06 횡단 암 경계(지반선 평행 · 기본 −0.5 m · 물량 가름용 값) — 토사 측점은 경계 없음"
+42 -1
View File
@@ -79,6 +79,26 @@ CHECK_CASES = [
_bad(lambda g: g["groundwater"].update(wet_rock="full")),
_bad(lambda g: g.update(seismic={"enabled": "yes", "kh": 0.6})),
_bad(lambda g: g.update(label_map=[], safety_factors=None, groundwater="x")),
# 지하수 갈래(532-3 덧) — 갈래 · 비율 · r_u · 수위선 구간 · 점
_bad(lambda g: g["groundwater"].update(wet_mode="rain", depth_ratio=1.5, ru=None)),
_bad(lambda g: g["groundwater"].update(ru=0.95, lines="x")),
_bad(
lambda g: g["groundwater"].update(
wet_mode="line",
lines=[
{"from_m": 40, "to_m": 20, "points": [[5, 1]]},
{"from_m": 0, "to_m": 20, "points": [[5, 1], [5, 2], [300, -1], "p", [1, 2, 3]]},
7,
],
)
),
_ground(
groundwater={
**DEFAULTS["groundwater"],
"wet_mode": "line",
"lines": [{"from_m": 0, "to_m": 20, "points": [[-10, 2], [0, 1.5], [10, 2]]}],
}
),
]
RESOLVE_CASES = [
@@ -124,6 +144,10 @@ def test_기본값_꼴은_Types_그대로():
"dry": "none",
"wet_soil": "surface",
"wet_rock": "half_height",
"wet_mode": "kds",
"depth_ratio": 0.5,
"ru": 0.2,
"lines": [],
}
assert DEFAULTS["seismic"] == {"enabled": False, "kh": 0.0}
# 07 §4 종합 성토 φ 28~30 — 브레인 기본 25 는 범위 밖이라 아래값 28
@@ -152,6 +176,23 @@ def test_범위_검사():
("safety_factors.wet.soft", "range"),
}
assert codes(CHECK_CASES[17]) == {("seismic.enabled", "enum"), ("seismic.kh", "range")}
assert codes(CHECK_CASES[19]) == {
("groundwater.wet_mode", "enum"),
("groundwater.depth_ratio", "range"),
("groundwater.ru", "number"),
}
assert codes(CHECK_CASES[20]) == {("groundwater.ru", "range"), ("groundwater.lines", "type")}
assert codes(CHECK_CASES[21]) == {
("groundwater.lines.0.to_m", "order"),
("groundwater.lines.0.points", "few_points"),
("groundwater.lines.1.points.1.0", "order"),
("groundwater.lines.1.points.2.0", "range"),
("groundwater.lines.1.points.2.1", "range"),
("groundwater.lines.1.points.3", "type"),
("groundwater.lines.1.points.4", "type"),
("groundwater.lines.2", "type"),
}
assert codes(CHECK_CASES[22]) == set()
def test_파이썬과_TS_같음():
@@ -168,7 +209,7 @@ def test_파이썬과_TS_같음():
out = json.loads(proc.stdout)
assert out["check"] == [model.check_ground(g, LIMITS) for g in CHECK_CASES]
assert out["resolve"] == [model.resolve_stations(g) for g in RESOLVE_CASES]
assert all(out["check"][1:]) # 틀린 판은 모두 걸림
assert all(out["check"][1:-1]) and out["check"][-1] == [] # 틀린 판은 모두 걸림 · 끝 판 통과
def test_측점_적용():
@@ -0,0 +1,273 @@
# -*- coding: utf-8 -*-
"""532-4 E03 벽(옹벽 · 석축 · 돌쌓기) · 532-3 덧 E02 지하수 갈래.
- 벽 = B06 화면 TS(`computeStoredLayouts`)를 서버 Node 묶음(`build:e03-walls`)으로 —
벽 선 측점 임계면이 벽 폴리곤을 안 지남(모서리 스침 ≤ 0.2 m · 절편 폭 아래) ·
벽 없는 측점 = 종전 그대로 · 복사본(구조물 정본 → B06 얹기) 끝까지.
- 지하수 = 건기 · 우기 갈래 넷(KDS · 깊이 비율 · r_u · 사용자 수위선) → 엔진 꼴 ·
[저장] · [초기화] · 옛 정본 채움.
./venv/Scripts/python.exe -m pytest resources/tester/test_532_4_stab_walls_water.py -q
"""
from __future__ import annotations
import asyncio
import copy
import json
import sys
from pathlib import Path
from uuid import UUID
import numpy as np
import pytest
from shapely.geometry import LineString, Polygon
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from E02_StabGround import E02_StabGround_Model as ground_model # noqa: E402
from E02_StabGround import E02_StabGround_Router as router # noqa: E402
from E03_StabSection import E03_StabSection_Types as types # noqa: E402
from E03_StabSection.E03_StabSection_Engine_Model import section_model_from_b06 # noqa: E402
from E03_StabSection.E03_StabSection_Engine_Search import analyze # noqa: E402
from E03_StabSection.E03_StabSection_Engine_Station import analyze_station # noqa: E402
from E03_StabSection.E03_StabSection_Walls import ( # noqa: E402
design_with_walls,
walls_by_station,
walls_for_sections,
walls_of,
)
# 오른쪽(−) 산 · 왼쪽(+) 골 · 노면 −2~2 · 좌 성토 1:1.5(test_532_2 와 같은 단면)
GROUND = [(x / 2, 10.0 - 0.6 * (x / 2)) for x in range(-40, 41)]
LINE = [(-20.0, 22.0), (-6.0, 13.6), (-2.6, 10.0), (-2.0, 10.0), (2.0, 10.0), (14.0, 2.0)]
DESIGN = {
"section_mode": "right_cut",
"ground_type": "soil",
"road_edges": {
"left": {"offset_m": 2.0, "elevation_m": 10.0},
"right": {"offset_m": -2.0, "elevation_m": 10.0},
},
"carriageway_edges": {
"left": {"offset_m": 1.5, "elevation_m": 10.0},
"right": {"offset_m": -1.5, "elevation_m": 10.0},
},
"cross_slope_pct": 0,
"design_elevation_m": 10.0,
"design_line": [{"offset_m": a, "elevation_m": b} for a, b in LINE],
}
CROSS = {
"chainage_m": 100.0,
"label": "STA.0+100",
"samples": [{"offset_m": a, "elevation_m": b, "valid": True} for a, b in GROUND],
"design": DESIGN,
}
WALL = {
"structure_id": "w1",
"type_id": "masonry_wet",
"name": "돌쌓기(찰)",
"start_m": 90.0,
"end_m": 110.0,
"anchor_m": 100.0,
"form": "돌쌓기(찰)",
"height_m": 3.0,
"tiers": 1,
"extras": [],
}
FAST = {"conditions": ("dry",), "methods": ("bishop", "janbu")}
CORNER_M = 0.2 # 임계면이 벽 모서리를 스치는 길이 한도(절편 폭 아래)
@pytest.fixture(scope="module")
def found():
return walls_for_sections([{**CROSS, "revetment": WALL}, {**CROSS, "chainage_m": 120.0}])
def test_벽_도형_B06_산식(found):
assert list(found) == ["100.00"] # 벽 없는 측점은 안 실음
entry = found["100.00"]
(wall,) = entry["walls"]
assert wall["material_id"] == "wall" and wall["kind"] == "own"
poly = Polygon(wall["polygon"])
assert poly.is_valid and len(wall["polygon"]) == 4
# 높이 = 바닥(근입 0.5 포함) ~ 상단 3 m · 상단 = 노견 높이 · 성토 쪽(좌 +)
zs = [p[1] for p in wall["polygon"]]
assert max(zs) - min(zs) == pytest.approx(3.0) and max(zs) == pytest.approx(10.0)
assert min(p[0] for p in wall["polygon"]) > 0
# 지표 = 그려지는 설계선 — 벽 상단(노견)에서 끊겨 바깥은 원지반
line = entry["design_line"]
gx, gz = zip(*GROUND)
out = [(x, z) for x, z in line if x > 2.0 + 1e-3]
assert out and all(abs(z - np.interp(x, gx, gz)) < 1e-6 for x, z in out)
def test_임계면이_벽을_안_지남(found):
entry = found["100.00"]
design = design_with_walls(DESIGN, entry)
res = analyze_station(CROSS, design, walls=walls_of(entry), **FAST)
poly = Polygon(entry["walls"][0]["polygon"])
fill = [c for c in res["cases"] if c["role"] == "fill"]
assert fill
for case in fill:
for got in case["result"]["methods"].values():
assert got["min"]["F"] > 0
assert LineString(got["min"]["points"]).intersection(poly).length <= CORNER_M
plain = analyze_station(CROSS, DESIGN, **FAST)
# 벽이 성토 비탈을 대신함 — 성토 쪽 최소 F 가 달라짐 · 절토 쪽은 그대로
for a, b in zip(plain["cases"], res["cases"]):
if a["role"] == "cut":
assert a["min_F"] == pytest.approx(b["min_F"], abs=1e-9)
else:
assert a["min_F"]["bishop"] != pytest.approx(b["min_F"]["bishop"], abs=1e-3)
def test_벽_없는_측점_모형_그대로():
assert design_with_walls(DESIGN, None) is DESIGN and walls_of(None) is None
a = section_model_from_b06(CROSS, DESIGN)
b = section_model_from_b06(CROSS, design_with_walls(DESIGN, None), walls=walls_of(None))
assert json.dumps(a, sort_keys=True) == json.dumps(b, sort_keys=True)
def _write_copy(root: Path, with_wall: bool) -> Path:
"""E01 동결 복사본 꼴 — db.json(측점 행 · 노선) · 횡단 파일 · 구조물 정본."""
from B05_Profile.B05_Profile_Structures_Repository import save_structures
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
source = root / "E01_StabRegister" / "source"
(source / "B06_Section" / "cross_sections").mkdir(parents=True)
(source / "copy_manifest.json").write_text("{}", encoding="utf-8")
rows = []
for k, chainage in enumerate((80.0, 100.0)):
rel = f"B06_Section/cross_sections/cross_{int(chainage):06d}.json"
cross = {k: v for k, v in CROSS.items() if k != "design"}
cross.update(chainage_m=chainage, center_x=float(chainage), center_y=0.0)
(source / rel).write_text(json.dumps(cross), encoding="utf-8")
rows.append(
{
"chainage_m": chainage,
"sequence_num": k,
"data": json.dumps({"design": DESIGN}),
"cross_section_file_path": rel,
}
)
db = {"cross_sections": rows, "route": {"route_data_path": None}}
(source / "db.json").write_text(json.dumps(db), encoding="utf-8")
if with_wall:
item = StructureInstance(
type_id="masonry_wet",
placement="interval",
chainage_m=100,
start_m=95,
end_m=105,
options={"height_m": 3.0},
)
save_structures(str(source), [item], base_revision=0)
return source
def test_복사본_구조물_정본에서_측점별_벽(tmp_path):
source = _write_copy(tmp_path / "a", with_wall=True)
got = walls_by_station(source)
assert list(got) == ["100.00"]
(wall,) = got["100.00"]["walls"]
assert wall["name"] == "돌쌓기(찰)" and len(wall["polygon"]) == 4
assert walls_by_station(_write_copy(tmp_path / "b", with_wall=False)) == {}
# ---- 지하수 갈래 -----------------------------------------------------------------
STATIONS = [
{"chainage_m": 0.0, "ground_type": "soil", "soil_thickness_m": None},
{"chainage_m": 20.0, "ground_type": "blasting_rock", "soil_thickness_m": 0.0},
]
SURFACE = [[-10.0, 16.0], [-2.0, 10.0], [2.0, 10.0], [10.0, 4.0]]
HEIGHTS = {"left": 6.0, "right": 4.0}
def _water(chainage=0.0, condition="wet", **groundwater):
ground = ground_model.build_initial(ground_model.load_defaults(), copy.deepcopy(STATIONS))
ground["groundwater"].update(groundwater)
out = ground_model.groundwater_for(
ground, condition, chainage_m=chainage, surface=SURFACE, heights=HEIGHTS
)
assert types.groundwater(out)["mode"] == out["mode"] # 엔진 꼴 그대로
return out
def _depth(out):
sx, sz = zip(*SURFACE)
return {round(x, 6): round(float(np.interp(x, sx, sz)) - z, 6) for x, z in out["line"]}
def test_지하수_건기_KDS():
assert _water(condition="dry") == {"mode": "none"}
assert _water(condition="seismic") == {"mode": "none"}
assert set(_depth(_water()).values()) == {0.0} # 토사 측점 = 지표 포화
rock = _depth(_water(chainage=20.0)) # 연암(겉 = 암 · 두께 0) = 비탈 높이 ½
assert rock[10.0] == 3.0 and rock[-10.0] == 2.0
def test_지하수_깊이비율_ru_수위선():
ratio = _depth(_water(wet_mode="depth_ratio", depth_ratio=0.25))
assert ratio[10.0] == 1.5 and ratio[-10.0] == 1.0 and ratio[2.0] == 1.5
assert _water(wet_mode="ru", ru=0.3) == {"mode": "ru", "ru": 0.3}
lines = [
{"from_m": 0.0, "to_m": 10.0, "points": [[-5.0, 2.0], [5.0, 1.0]]},
{"from_m": 0.0, "to_m": 5.0, "points": [[-5.0, 3.0], [5.0, 3.0]]}, # 뒤 구간이 이김
]
user = _depth(_water(wet_mode="line", lines=lines))
assert set(user.values()) == {3.0}
one = _depth(_water(wet_mode="line", lines=lines[:1]))
assert one[-10.0] == 2.0 and one[2.0] == 1.3 and one[10.0] == 1.0 # 끝은 끝값
# 덮지 않은 측점 = KDS
assert set(_depth(_water(chainage=20.0, wet_mode="line", lines=lines)).values()) == {2.0, 3.0}
def test_지하수_측점_모형에_끼움():
model = section_model_from_b06(CROSS, DESIGN, wet=True)
ground = ground_model.build_initial(ground_model.load_defaults(), [])
ground["groundwater"].update(wet_mode="depth_ratio", depth_ratio=0.5)
gw = ground_model.station_groundwater(ground, "wet", model)
left = next(s["height_m"] for s in model["sides"] if s["side"] == "left")
sx, sz = zip(*model["surface"])
x, z = next(p for p in gw["line"] if p[0] > 1)
assert float(np.interp(x, sx, sz)) - z == pytest.approx(0.5 * left)
model["groundwater"] = types.groundwater(gw)
side = next(s for s in model["sides"] if s["side"] == "left")
res = analyze(model, direction=side["direction"], limits=side["limits"], methods=("bishop",))
assert res["methods"]["bishop"]["min"]["F"] > 0 # 끼운 모형이 엔진에서 돎
@pytest.fixture
def project(tmp_path, monkeypatch):
async def fake_project(_project_id):
return tmp_path, {"company_id": 1, "project_kind": "STABILITY"}
monkeypatch.setattr(router, "_project", fake_project)
monkeypatch.setattr(router, "_b06_stations", lambda _root: copy.deepcopy(STATIONS))
return tmp_path
def test_지하수_저장_초기화_옛정본(project):
pid = UUID("9872e9a7-e483-427b-a6b2-72cdbc063bc3")
session = {"role": "USER", "company_id": 1}
first = asyncio.run(router.get_ground(pid, session))
ground = copy.deepcopy(first["ground"])
assert ground["groundwater"]["wet_mode"] == "kds"
line = {"from_m": 0.0, "to_m": 20.0, "points": [[10.0, 2.0], [-10.0, 1.0]][::-1]}
ground["groundwater"].update(wet_mode="line", lines=[line], ru=0.35)
asyncio.run(router.put_ground(pid, router.GroundRequest(ground=ground)))
stored = json.loads(ground_model.ground_path(project).read_text(encoding="utf-8"))
assert stored["groundwater"] == {**ground["groundwater"], "lines": [line]}
bad = copy.deepcopy(ground)
bad["groundwater"]["lines"][0]["points"] = [[1.0, 1.0]]
with pytest.raises(router.HTTPException):
asyncio.run(router.put_ground(pid, router.GroundRequest(ground=bad)))
back = asyncio.run(router.reset_ground(pid))["ground"]
assert back["groundwater"]["wet_mode"] == "kds" and back["groundwater"]["lines"] == []
# 옛 정본(갈래 칸 없음) = GET 이 기본값으로 채움 · 있는 칸은 그대로
old = json.loads(ground_model.ground_path(project).read_text(encoding="utf-8"))
old["groundwater"] = {"dry": "none", "wet_soil": "none", "wet_rock": "half_height"}
ground_model.ground_path(project).write_text(json.dumps(old), encoding="utf-8")
got = asyncio.run(router.get_ground(pid, session))["ground"]["groundwater"]
assert got["wet_soil"] == "none" and got["wet_mode"] == "kds" and got["lines"] == []
+28 -1
View File
@@ -13,7 +13,7 @@ export const ui_locales_e = {
E02_Card_Zones: ["층 두께 · 구간 덮어쓰기", "Layer Thickness · Zone Overrides"],
E02_Card_Stations: ["측점별 적용 지층", "Layers per Station"],
E02_Card_Safety: ["기준안전율", "Required Factors of Safety"],
E02_Card_Loads: ["노면 하중 · 지하수 · 지진", "Surcharge · Groundwater · Seismic"],
E02_Card_Loads: ["노면 하중 · 지진", "Surcharge · Seismic"],
E02_Note: [
"문헌 추정치 · 현장 시험 없음 — 값은 바꿀 수 있습니다. 칸에 마우스를 올리면 근거가 보입니다.",
"Literature estimates without field tests — values are editable. Hover a cell for its source.",
@@ -86,6 +86,32 @@ export const ui_locales_e = {
E02_Rule_half_height: ["비탈 높이 ½ 포화", "Saturated to ½ height"],
E02_Seismic: ["지진 검토", "Seismic check"],
E02_Kh: ["수평지진계수 kh", "Horizontal coefficient kh"],
E02_Card_Water: ["지하수", "Groundwater"],
E02_Water_Hint: [
"기본 = KDS 11 70 05 표 4.3-3(우기 토사 · 풍화암 지표 포화 · 암반 비탈 높이 ½) — 우기 기준안전율 깎기 1.2 와 짝. 다른 규칙을 고르면 기준안전율도 함께 검토하세요.",
"Default = KDS 11 70 05 Table 4.3-3 (wet: soil · weathered rock saturated to surface · rock to ½ slope height) — pairs with wet cut FS 1.2. Review the factors when choosing another rule.",
],
E02_WetMode: ["우기 규칙", "Wet-season rule"],
E02_WetMode_kds: ["KDS 기본", "KDS default"],
E02_WetMode_depth_ratio: [
"지표 아래 깊이 = 비탈 높이 × 비율",
"Depth below surface = slope height × ratio",
],
E02_WetMode_ru: ["간극수압비 r_u", "Pore pressure ratio r_u"],
E02_WetMode_line: ["사용자 수위선", "User water line"],
E02_DepthRatio: ["깊이 비율", "Depth ratio"],
E02_Ru: ["r_u", "r_u"],
E02_Line_Hint: [
"구간마다 점 = offset(m · 좌 +) · 공사 뒤 지표 아래 깊이(m). 덮지 않은 측점은 KDS 규칙.",
"Per zone, points = offset (m, left +) · depth below finished surface (m). Uncovered stations use the KDS rule.",
],
E02_Line_Empty: [
"수위선 구간이 없습니다 — 모든 측점이 KDS 규칙.",
"No water-line zones — all stations use the KDS rule.",
],
E02_Btn_AddLine: ["수위선 구간 추가", "Add water line"],
E02_Btn_AddPoint: ["점 추가", "Add point"],
E02_Col_Points: ["점 (offset · 깊이)", "Points (offset · depth)"],
/* --- 상태 · 알림 --- */
E02_Unsaved: ["저장 안 된 변경이 있습니다.", "There are unsaved changes."],
@@ -113,4 +139,5 @@ export const ui_locales_e = {
E02_Err_ref: ["지층을 고르세요", "Pick a layer"],
E02_Err_empty: ["지층이 하나는 있어야 합니다", "At least one layer is required"],
E02_Err_type: ["꼴이 틀립니다", "Invalid shape"],
E02_Err_few_points: ["점이 둘 이상 있어야 합니다", "At least two points are required"],
} as const satisfies Record<string, LocaleEntry>;