feat(536-3): E 보강 검토 앞부분 — 벽 외적 안정(활동 · 전도 · 지지력 · KDS 11 80 05) · 비탈 완화 · 소단 가정 재해석 · 최소 완화 찾기 · E03 「보강 검토」 탭 · E05 보강검토 시트(있을 때만)

- E03_StabSection_WallCheck: Coulomb · Rankine · M-O 토압 · 상재하중 · 수압 · 양압력 · Meyerhof 지지력 · 평상시 / 지진 시 판정
- E03_StabSection_WhatIf: 한 쪽 비탈 1:n(+ 소단) 다시 그림 · 원지반 만나는 가장 완만한 n · 성긴 걸음 + 이분 최소 완화(완만할수록 늘 안전하지 않음)
- E03_StabSection_Router_WhatIf: POST whatif · wallcheck · GET whatif · 저장본 _derived/stab2d_whatif(+ _view) · 원본 측점 · 요약 그대로
- E05: s8 보강검토(양식 · 자리표) · 저장본 없으면 시트 뺌 · 갱신 때 사라진 프로그램 몫 뺌
- 시험 test_536_3 12 · 534-3 양식 시험 시트 8(보강검토 빈 판 1쪽)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 23:59:39 +09:00
co-authored by Claude Opus 5.5
parent caf073a126
commit ab3eee034d
14 changed files with 2038 additions and 9 deletions
@@ -0,0 +1,345 @@
"""E03 보강 검토 API — 비탈 완화 · 소단 가정 재해석 · 벽 외적 안정 (요구 345 · 351 · PLAN 536-3).
- `POST /api/projects/{id}/stab/whatif/{station_key}` — 가정(비탈 · 1:n · 소단 폭 · 높이 · 최소 완화 찾기)
→ 형상만 바꾼 모형으로 `analyze_station` 다시 · 전후 안전율 · 판정 · 바뀐 단면(화면 꼴)
- `POST …/stab/wallcheck/{station_key}` — 그 측점 벽마다 활동 · 전도 · 지지력(평상시 · 지진 시)
- `GET …/stab/whatif` — 지금 2D 결과에 붙은 가정 · 벽 검토 목록(단면 꼴 뺌 · E05 「보강검토」 시트)
가정 결과 = 파생 저장본 `_derived/stab2d_whatif/<지문>` + 단면 꼴 `stab2d_whatif_view/` (원본 결과 그대로) ·
지문 = 원본 측점 지문 + 가정. 엔진 = 부르기만(`analyze_station` · `section_model_from_b06`).
계산 자리 = ③ 서버 단독.
"""
from __future__ import annotations
import asyncio
import hashlib
import math
import time
from pathlib import Path
from typing import Any, Literal
from functools import lru_cache
from uuid import UUID
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from shapely.geometry import Point, Polygon
from common_util.common_util_derived_store import (
derived_dir,
derived_key,
read_derived,
write_derived,
)
from E01_StabRegister import E01_StabRegister_Source as src
from E02_StabGround import E02_StabGround_Model as ground_model
from E02_StabGround import E02_StabGround_Router as e02
from E03_StabSection import E03_StabSection_Router as e03
from E03_StabSection import E03_StabSection_View as view
from E03_StabSection import E03_StabSection_Walls as walls_mod
from E03_StabSection import E03_StabSection_WallCheck as wallcheck
from E03_StabSection import E03_StabSection_WhatIf as whatif
from E03_StabSection.E03_StabSection_Engine_Model import WALL_MATERIAL, section_model_from_b06
from E03_StabSection.E03_StabSection_Engine_Station import analyze_station
router = APIRouter(prefix="/api/projects", tags=["E03 Stab What-if"])
KIND = "stab2d_whatif"
KIND_VIEW = "stab2d_whatif_view" # 바뀐 단면 화면 꼴(무거움 · 목록은 안 읽음)
KEEP = 300
@lru_cache(maxsize=1)
def code_hash() -> str:
"""가정 · 벽 검토 코드 지문 — 바뀌면 저장본을 다시 셈(엔진 지문은 원본 측점 지문에 있음)."""
digest = hashlib.sha1()
for name in ("E03_StabSection_WhatIf.py", "E03_StabSection_WallCheck.py", Path(__file__).name):
digest.update((Path(__file__).parent / name).read_bytes())
return digest.hexdigest()[:12]
class WhatIfRequest(BaseModel):
side: Literal["left", "right"]
ratio: float | None = Field(None, gt=0.05, le=whatif.MAX_RATIO)
bench_w: float = Field(0.0, ge=0.0, le=10.0)
bench_h: float = Field(0.0, ge=0.0, le=30.0)
search: bool = False
#: 최소 완화 찾기 목표 — 조건 하나(None = 모든 조건) · 목표 안전율(None = 그 조건 기준안전율)
condition: Literal["dry", "wet", "seismic"] | None = None
target_fs: float | None = Field(None, gt=0.5, le=5.0)
class WallRequest(BaseModel):
method: Literal["coulomb", "rankine"] = "coulomb"
water_ratio: float = Field(0.0, ge=0.0, le=1.0)
kh: float | None = Field(None, ge=0.0, le=0.5)
def _context(root: Path, station_key: str) -> dict[str, Any]:
"""측점 하나 — 지금 입력과 같은 2D 결과 · 원본 저장본 · 엔진 입력(일꾼과 같은 값)."""
ground = e03._ground(root)
plan = e03.plan_run(root, ground)
summary, fresh = e03._latest_summary(root, plan["fp"])
if not summary or not fresh:
raise HTTPException(status_code=409, detail="2D 결과가 지금 입력과 다릅니다 — 먼저 [해석].")
row = next((r for r in summary["stations"] if r["key"] == station_key), None)
saved = read_derived(root, e03.KIND_STATION, row["fp"]) if row else None
if not saved or not saved["value"]["out"]:
raise HTTPException(status_code=404, detail="그 측점 해석 결과가 없습니다.")
value = saved["value"]
cross = src.cross_section(plan["source"], row["chainage_m"])
entry = next((it["walls"] for it in plan["items"] if it["key"] == station_key), None)
shared = plan["shared"]
kw = {
"materials": shared["materials"],
"conditions": list(shared["conditions"]),
"seismic_kh": shared["seismic_kh"],
"road_surcharge_kpa": shared["road_surcharge_kpa"],
"layers": value["inputs"]["layers"],
"ground": [tuple(p) for p in value["inputs"]["ground"]],
}
walls = walls_mod.walls_of(entry)
if walls:
kw["walls"] = walls
return {
"ground": ground,
"plan": plan,
"row": row,
"value": value,
"cross": cross,
"design": walls_mod.design_with_walls(cross["design"], entry),
"walls": walls or [],
"kw": kw,
}
def _wall_side(wall: dict[str, Any]) -> str:
return "left" if Polygon(wall["polygon"]).centroid.x > 0 else "right"
def _section(ctx: dict[str, Any], design: dict[str, Any], row: dict, out: dict) -> dict:
"""바뀐 단면 화면 꼴 — `GET …/section` 과 같은 꼴(지하수선 = E02 고른 갈래)."""
kw = {k: ctx["kw"][k] for k in ("materials", "layers", "ground", "road_surcharge_kpa")}
kw["walls"] = ctx["kw"].get("walls")
dry = section_model_from_b06(ctx["cross"], design, **kw)
water = {}
for cond in ctx["kw"]["conditions"]:
model = section_model_from_b06(ctx["cross"], design, wet=cond == "wet", **kw)
gw = ground_model.station_groundwater(ctx["ground"], cond, model)
water[cond] = gw.get("line") if gw.get("mode") == "line" else None
return view.section_view(row, out, dry, water)
def _side_cases(row: dict[str, Any], side: str) -> dict[str, Any]:
return {cond: by[side] for cond, by in (row.get("cases") or {}).items() if side in by}
def run_whatif(root: Path, station_key: str, req: WhatIfRequest) -> dict[str, Any]:
ctx = _context(root, station_key)
row, design, ground = ctx["row"], ctx["design"], ctx["kw"]["ground"]
role = {s["side"]: s["role"] for s in row["sides"]}.get(req.side)
if role is None:
raise HTTPException(status_code=422, detail="그 쪽 비탈이 없습니다.")
if any(_wall_side(w) == req.side for w in ctx["walls"]):
raise HTTPException(status_code=422, detail="벽이 선 비탈 — 벽 외적 안정으로 검토하세요.")
before_ratio = whatif.current_ratio(design, req.side, role)
assume = {
"side": req.side,
"role": role,
"ratio": float(req.ratio or before_ratio),
"bench_w": req.bench_w if req.bench_h > 0 else 0.0,
"bench_h": req.bench_h if req.bench_w > 0 else 0.0,
}
goal = {"condition": req.condition, "target_fs": req.target_fs} if req.search else None
key = derived_key(code_hash(), row["fp"], assume, goal)
cached, shown = read_derived(root, KIND, key), read_derived(root, KIND_VIEW, key)
if cached and shown:
return {**cached["value"], "view": shown["value"]}
t0 = time.perf_counter()
meta, factors = ctx["value"]["meta"], ctx["plan"]["factors"]
memo: dict[float, tuple[dict, dict] | None] = {}
def run(n: float) -> tuple[dict, dict] | None:
n = round(n, 4)
if n not in memo:
variant = whatif.design_variant(design, ground, {**assume, "ratio": n})
# None = 해석 폭 안에서 원지반을 못 만남(자연 비탈보다 완만)
memo[n] = variant and (analyze_station(ctx["cross"], variant, **ctx["kw"]), variant)
return memo[n]
reached: list[float] = [] # 원지반을 만난 기울기
def passes(n: float) -> bool:
done = run(n) # 원본과 같은 방법 · 조건(방법을 줄이면 탐색 씨앗이 달라 값이 바뀜)
if done is None:
return False
reached.append(n)
cases = _side_cases(view.station_row(meta, done[0], factors), req.side)
if req.condition:
cases = {k: v for k, v in cases.items() if k == req.condition}
need = (lambda c: c["required"]) if req.target_fs is None else (lambda c: req.target_fs)
return bool(cases) and all(
c["fs"] is not None and need(c) is not None and c["fs"] >= need(c)
for c in cases.values()
)
search = None
if req.search:
top = whatif.gentlest_ratio(design, ground, assume, before_ratio) or before_ratio
search = {**whatif.search_ratio(passes, before_ratio, max_ratio=top), "gentlest": top}
# 못 찾으면 원지반을 만난 가장 완만한 기울기로 보여 줌
assume["ratio"] = search["found"] or max(reached, default=before_ratio)
assume["ratio"] = round(assume["ratio"], 4)
done = run(assume["ratio"])
if done is None:
raise HTTPException(
status_code=422,
detail=f"가정 비탈(1:{assume['ratio']:g} · 소단 포함)이 해석 폭 안에서 원지반을 못 만납니다"
" — 자연 비탈보다 완만.",
)
out, variant = done
after = view.station_row(meta, out, factors)
record = {
"type": "slope",
"station_key": station_key,
"chainage_m": row["chainage_m"],
"label": row.get("label"),
"base_fp": row["fp"],
"assume": {**assume, "ratio_before": before_ratio},
"search": search and {**search, **goal},
"before": _side_cases(row, req.side),
"after": _side_cases(after, req.side),
"computed_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"elapsed_s": round(time.perf_counter() - t0, 2),
}
shown = _section(ctx, variant, after, out)
write_derived(root, KIND_VIEW, key, shown, keep=KEEP)
write_derived(root, KIND, key, record, keep=KEEP)
return {**record, "view": shown}
def _material_at(model: dict[str, Any], x: float, z: float, skip: str = "wall") -> str | None:
pt = Point(x, z)
for region in model["regions"]:
if region["material_id"] != skip and Polygon(region["polygon"]).buffer(1e-6).contains(pt):
return region["material_id"]
return None
def _surface_z(surface: list, x: float) -> float:
pts = sorted(surface)
for (xa, za), (xb, zb) in zip(pts, pts[1:]):
if xa <= x <= xb:
return za + (zb - za) * ((x - xa) / (xb - xa) if xb > xa else 0.0)
return pts[0][1] if x < pts[0][0] else pts[-1][1]
def run_wallcheck(root: Path, station_key: str, req: WallRequest) -> dict[str, Any]:
ctx = _context(root, station_key)
if not ctx["walls"]:
raise HTTPException(status_code=404, detail="그 측점에 벽이 없습니다.")
row = ctx["row"]
seismic = ctx["ground"].get("seismic") or {}
kh = (
req.kh
if req.kh is not None
else float(seismic.get("kh") or 0.0) * bool(seismic.get("enabled"))
)
params = {"method": req.method, "water_ratio": req.water_ratio, "kh": kh}
key = derived_key(code_hash(), row["fp"], "wall", params)
cached = read_derived(root, KIND, key)
if cached:
return cached["value"]
kw = {k: ctx["kw"][k] for k in ("materials", "layers", "ground", "road_surcharge_kpa", "walls")}
model = section_model_from_b06(ctx["cross"], ctx["design"], **kw)
mats = {m["id"]: m for m in model["materials"]}
roles = {s["side"]: s["role"] for s in row["sides"]}
walls = []
for wall in ctx["walls"]:
poly = Polygon(wall["polygon"])
side = _wall_side(wall)
role = roles.get(side, "fill")
outward = 1 if side == "left" else -1
back = -outward if role == "fill" else outward # 쌓기 = 노면 쪽 흙 · 깎기 = 산 쪽 흙
x0, z0, x1, z1 = poly.bounds
H = z1 - z0
heel = x1 if back > 0 else x0
toe = x0 if back > 0 else x1
fill_id = _material_at(model, heel + back * 0.3, z0 + 0.5 * H) or "fill"
found_id = _material_at(model, 0.5 * (x0 + x1), z0 - 0.2) or fill_id
backfill, foundation = mats.get(fill_id, mats.get("fill")), mats.get(found_id)
surface = model["surface"]
dz = _surface_z(surface, heel + back * max(H, 1.0)) - _surface_z(surface, heel)
beta = math.degrees(math.atan2(dz, max(H, 1.0)))
beta = min(max(beta, 0.0), float(backfill["phi"]) - 1.0)
common = {
"backfill": backfill,
"foundation": foundation,
"method": req.method,
"beta_deg": beta,
"surcharge_kpa": ctx["kw"]["road_surcharge_kpa"] if role == "fill" else 0.0,
"water_ratio": req.water_ratio,
"wall_gamma": float(WALL_MATERIAL["gamma_t"]),
"embed_m": max(_surface_z(surface, toe - back * 0.05) - z0, 0.0),
}
cases = [wallcheck.check_wall(wall["polygon"], back, **common)]
if kh > 0:
cases.append(wallcheck.check_wall(wall["polygon"], back, kh=kh, **common))
walls.append(
{
"name": wall.get("name"),
"kind": wall.get("kind"),
"side": side,
"role": role,
"backfill": fill_id,
"foundation": found_id,
"cases": cases,
}
)
record = {
"type": "wall",
"station_key": station_key,
"chainage_m": row["chainage_m"],
"label": row.get("label"),
"base_fp": row["fp"],
"params": params,
"walls": walls,
"computed_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
}
write_derived(root, KIND, key, record, keep=KEEP)
return record
def records(root: Path) -> list[dict[str, Any]]:
"""가장 최근 2D 요약 측점에 붙은 가정 · 벽 검토 — (측점 · 종류 · 비탈)마다 가장 최근 하나 ·
누가거리 순."""
summary, _ = e03._latest_summary(root, "")
fps = {r["fp"] for r in (summary or {}).get("stations") or []}
latest: dict[tuple, dict[str, Any]] = {}
for path in derived_dir(root, KIND).glob("*.json"):
saved = read_derived(root, KIND, path.stem)
value = (saved or {}).get("value")
if not value or value.get("base_fp") not in fps:
continue
slot = (value["station_key"], value["type"], (value.get("assume") or {}).get("side"))
if slot not in latest or value["computed_at"] > latest[slot]["computed_at"]:
latest[slot] = value
return sorted(latest.values(), key=lambda v: (float(v["chainage_m"]), v["type"]))
@router.post("/{project_id}/stab/whatif/{station_key}", response_model=None)
async def post_whatif(project_id: UUID, station_key: str, payload: WhatIfRequest) -> dict:
root, _ = await e02._project(project_id)
return await asyncio.to_thread(run_whatif, root, station_key, payload)
@router.post("/{project_id}/stab/wallcheck/{station_key}", response_model=None)
async def post_wallcheck(project_id: UUID, station_key: str, payload: WallRequest) -> dict:
root, _ = await e02._project(project_id)
return await asyncio.to_thread(run_wallcheck, root, station_key, payload)
@router.get("/{project_id}/stab/whatif", response_model=None)
async def get_whatif(project_id: UUID) -> dict:
root, _ = await e02._project(project_id)
return {"records": await asyncio.to_thread(records, root)}
+72 -1
View File
@@ -5,6 +5,7 @@
* 위 = 평면 위험 띠 · 가운데 = 횡단(임계면 · 등안전율 · 후보 · 절편) · 우 = 결과 · 절편표 · 자유물체도 · 그래프
* 계산 = 서버 단독([해석] → POST …/stab/analyze2d · 잠금판 진행률 = GET …/stab/progress) ·
* 대표 단면 켬/끔 = sessionStorage 캐시 → [대표 단면 저장] = PUT(자동저장 없음).
* 「보강 검토」 탭 = 우측을 가정 재해석 · 벽 외적 안정으로 바꿈(`UI_Reinforce` · 서버 따로 저장본).
* ========================================================================== */
import "./E03_StabSection_UI_Style.css";
@@ -31,6 +32,13 @@ import {
type SectionToggles,
} from "./E03_StabSection_UI_Section";
import { openView3d } from "./E03_StabSection_UI_View3D";
import {
buildReinforce,
type SlopeAsk,
type WallAsk,
type WallRecord,
type WhatIfRecord,
} from "./E03_StabSection_UI_Reinforce";
import type {
Analysis,
Condition,
@@ -101,6 +109,10 @@ export function renderE03StabSection(root: HTMLElement): void {
let side: string | null = null;
let picked: number | null = null;
const toggles: SectionToggles = { map: true, centers: false, candidates: true };
let mode: "result" | "reinforce" = "result";
let busy = false;
const slopeRecs = new Map<string, WhatIfRecord>(); // 측점 열쇠 → 마지막 가정
const wallRecs = new Map<string, WallRecord>();
let repCache: Record<string, boolean | null> = JSON.parse(
sessionStorage.getItem(repKey(pid)) ?? "{}",
);
@@ -292,7 +304,21 @@ export function renderE03StabSection(root: HTMLElement): void {
(v) => ((toggles.candidates = v), paintSection()),
),
);
toolbar.append(title, sideField.root, flags);
const modeField = createSelectField({
options: [
{ value: "result", text: t("E03_Tab_Result") },
{ value: "reinforce", text: t("E03_Tab_Reinforce") },
],
value: mode,
compact: true,
ariaLabel: t("E03_Tab"),
onChange: (v) => {
mode = v as typeof mode;
paintSection();
},
});
modeField.select.dataset.role = "mode";
toolbar.append(title, sideField.root, modeField.root, flags);
const draw = { view, analysis, toggles, picked };
canvas.render(draw);
legendHost.append(buildLegends(draw));
@@ -307,6 +333,25 @@ export function renderE03StabSection(root: HTMLElement): void {
);
if (view.regions.some((r) => r.material_id === WALL_ID))
legendHost.append(el("p", { className: "e03-muted", text: t("E03_Walls_Hint") }));
const row = currentRow();
if (mode === "reinforce" && row) {
if (row.walls && !wallRecs.has(row.key) && !busy && result?.editable)
queueMicrotask(() => void runWall(row.key, { method: "coulomb", water_ratio: 0 }));
resultHost.append(
buildReinforce({
row,
condition,
side,
editable: !!result?.editable,
busy,
slope: slopeRecs.get(row.key),
wall: wallRecs.get(row.key),
runSlope: (ask) => void runSlope(row.key, ask),
runWall: (ask) => void runWall(row.key, ask),
}),
);
return;
}
resultHost.append(
buildResult(view, analysis, picked, (i) => {
picked = i;
@@ -315,6 +360,32 @@ export function renderE03StabSection(root: HTMLElement): void {
);
};
/** 가정 재해석 · 벽 검토 — 서버 계산(수십 초) 동안 단추만 잠금 */
async function reinforce<T>(
path: string,
body: unknown,
done: (value: T) => void,
): Promise<void> {
busy = true;
paintSection();
try {
done(await api<T>(path, { method: "POST", body: JSON.stringify(body) }));
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
} finally {
busy = false;
paintSection();
}
}
const runSlope = (key: string, ask: SlopeAsk): Promise<void> =>
reinforce<WhatIfRecord>(`${base}/whatif/${encodeURIComponent(key)}`, ask, (rec) =>
slopeRecs.set(key, rec),
);
const runWall = (key: string, ask: WallAsk): Promise<void> =>
reinforce<WallRecord>(`${base}/wallcheck/${encodeURIComponent(key)}`, ask, (rec) =>
wallRecs.set(key, rec),
);
const paintPlan = (): void => {
planHost.replaceChildren(
result ? buildPlan(result.stations, condition, active, (key) => void select(key)) : el("div"),
@@ -0,0 +1,314 @@
/* =============================================================================
* E03_StabSection_UI_Reinforce.ts
* E03 「보강 검토」 탭(PLAN 536-3) — 고른 측점 비탈 가정(1:n 완화 · 소단 폭 · 높이 · 최소 완화 찾기)
* → 서버 재해석(POST …/stab/whatif/{측점}) · 전후 안전율 · 판정 표 · 후 단면 그림 ·
* 벽 있는 측점 = 벽 외적 안정 표(POST …/stab/wallcheck/{측점} · 활동 · 전도 · 지지력).
* 가정은 형상만 바꾼 따로 저장본(원본 결과 그대로) · 계산은 서버.
* ========================================================================== */
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { createSectionCanvas } from "./E03_StabSection_UI_Section";
import { verdictChip } from "./E03_StabSection_UI_Result";
import type { Condition, SectionView, SideCase, StationRow } from "./logic/E03_StabSection_Logic";
export interface WhatIfRecord {
type: "slope";
station_key: string;
assume: {
side: string;
role: "cut" | "fill";
ratio: number;
ratio_before: number;
bench_w: number;
bench_h: number;
};
search: {
found: number | null;
gentlest: number;
tried: [number, boolean][];
condition: Condition | null;
} | null;
before: Partial<Record<Condition, SideCase>>;
after: Partial<Record<Condition, SideCase>>;
elapsed_s: number;
view: SectionView;
}
type Check = "sliding" | "overturning" | "bearing";
interface WallCase {
case: "normal" | "seismic";
fs: Record<Check, number>;
required: Record<Check, number>;
ok: Record<Check, boolean>;
coefficients: { ka: number; kae: number | null };
base: { e: number; q_max: number; q_ult: number };
geometry: { H: number; B: number };
}
export interface WallRecord {
type: "wall";
params: { method: string; water_ratio: number; kh: number };
walls: { name: string | null; side: string; role: string; cases: WallCase[] }[];
}
export interface SlopeAsk {
side: string;
ratio: number | null;
bench_w: number;
bench_h: number;
search: boolean;
/** 최소 완화 찾기 목표 조건(그 조건 기준안전율) */
condition: Condition | null;
}
export interface WallAsk {
method: string;
water_ratio: number;
}
export interface ReinforceOptions {
row: StationRow;
condition: Condition;
side: string | null;
editable: boolean;
busy: boolean;
slope: WhatIfRecord | undefined;
wall: WallRecord | undefined;
runSlope: (ask: SlopeAsk) => void;
runWall: (ask: WallAsk) => void;
}
const fmt = (v: number | null | undefined, d = 2): string =>
v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d);
const CONDS: Condition[] = ["dry", "wet", "seismic"];
const CHECKS: Check[] = ["sliding", "overturning", "bearing"];
function num(input: HTMLInputElement): number | null {
const v = Number(input.value);
return input.value.trim() === "" || !Number.isFinite(v) ? null : v;
}
function slopeTable(rec: WhatIfRecord): HTMLElement {
const table = el("table", { className: "e03-table", attrs: { "data-role": "whatif-table" } });
const head = ["E03_Condition", "E03_Rf_Before", "E03_Rf_After", "E03_Required"] as LocaleKey[];
table.append(
el("thead", {
children: [
el("tr", {
children: [
...head.map((k, i) => el("th", { className: i ? "is-num" : "", text: t(k) })),
el("th", { text: t("E03_Rf_VerdictBefore") }),
el("th", { text: t("E03_Rf_VerdictAfter") }),
],
}),
],
}),
);
const body = el("tbody");
for (const cond of CONDS) {
const a = rec.after[cond];
if (!a) continue;
const b = rec.before[cond];
const tr = el("tr", { attrs: { "data-cond": cond } });
tr.append(
el("td", { text: t(`E03_Cond_${cond}` as LocaleKey) }),
el("td", { className: "is-num", text: fmt(b?.fs, 3) }),
el("td", { className: "is-num", text: fmt(a.fs, 3), attrs: { "data-role": "fs-after" } }),
el("td", { className: "is-num", text: fmt(a.required) }),
el("td", { children: [verdictChip(b?.ok ?? null)] }),
el("td", { children: [verdictChip(a.ok)] }),
);
body.append(tr);
}
table.append(body);
return table;
}
function assumeText(rec: WhatIfRecord): string {
const a = rec.assume;
let text = `1:${a.ratio_before} → 1:${a.ratio}`;
if (a.bench_w && a.bench_h) text += ` · ${t("E03_Rf_Bench")} ${a.bench_w} m / ${a.bench_h} m`;
if (rec.search)
text += rec.search.found
? ` · ${t("E03_Rf_Found")} (${rec.search.tried.length}${t("E03_Iter")})`
: ` · ${t("E03_Rf_NotFound").replace("{n}", String(rec.search.gentlest))}`;
return `${text} · ${rec.elapsed_s} s`;
}
function wallTable(rec: WallRecord): HTMLElement {
const box = el("div", { attrs: { "data-role": "wall-table" } });
for (const wall of rec.walls) {
for (const c of wall.cases) {
box.append(
el("p", {
className: "e03-muted",
text:
`${wall.name ?? t("E03_Rf_Wall")} · ${t(`E03_Rf_Case_${c.case}` as LocaleKey)} · ` +
`H ${fmt(c.geometry.H)} m · B ${fmt(c.geometry.B)} m · Ka ${fmt(c.coefficients.ka, 3)}` +
(c.coefficients.kae ? ` · KAE ${fmt(c.coefficients.kae, 3)}` : "") +
` · e ${fmt(c.base.e)} m · q_max ${fmt(c.base.q_max, 1)} kPa`,
}),
);
const table = el("table", { className: "e03-table" });
table.append(
el("thead", {
children: [
el("tr", {
children: [
el("th", { text: t("E03_Rf_Check") }),
el("th", { className: "is-num", text: t("E03_Rf_Fs") }),
el("th", { className: "is-num", text: t("E03_Required") }),
el("th", { text: t("E03_Verdict") }),
],
}),
],
}),
);
const body = el("tbody");
for (const k of CHECKS)
body.append(
el("tr", {
attrs: { "data-check": k, "data-case": c.case },
children: [
el("td", { text: t(`E03_Rf_${k}` as LocaleKey) }),
el("td", { className: "is-num", text: fmt(c.fs[k]) }),
el("td", { className: "is-num", text: fmt(c.required[k], 1) }),
el("td", { children: [verdictChip(c.ok[k])] }),
],
}),
);
table.append(body);
box.append(table);
}
}
return box;
}
export function buildReinforce(o: ReinforceOptions): HTMLElement {
const host = el("div", { className: "e03-card", attrs: { "data-role": "reinforce" } });
const sides = o.row.sides;
const sideField = createSelectField({
label: t("E03_Side"),
options: sides.map((s) => ({
value: s.side,
text: `${t(s.side === "left" ? "E03_Left" : "E03_Right")} · ${t(s.role === "cut" ? "E03_Cut" : "E03_Fill")}`,
})),
value: o.slope?.assume.side ?? o.side ?? undefined,
compact: true,
});
const ratio = createInputField({
label: t("E03_Rf_Ratio"),
type: "number",
min: 0.1,
max: 4,
compact: true,
value: o.slope ? String(o.slope.assume.ratio) : "",
placeholder: t("E03_Rf_RatioNow"),
});
const benchW = createInputField({
label: t("E03_Rf_BenchW"),
type: "number",
min: 0,
compact: true,
value: o.slope?.assume.bench_w ? String(o.slope.assume.bench_w) : "",
});
const benchH = createInputField({
label: t("E03_Rf_BenchH"),
type: "number",
min: 0,
compact: true,
value: o.slope?.assume.bench_h ? String(o.slope.assume.bench_h) : "",
});
for (const f of [ratio, benchW, benchH]) f.input.step = "0.05";
const ask = (search: boolean): SlopeAsk => ({
side: sideField.select.value,
ratio: search ? null : num(ratio.input),
bench_w: num(benchW.input) ?? 0,
bench_h: num(benchH.input) ?? 0,
search,
condition: search ? o.condition : null,
});
const run = createButton({
label: o.busy ? t("E03_Rf_Busy") : t("E03_Rf_Run"),
variant: "filled",
onClick: () => o.runSlope(ask(false)),
});
const find = createButton({
label: t("E03_Rf_Search"),
variant: "ghost",
onClick: () => o.runSlope(ask(true)),
});
run.disabled = find.disabled = o.busy || !o.editable;
run.dataset.role = "whatif-run";
find.dataset.role = "whatif-search";
const fields = el("div", { className: "e03-panel__row" });
fields.append(sideField.root, ratio.root, benchW.root, benchH.root);
const buttons = el("div", { className: "e03-panel__row" });
buttons.append(find, run);
host.append(
el("h3", { className: "e03-card__title", text: t("E03_Rf_Title") }),
el("p", { className: "e03-muted", text: t("E03_Rf_Hint") }),
el("p", {
className: "e03-muted",
text: t("E03_Rf_Goal").replace("{c}", t(`E03_Cond_${o.condition}` as LocaleKey)),
}),
fields,
buttons,
);
if (o.slope) {
host.append(
el("p", {
className: "e03-muted",
text: assumeText(o.slope),
attrs: { "data-role": "assume" },
}),
slopeTable(o.slope),
el("h4", { className: "e03-card__title", text: t("E03_Rf_AfterSection") }),
);
const canvas = createSectionCanvas(() => undefined);
canvas.root.style.height = "260px";
canvas.root.style.flex = "none";
canvas.root.dataset.role = "whatif-section";
host.append(canvas.root);
const analyses = o.slope.view.analyses.filter((a) => a.condition === o.condition);
const analysis = analyses.find((a) => a.side === o.slope!.assume.side) ?? analyses[0] ?? null;
const draw = {
view: o.slope.view,
analysis,
toggles: { map: false, centers: false, candidates: false },
picked: null,
};
requestAnimationFrame(() => canvas.render(draw));
}
if (o.row.walls) {
const method = createSelectField({
label: t("E03_Rf_Method"),
options: [
{ value: "coulomb", text: "Coulomb" },
{ value: "rankine", text: "Rankine" },
],
value: o.wall?.params.method,
compact: true,
});
const water = createInputField({
label: t("E03_Rf_Water"),
type: "number",
min: 0,
max: 1,
compact: true,
value: String(o.wall?.params.water_ratio ?? 0),
});
water.input.step = "0.1";
const check = createButton({
label: t("E03_Rf_WallRun"),
variant: "ghost",
onClick: () => o.runWall({ method: method.select.value, water_ratio: num(water.input) ?? 0 }),
});
check.disabled = o.busy || !o.editable;
check.dataset.role = "wall-run";
const row = el("div", { className: "e03-panel__row" });
row.append(method.root, water.root, check);
host.append(el("h3", { className: "e03-card__title", text: t("E03_Rf_WallTitle") }), row);
if (o.wall) host.append(wallTable(o.wall));
}
return host;
}
@@ -0,0 +1,220 @@
"""E03 벽(옹벽 · 석축) 외적 안정 — 활동 · 전도 · 지지력 (요구 345 · 351 · PLAN 536-3).
storage 를 모르는 순수 함수. 벽 폴리곤(2D 모형 `walls` · x = B06 offset · z = 표고) + 배면 · 기초 물성
→ 하중 · 안전율 · 판정. 기준 = KDS 11 80 05(지식DB `구조물_안정계산.md` §2 · `original/안정성평가/04`).
- 토압 = Coulomb(벽 배면 = 뒷굽 바닥 ~ 배면 꼭대기 가상면 · 벽마찰 δ) 또는 Rankine(뒷굽 연직 가상면 ·
δ = β) · 지진 시 = Mononobe-Okabe(정적 H/3 + 증분 0.6H · Seed-Whitman) + 벽 관성 kh·W.
- 가상면과 벽 사이 흙 = 벽 무게에 더함 · 배면 점착 = 0(보수 쪽).
- 상재하중 q = 배면 등분포(노면 하중) · 수압 = 배면 수위 hw(바닥부터) 정수압 + 바닥 삼각 양압력 ·
수위 아래 토압 = 수중 단위중량.
- 활동 = (V·μ + c_b·B) / ΣH(수동토압 빼고) · 전도 = 앞굽 기준 저항 / 전도 모멘트 ·
지지력 = Meyerhof 극한(유효폭 B' · 경사 계수) / 최대 지반반력(사다리꼴 · 삼각).
"""
from __future__ import annotations
import math
from typing import Any
import numpy as np
from shapely.geometry import LineString, Polygon, box
from shapely.ops import unary_union
GAMMA_W = 9.81
#: KDS 11 80 05 기준안전율 — 평상시 · 지진 시(활동은 수동토압 뺀 값)
REQUIRED = {
"normal": {"sliding": 1.5, "overturning": 2.0, "bearing": 3.0},
"seismic": {"sliding": 1.2, "overturning": 1.5, "bearing": 2.0},
}
#: 벽 단위중량(kN/m³) — 콘크리트 · 찰쌓기 23 · 메쌓기(공극) 20
WALL_GAMMA = {"default": 23.0, "dry_masonry": 20.0}
_N = 400 # 토압 적분 칸
def ka_coulomb(phi: float, delta: float, theta: float, beta: float, psi: float = 0.0) -> float:
"""Coulomb 주동토압계수(ψ > 0 = Mononobe-Okabe KAE · kv = 0). 각 = 도.
θ = 배면이 연직에서 앞쪽(벽 쪽)으로 누운 각(쐐기가 커지는 쪽 +) · β = 배면 흙 경사 · δ = 벽마찰."""
f, d, t, b, p = (math.radians(v) for v in (phi, delta, theta, beta, psi))
under = math.sin(f + d) * math.sin(f - b - p) / (math.cos(d + t + p) * math.cos(b - t))
root = math.sqrt(max(under, 0.0))
return math.cos(f - t - p) ** 2 / (
math.cos(p) * math.cos(t) ** 2 * math.cos(d + t + p) * (1.0 + root) ** 2
)
def ka_rankine(phi: float, beta: float = 0.0) -> float:
"""Rankine 주동토압계수(경사 배면 β · 합력은 β 로 기욺)."""
f, b = math.radians(phi), math.radians(beta)
root = math.sqrt(max(math.cos(b) ** 2 - math.cos(f) ** 2, 0.0))
return math.cos(b) * (math.cos(b) - root) / (math.cos(b) + root)
def bearing_factors(phi: float) -> tuple[float, float, float]:
"""Meyerhof 지지력 계수(Nc · Nq · Nγ) — φ 도."""
f = math.radians(phi)
if f < 1e-9:
return 5.14, 1.0, 0.0
nq = math.exp(math.pi * math.tan(f)) * math.tan(math.pi / 4 + f / 2) ** 2
return (nq - 1.0) / math.tan(f), nq, (nq - 1.0) * math.tan(1.4 * f)
def _pressure(
ka: float, H: float, gamma: float, gamma_sat: float, q: float, hw: float
) -> tuple[float, float]:
"""연직 높이 H 가상면 토압 합력 · 바닥에서 작용 높이 — σ' = 수위 위 γ · 아래 γ' 누적 + q."""
z = np.linspace(0.0, H, _N + 1) # 꼭대기에서 아래로
dry = np.minimum(z, H - hw)
wet = np.maximum(z - (H - hw), 0.0)
p = ka * (gamma * dry + (gamma_sat - GAMMA_W) * wet + q)
total = float(np.trapz(p, z))
arm = float(np.trapz(p * (H - z), z)) / total if total > 0 else H / 3
return total, arm
def check_wall(
polygon: list,
back: int,
*,
backfill: dict[str, float],
foundation: dict[str, float],
method: str = "coulomb",
delta_ratio: float = 2.0 / 3.0,
beta_deg: float = 0.0,
surcharge_kpa: float = 10.0,
water_ratio: float = 0.0,
kh: float = 0.0,
wall_gamma: float = WALL_GAMMA["default"],
base_friction: float | None = None,
base_adhesion: float = 0.0,
embed_m: float = 0.0,
) -> dict[str, Any]:
"""벽 하나 외적 안정. back = 배면(흙) 쪽 x 방향(+1 · −1). 물성 = {gamma_t, gamma_sat, c, phi}.
kh > 0 이면 지진 시(기준 `REQUIRED["seismic"]`) · 아니면 평상시. 돌려줌 = 하중 · 안전율 · 판정."""
wall = Polygon(polygon)
xs = np.array([p[0] for p in polygon], dtype=float)
zs = np.array([p[1] for p in polygon], dtype=float)
zb, zt = float(zs.min()), float(zs.max())
H = zt - zb
base = xs[zs <= zb + 1e-3]
heel = float(base.max() if back > 0 else base.min())
toe = float(base.min() if back > 0 else base.max())
B = abs(heel - toe)
phi, gamma = float(backfill["phi"]), float(backfill["gamma_t"])
gamma_sat = float(backfill.get("gamma_sat") or gamma)
hw = min(max(water_ratio, 0.0), 1.0) * H
if method == "rankine":
x_top, theta, delta = heel, 0.0, beta_deg
else:
top = xs[zs >= zt - 1e-3]
x_top = float(top.max() if back > 0 else top.min()) # 배면 꼭대기 모서리
theta = math.degrees(math.atan2(-(x_top - heel) * back, H))
delta = delta_ratio * phi
# 가상면과 벽 사이 흙 — 벽 쪽 반평면 ∩ 벽 상자 − 벽 · 가상면에 붙은 조각만(앞면 앞 흙 빼고)
reach = 10.0 * (H + B + 1.0)
side = [(heel, zb), (x_top, zt), (x_top - back * reach, zt), (heel - back * reach, zb)]
plane = LineString([(heel, zb), (x_top, zt)])
rest = Polygon(side).intersection(box(*wall.bounds)).difference(wall)
parts = [g for g in getattr(rest, "geoms", [rest]) if isinstance(g, Polygon)]
soil = unary_union([g for g in parts if g.area > 1e-9 and g.distance(plane) < 1e-6])
soil_w = soil.area * gamma
soil_x = soil.centroid.x if soil_w > 0 else heel
soil_z = soil.centroid.y if soil_w > 0 else zb
ka = (
ka_rankine(phi, beta_deg)
if method == "rankine"
else ka_coulomb(phi, delta, theta, beta_deg)
)
q_eff = surcharge_kpa * (
1.0
if method == "rankine"
else math.cos(math.radians(theta)) / math.cos(math.radians(theta - beta_deg))
)
Pa, ya = _pressure(ka, H, gamma, gamma_sat, q_eff, hw)
loads = [("earth", Pa, ya)]
kae = None
if kh > 0:
psi = math.degrees(math.atan(kh))
kae = ka_coulomb(phi, delta, theta, beta_deg, psi)
Pae, _ = _pressure(kae, H, gamma, gamma_sat, q_eff, hw)
loads.append(("seismic_earth", max(Pae - Pa, 0.0), 0.6 * H))
incline = math.radians(delta + theta)
def plane_x(y: float) -> float: # 가상면 위 높이 y 자리 x
return heel + (x_top - heel) * (y / H if H > 0 else 0.0)
def arm(x: float) -> float: # 앞굽에서 배면 쪽 수평 팔 길이
return (x - toe) * back
W = wall.area * wall_gamma
xw, zw = wall.centroid.x, wall.centroid.y
Ph = sum(P for _, P, _ in loads) * math.cos(incline)
Pv = sum(P for _, P, _ in loads) * math.sin(incline)
m_res = W * arm(xw) + soil_w * arm(soil_x)
m_over = 0.0
for _, P, y in loads:
m_res += P * math.sin(incline) * arm(plane_x(y))
m_over += P * math.cos(incline) * y
Pw = 0.5 * GAMMA_W * hw**2
U = 0.5 * GAMMA_W * hw * B
m_over += Pw * hw / 3.0 + U * (2.0 * B / 3.0)
inertia = kh * (W + soil_w)
m_over += kh * W * (zw - zb) + kh * soil_w * (soil_z - zb)
H_total = Ph + Pw + inertia
V = W + soil_w + Pv - U
phi_f = float(foundation["phi"])
mu = base_friction if base_friction is not None else math.tan(math.radians(2.0 / 3.0 * phi_f))
sliding = (V * mu + base_adhesion * B) / H_total if H_total > 0 else math.inf
overturning = m_res / m_over if m_over > 0 else math.inf
xbar = (m_res - m_over) / V if V > 0 else -math.inf
e = B / 2.0 - xbar
if xbar <= 0:
q_max, b_eff = math.inf, 0.0
elif abs(e) <= B / 6.0:
q_max, b_eff = V / B * (1.0 + 6.0 * abs(e) / B), B - 2.0 * abs(e)
else:
q_max, b_eff = 2.0 * V / (3.0 * min(xbar, B - xbar)), B - 2.0 * abs(e)
nc, nq, ng = bearing_factors(phi_f)
alpha = math.degrees(math.atan2(H_total, V)) if V > 0 else 90.0
i_cq = (1.0 - alpha / 90.0) ** 2
i_g = (1.0 - alpha / phi_f) ** 2 if phi_f > 0 and alpha < phi_f else 0.0
gf = float(foundation["gamma_t"])
q_ult = (
float(foundation.get("c") or 0.0) * nc * i_cq
+ gf * max(embed_m, 0.0) * nq * i_cq
+ 0.5 * gf * max(b_eff, 0.0) * ng * i_g
)
bearing = q_ult / q_max if q_max > 0 and math.isfinite(q_max) else 0.0
case = "seismic" if kh > 0 else "normal"
fs = {"sliding": sliding, "overturning": overturning, "bearing": bearing}
need = REQUIRED[case]
return {
"case": case,
"method": method,
"geometry": {"H": H, "B": B, "toe_x": toe, "heel_x": heel, "theta_deg": theta},
"coefficients": {"ka": ka, "kae": kae, "delta_deg": delta, "beta_deg": beta_deg},
"loads": {
"W": W,
"W_soil": soil_w,
"Pa": Pa,
"Pa_arm": ya,
"Ph": Ph,
"Pv": Pv,
"Pw": Pw,
"U": U,
"inertia": inertia,
"V": V,
"H": H_total,
"M_resist": m_res,
"M_overturn": m_over,
},
"base": {"e": e, "q_max": q_max, "q_ult": q_ult, "B_eff": b_eff, "mu": mu, "alpha": alpha},
"fs": fs,
"required": dict(need),
"ok": {k: bool(fs[k] >= need[k]) for k in fs},
}
+199
View File
@@ -0,0 +1,199 @@
"""E03 보강 가정 — 비탈 기울기 완화 · 소단 덧붙임 → 설계선 다시 그림 · 최소 완화 찾기 (PLAN 536-3).
storage 를 모르는 순수 함수. 가정 = 모형 형상만 바꿈(설계 · 임도 쪽으로 안 돌려 씀 · 요구 345 E).
- `reshape_side` = 한 쪽 비탈을 비탈 시작점에서 1:n(+ 높이 h 마다 폭 w 소단)으로 원지반까지 다시 그림 ·
안쪽(노면 · 측구)은 그대로 · 2단(암 · 토사) 경사는 한 경사로 갈음.
- `slope_start` = 비탈 시작점 — 깎기 = 측구 바깥(`cut_slope_segments` 시작) · 쌓기 = 노면 끝.
- `search_ratio` = 목표 안전율을 맞추는 가장 급한 1:n(이분 · `step` 단위 올림) — 해석은 부르는 쪽 함수.
좌표 = B06 offset(좌 +) · 표고.
"""
from __future__ import annotations
import math
from typing import Any, Callable
import numpy as np
#: 최소 완화 찾기 — 가장 완만한 기울기 · 찾는 단위
MAX_RATIO = 4.0
RATIO_STEP = 0.05
def current_ratio(design: dict[str, Any], side: str, role: str) -> float:
"""지금 설계 기울기 1:n 의 n — 깎기 = 그 쪽 첫 구간 · 쌓기 = 쌓기 기울기."""
if role == "fill":
return float(design.get("fill_slope_ratio") or 1.5)
segs = [s for s in design.get("cut_slope_segments") or [] if s.get("side") == side]
if segs:
return float(min(segs, key=lambda s: abs(s["start_offset_m"]))["ratio"])
return float(design.get("cut_slope_ratio") or 1.0)
def slope_start(design: dict[str, Any], side: str, role: str) -> float:
"""비탈 시작 offset."""
edge = float(((design.get("road_edges") or {}).get(side) or {}).get("offset_m", 0.0))
if role == "cut":
segs = [s for s in design.get("cut_slope_segments") or [] if s.get("side") == side]
if segs:
return float(min(segs, key=lambda s: abs(s["start_offset_m"]))["start_offset_m"])
return edge
def _profile(
x0: float, z0: float, sign: float, up: float, ratio: float, bench_w: float, bench_h: float
) -> list[tuple[float, float]]:
"""비탈 꼭짓점(시작 → 바깥) — 원지반을 넉넉히 지나도록 길게(뒤에서 자름)."""
pts = [(x0, z0)]
x, z = x0, z0
while abs(x - x0) < 200.0:
rise = bench_h if bench_w > 0 and bench_h > 0 else 200.0
x, z = x + sign * rise * ratio, z + up * rise
pts.append((x, z))
if bench_w > 0 and bench_h > 0:
x += sign * bench_w
pts.append((x, z))
return pts
def _daylight(
pts: list[tuple[float, float]], ground: list, sign: float, up: float
) -> list[tuple[float, float]] | None:
"""비탈이 원지반과 처음 만나는 점까지 — 못 만나면 None(지형 밖). 시작부터 원지반 밖이면 시작점만."""
gx = np.array([p[0] for p in ground], dtype=float)
gz = np.array([p[1] for p in ground], dtype=float)
out = [pts[0]]
for (xa, za), (xb, zb) in zip(pts, pts[1:]):
inside = gx[(gx > min(xa, xb)) & (gx < max(xa, xb))]
xs = np.unique(np.r_[xa, xb, inside])
xs = xs[np.argsort(sign * xs)]
zl = za + (xs - xa) / (xb - xa) * (zb - za) if xb != xa else np.full(len(xs), zb)
f = up * (zl - np.interp(xs, gx, gz)) # ≥ 0 = 원지반 밖으로 나옴
if len(out) == 1 and f[0] >= 0:
return out
for k in range(1, len(xs)):
if not gx[0] <= xs[k] <= gx[-1]:
return None
if f[k] >= 0:
s = f[k - 1] / (f[k - 1] - f[k])
x = float(xs[k - 1] + s * (xs[k] - xs[k - 1]))
return out + [(x, float(zl[k - 1] + s * (zl[k] - zl[k - 1])))]
out.append((xb, zb))
return None
def reshape_side(
design_line: list,
ground: list,
*,
side: str,
role: str,
start_x: float,
ratio: float,
bench_w: float = 0.0,
bench_h: float = 0.0,
) -> list[list[float]] | None:
"""한 쪽 비탈만 다시 그린 설계선([[offset, z] …]) — 원지반을 못 만나면 None."""
line = sorted((float(x), float(z)) for x, z in design_line)
sign = 1.0 if side == "left" else -1.0
up = 1.0 if role == "cut" else -1.0
lx = np.array([p[0] for p in line])
lz = np.array([p[1] for p in line])
z0 = float(np.interp(start_x, lx, lz))
inner = [p for p in line if sign * p[0] < sign * start_x - 1e-9]
slope = _daylight(_profile(start_x, z0, sign, up, ratio, bench_w, bench_h), ground, sign, up)
if slope is None:
return None
return [list(p) for p in sorted(inner + slope)]
def design_variant(
design: dict[str, Any], ground: list, assume: dict[str, Any]
) -> dict[str, Any] | None:
"""가정 하나(side · role · ratio · bench_w · bench_h) → 설계선만 바꾼 설계 사본(None = 원지반 못 만남)."""
side, role = assume["side"], assume["role"]
pts = [(p["offset_m"], p["elevation_m"]) for p in design.get("design_line") or []]
line = reshape_side(
pts,
ground,
side=side,
role=role,
start_x=slope_start(design, side, role),
ratio=float(assume["ratio"]),
bench_w=float(assume.get("bench_w") or 0.0),
bench_h=float(assume.get("bench_h") or 0.0),
)
if line is None:
return None
return {**design, "design_line": [{"offset_m": x, "elevation_m": z} for x, z in line]}
def gentlest_ratio(
design: dict[str, Any],
ground: list,
assume: dict[str, Any],
start: float,
step: float = RATIO_STEP,
) -> float | None:
"""원지반을 만나는 가장 완만한 n(start ~ MAX_RATIO · step 단위 · 형상만 · 이분) — 없으면 None."""
def meets(n: float) -> bool:
return design_variant(design, ground, {**assume, "ratio": n}) is not None
lo = math.ceil(start / step - 1e-9) * step
hi = math.floor(MAX_RATIO / step + 1e-9) * step
if not meets(lo):
return None
if meets(hi):
return round(hi, 4)
while hi - lo > step + 1e-9: # lo = 만남 · hi = 못 만남
mid = round(round((lo + hi) / 2 / step) * step, 6)
if mid in (lo, hi):
break
lo, hi = (mid, hi) if meets(mid) else (lo, mid)
return round(lo, 4)
def search_ratio(
passes: Callable[[float], bool],
start: float,
*,
max_ratio: float = MAX_RATIO,
step: float = RATIO_STEP,
coarse: int = 5,
) -> dict[str, Any]:
"""목표를 맞추는 가장 급한 n(start ≤ n ≤ max_ratio · step 단위). passes(n) = 해석 · 판정.
완만할수록 늘 안전하지 않음(급한 자연 비탈 = 완화가 깎기 높이를 키움) — 성긴 걸음(최대 `coarse` 칸)으로
처음 맞는 칸을 찾고 그 앞 칸 사이를 이분으로 좁힘 · 끝까지 못 맞추면 found None."""
tried: list[tuple[float, bool]] = []
def test(n: float) -> bool:
ok = passes(n)
tried.append((round(n, 4), ok))
return ok
def snap(n: float) -> float:
return round(round(n / step) * step, 6)
lo = snap(math.ceil(start / step - 1e-9) * step)
top = snap(math.floor(max_ratio / step + 1e-9) * step)
if test(lo):
return {"found": lo, "tried": tried}
gap = max(2 * step, snap((top - lo) / coarse))
hi = None
n = lo
while n < top - 1e-9:
n = min(snap(n + gap), top)
if test(n):
hi = n
break
lo = n
if hi is None:
return {"found": None, "tried": tried}
while hi - lo > step + 1e-9: # lo = 못 맞춤 · hi = 맞춤
mid = snap((lo + hi) / 2)
if mid in (lo, hi):
break
lo, hi = (lo, mid) if test(mid) else (mid, hi)
return {"found": round(hi, 4), "tried": tried}