Merge remote-tracking branches 'origin/sub_laptop_4' and 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-26 04:17:34 +09:00
9 changed files with 235 additions and 9 deletions
+14 -1
View File
@@ -53,6 +53,8 @@ def _summary(rock: dict[str, Any]) -> str:
+ f" — 위험 모드 평면 {s['modes']['planar']} · 쐐기 {s['modes']['wedge']} · "
f"전도 {s['modes']['toppling']}"
)
if s.get("small_blocks"):
text += f" · 소규모 블록 {s['small_blocks']} (판정에서 뺌 · 낙석 관리)"
if rock["stale"]:
text += " · 입력이 해석 뒤 바뀜 — E06 [해석] 뒤 [보고서 갱신]"
return text
@@ -100,7 +102,16 @@ def fill_rock(sheet: dict[str, Any], rock: dict[str, Any], labels: dict) -> None
if bolt["force"]
else "볼트 없음"
)
_set(sheet, "B8", f"{crack_text} · {bolt_text}")
min_block = s.get("min_block_m3", d["min_block_m3"])
_set(
sheet,
"B8",
f"{crack_text} · {bolt_text} · "
+ mark(
f"최소 블록 {min_block:g} m³ (이보다 작으면 판정에서 뺌)",
min_block == d["min_block_m3"],
),
)
_set(sheet, "B9", _summary(rock))
tpl = {r: _row_styles(sheet, r) for r in (11, 12, 13, 15, 16, 17)}
@@ -186,6 +197,8 @@ def _station_rows(sheet, put_row, r: int, entry: dict[str, Any], labels: dict) -
notes.append("들림")
elif case.get("sliding") in SLIDE_NAMES:
notes.append(SLIDE_NAMES[case["sliding"]].format(a=ids[0], b=ids[-1]))
if case.get("small"):
notes.append("소규모 · 낙석 관리")
if case.get("persistence_short"):
notes.append("지속성 < 활동면")
cond = CONDITION_NAMES.get(case["condition"], case["condition"])
+24 -6
View File
@@ -40,6 +40,7 @@ DEFAULT_SETTINGS: dict[str, Any] = {
"lateral_planar_deg": engine.LATERAL_PLANAR_DEG,
"lateral_toppling_deg": engine.LATERAL_TOPPLING_DEG,
"gamma_r": None, # None = E02 측점 암 물성 γt
"min_block_m3": 0.1, # 이보다 작은 블록은 판정에서 뺌(SWedge 최소 쐐기 크기 · 소규모 = 낙석 관리)
"water_fill_wet": 0.5, # 우기 = 균열 깊이 · 활동면 높이 ½(KDS 11 70 05 표 4.3-3 암반)
"crack": {"mode": "none", "z": None},
"bolt": {"force": 0.0, "plunge": 15.0},
@@ -56,6 +57,7 @@ LIMITS: dict[str, list[float]] = {
"lateral_deg": [5.0, 45.0],
"gamma_r": [15.0, 35.0],
"water_fill_wet": [0.0, 1.0],
"min_block_m3": [0.0, 1000.0],
"crack_z": [0.1, 100.0],
"bolt_force": [0.0, 5000.0],
"bolt_plunge": [-30.0, 60.0],
@@ -145,6 +147,8 @@ def check_joints(doc: dict[str, Any]) -> list[dict[str, str]]:
bad("settings.gamma_r")
if not _in(settings.get("water_fill_wet"), "water_fill_wet"):
bad("settings.water_fill_wet")
if not _in(settings.get("min_block_m3"), "min_block_m3"):
bad("settings.min_block_m3")
crack = settings.get("crack") if isinstance(settings.get("crack"), dict) else {}
if crack.get("mode") not in CRACK_MODES:
bad("settings.crack.mode", "enum")
@@ -186,6 +190,7 @@ def clean(doc: dict[str, Any]) -> dict[str, Any]:
"lateral_toppling_deg": float(s["lateral_toppling_deg"]),
"gamma_r": None if s.get("gamma_r") is None else float(s["gamma_r"]),
"water_fill_wet": float(s["water_fill_wet"]),
"min_block_m3": float(s["min_block_m3"]),
"crack": {
"mode": crack["mode"],
"z": float(crack["z"]) if crack["mode"] == "depth" else None,
@@ -282,6 +287,7 @@ def _cases_for(slope: dict, joints: list[dict], kin: dict, settings: dict, gamma
upper = {"dip_direction": slope["dip_direction"], "dip": slope["upper_dip"]}
by_id = {j["id"]: j for j in joints}
bolt = settings["bolt"]
min_block = settings["min_block_m3"]
crack = settings["crack"] if settings["crack"]["mode"] != "none" else None
out = []
for jid in kin["planar"]:
@@ -304,6 +310,7 @@ def _cases_for(slope: dict, joints: list[dict], kin: dict, settings: dict, gamma
short = (
r is not None and j.get("persistence_m") is not None and j["persistence_m"] < r["A"]
)
volume = r["W"] / gamma if r else None # 평면 = 폭 1 m 당 면적(m²) → m³
out.append(
{
"mode": "planar",
@@ -312,6 +319,8 @@ def _cases_for(slope: dict, joints: list[dict], kin: dict, settings: dict, gamma
"F": _fmt(r["F"]) if r else None,
"formed": r is not None,
"persistence_short": short,
"volume": _fmt(volume),
"small": volume is not None and volume < min_block,
"W": _fmt(r["W"]) if r else None,
"A": _fmt(r["A"]) if r else None,
"z": _fmt(r["z"]) if r else None,
@@ -353,6 +362,7 @@ def _cases_for(slope: dict, joints: list[dict], kin: dict, settings: dict, gamma
"sliding": r["mode"] if r else None,
"W": _fmt(r["W"]) if r else None,
"volume": _fmt(r["volume"]) if r else None,
"small": r is not None and r["volume"] < min_block,
}
)
return out
@@ -384,7 +394,11 @@ def analyze(source: Path, joints_doc: dict, ground: dict) -> list[dict[str, Any]
for case in cases:
need = (factors.get(case["condition"]) or {}).get("cut")
case["required"] = need
case["ok"] = None if case["F"] is None or need is None else case["F"] >= need
case["ok"] = (
None
if case["F"] is None or need is None or case["small"]
else case["F"] >= need
)
out.append(
{
"key": key,
@@ -406,18 +420,22 @@ def analyze(source: Path, joints_doc: dict, ground: dict) -> list[dict[str, Any]
def _verdict(kin: dict, cases: list[dict]) -> dict[str, Any]:
"""판정 — NG(기준 미달 하나라도) · check(전도만 · 블록 안 생김) · OK · none(위험 모드 없음)."""
values = [c["F"] for c in cases if c["F"] is not None]
"""판정 — NG(기준 미달 하나라도) · check(전도만 · 블록 안 생김) · OK · none(위험 모드 없음 ·
블록이 모두 소규모). 소규모 블록(최소 블록 크기 미만)은 판정 · 최소 Fs 에서 뺌."""
judged = [c for c in cases if not c["small"]]
values = [c["F"] for c in judged if c["F"] is not None]
min_f = min(values) if values else None
if any(c["ok"] is False for c in cases):
if any(c["ok"] is False for c in judged):
verdict = "NG"
elif kin["toppling"] or (cases and not values):
elif kin["toppling"] or (judged and not values):
verdict = "check"
elif cases:
elif judged:
verdict = "OK"
else:
verdict = "none"
small = {(c["mode"], tuple(c["ids"])) for c in cases if c["small"]}
return {
"small_blocks": len(small),
"counts": {
"planar": len(kin["planar"]),
"wedge": len(kin["wedge"]),
+1
View File
@@ -149,6 +149,7 @@ def _summary(stations: list[dict[str, Any]]) -> dict[str, Any]:
return {
"slopes": len(stations),
"verdicts": verdicts,
"small_blocks": sum(item["small_blocks"] for item in stations),
"modes": {
mode: sum(item["counts"][mode] for item in stations)
for mode in ("planar", "wedge", "toppling")
+27
View File
@@ -29,6 +29,7 @@ export interface RockSettings {
lateral_toppling_deg: number;
gamma_r: number | null;
water_fill_wet: number;
min_block_m3: number;
crack: { mode: "none" | "critical" | "depth"; z: number | null };
bolt: { force: number; plunge: number };
}
@@ -191,6 +192,30 @@ function select<T extends string>(
return box;
}
/** 최소 블록 크기 칸 + [기본값] — 기본값 = 근거 후보(사용자 확정 전) */
function minBlockControl(view: JointsView, s: RockSettings): HTMLElement {
const reset = createButton({
label: t("E06_Defaults"),
variant: "ghost",
size: "sm",
onClick: () => {
s.min_block_m3 = view.defaults.settings.min_block_m3;
view.change();
view.rebuild();
},
});
reset.disabled = !view.editable;
const box = el("span", { className: "e06-setting__ctl" });
box.append(
numberInput(view, s.min_block_m3, "min_block_m3", (v) => (s.min_block_m3 = v ?? NaN), {
step: "0.01",
path: "settings.min_block_m3",
}),
reset,
);
return box;
}
export function buildJointCards(view: JointsView): HTMLElement[] {
const s = view.doc.settings;
const setsCard = createPanelCard(t("E06_Card_Joints"), false);
@@ -260,6 +285,7 @@ export function buildJointCards(view: JointsView): HTMLElement[] {
path: "settings.water_fill_wet",
}),
),
settingRow("E06_Set_MinBlock", minBlockControl(view, s)),
settingRow(
"E06_Set_Crack",
select(
@@ -299,6 +325,7 @@ export function buildJointCards(view: JointsView): HTMLElement[] {
}),
),
el("p", { className: "e06-hint", text: t("E06_SettingsHint") }),
el("p", { className: "e06-hint", text: t("E06_MinBlockHint") }),
);
return [setsCard, setCard];
}
+18 -2
View File
@@ -16,6 +16,7 @@ export interface RockCase {
F: number | null;
formed: boolean;
persistence_short: boolean;
small: boolean;
required: number | null;
ok: boolean | null;
sliding?: string | null;
@@ -44,6 +45,7 @@ export interface StationEntry {
cases: RockCase[];
counts: { planar: number; wedge: number; toppling: number };
min_F: number | null;
small_blocks: number;
verdict: Verdict;
}
@@ -53,6 +55,7 @@ export interface RockResult {
slopes: number;
verdicts: Partial<Record<Verdict, number>>;
modes: Record<string, number>;
small_blocks: number;
};
stations: StationEntry[];
}
@@ -200,7 +203,13 @@ export function buildDetail(e: StationEntry): HTMLElement {
table.append(el("thead", { children: [head] }), el("tbody"));
for (const c of e.cases) {
const verdict: Verdict = c.F === null ? "check" : c.ok === false ? "NG" : "OK";
const note = !c.formed ? t("E06_NotFormed") : c.persistence_short ? t("E06_Persist") : "";
const note = !c.formed
? t("E06_NotFormed")
: c.small
? t("E06_Small")
: c.persistence_short
? t("E06_Persist")
: "";
const tr = el("tr", { attrs: { "data-mode": c.mode, "data-cond": c.condition } });
tr.append(
el("td", { text: t(`E06_Mode_${c.mode}` as LocaleKey) }),
@@ -208,11 +217,18 @@ export function buildDetail(e: StationEntry): HTMLElement {
el("td", { text: t(`E06_Cond_${c.condition}` as LocaleKey) }),
el("td", { text: `${fmt(c.F, 3)}${note ? ` ${note}` : ""}` }),
el("td", { text: fmt(c.required) }),
el("td", { children: [badge(verdict)] }),
el("td", { children: [c.small ? el("span", { text: "–" }) : badge(verdict)] }),
);
table.tBodies[0].append(tr);
}
box.append(table);
if (e.small_blocks)
box.append(
el("p", {
className: "e06-hint",
text: t("E06_SmallCount").replace("{n}", String(e.small_blocks)),
}),
);
if (kin.toppling.length)
box.append(el("p", { className: "e06-hint", text: t("E06_ToppleNote") }));
return box;
+6
View File
@@ -134,6 +134,12 @@ select.e06-input {
width: 5.5em;
}
.e06-setting__ctl {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
/* ---- 가운데 ---- */
.e06-toolbar {
display: flex;
+2
View File
@@ -28,6 +28,7 @@
| 전도 측방 한계 (°) | 절리가 사면 반대로 기운 방향 차이가 이 각 안이면 전도 후보 | 5 ~ 45 | 20 |
| 암 단위중량 (kN/m³ · 비우면 E02) | 덩이 무게 계산 값 · 비우면 지반 조건(E02)의 그 측점 암 물성 γt · 그것도 없으면 25 | 15 ~ 35 | 빈 칸 |
| 우기 수위 비 (0~1) | 우기 조건 물 차는 정도 · 0 = 마름 · 1 = 가득 참 | 0 ~ 1 | 0.5 |
| 최소 블록 크기 (m³) | 이보다 작은 블록은 판정(N.G · O.K)과 최소 Fs 에서 빼고 「소규모 · 낙석 관리」로 표시 · 평면은 폭 1 m 당 부피 · 0 = 거르지 않음 · [기본값] 으로 되돌림 | 0 ~ 1,000 | 0.1 |
| 인장균열 | 사면 윗면에서 아래로 갈라진 틈을 볼지 · 없음 · 임계(최소 안전율 자리) · 깊이 지정 | - | 없음 |
| 균열 깊이 (m) | 「깊이 지정」 때만 보임 · 윗면에서 잰 균열 깊이 | 0.1 ~ 100 | 1 |
| 볼트 힘 (평면 kN/m · 쐐기 kN) | 록볼트 · 앵커가 잡아 주는 힘 · 평면은 폭 1 m 당 · 쐐기는 덩이 하나당 | 0 ~ 5,000 | 0 |
@@ -35,6 +36,7 @@
- 측방 한계 기본 20° = 상용 Dips 기본 · 지식DB 03 은 전도에 30° 를 씀 — 화면에서 바꿀 수 있음.
- 우기 수위 비 기본 0.5 = 비탈 높이 ½ 수위(KDS 11 70 05 암반).
- 최소 블록 크기 기본 0.1 m³ = 상용 쐐기 해석(SWedge)의 최소 쐐기 크기 옵션과 같은 뜻(사용자 확정 전). 아주 얇은 쐐기가 들림으로 N.G 를 내는 것을 막음. 거른 블록 수는 비탈 상세와 보고서 요약에 나옴.
- 인장균열 「임계」 = 균열 자리를 마루 뒤로 옮겨 가며 안전율이 가장 낮은 자리를 찾음. 균열 없는 쪽이 더 낮으면 균열 없음으로 셈. 평면 무너짐에만 씀.
- 볼트 = 수동 지지력(덩이가 움직이면 버팀). 0 이면 보강 없음.
+132
View File
@@ -497,3 +497,135 @@ def test_e05_rock_sheet(monkeypatch):
rock.fill_rock(sheet, old, {})
assert sheet["칸"]["A13"]["값"] == doc["sets"][0]["name"]
assert sheet["칸"]["G13"]["값"] == "-" # 구간 모름
# ---- 최소 블록 크기 (요구 351 · 536-1 덧) --------------------------------------------
def _cases_with_volume(monkeypatch, volume: float, min_block: float):
"""엔진을 가짜 부피로 바꿔 경계(< 최소 블록)만 본다 — 평면(W = γ·면적) · 쐐기 둘."""
gamma = 25.0
monkeypatch.setattr(
model.engine,
"plane_fs",
lambda **_k: {"F": 0.5, "W": volume * gamma, "A": 5.0, "z": 0.0},
)
monkeypatch.setattr(
model.engine,
"wedge_fs",
lambda **_k: {"F": 0.0, "volume": volume, "line_length": 2.0, "mode": "lift", "W": 1.0},
)
joints = [
{"id": "J1", "dip_direction": 90, "dip": 40, "phi": 30, "c": 0},
{"id": "J2", "dip_direction": 200, "dip": 50, "phi": 30, "c": 0},
]
slope = {"dip_direction": 90, "dip": 68, "upper_dip": 0, "height_m": 5}
kin = {"planar": ["J1"], "wedge": [{"ids": ["J1", "J2"]}], "toppling": []}
settings = dict(model.DEFAULT_SETTINGS, min_block_m3=min_block)
return model._cases_for(slope, joints, kin, settings, gamma, [("dry", 0.0, 0.0)])
def test_min_block_default():
assert model.DEFAULT_SETTINGS["min_block_m3"] == 0.1
assert model.upgrade({"sets": [], "settings": {}})["settings"]["min_block_m3"] == 0.1
@pytest.mark.parametrize(
"volume,small", [(0.0999, True), (0.1, False), (0.3, False), (0.0003, True)]
)
def test_min_block_boundary(monkeypatch, volume, small):
"""최소 블록 0.1 m³ — 미만만 소규모(경계 = 거르지 않음) · 평면 · 쐐기 같은 규칙."""
cases = _cases_with_volume(monkeypatch, volume, 0.1)
assert [c["small"] for c in cases] == [small, small]
def test_min_block_zero_keeps_all(monkeypatch):
cases = _cases_with_volume(monkeypatch, 0.0003, 0.0)
assert not any(c["small"] for c in cases)
def test_min_block_verdict_and_counts(monkeypatch):
"""얇은 쐐기(들림 Fs 0)가 N.G 를 내던 자리 — 거르면 판정 · 최소 Fs 에서 빠지고 거른 블록 수가 남음."""
kin = {"planar": ["J1"], "wedge": [{"ids": ["J1", "J2"]}], "toppling": []}
for min_block, verdict, n_small in ((0.0, "NG", 0), (0.1, "none", 2)):
cases = _cases_with_volume(monkeypatch, 0.0003, min_block)
for c in cases:
c["required"] = 1.2
c["ok"] = None if c["small"] else c["F"] >= 1.2
out = model._verdict(kin, cases)
assert (out["verdict"], out["small_blocks"]) == (verdict, n_small)
assert out["min_F"] == (0.0 if not n_small else None)
# 큰 블록 + 작은 블록 섞임 — 작은 것만 빠지고 큰 것으로 판정
big = dict(_cases_with_volume(monkeypatch, 0.5, 0.1)[0], required=1.2, ok=False)
tiny = dict(_cases_with_volume(monkeypatch, 0.0003, 0.1)[1], required=1.2, ok=None)
tiny["F"] = 0.0
out = model._verdict(kin, [big, tiny])
assert (out["verdict"], out["min_F"], out["small_blocks"]) == ("NG", 0.5, 1)
def test_min_block_check_range():
doc = _doc()
doc["settings"]["min_block_m3"] = -1
assert {"path": "settings.min_block_m3", "code": "range"} in model.check_joints(doc)
doc["settings"]["min_block_m3"] = 2.5
assert model.check_joints(doc) == []
assert model.clean(doc)["settings"]["min_block_m3"] == 2.5
def test_analyze_min_block_filter(monkeypatch):
"""측점 해석 전체 길 — 최소 블록을 크게 주면 모든 경우 소규모 · ok 없음 · 판정 none."""
cross, design = _cross("left")
monkeypatch.setattr(
model,
"rock_stations",
lambda _s: [{"chainage_m": 20.0, "label": "STA", "ground_type": "ripping_rock"}],
)
monkeypatch.setattr(model.src, "cross_section", lambda _s, _c: {**cross, "design": design})
ground = {
"materials": [],
"stations": [],
"safety_factors": {"dry": {"cut": 1.5}, "wet": {"cut": 1.2}},
"seismic": {"enabled": False},
}
base = model.analyze(None, _doc(), ground)[0]
assert not any(c["small"] for c in base["cases"]) and base["small_blocks"] == 0
doc = _doc()
doc["settings"]["min_block_m3"] = 1000.0
e = model.analyze(None, doc, ground)[0]
assert all(c["small"] and c["ok"] is None for c in e["cases"])
assert (e["verdict"], e["min_F"], e["small_blocks"]) == ("none", None, 1)
def test_e05_rock_sheet_small(monkeypatch):
"""E05 s11 — 최소 블록 글 · 소규모 요약 · 줄 비고."""
from E05_StabReport import E05_StabReport_Build as build
from E05_StabReport import E05_StabReport_Rock as rock
from E06_StabRock import E06_StabRock_Router as router
cross, design = _cross("left")
monkeypatch.setattr(
model,
"rock_stations",
lambda _s: [{"chainage_m": 20.0, "label": "STA", "ground_type": "ripping_rock"}],
)
monkeypatch.setattr(model.src, "cross_section", lambda _s, _c: {**cross, "design": design})
ground = {
"materials": [],
"stations": [],
"safety_factors": {"dry": {"cut": 1.5}, "wet": {"cut": 1.2}},
"seismic": {"enabled": False},
}
doc = _doc()
doc["settings"]["min_block_m3"] = 1000.0
stations = model.analyze(None, doc, ground)
value = {
"settings": doc["settings"],
"sets": doc["sets"],
"summary": router._summary(stations),
"stations": stations,
"stale": False,
}
sheet = next(s for s in build.form()["시트"] if s["id"] == "s11")
rock.fill_rock(sheet, value, {})
cells = sheet["칸"]
assert "최소 블록 1000 m³" in cells["B8"]["값"] and "사용자 입력" in cells["B8"]["값"]
assert "소규모 블록 1" in cells["B9"]["값"]
assert any("소규모 · 낙석 관리" in str(c.get("값")) for c in cells.values())
+11
View File
@@ -40,6 +40,12 @@ export const ui_locales_e06 = {
"우기 수위 비 (0~1 · 균열 · 활동면 높이 대비)",
"Wet water ratio (0–1, of crack / sliding-plane height)",
],
E06_Set_MinBlock: ["최소 블록 크기 (m³)", "Minimum block size (m³)"],
E06_Defaults: ["기본값", "Default"],
E06_MinBlockHint: [
"최소 블록 크기 기본 0.1 m³ = 상용 쐐기 해석(SWedge)의 최소 쐐기 크기 옵션과 같은 뜻 — 그보다 작은 블록은 판정에서 빼고 「소규모 · 낙석 관리」로 표시 (평면은 폭 1 m 당 부피) · 사용자 확정 전.",
"Default minimum block 0.1 m³ = same meaning as the minimum wedge size option of commercial wedge analysis (SWedge): smaller blocks are left out of the verdict and flagged small — rockfall management (planar = volume per 1 m width) · not yet confirmed by user.",
],
E06_Set_Crack: ["인장균열", "Tension crack"],
E06_Crack_None: ["없음", "None"],
E06_Crack_Critical: ["임계(최소 안전율 자리)", "Critical (minimum FS)"],
@@ -116,6 +122,11 @@ export const ui_locales_e06 = {
E06_Cond_dry: ["건기", "Dry"],
E06_Cond_wet: ["우기", "Wet"],
E06_Cond_seismic: ["지진", "Seismic"],
E06_Small: ["(소규모 · 낙석 관리)", "(small · rockfall management)"],
E06_SmallCount: [
"소규모 블록 {n}개 · 낙석 관리 — 판정에서 뺌",
"{n} small block(s) · rockfall management — not judged",
],
E06_NotFormed: ["(블록 안 생김)", "(no block)"],
E06_Persist: ["(지속성 < 활동면)", "(persistence < plane)"],
E06_ToppleNote: [