diff --git a/E03_StabSection/E03_StabSection_Router_WhatIf.py b/E03_StabSection/E03_StabSection_Router_WhatIf.py index 8519d8cb6..2893b383c 100644 --- a/E03_StabSection/E03_StabSection_Router_WhatIf.py +++ b/E03_StabSection/E03_StabSection_Router_WhatIf.py @@ -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), } diff --git a/E03_StabSection/E03_StabSection_UI_Page.ts b/E03_StabSection/E03_StabSection_UI_Page.ts index 0d0846373..e9ad0a2df 100644 --- a/E03_StabSection/E03_StabSection_UI_Page.ts +++ b/E03_StabSection/E03_StabSection_UI_Page.ts @@ -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(); // 측점 열쇠 → 마지막 가정 const wallRecs = new Map(); + const supportRecs = new Map(); let repCache: Record = 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 => + reinforce(`${base}/whatif/${encodeURIComponent(key)}`, ask, (rec) => { + supportRecs.set(key, rec); + side = rec.assume.side; + }); const runWall = (key: string, ask: WallAsk): Promise => reinforce(`${base}/wallcheck/${encodeURIComponent(key)}`, ask, (rec) => wallRecs.set(key, rec), diff --git a/E03_StabSection/E03_StabSection_UI_Reinforce.ts b/E03_StabSection/E03_StabSection_UI_Reinforce.ts index 0b30092bd..de3893053 100644 --- a/E03_StabSection/E03_StabSection_UI_Reinforce.ts +++ b/E03_StabSection/E03_StabSection_UI_Reinforce.ts @@ -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): 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( diff --git a/E03_StabSection/E03_StabSection_UI_Section.ts b/E03_StabSection/E03_StabSection_UI_Section.ts index 8bbd73618..391467554 100644 --- a/E03_StabSection/E03_StabSection_UI_Section.ts +++ b/E03_StabSection/E03_StabSection_UI_Section.ts @@ -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)); diff --git a/E03_StabSection/E03_StabSection_UI_Support.css b/E03_StabSection/E03_StabSection_UI_Support.css new file mode 100644 index 000000000..6f6829aa8 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Support.css @@ -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); +} diff --git a/E03_StabSection/E03_StabSection_UI_Support.ts b/E03_StabSection/E03_StabSection_UI_Support.ts new file mode 100644 index 000000000..528a7a397 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Support.ts @@ -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>; + after: Partial>; + support_forces: Partial> | 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; +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(); + 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; +} diff --git a/E03_StabSection/logic/E03_StabSection_Support.ts b/E03_StabSection/logic/E03_StabSection_Support.ts new file mode 100644 index 000000000..36a6582ff --- /dev/null +++ b/E03_StabSection/logic/E03_StabSection_Support.ts @@ -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 = { + 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, + }; + }); +} diff --git a/E05_StabReport/E05_StabReport_Reinforce.py b/E05_StabReport/E05_StabReport_Reinforce.py index bd41246a7..cda63ae27 100644 --- a/E05_StabReport/E05_StabReport_Reinforce.py +++ b/E05_StabReport/E05_StabReport_Reinforce.py @@ -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 diff --git a/resources/master_template/report/stab_report_slots.md b/resources/master_template/report/stab_report_slots.md index 4fdae58dd..b566cf62d 100644 --- a/resources/master_template/report/stab_report_slots.md +++ b/resources/master_template/report/stab_report_slots.md @@ -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 행 서식을 줄마다 복사 · 저장본 = (측점 · 종류 · 비탈)마다 가장 최근 하나. diff --git a/resources/tester/test_536_3_e03_reinforce.py b/resources/tester/test_536_3_e03_reinforce.py index a7266876a..7cd98a330 100644 --- a/resources/tester/test_536_3_e03_reinforce.py +++ b/resources/tester/test_536_3_e03_reinforce.py @@ -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") diff --git a/ui_template/ui_template_locale_e03.ts b/ui_template/ui_template_locale_e03.ts index 3f5552b86..d4191171f 100644 --- a/ui_template/ui_template_locale_e03.ts +++ b/ui_template/ui_template_locale_e03.ts @@ -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;