feat(536-3): E 보강 검토 뒷부분 — 네일 · 앵커 가정 입력 · supports 꼴 POST whatif · 최소 길이 찾기 · 횡단 그림 · 결과 표 · E05 네일 · 앵커 줄 (엔진 고리 전 = 그림만)
- logic/E03_StabSection_Support: 배치(행 수 · 첫 행 높이 · 수직 · 수평 간격 · 제원) → supports(브레인 정함 꼴 + side) · 머리 = 비탈 면 · 방향 = 활동 반대(비탈 속) · 그림 좌표(머리 · 끝 · 정착 구간 · 임계면 교점 · mode) - UI_Support: 「네일 · 앵커 가정」 카드 · 기본값 후보(지식DB 안정성평가 02 §4 · 사용자 확정 전) · [보강 해석] · [최소 길이 찾기] · 동원력 · 지배 표 · 후 단면 mode 색 - Router_WhatIf: supports 받음 · 엔진에 supports 인자 있으면 넘김(cases[i].result.support_forces 읽음) · 없으면 supports_pending · 최소 길이 = 성긴 걸음 + 이분(0.5 m · 25 m 까지) · 지금 기울기 그대로면 원본 설계선 · 저장본 종류 support - E05 s8: 1 절 가정 줄에 네일 · 앵커 · 3 절 네일마다 동원력 · 지배(있을 때만) - 시험 test_536_3 14(배치 해석해 · 그림 좌표 · 엔진 전 그림만 · 가짜 엔진 최소 길이 5.0 m · E05 줄) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user