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

This commit is contained in:
2026-10-26 04:23:47 +09:00
11 changed files with 997 additions and 20 deletions
@@ -1,7 +1,9 @@
"""E03 보강 검토 API — 비탈 완화 · 소단 가정 재해석 · 벽 외적 안정 (요구 345 · 351 · PLAN 536-3).
- `POST /api/projects/{id}/stab/whatif/{station_key}` — 가정(비탈 · 1:n · 소단 폭 · 높이 · 최소 완화 찾기)
→ 형상만 바꾼 모형으로 `analyze_station` 다시 · 전후 안전율 · 판정 · 바뀐 단면(화면 꼴)
- `POST /api/projects/{id}/stab/whatif/{station_key}` — 가정(비탈 · 1:n · 소단 폭 · 높이 ·
최소 완화 찾기 · 네일 · 앵커 `supports` · 최소 길이 찾기) → 형상만 바꾼 모형으로
`analyze_station` 다시 · 전후 안전율 · 판정 · 바뀐 단면(화면 꼴) ·
네일마다 동원력 · mode(엔진 `cases[i].result.support_forces`)
- `POST …/stab/wallcheck/{station_key}` — 그 측점 벽마다 활동 · 전도 · 지지력(평상시 · 지진 시)
- `GET …/stab/whatif` — 지금 2D 결과에 붙은 가정 · 벽 검토 목록(단면 꼴 뺌 · E05 「보강검토」 시트)
@@ -14,6 +16,7 @@ from __future__ import annotations
import asyncio
import hashlib
import inspect
import math
import time
from pathlib import Path
@@ -46,6 +49,9 @@ router = APIRouter(prefix="/api/projects", tags=["E03 Stab What-if"])
KIND = "stab2d_whatif"
KIND_VIEW = "stab2d_whatif_view" # 바뀐 단면 화면 꼴(무거움 · 목록은 안 읽음)
KEEP = 300
#: 최소 길이 찾기 — 가장 긴 길이 · 찾는 단위(m)
SUPPORT_MAX_LEN = 25.0
SUPPORT_LEN_STEP = 0.5
@lru_cache(maxsize=1)
@@ -57,6 +63,23 @@ def code_hash() -> str:
return digest.hexdigest()[:12]
class SupportIn(BaseModel):
"""네일 · 앵커 하나 — 브레인 정함 꼴(sub4 엔진 같은 이름) · side 없으면 요청 비탈."""
type: Literal["nail", "anchor"]
side: Literal["left", "right"] | None = None
x: float
z: float
angle_deg: float = Field(ge=-10.0, le=80.0)
length_m: float = Field(gt=0.0, le=50.0)
spacing_m: float = Field(gt=0.1, le=10.0)
tensile_kN: float = Field(gt=0.0)
bond_kPa: float = Field(gt=0.0)
bond_length_m: float = Field(0.0, ge=0.0, le=50.0)
hole_dia_mm: float = Field(gt=0.0, le=500.0)
prestress_kN: float = Field(0.0, ge=0.0)
class WhatIfRequest(BaseModel):
side: Literal["left", "right"]
ratio: float | None = Field(None, gt=0.05, le=whatif.MAX_RATIO)
@@ -66,6 +89,31 @@ class WhatIfRequest(BaseModel):
#: 최소 완화 찾기 목표 — 조건 하나(None = 모든 조건) · 목표 안전율(None = 그 조건 기준안전율)
condition: Literal["dry", "wet", "seismic"] | None = None
target_fs: float | None = Field(None, gt=0.5, le=5.0)
#: 네일 · 앵커 — 있으면 search = 최소 길이 찾기(모두 같은 길이 · 앵커 정착장 그대로)
supports: list[SupportIn] = Field(default_factory=list, max_length=40)
def engine_takes_supports() -> bool:
"""sub4 엔진에 supports 인자가 들어왔는지 — 없으면 네일 · 앵커는 그림만(결과 = 보강 없음)."""
return "supports" in inspect.signature(analyze_station).parameters
def _sized(supports: list[dict], length: float | None) -> list[dict]:
if length is None:
return supports
return [
{**s, "length_m": max(length, s["bond_length_m"] + 0.5 * (s["type"] == "anchor"))}
for s in supports
]
def _forces(out: dict, side: str) -> dict[str, list]:
"""조건마다 그 비탈 임계면 기준 네일 힘(엔진 `cases[i].result.support_forces`)."""
return {
c["condition"]: c["result"]["support_forces"]
for c in out.get("cases") or []
if c.get("side") == side and (c.get("result") or {}).get("support_forces") is not None
}
class WallRequest(BaseModel):
@@ -151,30 +199,36 @@ def run_whatif(root: Path, station_key: str, req: WhatIfRequest) -> dict[str, An
"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)
supports = [{**s.model_dump(), "side": s.side or req.side} for s in req.supports]
engine = engine_takes_supports()
key = derived_key(code_hash(), row["fp"], assume, goal, supports, engine)
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] = {}
memo: dict[tuple, tuple[dict, dict] | None] = {}
same = not assume["bench_w"] # 지금 기울기 그대로 = 원본 설계선(다시 그리지 않음)
def run(n: float) -> tuple[dict, dict] | None:
def run(n: float, length: float | None = None) -> 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]
if (n, length) not in memo:
if same and abs(n - before_ratio) < 1e-9:
variant = design
else: # None = 해석 폭 안에서 원지반을 못 만남(자연 비탈보다 완만)
variant = whatif.design_variant(design, ground, {**assume, "ratio": n})
kw = dict(ctx["kw"])
if supports and engine:
kw["supports"] = _sized(supports, length)
memo[(n, length)] = variant and (analyze_station(ctx["cross"], variant, **kw), variant)
return memo[(n, length)]
reached: list[float] = [] # 원지반을 만난 기울기
def passes(n: float) -> bool:
done = run(n) # 원본과 같은 방법 · 조건(방법을 줄이면 탐색 씨앗이 달라 값이 바뀜)
def meets(done: tuple[dict, dict] | None) -> bool:
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}
@@ -184,14 +238,34 @@ def run_whatif(root: Path, station_key: str, req: WhatIfRequest) -> dict[str, An
for c in cases.values()
)
def passes(n: float) -> bool:
done = run(n) # 원본과 같은 방법 · 조건(방법을 줄이면 탐색 씨앗이 달라 값이 바뀜)
if done is not None:
reached.append(n)
return meets(done)
search = None
if req.search:
length = None
if req.search and supports:
start = max([2.0] + [s["bond_length_m"] + 0.5 for s in supports if s["type"] == "anchor"])
if engine:
found = whatif.search_ratio(
lambda L: meets(run(assume["ratio"], L)),
start,
max_ratio=SUPPORT_MAX_LEN,
step=SUPPORT_LEN_STEP,
)
else:
found = {"found": None, "tried": []}
search = {**found, "by": "length", "longest": SUPPORT_MAX_LEN}
length = found["found"] or SUPPORT_MAX_LEN
elif 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"])
done = run(assume["ratio"], length)
if done is None:
raise HTTPException(
status_code=422,
@@ -200,8 +274,10 @@ def run_whatif(root: Path, station_key: str, req: WhatIfRequest) -> dict[str, An
)
out, variant = done
after = view.station_row(meta, out, factors)
if supports:
assume["supports"] = _sized(supports, length)
record = {
"type": "slope",
"type": "support" if supports else "slope",
"station_key": station_key,
"chainage_m": row["chainage_m"],
"label": row.get("label"),
@@ -210,6 +286,9 @@ def run_whatif(root: Path, station_key: str, req: WhatIfRequest) -> dict[str, An
"search": search and {**search, **goal},
"before": _side_cases(row, req.side),
"after": _side_cases(after, req.side),
# 엔진 고리 전 = 네일 · 앵커 힘 없음(결과 = 보강 없는 재해석 · 화면은 그림만)
"support_forces": _forces(out, req.side) if supports else None,
"supports_pending": bool(supports) and not engine,
"computed_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"elapsed_s": round(time.perf_counter() - t0, 2),
}
@@ -39,6 +39,7 @@ import {
type WallRecord,
type WhatIfRecord,
} from "./E03_StabSection_UI_Reinforce";
import { buildSupport, type SupportAsk, type SupportRecord } from "./E03_StabSection_UI_Support";
import type {
Analysis,
Condition,
@@ -113,6 +114,7 @@ export function renderE03StabSection(root: HTMLElement): void {
let busy = false;
const slopeRecs = new Map<string, WhatIfRecord>(); // 측점 열쇠 → 마지막 가정
const wallRecs = new Map<string, WallRecord>();
const supportRecs = new Map<string, SupportRecord>();
let repCache: Record<string, boolean | null> = JSON.parse(
sessionStorage.getItem(repKey(pid)) ?? "{}",
);
@@ -349,6 +351,16 @@ export function renderE03StabSection(root: HTMLElement): void {
runSlope: (ask) => void runSlope(row.key, ask),
runWall: (ask) => void runWall(row.key, ask),
}),
buildSupport({
row,
view,
condition,
side,
editable: !!result?.editable,
busy,
record: supportRecs.get(row.key),
run: (ask) => void runSupport(row.key, ask),
}),
);
return;
}
@@ -382,6 +394,11 @@ export function renderE03StabSection(root: HTMLElement): void {
slopeRecs.set(key, rec);
side = rec.assume.side; // 전 그림 = 가정한 비탈
});
const runSupport = (key: string, ask: SupportAsk): Promise<void> =>
reinforce<SupportRecord>(`${base}/whatif/${encodeURIComponent(key)}`, ask, (rec) => {
supportRecs.set(key, rec);
side = rec.assume.side;
});
const runWall = (key: string, ask: WallAsk): Promise<void> =>
reinforce<WallRecord>(`${base}/wallcheck/${encodeURIComponent(key)}`, ask, (rec) =>
wallRecs.set(key, rec),
@@ -87,7 +87,8 @@ function num(input: HTMLInputElement): number | null {
return input.value.trim() === "" || !Number.isFinite(v) ? null : v;
}
function slopeTable(rec: WhatIfRecord): HTMLElement {
/** 전후 안전율 · 판정 표 — 비탈 가정 · 네일 · 앵커 가정 같이 씀 */
export function slopeTable(rec: Pick<WhatIfRecord, "before" | "after">): 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(
+49 -1
View File
@@ -1,12 +1,14 @@
/* =============================================================================
* E03_StabSection_UI_Section.ts
* E03 가운데 횡단(PLAN 532-3) — 지층 색 · 벽(회색 빗금 · 532-5) · 지표 · 지하수선 · 노면 하중 · 등안전율 색 지도(원호 중심 격자
* 최소 안전율) · 원호 중심 점 · 다른 후보(옅게) · 임계 활동면(굵게 · 중심 · 반경) · 절편(누르면 고름).
* 최소 안전율) · 원호 중심 점 · 다른 후보(옅게) · 임계 활동면(굵게 · 중심 · 반경) · 절편(누르면 고름) ·
* 보강 검토 네일 · 앵커 선(536-3 · 있을 때만).
* 도형 좌표 = 결과 좌표 그대로 · 묶음 transform(`viewMatrix`) 하나 — 계산은 logic.
* 줌 · 팬 = 공용 `attachSvgZoomPan`(휠 = 줌 · 가운데 끌기 = 팬 · 두 번 누름 = 원배율).
* ========================================================================== */
import "./E03_StabSection_UI_Walls.css";
import "./E03_StabSection_UI_Support.css";
import { t } from "@ui/ui_template_locale";
import { el } from "@ui/ui_template_elements";
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
@@ -24,6 +26,7 @@ import {
type Point,
type SectionView,
} from "./logic/E03_StabSection_Logic";
import type { SupportDraw } from "./logic/E03_StabSection_Support";
const SVG_NS = "http://www.w3.org/2000/svg";
const PAD = 28;
@@ -53,6 +56,8 @@ export interface SectionDraw {
analysis: Analysis | null;
toggles: SectionToggles;
picked: number | null;
/** 보강 검토 네일 · 앵커(결과 좌표 · `supportDraws`) */
supports?: SupportDraw[];
}
const fmt = (v: number | null | undefined, d = 3): string =>
@@ -326,6 +331,49 @@ export function createSectionCanvas(onPick: (index: number | null) => void): Sec
);
}
}
for (const s of draw.supports ?? []) {
const g = svgEl("g", {
class: `e03-support e03-support--${s.mode}`,
"data-role": "support",
"data-i": String(s.index),
"data-mode": s.mode,
});
g.append(
svgEl("polyline", { points: pointsText(s.bond), class: "e03-support__bond", ...ns }),
svgEl("polyline", {
points: pointsText([s.head, s.tail]),
class: "e03-support__bar",
...ns,
}),
svgEl("rect", {
x: s.head[0] - 2.5 * unit,
y: s.head[1] - 2.5 * unit,
width: 5 * unit,
height: 5 * unit,
class: "e03-support__head",
}),
);
if (s.cross)
g.append(
svgEl("circle", {
cx: s.cross[0],
cy: s.cross[1],
r: 3 * unit,
class: "e03-support__cross",
"data-role": "support-cross",
...ns,
}),
);
g.append(
svgEl(
"title",
{},
`${s.index + 1} · ${t(`E03_Sp_Mode_${s.mode}` as never)}` +
(s.force === null ? "" : ` · ${fmt(s.force, 1)} kN/m`),
),
);
world.append(g);
}
layer.append(world, labels);
svg.append(layer);
svg.addEventListener("click", () => draw.picked !== null && onPick(null));
@@ -0,0 +1,51 @@
/* E03 보강 검토 네일 · 앵커 선(536-3) — 머리 · 정착 구간 · 활동면과 만나는 점 · 결과 mode 색 */
.e03-support {
--e03-support: #6b6b6b;
stroke: var(--e03-support);
fill: none;
}
.e03-support--tensile {
--e03-support: #e34948;
}
.e03-support--pullout {
--e03-support: #eda100;
}
.e03-support--stripping {
--e03-support: #6250d6;
}
.e03-support--not_crossing {
--e03-support: #9a9a9a;
}
.e03-support__bar {
stroke-width: 1.5;
}
.e03-support__bond {
stroke-width: 4;
stroke-opacity: 0.55;
}
.e03-support__head {
fill: var(--e03-support);
stroke: none;
}
.e03-support__cross {
fill: var(--e03-plot);
stroke: var(--e03-support);
stroke-width: 1.5;
}
.e03-support-swatch {
display: inline-block;
width: 14px;
height: 4px;
margin-right: 4px;
vertical-align: middle;
background: var(--e03-support);
}
@@ -0,0 +1,300 @@
/* =============================================================================
* E03_StabSection_UI_Support.ts
* E03 「보강 검토」 탭 네일 · 앵커 가정(PLAN 536-3 뒷부분) — 종류 · 배치(행 수 · 첫 행 높이 · 수직 · 수평 간격) ·
* 제원 → supports 목록(`layoutSupports` · 입력 꼴 그대로 POST …/stab/whatif/{측점}) · 최소 길이 찾기 ·
* 전후 안전율 표 · 네일마다 동원력 · mode 표 · 후 단면 그림(머리 · 정착 구간 · 활동면과 만나는 점 · mode 색).
* 기본값 = 국내 흔한 값 후보(사용자 확정 전) · 계산은 서버.
* ========================================================================== */
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { slopeTable } from "./E03_StabSection_UI_Reinforce";
import { createSectionCanvas } from "./E03_StabSection_UI_Section";
import type { Condition, SectionView, SideCase, StationRow } from "./logic/E03_StabSection_Logic";
import {
layoutSupports,
supportDraws,
SUPPORT_DEFAULTS,
type SupportForce,
type SupportIn,
type SupportLayout,
type SupportType,
} from "./logic/E03_StabSection_Support";
export interface SupportRecord {
type: "support";
station_key: string;
assume: { side: string; role: "cut" | "fill"; supports: SupportIn[] };
search: {
found: number | null;
tried: [number, boolean][];
longest: number;
condition: Condition | null;
} | null;
before: Partial<Record<Condition, SideCase>>;
after: Partial<Record<Condition, SideCase>>;
support_forces: Partial<Record<Condition, SupportForce[]>> | null;
supports_pending: boolean;
elapsed_s: number;
view: SectionView;
}
export interface SupportAsk {
side: string;
ratio: null;
bench_w: 0;
bench_h: 0;
search: boolean;
condition: Condition | null;
supports: SupportIn[];
}
export interface SupportOptions {
row: StationRow;
/** 지금 측점 단면(원본) — 비탈 면 · 활동 방향 */
view: SectionView;
condition: Condition;
side: string | null;
editable: boolean;
busy: boolean;
record: SupportRecord | undefined;
run: (ask: SupportAsk) => void;
}
/** 마지막 배치 — 다시 그려도(계산 중 · 측점 바꿈) 입력 그대로 */
let layout: SupportLayout = { ...SUPPORT_DEFAULTS.nail };
type NumKey = Exclude<keyof SupportLayout, "type">;
const FIELDS: NumKey[] = [
"rows",
"first_m",
"v_spacing_m",
"h_spacing_m",
"length_m",
"angle_deg",
"tensile_kN",
"bond_kPa",
"bond_length_m",
"hole_dia_mm",
"prestress_kN",
];
const ANCHOR_ONLY: NumKey[] = ["bond_length_m", "prestress_kN"];
const MODES = ["tensile", "pullout", "stripping", "not_crossing"] as const;
const fmt = (v: number | null | undefined, d = 2): string =>
v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d);
function assumeText(rec: SupportRecord): string {
const s = rec.assume.supports;
const first = s[0];
if (!first) return "";
let text =
`${t(`E03_Sp_${first.type}` as LocaleKey)} ${s.length}${t("E03_Sp_Count")} · ` +
`L ${fmt(first.length_m, 1)} m · ${first.angle_deg}° · @${first.spacing_m} m`;
if (rec.search)
text += rec.search.found
? ` · ${t("E03_Sp_Found")} (${rec.search.tried.length}${t("E03_Iter")})`
: ` · ${t("E03_Sp_NotFound").replace("{n}", String(rec.search.longest))}`;
return `${text} · ${rec.elapsed_s} s`;
}
function forceTable(rec: SupportRecord, condition: Condition): HTMLElement {
const forces = rec.support_forces?.[condition] ?? null;
const table = el("table", { className: "e03-table", attrs: { "data-role": "support-table" } });
const head = ["E03_Sp_No", "E03_Sp_Type", "E03_Sp_Head", "E03_Sp_Length", "E03_Sp_Force"];
table.append(
el("thead", {
children: [
el("tr", {
children: [
...head.map((k, i) =>
el("th", { className: i > 1 ? "is-num" : "", text: t(k as LocaleKey) }),
),
el("th", { text: t("E03_Sp_Mode") }),
],
}),
],
}),
);
const body = el("tbody");
rec.assume.supports.forEach((s, i) => {
const f = forces?.find((v) => v.index === i);
const mode = f?.mode ?? "pending";
body.append(
el("tr", {
attrs: { "data-i": String(i), "data-mode": mode },
children: [
el("td", { text: String(i + 1) }),
el("td", { text: t(`E03_Sp_${s.type}` as LocaleKey) }),
el("td", { className: "is-num", text: `${fmt(s.x)} / ${fmt(s.z)}` }),
el("td", { className: "is-num", text: fmt(s.length_m, 1) }),
el("td", { className: "is-num", text: fmt(f?.mobilized_kN_per_m, 1) }),
el("td", {
children: [
el("span", {
className: `e03-support e03-support--${mode}`,
children: [el("span", { className: "e03-support-swatch" })],
}),
el("span", { text: t(`E03_Sp_Mode_${mode}` as LocaleKey) }),
],
}),
],
}),
);
});
table.append(body);
return table;
}
function legend(): HTMLElement {
const box = el("p", { className: "e03-muted", attrs: { "data-role": "support-legend" } });
for (const mode of MODES)
box.append(
el("span", {
className: `e03-support e03-support--${mode}`,
children: [el("span", { className: "e03-support-swatch" })],
}),
el("span", { text: `${t(`E03_Sp_Mode_${mode}` as LocaleKey)} ` }),
);
return box;
}
export function buildSupport(o: SupportOptions): HTMLElement {
const host = el("div", { className: "e03-card", attrs: { "data-role": "support" } });
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.record?.assume.side ?? o.side ?? undefined,
compact: true,
});
const typeField = createSelectField({
label: t("E03_Sp_Type"),
options: (["nail", "anchor"] as SupportType[]).map((v) => ({
value: v,
text: t(`E03_Sp_${v}` as LocaleKey),
})),
value: layout.type,
compact: true,
});
typeField.select.dataset.role = "support-type";
const inputs = new Map<NumKey, HTMLInputElement>();
const fields = el("div", { className: "e03-panel__row" });
fields.append(sideField.root, typeField.root);
for (const key of FIELDS) {
const f = createInputField({
label: t(`E03_Sp_F_${key}` as LocaleKey),
type: "number",
min: 0,
compact: true,
value: String(layout[key]),
});
f.input.step = key === "rows" ? "1" : "0.1";
f.input.dataset.field = key;
f.input.disabled = layout.type === "nail" && ANCHOR_ONLY.includes(key);
f.input.addEventListener("input", () => {
const v = Number(f.input.value);
if (f.input.value.trim() !== "" && Number.isFinite(v)) layout = { ...layout, [key]: v };
});
inputs.set(key, f.input);
fields.append(f.root);
}
typeField.select.addEventListener("change", () => {
layout = { ...SUPPORT_DEFAULTS[typeField.select.value as SupportType] };
for (const [key, input] of inputs) {
input.value = String(layout[key]);
input.disabled = layout.type === "nail" && ANCHOR_ONLY.includes(key);
}
});
const ask = (search: boolean): SupportAsk | null => {
const side = sideField.select.value;
const info = sides.find((s) => s.side === side);
if (!info) return null;
const supports = layoutSupports(o.view.surface, side, info.role, info.height_m, layout);
return {
side,
ratio: null,
bench_w: 0,
bench_h: 0,
search,
condition: search ? o.condition : null,
supports,
};
};
const send = (search: boolean): void => {
const a = ask(search);
if (a && a.supports.length) o.run(a);
};
const run = createButton({
label: o.busy ? t("E03_Rf_Busy") : t("E03_Sp_Run"),
variant: "filled",
onClick: () => send(false),
});
const find = createButton({
label: t("E03_Sp_Search"),
variant: "ghost",
onClick: () => send(true),
});
run.disabled = find.disabled = o.busy || !o.editable;
run.dataset.role = "support-run";
find.dataset.role = "support-search";
const buttons = el("div", { className: "e03-panel__row" });
buttons.append(find, run);
host.append(
el("h3", { className: "e03-card__title", text: t("E03_Sp_Title") }),
el("p", { className: "e03-muted", text: t("E03_Sp_Hint") }),
el("p", {
className: "e03-muted",
text: t("E03_Sp_Source"),
attrs: { "data-role": "support-source" },
}),
fields,
buttons,
);
const rec = o.record;
if (!rec) return host;
host.append(
el("p", {
className: "e03-muted",
text: assumeText(rec),
attrs: { "data-role": "support-assume" },
}),
);
if (rec.supports_pending)
host.append(
el("p", {
className: "e03-muted",
text: t("E03_Sp_Pending"),
attrs: { "data-role": "support-pending" },
}),
);
host.append(slopeTable(rec), forceTable(rec, o.condition), legend());
const canvas = createSectionCanvas(() => undefined);
canvas.root.style.height = "260px";
canvas.root.style.flex = "none";
canvas.root.dataset.role = "support-section";
host.append(canvas.root);
const analyses = rec.view.analyses.filter((a) => a.condition === o.condition);
const analysis = analyses.find((a) => a.side === rec.assume.side) ?? analyses[0] ?? null;
const direction =
rec.view.analyses.find((a) => a.side === rec.assume.side)?.direction ??
(rec.assume.side === "left" ? -1 : 1);
const draw = {
view: rec.view,
analysis,
toggles: { map: false, centers: false, candidates: false },
picked: null,
supports: supportDraws(
rec.assume.supports,
direction,
analysis?.critical?.points ?? null,
rec.support_forces?.[o.condition],
),
};
requestAnimationFrame(() => canvas.render(draw));
return host;
}
@@ -0,0 +1,220 @@
/* =============================================================================
* E03_StabSection_Support.ts
* E03 보강 검토 네일 · 앵커 화면 계산(PLAN 536-3 뒷부분) — 화면 · fetch · sessionStorage 없음 · 순수 함수만.
* 배치(행 수 · 첫 행 높이 · 수직 · 수평 간격 · 제원) → supports 목록(서버 POST whatif 입력 꼴 그대로) ·
* 네일 선 그림 좌표(머리 · 끝 · 정착 구간 · 임계 활동면과 만나는 점).
* 좌표 = 서버 결과 그대로(x = B06 offset · 좌 + · z = 표고). 수평 방향 = 그 비탈 활동 방향 반대(비탈 속으로).
* 높이 = 비탈 밑에서 위로 — 깎기 밑 = 노면(중심 표고) · 쌓기 밑 = 노면 − 비탈 높이.
* ========================================================================== */
import type { Point } from "./E03_StabSection_Logic";
export type SupportType = "nail" | "anchor";
export type SupportMode = "tensile" | "pullout" | "stripping" | "not_crossing";
/** 서버 · 엔진 입력 꼴(브레인 정함 · sub4 엔진 같은 이름) */
export interface SupportIn {
type: SupportType;
side: string;
x: number;
z: number;
/** 수평 아래로 + */
angle_deg: number;
length_m: number;
/** 종방향 간격 — 단위 폭 힘 = 힘 / 간격 */
spacing_m: number;
tensile_kN: number;
/** 그라우트 · 지반 부착 — 네일 = 전 길이 · 앵커 = 정착장만 */
bond_kPa: number;
/** 앵커 정착장 · 네일은 무시 */
bond_length_m: number;
hole_dia_mm: number;
/** 앵커만 · 0 = 수동 */
prestress_kN: number;
}
export interface SupportForce {
index: number;
mobilized_kN_per_m: number;
mode: SupportMode;
}
/** 화면 배치 입력 */
export interface SupportLayout {
type: SupportType;
rows: number;
first_m: number;
v_spacing_m: number;
h_spacing_m: number;
length_m: number;
angle_deg: number;
tensile_kN: number;
bond_kPa: number;
bond_length_m: number;
hole_dia_mm: number;
prestress_kN: number;
}
/** 기본값 후보(사용자 확정 전) — 지식DB 안정성평가 02 §4(천공 φ100 · 풍화토 주면마찰 50~100 kPa ·
* SD400 D25 허용 0.6 fy·As = 121.6 kN) · 앵커 = 강연선 φ12.7 × 4 허용 0.6 fpu = 441 kN 흔한 값 */
export const SUPPORT_DEFAULTS: Record<SupportType, SupportLayout> = {
nail: {
type: "nail",
rows: 3,
first_m: 1.0,
v_spacing_m: 1.5,
h_spacing_m: 1.5,
length_m: 6.0,
angle_deg: 15,
tensile_kN: 121.6,
bond_kPa: 80,
bond_length_m: 0,
hole_dia_mm: 100,
prestress_kN: 0,
},
anchor: {
type: "anchor",
rows: 2,
first_m: 1.5,
v_spacing_m: 2.5,
h_spacing_m: 2.5,
length_m: 12.0,
angle_deg: 30,
tensile_kN: 441,
bond_kPa: 150,
bond_length_m: 6.0,
hole_dia_mm: 115,
prestress_kN: 200,
},
};
/** 비탈 면 위 표고 z 인 점의 x — 노면 중심(x = 0)에서 그 쪽 바깥으로 처음 만나는 곳 */
export function faceX(surface: Point[], side: string, z: number): number | null {
const s = side === "left" ? 1 : -1;
const pts = surface
.filter((p) => s * p[0] >= 0)
.slice()
.sort((a, b) => s * a[0] - s * b[0]);
for (let i = 1; i < pts.length; i += 1) {
const [a, b] = [pts[i - 1], pts[i]];
if ((a[1] - z) * (b[1] - z) <= 0 && a[1] !== b[1]) {
return a[0] + ((z - a[1]) / (b[1] - a[1])) * (b[0] - a[0]);
}
}
return null;
}
function roadZ(surface: Point[]): number | null {
const pts = surface.slice().sort((a, b) => a[0] - b[0]);
for (let i = 1; i < pts.length; i += 1) {
const [a, b] = [pts[i - 1], pts[i]];
if (a[0] <= 0 && b[0] >= 0)
return b[0] === a[0] ? a[1] : a[1] + (-a[0] / (b[0] - a[0])) * (b[1] - a[1]);
}
return null;
}
/** 배치 → supports — 행 높이 = 첫 행 + i × 수직 간격(비탈 높이 안만) · 머리 = 그 높이의 비탈 면 */
export function layoutSupports(
surface: Point[],
side: string,
role: "cut" | "fill",
heightM: number | null,
layout: SupportLayout,
): SupportIn[] {
const road = roadZ(surface);
if (road === null) return [];
const H = heightM ?? Infinity;
const base = role === "cut" ? road : road - (heightM ?? 0);
const out: SupportIn[] = [];
for (let i = 0; i < Math.max(0, Math.floor(layout.rows)); i += 1) {
const h = layout.first_m + i * layout.v_spacing_m;
if (h <= 0 || h >= H) break;
const z = base + h;
const x = faceX(surface, side, z);
if (x === null) break;
out.push({
type: layout.type,
side,
x: round(x),
z: round(z),
angle_deg: layout.angle_deg,
length_m: layout.length_m,
spacing_m: layout.h_spacing_m,
tensile_kN: layout.tensile_kN,
bond_kPa: layout.bond_kPa,
bond_length_m: layout.type === "anchor" ? layout.bond_length_m : 0,
hole_dia_mm: layout.hole_dia_mm,
prestress_kN: layout.type === "anchor" ? layout.prestress_kN : 0,
});
}
return out;
}
const round = (v: number): number => Math.round(v * 1000) / 1000;
export interface SupportDraw {
index: number;
type: SupportType;
head: Point;
tail: Point;
/** 부착 구간 — 네일 = 전 길이 · 앵커 = 끝에서 정착장 */
bond: [Point, Point];
/** 임계 활동면과 만나는 점(없으면 null) */
cross: Point | null;
/** 결과 mode · 결과 없음 = "pending" */
mode: SupportMode | "pending";
force: number | null;
}
/** 두 선분 교점(a→b 위 비율 t 와 함께) */
function hit(a: Point, b: Point, c: Point, d: Point): [number, Point] | null {
const r = [b[0] - a[0], b[1] - a[1]];
const q = [d[0] - c[0], d[1] - c[1]];
const den = r[0] * q[1] - r[1] * q[0];
if (Math.abs(den) < 1e-12) return null;
const t = ((c[0] - a[0]) * q[1] - (c[1] - a[1]) * q[0]) / den;
const u = ((c[0] - a[0]) * r[1] - (c[1] - a[1]) * r[0]) / den;
if (t < 0 || t > 1 || u < 0 || u > 1) return null;
return [t, [a[0] + t * r[0], a[1] + t * r[1]]];
}
/** 선분이 꺾은선과 처음(머리 쪽) 만나는 점 */
export function crossPoint(head: Point, tail: Point, line: Point[]): Point | null {
let best: [number, Point] | null = null;
for (let i = 1; i < line.length; i += 1) {
const h = hit(head, tail, line[i - 1], line[i]);
if (h && (!best || h[0] < best[0])) best = h;
}
return best ? best[1] : null;
}
/** 그림 좌표 — direction = 그 비탈 활동 방향(+1 = +x 쪽으로 미끄러짐) */
export function supportDraws(
supports: SupportIn[],
direction: number,
critical: Point[] | null,
forces: SupportForce[] | null | undefined,
): SupportDraw[] {
const into = direction >= 0 ? -1 : 1;
return supports.map((s, index) => {
const a = (s.angle_deg * Math.PI) / 180;
const ux = into * Math.cos(a);
const uz = -Math.sin(a);
const head: Point = [s.x, s.z];
const at = (d: number): Point => [round(s.x + ux * d), round(s.z + uz * d)];
const tail = at(s.length_m);
const bondFrom = s.type === "anchor" ? Math.max(0, s.length_m - s.bond_length_m) : 0;
const f = forces?.find((v) => v.index === index) ?? null;
return {
index,
type: s.type,
head,
tail,
bond: [at(bondFrom), tail],
cross: critical ? crossPoint(head, tail, critical) : null,
mode: f ? f.mode : "pending",
force: f ? f.mobilized_kN_per_m : null,
};
});
}
+71 -2
View File
@@ -1,4 +1,5 @@
"""E05 보고서 s8 보강검토 — E03 보강 검토 저장본(비탈 완화 · 소단 가정 · 벽 외적 안정) → 시트 (PLAN 536-3).
"""E05 보고서 s8 보강검토 — E03 보강 검토 저장본(비탈 완화 · 소단 · 네일 · 앵커 가정 · 벽 외적 안정) → 시트
(PLAN 536-3).
칸 자리 = `resources/master_template/report/stab_report_slots.md` s8.
저장본이 없으면 시트를 뺌(있을 때만) — `build_program` 이 책에서 지움.
@@ -19,6 +20,14 @@ SIDE_NAMES = {"left": "좌", "right": "우"}
ROLE_NAMES = {"cut": "깎기", "fill": "쌓기"}
CASE_NAMES = {"normal": "평상시", "seismic": "지진 시"}
CHECKS = ("sliding", "overturning", "bearing")
SUPPORT_NAMES = {"nail": "네일", "anchor": "앵커"}
MODE_NAMES = {
"tensile": "인장",
"pullout": "인발",
"stripping": "머리 쪽 빠짐",
"not_crossing": "활동면 안 만남",
}
SUPPORT_HEAD = ("측점", "비탈 · 조건", "번호 · 종류", "머리 x / z", "길이 m", "각도 °", "간격 m")
ST_NG, ST_OK = 10, 11
@@ -51,7 +60,29 @@ def _verdict(sheet: dict[str, Any], a1: str, ok: bool | None) -> None:
_set(sheet, a1, "O.K" if ok else "N.G", ST_OK if ok else ST_NG)
def _support_text(rec: dict[str, Any]) -> str:
sup = rec["assume"]["supports"]
first = sup[0]
text = (
f"{SUPPORT_NAMES[first['type']]} {len(sup)}개 · L {first['length_m']:g} m · "
f"{first['angle_deg']:g}° · @{first['spacing_m']:g} m"
)
search = rec.get("search")
if search:
goal = CONDITION_NAMES.get(search.get("condition"), "전 조건")
text += (
f" · 최소 길이({goal} 목표)"
if search.get("found")
else f" · 최소 길이 못 찾음({goal} · {search.get('longest', 0):g} m)"
)
if rec.get("supports_pending"):
text += " · 보강 엔진 연결 전(보강 없는 값)"
return text
def _assume_text(rec: dict[str, Any]) -> str:
if rec.get("type") == "support":
return _support_text(rec)
a = rec["assume"]
text = f"1:{a['ratio_before']:g} → 1:{a['ratio']:g}"
if a.get("bench_w") and a.get("bench_h"):
@@ -90,7 +121,9 @@ def fill_reinforce(sheet: dict[str, Any], recs: list[dict[str, Any]], labels: di
return rec.get("label") or labels.get(round(ch, 3)) or f"{ch:.2f}"
r = 6
slopes = [x for x in recs if x["type"] == "slope"]
slopes = [x for x in recs if x["type"] in ("slope", "support")]
if any(x["type"] == "support" for x in slopes):
_set(sheet, "A4", "1. 비탈 완화 · 소단 · 네일 · 앵커 가정 — 전후 안전율 (Bishop)")
for rec in slopes:
a = rec["assume"]
for cond, after in rec["after"].items():
@@ -138,3 +171,39 @@ def fill_reinforce(sheet: dict[str, Any], recs: list[dict[str, Any]], labels: di
if not walls:
put_row(r, tpl[10])
_set(sheet, f"A{r}", "벽 검토 없음")
r += 1
supports = [x for x in recs if x["type"] == "support"]
if not supports:
return
r += 1
put_row(r, tpl[8])
_set(sheet, f"A{r}", "3. 네일 · 앵커 — 네일마다 동원력 · 지배 (단위 폭 = 힘 / 간격)")
sheet["병합"].append(f"A{r}:I{r}")
sheet["행"][str(r)] = sheet["행"][str(r + 1)] = {"높이": 22}
put_row(r + 1, tpl[9])
for c, text in zip("ABCDEFGHI", (*SUPPORT_HEAD, "동원력 kN/m", "지배")):
_set(sheet, f"{c}{r + 1}", text)
r += 2
for rec in supports:
a = rec["assume"]
forces = rec.get("support_forces") or {}
for cond in rec["after"]:
by = {f["index"]: f for f in forces.get(cond) or []}
for i, sup in enumerate(a["supports"]):
f = by.get(i)
put_row(r, tpl[10])
_set(sheet, f"A{r}", name(rec))
_set(
sheet,
f"B{r}",
f"{SIDE_NAMES[a['side']]} · {ROLE_NAMES[a['role']]} · "
f"{CONDITION_NAMES.get(cond, cond)}",
)
_set(sheet, f"C{r}", f"{i + 1} · {SUPPORT_NAMES[sup['type']]}")
_set(sheet, f"D{r}", f"{sup['x']:.2f} / {sup['z']:.2f}")
_set(sheet, f"E{r}", float(sup["length_m"]))
_set(sheet, f"F{r}", float(sup["angle_deg"]))
_set(sheet, f"G{r}", float(sup["spacing_m"]))
_set(sheet, f"H{r}", float(f["mobilized_kN_per_m"]) if f else None)
_set(sheet, f"I{r}", MODE_NAMES.get(f["mode"], f["mode"]) if f else "-")
r += 1
@@ -114,6 +114,7 @@
| 6 행부터 가정 하나 × 조건 하나 = 한 줄 | A 측점 · B 비탈(좌 · 우 · 깎기 · 쌓기)(서식 7) · C 가정 글(1:n 전 → 후 · 소단 폭 / 높이 · 최소 완화)(5) · D 조건(7) · E 전 Fs · F 후 Fs · G 기준(8) · H 전 판정 · I 후 판정(7 → N.G 10 · O.K 11) · 없으면 「가정 없음」 |
| 가정 줄 다음 한 줄 띄고 `8` · `9` 행 꼴(제목 3 · 머리 6) | 2. 벽 외적 안정 |
| 그 아래 벽 하나 × 경우 하나 = 한 줄 | A 측점(7) · B 벽 이름 · 평상시 / 지진 시 · 토압법 · 배면 수위(5) · C 활동 · E 전도 · G 지지력 Fs(8) · D · F · H 기준(8 · KDS 11 80 05) · I 판정(7 → 10 · 11) · 없으면 「벽 검토 없음」 |
| 네일 · 앵커 가정이 있을 때만 — 벽 줄 다음 한 줄 띄고 `8` · `9` 행 꼴(제목 · 머리 글 = 프로그램) | 3. 네일 · 앵커 · 가정 하나 × 조건 하나 × 네일 하나 = 한 줄(10 행 서식) · A 측점 · B 비탈 · 조건 · C 번호 · 종류 · D 머리 x / z · E 길이 · F 각도 · G 간격 · H 동원력 kN/m · I 지배(인장 · 인발 · 머리 쪽 빠짐 · 활동면 안 만남 · 결과 없음 「-」) · 1 절 가정 줄에도 들어감(C 가정 글 = 종류 · 개수 · 길이 · 각도 · 간격 · 최소 길이) |
- 6 행 · 10 행 서식을 줄마다 복사 · 저장본 = (측점 · 종류 · 비탈)마다 가장 최근 하나.
@@ -7,6 +7,9 @@
- 길(검증_sub2 동결 복사 · 벽 측점 + 하나): 지금 기울기 그대로 = 원본 안전율(±3 %) · 최소 완화가
목표 안전율을 맞춤 · 원본 측점 · 요약 저장본 그대로 · 벽 측점 외적 안정 · 벽 쪽 비탈 가정 거절 ·
E05 보강검토 시트(있을 때만)
- 네일 · 앵커(뒷부분): 배치 → supports 꼴 · 그림 좌표(머리 · 끝 · 정착 구간 · 활동면 교점 · mode) = 해석해(TS) ·
엔진 고리 전 = 그림만(supports_pending) · 가짜 엔진(supports 인자 · 길이 비례 Fs)으로 최소 길이 찾기 ·
E05 네일 · 앵커 줄
./venv/Scripts/python.exe -m pytest resources/tester/test_536_3_e03_reinforce.py -q
"""
@@ -14,8 +17,10 @@
from __future__ import annotations
import asyncio
import json
import math
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
@@ -33,6 +38,7 @@ from E01_StabRegister import E01_StabRegister_Source as src # noqa: E402
from E02_StabGround import E02_StabGround_Router as e02 # noqa: E402
from E03_StabSection import E03_StabSection_Router as router # noqa: E402
from E03_StabSection import E03_StabSection_Router_WhatIf as wf # noqa: E402
from E03_StabSection import E03_StabSection_View as view # noqa: E402
from E03_StabSection import E03_StabSection_Worker as worker # noqa: E402
from E03_StabSection import E03_StabSection_WallCheck as wc # noqa: E402
from E03_StabSection import E03_StabSection_WhatIf as whatif # noqa: E402
@@ -326,3 +332,145 @@ def test_E05_보강검토_시트(project, monkeypatch):
empty = build.form()
build.build_program(empty, build.gather(project))
assert "s8" not in {s["id"] for s in empty["시트"]} # 없으면 시트 뺌
# ── 네일 · 앵커(뒷부분) ──────────────────────────────────────────────────────
TYPESCRIPT = ROOT / "config" / "node_modules" / "typescript"
SUPPORT_TS = ROOT / "E03_StabSection" / "logic" / "E03_StabSection_Support.ts"
SUPPORT_SCRIPT = r"""
const ts = require(process.argv[1]);
const fs = require("fs");
const src = fs.readFileSync(process.argv[2], "utf8");
const opts = { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 };
const js = ts.transpileModule(src, { compilerOptions: opts }).outputText;
const box = { exports: {} };
new Function("exports", "module", js)(box.exports, box);
const m = box.exports;
const c = JSON.parse(fs.readFileSync(0, "utf8"));
const nail = { ...m.SUPPORT_DEFAULTS.nail, rows: 9, length_m: 6, angle_deg: 15 };
const cut = m.layoutSupports(c.surface, "left", "cut", 4.5, nail);
const anchor = { ...m.SUPPORT_DEFAULTS.anchor, rows: 1 };
const fill = m.layoutSupports(c.surface, "right", "fill", 3.0, anchor);
process.stdout.write(JSON.stringify({
cut, fill,
draws: m.supportDraws(cut, -1, c.critical, [{ index: 0, mobilized_kN_per_m: 12.5, mode: "pullout" }]),
anchor: m.supportDraws(fill, -1, null, null),
none: m.layoutSupports([[1, 0], [2, 0]], "left", "cut", 3, nail),
}));
"""
# 노면 −3 ~ 3 (EL 100) · 좌 깎기 1:1 (3, 100) → (13, 110) · 우 쌓기 1:1.5 (−3, 100) → (−7.5, 97)
SURFACE = [[13, 110], [3, 100], [-3, 100], [-7.5, 97], [-20, 97]]
CRITICAL = [[3, 100], [6, 101], [12, 109]] # 머리 (4, 101) 네일과 만나는 활동면
def test_네일_배치_supports_꼴_그림_좌표():
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", "-e", SUPPORT_SCRIPT, str(TYPESCRIPT), str(SUPPORT_TS)],
input=json.dumps({"surface": SURFACE, "critical": CRITICAL}),
capture_output=True, text=True, cwd=str(ROOT),
) # fmt: skip
assert proc.returncode == 0, proc.stderr
out = json.loads(proc.stdout)
cut = out["cut"]
# 행 = 1 · 2.5 · 4.0 m(4.5 m 비탈 안만) · 머리 = 1:1 면 위
assert [(p["x"], p["z"]) for p in cut] == [(4.0, 101.0), (5.5, 102.5), (7.0, 104.0)]
keys = {"type", "side", "x", "z", "angle_deg", "length_m", "spacing_m", "tensile_kN",
"bond_kPa", "bond_length_m", "hole_dia_mm", "prestress_kN"} # fmt: skip
assert all(set(p) == keys and p["side"] == "left" and p["prestress_kN"] == 0 for p in cut)
assert cut[0]["spacing_m"] == 1.5 and cut[0]["bond_length_m"] == 0
# 쌓기 밑 = 노면 − 3 m · 첫 행 1.5 m = EL 98.5 · 1:1.5 면 x = −3 − 1.5 × 1.5
(anchor,) = out["fill"]
assert (anchor["x"], anchor["z"], anchor["side"]) == (-5.25, 98.5, "right")
assert anchor["bond_length_m"] == 6 and anchor["prestress_kN"] == 200
assert out["none"] == []
# 그림 — 깎기 좌(활동 −x) = 비탈 속(+x)으로 · 수평 아래 15° · 네일 정착 = 전 길이
d0 = out["draws"][0]
a = math.radians(15)
tail = (4 + 6 * math.cos(a), 101 - 6 * math.sin(a))
assert d0["tail"] == pytest.approx(tail, abs=1e-3)
assert d0["bond"][0] == [4.0, 101.0] and d0["bond"][1] == pytest.approx(tail, abs=1e-3)
assert (d0["mode"], d0["force"]) == ("pullout", 12.5)
# 교점 — (3,100)~(6,101) 선 z = 100 + (x−3)/3 과 네일 z = 101 − (x−4)·tan15
t15 = math.tan(a)
x = (101 + 4 * t15 - 99) / (1 / 3 + t15)
assert d0["cross"] == pytest.approx([x, 100 + (x - 3) / 3], abs=1e-4) # 끝 1 mm 반올림
assert [d["mode"] for d in out["draws"][1:]] == ["pending", "pending"]
# 앵커 — 쌓기 우(활동 −x 바깥) = 비탈 속(+x)으로 · 정착 = 끝에서 6 m · 결과 없음
(da,) = out["anchor"]
b = math.radians(30)
want = (-5.25 + 12 * math.cos(b), 98.5 - 12 * math.sin(b))
assert da["tail"] == pytest.approx(want, abs=1e-3)
assert da["bond"][0] == pytest.approx(
(-5.25 + 6 * math.cos(b), 98.5 - 6 * math.sin(b)), abs=1e-3
)
assert da["cross"] is None and da["mode"] == "pending"
def _nails(side: str, n: int = 2) -> list[wf.SupportIn]:
return [
wf.SupportIn(type="nail", x=5.0 * (1 if side == "left" else -1), z=1.0 + i, angle_deg=15,
length_m=6, spacing_m=1.5, tensile_kN=121.6, bond_kPa=80, hole_dia_mm=100)
for i in range(n)
] # fmt: skip
def _engine_with_supports(cross, design, *, supports=None, **kw):
"""가짜 엔진 — supports 인자 · 그 비탈 대표 방법 Fs × (1 + 0.05 L) · 동원력 = 10 L."""
out = _fast(cross, design, **kw)
for case in out["cases"]:
mine = [i for i, s in enumerate(supports or []) if s["side"] == case["side"]]
best = case["result"]["methods"].get(view.PRIMARY, {}).get("min")
if not mine or not best:
continue
length = supports[mine[0]]["length_m"]
best["F"] *= 1 + 0.05 * length
case["result"]["support_forces"] = [
{"index": i, "mobilized_kN_per_m": 10.0 * length, "mode": "pullout"} for i in mine
]
return out
def test_네일_엔진_전_그림만_엔진_뒤_최소_길이(project, monkeypatch):
from E05_StabReport import E05_StabReport_Build as build
key, side, before, _ = _plain_station(project)
stamps = _stamps(project)
pending = wf.run_whatif(project, key, wf.WhatIfRequest(side=side, supports=_nails(side)))
assert pending["type"] == "support" and pending["supports_pending"] is True
assert pending["support_forces"] == {} and close(pending["after"]["dry"]["fs"], before, 1e-6)
assert [s["side"] for s in pending["assume"]["supports"]] == [side, side]
monkeypatch.setattr(wf, "analyze_station", _engine_with_supports)
assert wf.engine_takes_supports()
target = round(before * 1.24, 3) # L ≥ 4.8 m → 0.5 m 단위 = 5.0 m
ask = wf.WhatIfRequest(side=side, supports=_nails(side), search=True, condition="dry",
target_fs=target) # fmt: skip
rec = wf.run_whatif(project, key, ask)
assert rec["supports_pending"] is False and rec["search"]["found"] == 5.0
assert all(s["length_m"] == 5.0 for s in rec["assume"]["supports"])
assert rec["after"]["dry"]["fs"] >= target
assert all(not ok for n, ok in rec["search"]["tried"] if n < 5.0)
forces = rec["support_forces"]["dry"]
assert [(f["index"], f["mobilized_kN_per_m"], f["mode"]) for f in forces] == [
(0, 50.0, "pullout"),
(1, 50.0, "pullout"),
]
assert _stamps(project) == stamps # 원본 측점 · 요약 저장본 그대로
recs = wf.records(project)
assert "support" in {r["type"] for r in recs}
book = build.form()
build.build_program(book, build.gather(project))
cells = next(s for s in book["시트"] if s["id"] == "s8")["칸"]
head = next(
int(a1[1:]) for a1, c in cells.items() if str(c.get("값", "")).startswith("3. 네일")
)
assert cells[f"I{head + 1}"]["값"] == "지배"
rows = [
r
for r in range(head + 2, head + 40)
if f"C{r}" in cells and "네일" in str(cells[f"C{r}"].get("값"))
]
assert len(rows) == 2 * len(rec["after"])
assert cells[f"E{rows[0]}"]["값"] == 5.0 and cells[f"I{rows[0]}"]["값"] == "인발"
assert any("네일 2개 · L 5 m" in str(c.get("값")) for a1, c in cells.items() if a1[0] == "C")
+43
View File
@@ -168,4 +168,47 @@ export const ui_locales_e03 = {
E03_Rf_sliding: ["활동", "Sliding"],
E03_Rf_overturning: ["전도", "Overturning"],
E03_Rf_bearing: ["지지력", "Bearing"],
/* --- 보강 검토 네일 · 앵커(536-3) --- */
E03_Sp_Title: ["네일 · 앵커 가정", "Nail · Anchor Assumption"],
E03_Sp_Hint: [
"비탈 밑에서 첫 행 높이 · 수직 간격마다 비탈 면에 머리를 두고 비탈 속으로 박습니다(가정 검토 · 원본 결과는 그대로).",
"Heads are placed on the slope face from the toe at first-row height and vertical spacing, driven into the slope (assumption only · original results unchanged).",
],
E03_Sp_Source: [
"기본값 후보(사용자 확정 전) — 지식DB 안정성평가 02 §4: 천공 φ100 mm · 풍화토 주면마찰 50~100 kPa · SD400 D25 허용 121.6 kN · 앵커 = 강연선 φ12.7 × 4 허용 441 kN · 정착장 6 m",
"Default candidates (not yet confirmed) — KB stability 02 §4: hole φ100 mm · weathered soil bond 50–100 kPa · SD400 D25 allowable 121.6 kN · anchor = 4 × φ12.7 strand allowable 441 kN · bond length 6 m",
],
E03_Sp_Type: ["종류", "Type"],
E03_Sp_nail: ["네일", "Nail"],
E03_Sp_anchor: ["앵커", "Anchor"],
E03_Sp_F_rows: ["행 수", "Rows"],
E03_Sp_F_first_m: ["첫 행 높이 m", "First row height m"],
E03_Sp_F_v_spacing_m: ["수직 간격 m", "Vertical spacing m"],
E03_Sp_F_h_spacing_m: ["수평 간격 m", "Horizontal spacing m"],
E03_Sp_F_length_m: ["길이 m", "Length m"],
E03_Sp_F_angle_deg: ["각도 °(수평 아래)", "Angle ° (below horizontal)"],
E03_Sp_F_tensile_kN: ["인장 한도 kN", "Tensile limit kN"],
E03_Sp_F_bond_kPa: ["부착 kPa", "Bond kPa"],
E03_Sp_F_bond_length_m: ["정착장 m", "Bond length m"],
E03_Sp_F_hole_dia_mm: ["천공 지름 mm", "Hole dia. mm"],
E03_Sp_F_prestress_kN: ["선인장 kN", "Prestress kN"],
E03_Sp_Run: ["보강 해석", "Analyze Reinforcement"],
E03_Sp_Search: ["최소 길이 찾기", "Find Minimum Length"],
E03_Sp_Count: ["개", " pcs"],
E03_Sp_Found: ["기준안전율을 맞추는 가장 짧은 길이", "Shortest length meeting required FS"],
E03_Sp_NotFound: ["못 찾음 — 길이 {n} m 까지 봄", "Not found — checked up to {n} m"],
E03_Sp_Pending: [
"보강 엔진 연결 전 — 안전율 = 보강 없는 값 · 그림만 보입니다.",
"Reinforcement engine not connected yet — FS without reinforcement · drawing only.",
],
E03_Sp_No: ["번호", "No."],
E03_Sp_Head: ["머리 x / z m", "Head x / z m"],
E03_Sp_Length: ["길이 m", "Length m"],
E03_Sp_Force: ["동원력 kN/m", "Mobilized kN/m"],
E03_Sp_Mode: ["지배", "Governing"],
E03_Sp_Mode_tensile: ["인장", "Tensile"],
E03_Sp_Mode_pullout: ["인발", "Pullout"],
E03_Sp_Mode_stripping: ["머리 쪽 빠짐", "Stripping"],
E03_Sp_Mode_not_crossing: ["활동면 안 만남", "Not crossing"],
E03_Sp_Mode_pending: ["결과 없음", "No result"],
} as const satisfies Record<string, LocaleEntry>;