Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-25 20:44:44 +09:00
16 changed files with 985 additions and 37 deletions
+2
View File
@@ -72,5 +72,7 @@ config/b07_extent_node/
config/b07_note_node/
# 계산 등록부 서버 Node 입구 번들 — `npm run build:design-calc` 산출물(472-3).
config/design_calc_node/
# E03 안정성 모형 벽 도형 번들 — `npm run build:e03-walls` 산출물(532-4).
config/e03_walls_node/
~$*
+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");
+114
View File
@@ -0,0 +1,114 @@
"""E03 2D 모형의 벽(옹벽 · 석축 · 돌쌓기 · 관 기슭막이) — 복사본 → 측점별 walls
(532-4 · 분석 345-D ② ⑤).
도형은 짓지 않음 — B06 화면 TS 를 서버 Node 로 돌림(`E03_StabSection_Walls_Node.ts` · 5장 ②).
얹기(어느 측점에 어느 벽) = B06 `attach_saved_shapes` 그대로(복사본 = 원본 프로젝트 배치).
벽 재료 = 모형 `WALL_MATERIAL`(고강도 — 활동면이 벽을 못 지남) · 꼴 = `build_section_model(walls)`.
벽이 선 측점은 지표도 B06 면적과 같은 「그려지는 설계선」(벽 상단에서 끊김)으로 바꿔 넘김.
부르는 법(E03 분석 길):
found = walls_by_station(root) # 복사본 루트 → {chainage_key: entry}
entry = found.get(chainage_key(cross["chainage_m"]))
model = section_model_from_b06(cross, design_with_walls(design, entry), walls=walls_of(entry))
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
from common_util.common_util_node_bundle import ROOT, build_bundle, bundle_stale, run_bundle_json
from common_util.common_util_station_key import chainage_key
from E01_StabRegister import E01_StabRegister_Source as source
from E03_StabSection.E03_StabSection_Engine_Model import WALL_MATERIAL
logger = logging.getLogger(__name__)
NPM_SCRIPT = "build:e03-walls"
ENTRY = ROOT / "E03_StabSection" / "E03_StabSection_Walls_Node.ts"
BUNDLE = ROOT / "config" / "e03_walls_node" / "E03_StabSection_Walls_Node.js"
def _sections(root: Path) -> list[dict[str, Any]]:
"""복사본 측점 → B06 상세 꼴(chainage_m · 중심 · samples · design)
+ 형상 얹기(revetment · culvert …)."""
from B06_Section.B06_Section_Engine_Attach import attach_saved_shapes
sections = []
for station in source.stations(root):
cross = source.cross_section(root, station["chainage_m"])
sections.append(
{
**cross,
"center_x": station.get("center_x"),
"center_y": station.get("center_y"),
}
)
route = source.db_snapshot(root).get("route") or {}
attach_saved_shapes(root, sections, route.get("route_data_path"))
return sections
def _fresh_bundle() -> bool:
"""입구 TS 는 `SOURCE_DIRS` 밖(E03) — 입구가 묶음보다 새로우면 여기서 다시 지음."""
stale = bundle_stale(BUNDLE) or ENTRY.stat().st_mtime > BUNDLE.stat().st_mtime
return not stale or build_bundle(NPM_SCRIPT)
def walls_by_station(root: Path | str) -> dict[str, dict[str, Any]]:
"""복사본 루트 → {chainage_key: {walls: [모형 벽 …], design_line: [[offset, z] …] | None}}.
벽 · 트림이 없는 측점은 안 실음(그 측점 모형 = 종전 그대로). Node 실패는 던짐 — 벽을 빼고
해석하면 활동면이 벽을 지난 값이 조용히 남음.
"""
return walls_for_sections(_sections(Path(root)))
def walls_for_sections(sections: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""형상 얹은 B06 횡단 목록 → 측점 entry(`walls_by_station` 몸 · 시험이 바로 부름)."""
from B05_Profile.B05_Profile_Structures_Schema import load_definition
if not any(s.get("revetment") or s.get("culvert") for s in sections):
return {}
if not _fresh_bundle():
raise RuntimeError("E03 벽 도형 번들을 못 지음")
payload = json.loads(
json.dumps({"definition": load_definition(), "sections": sections}, default=str)
)
out = run_bundle_json(BUNDLE, NPM_SCRIPT, payload)
if not isinstance(out, dict):
raise RuntimeError("E03 벽 도형 Node 실패")
found: dict[str, dict[str, Any]] = {}
for row in out["stations"]:
walls = [
{
"material_id": WALL_MATERIAL["id"],
"kind": w["kind"],
"name": w.get("name"),
"structure_id": w.get("structure_id"),
"form": w.get("form"),
"polygon": w["polygon"],
}
for w in row["walls"]
]
if walls or row.get("design_line"):
found[chainage_key(float(row["chainage_m"]))] = {
"walls": walls,
"design_line": row.get("design_line"),
}
return found
def walls_of(entry: dict[str, Any] | None) -> list[dict[str, Any]] | None:
"""측점 entry → `section_model_from_b06(walls=…)` 값(없으면 None = 벽 없음)."""
return (entry or {}).get("walls") or None
def design_with_walls(design: dict[str, Any], entry: dict[str, Any] | None) -> dict[str, Any]:
"""벽이 선 측점 = 설계선을 그려지는 선(벽 상단에서 끊김)으로 바꾼 사본 · 아니면 그대로."""
line = (entry or {}).get("design_line")
if not line:
return design
return {**design, "design_line": [{"offset_m": o, "elevation_m": z} for o, z in line]}
@@ -0,0 +1,102 @@
/* =============================================================================
* E03_StabSection_Walls_Node.ts
* E 2D 해석 모형의 벽(옹벽 · 석축 · 돌쌓기 · 관 기슭막이) 단면 꼭짓점을 **서버가** 내는 입구(532-4).
*
* 도형은 B06 화면이 그리는 TS 그대로(`computeStoredLayouts` — 정본만 읽는 한 벌 · CLAUDE.md 5장 ②) ·
* 지표 = B06 면적과 같은 「실제로 그려지는 설계선」(`drawnProfile` · 벽 상단에서 끊김). 여기에 계산 없음.
* 파이썬 껍데기 = `E03_StabSection_Walls.py` · 번들 = `npm run build:e03-walls`.
*
* 실행: node <번들> <입력.json> <출력.json>
* 입력 { definition?: 구조물 정의 · sections: 형상 얹은 B06 횡단(chainage_m · samples · design · revetment …) }
* 출력 { stations: [{ chainage_m, walls: [{ kind, name, structure_id, form, polygon: [[offset, z], …] }],
* design_line: [[offset, z], …] | null(트림 없음 = 설계선 그대로) }] } — 입력 차례 그대로
* 끝 코드: 0 성공 / 2 인자 오류
* ========================================================================== */
import { readFileSync, writeFileSync } from "node:fs";
import { drawnProfile } from "@util/common_util_cross_structure_areas";
import { useCatalogDefinition, type CatalogDefinition } from "@util/common_util_structure_catalog";
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import type { WallLayout } from "../B06_Section/logic/B06_Section_Cross_Culvert_Types";
import {
computeStoredLayouts,
trimOfLayouts,
} from "../B06_Section/logic/B06_Section_Structure_Layouts";
/** 트림 경계 바로 바깥 점 — 벽 쪽 연직 단차를 살림(`computeStructureAreas` 와 같은 값). */
const BOUNDARY_EPS_M = 1e-6;
type Pair = [number, number];
function stationWalls(section: CrossSection, sections: readonly CrossSection[]) {
const layouts = computeStoredLayouts(section, sections);
const walls: Array<{
kind: string;
name: string | null;
structure_id: string | null;
form: string | null;
polygon: Pair[];
}> = [];
const push = (kind: string, list: readonly WallLayout[] | undefined, name: string | null) => {
for (const wall of list ?? []) {
if (wall.points.length >= 3)
walls.push({
kind,
name,
structure_id: kind === "own" ? (layouts?.own?.structureId ?? null) : null,
form: wall.form,
polygon: wall.points.map((p) => [p.offset, p.elevation]),
});
}
};
push("own", layouts?.own?.walls, layouts?.own?.name ?? null);
push("culvert", layouts?.culvert?.walls, "기슭막이");
push("culvert", layouts?.culvert?.basinExtras, "기슭막이");
const trim = layouts ? trimOfLayouts(layouts) : null;
const line = layouts?.design.design_line;
let design_line: Pair[] | null = null;
if (trim && Array.isArray(line) && line.length >= 2) {
const ground = section.samples
.filter((s) => s.valid !== false && typeof s.elevation_m === "number")
.map((s) => ({ offset: s.offset_m ?? 0, elevation: s.elevation_m as number }))
.sort((a, b) => a.offset - b.offset);
const profile = drawnProfile({
designLine: line as Array<{ offset_m: number; elevation_m: number }>,
ground,
trim,
});
if (profile && ground.length >= 2) {
const lo = ground[0].offset;
const hi = ground[ground.length - 1].offset;
const grid = new Set<number>();
const add = (o: number) => {
if (o >= lo && o <= hi) grid.add(Math.round(o * 1e6) / 1e6);
};
ground.forEach((g) => add(g.offset));
line.forEach((p) => add(p.offset_m));
for (const b of [trim.minOffset, trim.maxOffset])
[b, b - BOUNDARY_EPS_M, b + BOUNDARY_EPS_M].forEach(add);
trim.minSlope?.points.forEach((p) => add(p.offset));
trim.maxSlope?.points.forEach((p) => add(p.offset));
design_line = [...grid].sort((a, b) => a - b).map((o) => [o, profile.drawnZ(o)]);
}
}
return { chainage_m: section.chainage_m, walls, design_line };
}
const [inputPath, outputPath] = process.argv.slice(2);
if (!inputPath || !outputPath) {
console.error("사용법: node <번들> <입력.json> <출력.json>");
process.exit(2);
}
const input = JSON.parse(readFileSync(inputPath, "utf8")) as {
definition?: CatalogDefinition | null;
sections: CrossSection[];
};
if (input.definition) useCatalogDefinition(input.definition);
const sections = input.sections ?? [];
writeFileSync(
outputPath,
JSON.stringify({ stations: sections.map((s) => stationWalls(s, sections)) }),
"utf8",
);
@@ -72,8 +72,9 @@ function interpolator(
};
}
/** 폐회로의 위(실제로 그려지는 설계선)·아래(지반선) 보간기 — 면적과 벽 겹침이 같은 선을 봄. */
function drawnProfile(
/** 폐회로의 위(실제로 그려지는 설계선)·아래(지반선) 보간기 — 면적과 벽 겹침이 같은 선을 봄 ·
* E03 안정성 모형 지표도 이 선(`E03_StabSection_Walls_Node.ts`). */
export function drawnProfile(
input: StructureAreaInput,
): { drawnZ: (offset: number) => number; ground: (offset: number) => number } | null {
const design = interpolator(
+1
View File
@@ -12,6 +12,7 @@
"build:b07-extent": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts --outDir ../config/b07_extent_node",
"build:b07-note": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B07_DesignDetail/B07_DesignDetail_Note_Options_Node.ts --outDir ../config/b07_note_node",
"build:design-calc": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../A00_Common/design/logic/design_calc_node.ts --outDir ../config/design_calc_node",
"build:e03-walls": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../E03_StabSection/E03_StabSection_Walls_Node.ts --outDir ../config/e03_walls_node",
"install:b07-cad": "npm --prefix B07_DesignDetail/openwebcad install",
"build:b07-cad": "npm run install:b07-cad && npm --prefix B07_DesignDetail/openwebcad run build",
"preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner",
@@ -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>;