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

This commit is contained in:
2026-10-25 20:09:31 +09:00
31 changed files with 7030 additions and 11 deletions
+8
View File
@@ -36,6 +36,14 @@ const FLOWS: readonly GroupFlow[] = [
},
];
/** 종류별 단계 묶음(대시보드 표 단계 막대 · 528-2) — ROAD · 모르는 종류 = 없음(B 막대 그대로) */
export function groupFlowOf(
kind: string | null | undefined,
): { routes: readonly RoutePath[]; steps: string[]; icons: readonly string[] } | null {
const flow = { PLAN: FLOWS[0], STABILITY: FLOWS[1] }[kind ?? ""];
return flow ? { ...flow, steps: flow.labels.map((key) => t(key)) } : null;
}
/** 단계 틀을 세움 — mainContent · leftPanel 이 없으면 빈 단계 안내 · [?] 줄만 */
export function mountGroupStep(
root: HTMLElement,
+2
View File
@@ -39,6 +39,8 @@ export interface ProjectItem {
id: string;
company_id?: number | null;
name: string;
/** 종류(요구 345) — ROAD 임도 · PLAN 예정노선 · STABILITY 안정성평가 · EROSION 사방 */
project_kind?: string | null;
region?: string | null;
road_type?: string | null;
project_year?: number | null;
@@ -0,0 +1,70 @@
/* =============================================================================
* B01_Dashboard_UI_AssignedPlans.ts
* 대시보드 「받은 예정노선」 구역(요구서 345 2-2 · PLAN 528-2) — 우리 회사에 지정된 예정노선 연도.
* 열 = 예정노선 · 연도 · 연결 상태(연결됨 · 미연결 · 업데이트 있음 배지) · [보기] [연결]
* [보기] = 그 예정노선 첫 단계(D02 · 공유 회사 = GET 만) · [연결] = 자리만(동작은 531-3)
* ========================================================================== */
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
import { createButton, createTag } from "@ui/ui_template_elements";
import { table, text } from "@ui/ui_template_general_blocks";
import { goToWorkflowStage } from "../A00_Common/b_workflow_nav";
import { DASHBOARD_VISIBLE_ROWS, L } from "./B01_Dashboard_UI_Common";
export interface AssignedPlan {
project_id: string;
project_name: string;
year: number;
linked_project_id: string | null;
update_available: boolean;
}
/** 받은 예정노선 — 실패 · 없음 = 빈 목록(구역을 안 그림) */
export async function fetchAssignedPlans(): Promise<AssignedPlan[]> {
const response = await fetch(`${API_BASE_URL}/dashboard/plan-routes/assigned`, {
credentials: "include",
});
if (!response.ok) return [];
return ((await response.json()) as { routes?: AssignedPlan[] }).routes ?? [];
}
function linkState(plan: AssignedPlan): HTMLElement {
const cell = document.createElement("div");
cell.className = "b01-dashboard__actions";
cell.append(
createTag(
L(plan.linked_project_id ? "DE_Assigned_Linked" : "DE_Assigned_Unlinked"),
plan.linked_project_id ? "success" : "neutral",
),
);
if (plan.update_available) cell.append(createTag(L("DE_Assigned_Update"), "warning"));
return cell;
}
export function assignedPlanTable(plans: AssignedPlan[]): HTMLElement {
return table(
[
L("DE_New_Plan"),
L("DE_Assigned_Year"),
L("DE_Assigned_State"),
L("B01_Dashboard_Table_Action"),
],
plans.map((plan) => {
const actions = document.createElement("div");
actions.className = "b01-dashboard__actions";
const link = createButton({ label: L("DE_Assigned_Link"), variant: "ghost" });
link.disabled = true;
link.title = L("DE_New_Soon");
actions.append(
createButton({
label: L("DE_Assigned_View"),
variant: "ghost",
onClick: () => void goToWorkflowStage(plan.project_id, ROUTES.D02_PLAN_BASE),
}),
link,
);
return [text(plan.project_name), text(plan.year), linkState(plan), actions];
}),
DASHBOARD_VISIBLE_ROWS,
);
}
+20 -1
View File
@@ -45,6 +45,11 @@ import { openAddMemberModal, openCreateCompanyModal } from "./B01_Dashboard_UI_M
import { buildProfileForm, buildSecurityForm } from "./B01_Dashboard_UI_Profile";
import { openCompanyPicker } from "./B01_Dashboard_UI_CompanyPick";
import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject";
import {
assignedPlanTable,
fetchAssignedPlans,
type AssignedPlan,
} from "./B01_Dashboard_UI_AssignedPlans";
import { projectTable } from "./B01_Dashboard_UI_Projects";
import { buildResourcePanel } from "./B01_Dashboard_UI_Resources";
import { buildTempUploadSection } from "./B01_Dashboard_UI_TempUpload";
@@ -65,6 +70,8 @@ export interface DashboardState {
allJoinRequests: JoinRequest[];
auditLogs: AuditLog[];
resources: ResourceData | null;
/** 우리 회사에 지정된 예정노선(528-2) — 회사관리자 · 사용자만 */
assignedPlans: AssignedPlan[];
/** 시스템 관리자가 고른 회사(29-1) — 프로젝트 · 사용자 · 가입 요청 · 회사 관리가 이 회사 것 */
adminCompanyId: number | null;
}
@@ -96,6 +103,7 @@ export async function renderB01Dashboard(root: HTMLElement): Promise<void> {
allJoinRequests: [],
auditLogs: [],
resources: null,
assignedPlans: [],
adminCompanyId: null,
};
await loadRoleData(state);
@@ -134,12 +142,14 @@ async function loadRoleData(state: DashboardState): Promise<void> {
});
return;
}
const [projects, company] = await Promise.all([
const [projects, company, assigned] = await Promise.all([
fetchUserProjects().catch(() => []),
fetchUserCompany().catch(() => null),
fetchAssignedPlans().catch(() => []),
]);
state.userProjects = projects;
state.company = company;
state.assignedPlans = assigned;
if (state.user.role === "ADMIN") {
const [members, requests, companyProjects] = await Promise.all([
fetchCompanyMembers().catch(() => []),
@@ -219,6 +229,7 @@ const SECTION_ICONS: [Parameters<typeof L>[0], string][] = [
["B01_Dashboard_Resources", "📊"],
["B01_Dashboard_SystemSettings", "⚙️"],
["B01_Dashboard_Projects", "🗂️"],
["DE_Assigned_Title", "📬"],
["B01_Temp_Section", "📦"],
["B01_Dashboard_Users", "👥"],
["B01_Dashboard_Members", "👥"],
@@ -246,6 +257,12 @@ function mountSectionShortcuts(grid: HTMLElement): void {
setDrawerSteps(bar);
}
/** 받은 예정노선 구역 — 받은 것이 있을 때만 */
function assignedPlanSection(state: DashboardState): HTMLElement[] {
if (!state.assignedPlans.length) return [];
return [section(L("DE_Assigned_Title"), assignedPlanTable(state.assignedPlans), true)];
}
function buildPage(state: DashboardState): HTMLElement {
// 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양.
const page = document.createElement("div");
@@ -297,6 +314,7 @@ function buildPage(state: DashboardState): HTMLElement {
section(L("B01_Dashboard_Projects"), projectTable(state.companyProjects, state.user), true, [
createButton({ label: "+", onClick: () => void openNewProjectPicker() }),
]),
...assignedPlanSection(state),
buildTempUploadSection(),
section(L("B01_Dashboard_Members"), memberTable(state.members, state.user), true, [
createButton({ label: "+", onClick: () => openAddMemberModal() }),
@@ -310,6 +328,7 @@ function buildPage(state: DashboardState): HTMLElement {
section(L("B01_Dashboard_Projects"), projectTable(state.userProjects, state.user), true, [
createButton({ label: "+", onClick: () => void openNewProjectPicker() }),
]),
...assignedPlanSection(state),
// 임시 보관함 — 프로젝트를 만들기 전에 자료를 올려 두는 곳이라 프로젝트 목록
// 바로 아래에 둔다(2026-08-08 사용자 지시).
buildTempUploadSection(),
+19 -3
View File
@@ -3,15 +3,27 @@ import { table, text } from "@ui/ui_template_general_blocks";
import { createStepBar } from "@ui/ui_template_workflow_layout";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
import { groupFlowOf } from "../A00_Common/group_workflow";
import type { DashboardUser, ProjectItem } from "./B01_Dashboard_Api_Fetch";
import { canDeleteProject, canEditProject } from "./B01_Dashboard_UI_Helper";
import { openDeleteProjectModal, openEditProjectModal } from "./B01_Dashboard_UI_Modals";
import { DASHBOARD_VISIBLE_ROWS, formatDate, L } from "./B01_Dashboard_UI_Common";
/** 종류 열 글(528-2) — 칸이 없는 옛 응답 = 임도 */
const KIND_LABEL = {
ROAD: "DE_New_Road",
PLAN: "DE_New_Plan",
STABILITY: "DE_New_Stability",
EROSION: "DE_New_Erosion",
} as const;
const kindText = (project: ProjectItem): string =>
L(KIND_LABEL[(project.project_kind ?? "ROAD") as keyof typeof KIND_LABEL] ?? "DE_New_Road");
export function projectTable(projects: ProjectItem[], currentUser: DashboardUser): HTMLElement {
return table(
[
L("B01_Dashboard_Table_Project"),
L("DE_Kind_Column"),
L("B01_Dashboard_Table_Region"),
// 진행도(%) 열은 없앴다 (2026-09-06 사용자 지시) — 워크플로 배지가 같은 것을 보여 준다.
L("B01_Dashboard_Table_Workflow"),
@@ -43,6 +55,7 @@ export function projectTable(projects: ProjectItem[], currentUser: DashboardUser
return [
text(project.name),
text(kindText(project)),
text(project.region),
workflow(project),
text(formatDate(project.updated_at)),
@@ -53,16 +66,19 @@ export function projectTable(projects: ProjectItem[], currentUser: DashboardUser
);
}
/** 단계 막대 — 종류별 단계(ROAD = B03~B09 · PLAN = D02~D04 · STABILITY = E02~E05) · 누름 = 그 단계 화면 */
export function workflow(project: ProjectItem): HTMLElement {
const group = groupFlowOf(project.project_kind);
const routes = group?.routes ?? WORKFLOW_STEP_ROUTES;
return createStepBar(
workflowSteps(),
group?.steps ?? workflowSteps(),
project.workflow_state?.current_stage ?? project.workflow_stage,
{
stages: project.workflow_state?.stages,
currentStage: project.workflow_state?.current_stage,
routes: WORKFLOW_STEP_ROUTES,
routes,
compact: true,
onStepClick: (stepIndex) => goToWorkflowStage(project.id, WORKFLOW_STEP_ROUTES[stepIndex]),
onStepClick: (stepIndex) => goToWorkflowStage(project.id, routes[stepIndex]),
},
);
}
@@ -20,6 +20,7 @@ from common_util.common_util_project_delete import hard_delete_project
from common_util.common_util_project_kind import create_kind_project
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from E02_StabGround.E02_StabGround_Router import seed_initial
logger = logging.getLogger(__name__)
@@ -96,6 +97,7 @@ async def post_stab_project(
)
try:
manifest = await _copy_source(project_id, source["project_id"])
await seed_initial(project_id) # E02 초기값(기본값 + 복사본 B06 측점)
except Exception:
logger.exception("E01 복사 실패 — 만든 프로젝트를 지움: %s", project_id)
await hard_delete_project(project_id, int(session["user_id"]), request)
+1 -2
View File
@@ -46,8 +46,7 @@ export function renderE01StabRegister(root: HTMLElement): void {
label: t("DE_Reg_Submit"),
variant: "filled",
onClick: () => {
source.setError();
if (!source.select.value) return void source.setError(t("B02_Proj_Error_Required"));
// 목록이 비면 단추가 꺼져 있음 — 고른 값은 늘 있음
void createGroupProject(
"/e01/project",
{ project_name: name.input.value.trim() || null, source_project_id: source.select.value },
+230
View File
@@ -0,0 +1,230 @@
"""E02 지반 조건 정본 꼴 · 검사 · 측점 적용(요구 347-2 · 3 · 4 · PLAN 532-3).
정본 = `E02_StabGround/ground.json` · 초기값 = `initial_snapshot/E02_StabGround/ground.json`
(E 생성 직후 = 마스터 기본값 + B06 측점 암 경계) · 기본값 = `resources/master_template/stability/
ground_defaults.json`(M02 마스터 층). 물성 한 줄 = `E03_StabSection_Types.Material` 꼴 + `water`.
⚠ 파이썬·TS 짝 — `check_ground` ↔ `logic/E02_StabGround_Check.ts` `checkGround` ·
`resolve_stations` ↔ `resolveStations`. 한쪽을 고치면 다른 쪽도 · 거울 시험
`resources/tester/test_532_3_e02_ground.py`.
"""
from __future__ import annotations
import copy
import json
import math
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[1]
DEFAULTS_PATH = (
PROJECT_ROOT / "resources" / "master_template" / "stability" / "ground_defaults.json"
)
STAGE_DIR = "E02_StabGround"
GROUND_NAME = "ground.json"
MATERIAL_FIELDS = ("gamma_t", "gamma_sat", "c", "phi")
WATER_GROUPS = ("soil", "rock")
LABELS = ("soil", "ripping_rock", "blasting_rock", "fill")
ROCK_LABELS = ("ripping_rock", "blasting_rock")
CASES = ("dry", "wet", "seismic", "short_term")
SLOPES = ("cut", "fill", "soft")
WATER_KEYS = ("dry", "wet_soil", "wet_rock")
WATER_RULES = ("none", "surface", "half_height")
# 정본에 남기는 칸 — 그 밖(limits · sources · note)은 기본값 파일에만
GROUND_KEYS = (
"version",
"materials",
"label_map",
"stations",
"zones",
"safety_factors",
"road_surcharge_kpa",
"groundwater",
"seismic",
)
def load_defaults() -> dict[str, Any]:
return json.loads(DEFAULTS_PATH.read_text(encoding="utf-8"))
def ground_path(project_root: Path) -> Path:
return Path(project_root) / STAGE_DIR / GROUND_NAME
def initial_path(project_root: Path) -> Path:
return Path(project_root) / "initial_snapshot" / STAGE_DIR / GROUND_NAME
def pick(doc: dict[str, Any]) -> dict[str, Any]:
"""정본 칸만 떠 옴(깊은 복사)."""
return copy.deepcopy({key: doc[key] for key in GROUND_KEYS if key in doc})
def build_initial(defaults: dict[str, Any], stations: list[dict[str, Any]]) -> dict[str, Any]:
"""초기값 = 기본값 정본 칸 + B06 측점(암 경계)."""
ground = pick(defaults)
ground["stations"] = stations
return ground
def _num(value: Any) -> bool:
return isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(value)
def _in(value: Any, bound: list[float]) -> bool:
return _num(value) and bound[0] <= value <= bound[1]
def _list(value: Any) -> list:
return value if isinstance(value, list) else []
def _dict(value: Any) -> dict:
return value if isinstance(value, dict) else {}
def check_ground(ground: dict[str, Any], limits: dict[str, list[float]]) -> list[dict[str, str]]:
"""틀린 칸 목록 [{path, code}] — 빈 목록 = 통과. code = number · range · order · sat_lt_t ·
name · name_dup · id_dup · enum · ref · empty · type."""
errors: list[dict[str, str]] = []
def bad(path: str, code: str) -> None:
errors.append({"path": path, "code": code})
def ranged(path: str, value: Any, key: str) -> None:
if not _num(value):
bad(path, "number")
elif not _in(value, limits[key]):
bad(path, "range")
materials = _list(ground.get("materials"))
if not materials:
bad("materials", "empty")
ids: list[str] = []
names: list[str] = []
for i, item in enumerate(materials):
base = f"materials.{i}"
if not isinstance(item, dict):
bad(base, "type")
continue
mid = item.get("id")
if not isinstance(mid, str) or not mid or mid in ids:
bad(f"{base}.id", "id_dup")
ids.append(mid if isinstance(mid, str) else "")
name = item["name"].strip() if isinstance(item.get("name"), str) else ""
low, high = limits["name_length"]
if not low <= len(name) <= high:
bad(f"{base}.name", "name")
elif name in names:
bad(f"{base}.name", "name_dup")
names.append(name)
for field in MATERIAL_FIELDS:
ranged(f"{base}.{field}", item.get(field), field)
if _num(item.get("gamma_t")) and _num(item.get("gamma_sat")):
if item["gamma_sat"] < item["gamma_t"]:
bad(f"{base}.gamma_sat", "sat_lt_t")
if item.get("water") not in WATER_GROUPS:
bad(f"{base}.water", "enum")
label_map = _dict(ground.get("label_map"))
for label in LABELS:
if not label_map.get(label) or label_map.get(label) not in ids:
bad(f"label_map.{label}", "ref")
for i, zone in enumerate(_list(ground.get("zones"))):
base = f"zones.{i}"
if not isinstance(zone, dict):
bad(base, "type")
continue
ranged(f"{base}.from_m", zone.get("from_m"), "chainage_m")
ranged(f"{base}.to_m", zone.get("to_m"), "chainage_m")
if _num(zone.get("from_m")) and _num(zone.get("to_m")):
if zone["from_m"] >= zone["to_m"]:
bad(f"{base}.to_m", "order")
for layer in ("soil", "rock"):
if zone.get(layer) is not None and zone.get(layer) not in ids:
bad(f"{base}.{layer}", "ref")
if zone.get("soil_thickness_m") is not None:
ranged(f"{base}.soil_thickness_m", zone.get("soil_thickness_m"), "soil_thickness_m")
factors = _dict(ground.get("safety_factors"))
for case in CASES:
for slope in SLOPES:
value = _dict(factors.get(case)).get(slope)
ranged(f"safety_factors.{case}.{slope}", value, "safety_factor")
ranged("road_surcharge_kpa", ground.get("road_surcharge_kpa"), "road_surcharge_kpa")
water = _dict(ground.get("groundwater"))
for key in WATER_KEYS:
if water.get(key) not in WATER_RULES:
bad(f"groundwater.{key}", "enum")
seismic = _dict(ground.get("seismic"))
if not isinstance(seismic.get("enabled"), bool):
bad("seismic.enabled", "enum")
ranged("seismic.kh", seismic.get("kh"), "kh")
return errors
def clean(ground: dict[str, Any]) -> dict[str, Any]:
"""검사를 지난 정본에서 알려진 칸만 남김 · 이름 앞뒤 빈칸 걷음."""
out = pick(ground)
out["materials"] = [
{
"id": m["id"],
"name": m["name"].strip(),
**{field: m[field] for field in MATERIAL_FIELDS},
"water": m["water"],
}
for m in out["materials"]
]
out["label_map"] = {label: out["label_map"][label] for label in LABELS}
out["zones"] = [
{key: z.get(key) for key in ("from_m", "to_m", "soil", "rock", "soil_thickness_m")}
for z in _list(out.get("zones"))
]
factors = out["safety_factors"]
out["safety_factors"] = {c: {s: factors[c][s] for s in SLOPES} for c in CASES}
out["groundwater"] = {key: out["groundwater"][key] for key in WATER_KEYS}
out["seismic"] = {"enabled": out["seismic"]["enabled"], "kh": out["seismic"]["kh"]}
return out
def resolve_stations(ground: dict[str, Any]) -> list[dict[str, Any]]:
"""측점마다 적용 지층 — 위 = 토사 연결 · 아래 = 암 측점의 암 연결 · 두께 = B06 · 구간이
덮어씀(뒤 구간이 이김 · 양 끝 포함 · 빈 칸은 안 바꿈) · 두께가 없으면 아래 지층 없음.
성토체는 측점과 상관없이 fill 연결."""
label_map = ground.get("label_map") or {}
out: list[dict[str, Any]] = []
for station in ground.get("stations") or []:
chainage = station["chainage_m"]
ground_type = station.get("ground_type")
upper = label_map.get("soil")
lower = label_map.get(ground_type) if ground_type in ROCK_LABELS else None
thickness = station.get("soil_thickness_m")
zoned = False
for zone in ground.get("zones") or []:
if not zone["from_m"] <= chainage <= zone["to_m"]:
continue
zoned = True
if zone.get("soil") is not None:
upper = zone["soil"]
if zone.get("rock") is not None:
lower = zone["rock"]
if zone.get("soil_thickness_m") is not None:
thickness = zone["soil_thickness_m"]
if thickness is None: # 경계 없음 = 전부 위 지층
lower = None
out.append(
{
"chainage_m": chainage,
"ground_type": ground_type,
"upper": upper,
"lower": lower,
"soil_thickness_m": thickness if lower is not None else None,
"zoned": zoned,
}
)
return out
+144
View File
@@ -0,0 +1,144 @@
"""E02 지반 조건 API (요구 347-2 · 3 · 4 · PLAN 532-3).
- `GET /api/projects/{id}/stab/ground` — 작업본(없으면 초기값) + 기본값 + 고칠 수 있는지
- `PUT …/stab/ground` — [저장] 검사 뒤 작업본 `E02_StabGround/ground.json`
· 측점(B06 암 경계)은 서버 값
- `POST …/stab/ground/reset` — [초기화] 초기값을 작업본에 덮어씀(재계산 아님)
초기값 = E 생성 직후 마스터 기본값 + 복사본 B06 측점(`ensure_initial`) · 그 밖에는 쓰지 않음.
공유 회사(보기만)는 `require_project_access` 가 PUT · POST 를 막음.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from uuid import UUID
import aiomysql
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_auth import verify_session
from common_util.common_util_json import atomic_write_json
from common_util.common_util_project_copy import MANIFEST_NAME
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from config.config_system_design import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
from E01_StabRegister.E01_StabRegister_Source import db_snapshot, source_dir
from E02_StabGround import E02_StabGround_Model as model
router = APIRouter(prefix="/api/projects", tags=["E02 Stab Ground"])
class GroundRequest(BaseModel):
ground: dict[str, Any]
def _b06_stations(project_root: Path) -> list[dict]:
"""측점 · 지반 라벨 · 토층 두께(암 측점 = 암 경계 깊이 · 토사 = 없음).
E01 동결 복사본(`E01_StabRegister/source/db.json`)만 읽음 — 복사본이 없으면 빈 목록.
"""
root = source_dir(project_root)
if not (root / MANIFEST_NAME).is_file():
return []
rows = sorted(db_snapshot(root)["cross_sections"], key=lambda r: float(r["chainage_m"] or 0))
stations = []
for row in rows:
data = json.loads(row["data"]) if isinstance(row["data"], str) else row["data"] or {}
design = data.get("design") or {}
ground_type = design.get("ground_type") or "soil"
offset = design.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M)
rock = ground_type in model.ROCK_LABELS and offset is not None
stations.append(
{
"chainage_m": round(float(row["chainage_m"]), 3),
"ground_type": ground_type,
"soil_thickness_m": round(abs(float(offset)), 3) if rock else None,
}
)
return stations
async def _project(project_id: UUID) -> tuple[Path, dict[str, Any]]:
async with get_db_pool().acquire() as connection:
try:
stored = await get_project_storage_relative_path(connection, project_id)
except Exception as error:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") from error
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"SELECT company_id, project_kind FROM projects WHERE id = %s",
(str(project_id),),
)
project = await cursor.fetchone()
if not project or project["project_kind"] != "STABILITY":
raise HTTPException(status_code=400, detail="안정성평가 프로젝트가 아닙니다.")
return Path(resolve_stored_project_path(stored)), project
def ensure_initial(project_root: Path) -> dict:
"""초기값이 없을 때만 한 번 — 기본값 + 복사본 B06 측점(E 생성 직후 · 옛 E 프로젝트는 첫 GET)."""
path = model.initial_path(project_root)
if path.is_file():
return json.loads(path.read_text(encoding="utf-8"))
ground = model.build_initial(model.load_defaults(), _b06_stations(project_root))
atomic_write_json(path, ground)
return ground
async def seed_initial(project_id: str) -> None:
"""E01 생성 직후 부름 — 초기값 한 벌."""
root, _ = await _project(UUID(project_id))
ensure_initial(root)
def _read_working(project_root: Path) -> dict[str, Any] | None:
path = model.ground_path(project_root)
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None
def _editable(session: dict[str, Any], project: dict[str, Any]) -> bool:
return session["role"] == "SYSTEM_ADMIN" or session.get("company_id") == project["company_id"]
@router.get("/{project_id}/stab/ground")
async def get_ground(
project_id: UUID, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
root, project = await _project(project_id)
initial = ensure_initial(root)
working = _read_working(root)
return {
"ground": working or initial,
"saved": working is not None,
"defaults": model.load_defaults(),
"editable": _editable(session, project),
}
@router.put("/{project_id}/stab/ground")
async def put_ground(project_id: UUID, payload: GroundRequest) -> dict[str, Any]:
root, _ = await _project(project_id)
defaults = model.load_defaults()
errors = model.check_ground(payload.ground, defaults["limits"])
if errors:
raise HTTPException(status_code=422, detail={"errors": errors})
ground = model.clean(payload.ground)
# 측점(B06 암 경계)은 서버 값 — 브라우저 값을 받아 적지 않음
base = _read_working(root) or ensure_initial(root)
ground["stations"] = base.get("stations", [])
ground["version"] = defaults["version"]
atomic_write_json(model.ground_path(root), ground)
return {"ground": ground}
@router.post("/{project_id}/stab/ground/reset")
async def reset_ground(project_id: UUID) -> dict[str, Any]:
root, _ = await _project(project_id)
initial = ensure_initial(root)
atomic_write_json(model.ground_path(root), initial)
return {"ground": initial}
+244
View File
@@ -0,0 +1,244 @@
/* =============================================================================
* E02_StabGround_UI_Page.ts
* E02 지반 조건(요구서 345 4-3 · 요구 347-2 · 3 · 4 · PLAN 532-3) — B05~B09 틀(group_workflow).
* GET /api/projects/{id}/stab/ground → 작업본(없으면 초기값) · 기본값 · 고칠 수 있는지.
* 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT(서버가 다시 검사 · 측점은 서버 값) ·
* [초기화] = POST …/reset(초기값 덮어씀). 검사 = logic/E02_StabGround_Check(파이썬 짝).
* 칸 위 마우스 = 틀린 까닭 · 기본값 · 근거(지식DB 07 · KDS 11 70 05).
* ========================================================================== */
import "./E02_StabGround_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import {
createButton,
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { buildCards, type E02View } from "./E02_StabGround_UI_Tables";
import {
checkGround,
type CheckError,
type Ground,
type GroundDefaults,
} from "./logic/E02_StabGround_Check";
interface GroundResponse {
ground: Ground;
saved?: boolean;
defaults?: GroundDefaults;
editable?: boolean;
}
const cacheKey = (projectId: string): string => `e02_ground_cache:${projectId}`;
/** 검사 열쇠 → 범위 열쇠(limits) */
function limitKey(path: string): string {
const last = path.split(".").pop() ?? "";
if (path.startsWith("safety_factors.")) return "safety_factor";
if (last === "from_m" || last === "to_m") return "chainage_m";
return last;
}
function errorText(error: CheckError, defaults: GroundDefaults): string {
const text = t(`E02_Err_${error.code}` as LocaleKey);
const bound = defaults.limits[limitKey(error.path)];
return error.code === "range" && bound ? `${text} (${bound[0]} ~ ${bound[1]})` : text;
}
async function request(path: string, init?: RequestInit): Promise<GroundResponse> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...init,
});
const body = (await response.json().catch(() => ({}))) as GroundResponse & { detail?: unknown };
if (!response.ok) {
const detail = body.detail;
if (typeof detail === "string") throw new Error(detail);
const errors = (detail as { errors?: CheckError[] } | undefined)?.errors;
throw new Error(errors ? `${t("E02_Invalid")} ${errors.length}` : `HTTP ${response.status}`);
}
return body;
}
export function renderE02StabGround(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const main = el("div", { className: "e02-page" });
const status = el("p", { className: "e02-status" });
const saveButton = createButton({
label: t("Common_Btn_Save"),
variant: "filled",
onClick: () => void save(),
});
const resetButton = createButton({
label: t("Common_Btn_Reset"),
variant: "danger-ghost",
onClick: () => void reset(),
});
saveButton.disabled = resetButton.disabled = true; // 읽어 온 뒤 켬
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(resetButton, saveButton);
const panel = el("div", { className: "ui-sidebar-panel e02-panel" });
panel.append(el("p", { className: "e02-hint", text: t("E02_Note") }), status, actions);
mountGroupStep(root, ROUTES.E02_STAB_GROUND, { mainContent: main, leftPanel: panel });
if (!projectId)
return void main.append(el("p", { className: "e02-empty", text: t("E02_NoProject") }));
let view: E02View | null = null;
let refreshStations = (): void => {};
let dirty = false;
const paintStatus = (errors: CheckError[]): void => {
const lines: string[] = [];
if (view && !view.editable) lines.push(t("E02_ReadOnly"));
if (dirty) lines.push(t("E02_Unsaved"));
if (errors.length) lines.push(`${t("E02_Invalid")} ${errors.length}`);
status.textContent = lines.join("\n");
status.classList.toggle("is-error", errors.length > 0);
};
/** 틀린 칸 표시 — 칸마다 is-invalid + data-error(마우스 글) */
const mark = (): CheckError[] => {
if (!view) return [];
const errors = checkGround(view.ground, view.defaults.limits);
for (const node of main.querySelectorAll<HTMLElement>(".is-invalid")) {
node.classList.remove("is-invalid");
delete node.dataset.error;
}
for (const error of errors) {
const node = main.querySelector<HTMLElement>(`[data-path="${error.path}"]`);
if (!node) continue;
node.classList.add("is-invalid");
node.dataset.error = errorText(error, view.defaults);
}
paintStatus(errors);
return errors;
};
const render = (): void => {
if (!view) return;
const cards = buildCards(view);
refreshStations = cards.refreshStations;
main.replaceChildren(cards.root);
mark();
};
const remember = (): void => {
if (!view) return;
dirty = true;
sessionStorage.setItem(cacheKey(projectId), JSON.stringify(view.ground));
};
const adopt = (body: GroundResponse, keepCache: boolean): void => {
if (!view || !body.defaults) return;
let ground = body.ground;
const cached = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null;
if (cached) ground = JSON.parse(cached) as Ground;
else sessionStorage.removeItem(cacheKey(projectId));
dirty = Boolean(cached);
view.ground = ground;
view.defaults = body.defaults;
view.editable = body.editable ?? false;
saveButton.disabled = resetButton.disabled = !view.editable;
render();
};
async function save(): Promise<void> {
if (!view?.editable) return;
const errors = mark();
if (errors.length) {
showToast(
`${t("E02_Invalid")} ${errors.length} — ${errorText(errors[0], view.defaults)}`,
"error",
);
return;
}
showLoadingOverlay();
try {
const body = await request(`/projects/${projectId}/stab/ground`, {
method: "PUT",
body: JSON.stringify({ ground: view.ground }),
});
adopt({ ...body, defaults: view.defaults, editable: view.editable }, false);
showToast(t("E02_Saved"), "success");
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
} finally {
hideLoadingOverlay();
}
}
async function reset(): Promise<void> {
if (!view?.editable) return;
if (
!(await showConfirmDialog(
t("E02_ResetConfirm"),
t("Common_Btn_Reset"),
t("Common_Btn_Cancel"),
))
)
return;
showLoadingOverlay();
try {
const body = await request(`/projects/${projectId}/stab/ground/reset`, { method: "POST" });
adopt({ ...body, defaults: view.defaults, editable: view.editable }, false);
showToast(t("E02_ResetDone"), "success");
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
} finally {
hideLoadingOverlay();
}
}
attachHoverInfo(
main,
(target) => {
if (!view) return null;
const src = target.dataset.src;
const source = src ? view.defaults.sources[src] : undefined;
const userLayer = src?.startsWith("materials.") && !source;
return [
target.dataset.error,
target.dataset.def !== undefined ? `${t("E02_DefaultIs")} ${target.dataset.def}` : "",
source ?? (userLayer ? t("E02_UserLayer") : ""),
]
.filter(Boolean)
.join("\n");
},
"[data-path]",
);
void (async () => {
try {
const body = await request(`/projects/${projectId}/stab/ground`);
view = {
ground: body.ground,
defaults: body.defaults as GroundDefaults,
editable: false,
change: () => {
remember();
refreshStations();
mark();
},
rebuild: () => {
remember();
render();
},
};
adopt(body, true);
} catch (error) {
main.replaceChildren(
el("p", {
className: "e02-empty",
text: error instanceof Error ? error.message : t("Common_Status_Error"),
}),
);
}
})();
}
+153
View File
@@ -0,0 +1,153 @@
/* =============================================================================
* E02_StabGround_UI_Style.css
* E02 지반 조건 본문 카드 · 표 · 입력 칸 — 색은 테마 토큰만(밝음 · 어두움 같이)
* ========================================================================== */
.e02-page {
height: 100%;
overflow: auto;
padding: var(--spacing-16);
box-sizing: border-box;
}
.e02-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(560px, 100%), 1fr));
gap: var(--spacing-16);
align-items: start;
}
.e02-card {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
padding: var(--spacing-12) var(--spacing-16);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
}
.e02-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
}
.e02-card__title,
.e02-card__sub {
margin: 0;
color: var(--color-text);
font-size: var(--text-body);
}
.e02-card__sub {
font-size: var(--text-body-sm);
margin-top: var(--spacing-8);
}
.e02-card__actions {
display: flex;
gap: var(--spacing-4);
}
.e02-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-body-sm);
color: var(--color-text-body);
}
.e02-table th {
padding: var(--spacing-4);
border-bottom: 1px solid var(--color-border);
color: var(--color-text-secondary);
font-weight: var(--font-weight-medium);
text-align: left;
white-space: nowrap;
}
.e02-table td {
padding: 2px var(--spacing-4);
vertical-align: middle;
}
.e02-table--read td {
padding: 2px var(--spacing-8);
border-bottom: 1px solid var(--color-border);
white-space: nowrap;
}
.e02-table--read tr.is-zoned td {
background: var(--color-mist-violet);
color: var(--color-text);
}
.e02-stations {
max-height: 320px;
overflow: auto;
}
.e02-input {
width: 100%;
min-width: 64px;
box-sizing: border-box;
}
.e02-input--num {
text-align: right;
}
.e02-input--name {
min-width: 96px;
}
.e02-input.is-invalid {
border-color: var(--color-danger);
box-shadow: 0 0 0 1px var(--color-danger);
}
.e02-rowhead {
white-space: nowrap;
color: var(--color-text-secondary);
}
.e02-map,
.e02-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--spacing-8);
align-items: end;
}
.e02-map__title {
grid-column: 1 / -1;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.e02-field {
display: flex;
flex-direction: column;
gap: 2px;
font-size: var(--text-caption);
color: var(--color-text-secondary);
}
.e02-hint,
.e02-empty {
margin: 0;
font-size: var(--text-caption);
color: var(--color-text-secondary);
}
.e02-status {
margin: var(--spacing-8) 0 0;
white-space: pre-line;
font-size: var(--text-body-sm);
color: var(--color-warning);
}
.e02-status.is-error {
color: var(--color-danger);
}
+488
View File
@@ -0,0 +1,488 @@
/* =============================================================================
* E02_StabGround_UI_Tables.ts
* E02 지반 조건 본문 카드 넷(요구 347-2 · 3 · 4) — 물성 표(지층 추가 · 지움 · B06 지반 연결) ·
* 층 두께 · 구간 덮어쓰기 + 측점별 적용 · 기준안전율 · 하중 · 지하수 · 지진. 카드마다 [기본값].
* 칸 = data-path(검사 열쇠 · 파이썬과 같음) · data-src(근거 열쇠) · data-def(기본값 글).
* 값 바꿈 = view.change()(캐시 · 검사) · 줄 더함 · 지움 · [기본값] = view.rebuild().
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
import {
CASES,
LABELS,
MATERIAL_FIELDS,
SLOPES,
WATER_GROUPS,
WATER_KEYS,
WATER_RULES,
resolveStations,
type Ground,
type GroundDefaults,
type Material,
} from "./logic/E02_StabGround_Check";
export interface E02View {
ground: Ground;
defaults: GroundDefaults;
editable: boolean;
change: () => void;
rebuild: () => void;
}
const L = (key: string): string => t(key as LocaleKey);
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
const FIELD_LABEL: Record<(typeof MATERIAL_FIELDS)[number], LocaleKey> = {
gamma_t: "E02_Col_GammaT",
gamma_sat: "E02_Col_GammaSat",
c: "E02_Col_C",
phi: "E02_Col_Phi",
};
/** 숫자 칸 — 빈 칸 = null(nullable) · 아니면 숫자 아님으로 검사에 걸림 */
function numInput(
view: E02View,
path: string,
value: number | null,
set: (v: number | null) => void,
meta: { src?: string; def?: number | null; nullable?: boolean } = {},
): HTMLInputElement {
const input = el("input", {
className: "ui-input ui-input--sm e02-input e02-input--num",
}) as HTMLInputElement;
input.type = "number";
input.step = "any";
input.value = value === null || value === undefined ? "" : String(value);
input.dataset.path = path;
if (meta.src) input.dataset.src = meta.src;
if (meta.def !== undefined && meta.def !== null) input.dataset.def = String(meta.def);
if (meta.nullable) input.placeholder = t("E02_Zone_Keep");
input.disabled = !view.editable;
input.addEventListener("input", () => {
const raw = input.value.trim();
set(raw === "" ? (meta.nullable ? null : Number.NaN) : Number(raw));
view.change();
});
return input;
}
function selectInput(
view: E02View,
path: string,
options: { value: string; text: string }[],
value: string | null,
set: (v: string) => void,
meta: { src?: string; def?: string } = {},
): HTMLSelectElement {
const select = el("select", {
className: "ui-select ui-select--sm e02-input",
}) as HTMLSelectElement;
for (const option of options) select.append(new Option(option.text, option.value));
select.value = value ?? "";
select.dataset.path = path;
if (meta.src) select.dataset.src = meta.src;
if (meta.def) select.dataset.def = meta.def;
select.disabled = !view.editable;
select.addEventListener("change", () => {
set(select.value);
view.change();
});
return select;
}
function card(
title: LocaleKey,
...body: HTMLElement[]
): { section: HTMLElement; head: HTMLElement } {
const head = el("div", { className: "e02-card__head" });
head.append(el("h3", { className: "e02-card__title", text: t(title) }));
const section = el("section", { className: "e02-card ui-sidebar-section" });
section.append(head, ...body);
return { section, head };
}
function headButtons(view: E02View, head: HTMLElement, ...buttons: HTMLButtonElement[]): void {
if (!view.editable) return;
const row = el("div", { className: "e02-card__actions" });
row.append(...buttons);
head.append(row);
}
function table(headers: string[], rows: HTMLElement[][]): HTMLTableElement {
const node = el("table", { className: "e02-table" }) as HTMLTableElement;
const tr = node.createTHead().insertRow();
for (const text of headers) tr.append(el("th", { text }));
const body = node.createTBody();
for (const cells of rows) {
const row = body.insertRow();
for (const cell of cells) row.insertCell().append(cell);
}
return node;
}
function defaultMaterial(view: E02View, id: string): Material | undefined {
return view.defaults.materials.find((m) => m.id === id);
}
function layerOptions(ground: Ground, blank?: string): { value: string; text: string }[] {
const list = ground.materials.map((m) => ({ value: m.id, text: m.name || m.id }));
return blank === undefined ? list : [{ value: "", text: blank }, ...list];
}
/* --- 1. 지반물성치 --- */
function materialsCard(view: E02View): HTMLElement {
const g = view.ground;
const used = (id: string): boolean =>
Object.values(g.label_map).includes(id) || g.zones.some((z) => z.soil === id || z.rock === id);
const rows = g.materials.map((m, i) => {
const base = `materials.${i}`;
const name = el("input", {
className: "ui-input ui-input--sm e02-input e02-input--name",
}) as HTMLInputElement;
name.value = m.name;
name.dataset.path = `${base}.name`;
name.disabled = !view.editable;
name.addEventListener("input", () => {
m.name = name.value;
view.change();
});
const cells: HTMLElement[] = [name];
for (const field of MATERIAL_FIELDS)
cells.push(
numInput(view, `${base}.${field}`, m[field], (v) => (m[field] = v as number), {
src: `materials.${m.id}.${field}`,
def: defaultMaterial(view, m.id)?.[field] ?? null,
}),
);
cells.push(
selectInput(
view,
`${base}.water`,
WATER_GROUPS.map((w) => ({
value: w,
text: L(w === "soil" ? "E02_Water_Soil" : "E02_Water_Rock"),
})),
m.water,
(v) => (m.water = v as Material["water"]),
),
);
const remove = createButton({
label: t("E02_Btn_Remove"),
variant: "danger-ghost",
size: "sm",
onClick: () => {
if (used(m.id)) return void showToast(t("E02_InUse"), "warning");
g.materials.splice(i, 1);
view.rebuild();
},
});
remove.hidden = !view.editable;
cells.push(remove);
return cells;
});
const headers = [
t("E02_Col_Name"),
...MATERIAL_FIELDS.map((f) => t(FIELD_LABEL[f])),
t("E02_Col_Water"),
"",
];
const mapRow = el("div", { className: "e02-map" });
mapRow.append(el("span", { className: "e02-map__title", text: t("E02_LabelMap") }));
for (const label of LABELS) {
const field = el("label", { className: "e02-field" });
field.append(
el("span", { text: L(`E02_Label_${label}`) }),
selectInput(view, `label_map.${label}`, layerOptions(g), g.label_map[label], (v) => {
g.label_map[label] = v;
view.rebuild();
}),
);
mapRow.append(field);
}
const { section, head } = card("E02_Card_Materials", table(headers, rows), mapRow);
headButtons(
view,
head,
createButton({
label: t("E02_Btn_AddLayer"),
variant: "ghost",
size: "sm",
onClick: () => {
let n = 1;
while (g.materials.some((m) => m.name === `${t("E02_NewLayer")} ${n}`)) n += 1;
let id = `user_${n}`;
for (let k = n; g.materials.some((m) => m.id === id); k += 1) id = `user_${k}`;
const seed = view.defaults.materials[0];
g.materials.push({ ...seed, id, name: `${t("E02_NewLayer")} ${n}` });
view.rebuild();
},
}),
createButton({
label: t("E02_Btn_Defaults"),
variant: "ghost",
size: "sm",
onClick: () => {
g.materials = clone(view.defaults.materials);
g.label_map = clone(view.defaults.label_map);
const ids = new Set(g.materials.map((m) => m.id));
for (const zone of g.zones) {
if (zone.soil !== null && !ids.has(zone.soil)) zone.soil = null;
if (zone.rock !== null && !ids.has(zone.rock)) zone.rock = null;
}
view.rebuild();
},
}),
);
return section;
}
/* --- 2. 층 두께 · 구간 덮어쓰기 + 측점별 적용 --- */
function zonesCard(view: E02View): { section: HTMLElement; refresh: () => void } {
const g = view.ground;
const rows = g.zones.map((z, i) => {
const base = `zones.${i}`;
const remove = createButton({
label: t("E02_Btn_Remove"),
variant: "danger-ghost",
size: "sm",
onClick: () => {
g.zones.splice(i, 1);
view.rebuild();
},
});
remove.hidden = !view.editable;
const layer = (key: "soil" | "rock"): HTMLSelectElement =>
selectInput(view, `${base}.${key}`, layerOptions(g, t("E02_Zone_Keep")), z[key], (v) => {
z[key] = v || null;
});
return [
numInput(view, `${base}.from_m`, z.from_m, (v) => (z.from_m = v as number)),
numInput(view, `${base}.to_m`, z.to_m, (v) => (z.to_m = v as number)),
layer("soil"),
layer("rock"),
numInput(
view,
`${base}.soil_thickness_m`,
z.soil_thickness_m,
(v) => (z.soil_thickness_m = v),
{
nullable: true,
src: "stations",
},
),
remove,
];
});
const zoneBody = rows.length
? table(
[
t("E02_Col_From"),
t("E02_Col_To"),
t("E02_Col_Upper"),
t("E02_Col_Lower"),
t("E02_Col_Thickness"),
"",
],
rows,
)
: el("p", { className: "e02-empty", text: t("E02_Zone_Empty") });
const stationsHost = el("div", { className: "e02-stations" });
const refresh = (): void => {
stationsHost.replaceChildren();
if (!g.stations.length)
return void stationsHost.append(
el("p", { className: "e02-empty", text: t("E02_Stations_Empty") }),
);
const names = new Map(g.materials.map((m) => [m.id, m.name]));
const interval = inferStationInterval(g.stations);
const fmt = (v: number | null): string => (v === null ? "-" : v.toFixed(2));
const resolved = resolveStations(g);
const node = table(
[
t("E02_Col_Station"),
t("E02_Col_GroundType"),
t("E02_Col_B06Thickness"),
t("E02_Col_Upper"),
t("E02_Col_Lower"),
t("E02_Col_Thickness"),
],
resolved.map((r, i) => [
el("span", { text: stationLabel(r.chainage_m, interval) }),
el("span", { text: L(`E02_Ground_${r.ground_type}`) }),
el("span", { text: fmt(g.stations[i].soil_thickness_m) }),
el("span", { text: (r.upper && names.get(r.upper)) || "-" }),
el("span", { text: r.lower ? (names.get(r.lower) ?? "-") : t("E02_AllUpper") }),
el("span", { text: fmt(r.soil_thickness_m) }),
]),
);
resolved.forEach((r, i) => node.tBodies[0].rows[i].classList.toggle("is-zoned", r.zoned));
node.classList.add("e02-table--read");
stationsHost.append(node);
};
refresh();
const { section, head } = card(
"E02_Card_Zones",
el("p", { className: "e02-hint", text: t("E02_Zone_Hint") }),
zoneBody,
el("h4", { className: "e02-card__sub", text: t("E02_Card_Stations") }),
stationsHost,
);
const first = g.stations[0]?.chainage_m ?? 0;
const last = g.stations[g.stations.length - 1]?.chainage_m ?? 100;
headButtons(
view,
head,
createButton({
label: t("E02_Btn_AddZone"),
variant: "ghost",
size: "sm",
onClick: () => {
g.zones.push({
from_m: first,
to_m: last > first ? last : first + 20,
soil: null,
rock: null,
soil_thickness_m: null,
});
view.rebuild();
},
}),
createButton({
label: t("E02_Btn_Defaults"),
variant: "ghost",
size: "sm",
onClick: () => {
g.zones = clone(view.defaults.zones);
view.rebuild();
},
}),
);
return { section, refresh };
}
/* --- 3. 기준안전율 --- */
function safetyCard(view: E02View): HTMLElement {
const g = view.ground;
const rows = CASES.map((c) => [
el("span", { className: "e02-rowhead", text: L(`E02_Case_${c}`) }),
...SLOPES.map((s) =>
numInput(
view,
`safety_factors.${c}.${s}`,
g.safety_factors[c][s],
(v) => (g.safety_factors[c][s] = v as number),
{
src: `safety_factors.${c}`,
def: view.defaults.safety_factors[c][s],
},
),
),
]);
const { section, head } = card(
"E02_Card_Safety",
table(["", ...SLOPES.map((s) => L(`E02_Slope_${s}`))], rows),
);
headButtons(
view,
head,
createButton({
label: t("E02_Btn_Defaults"),
variant: "ghost",
size: "sm",
onClick: () => {
g.safety_factors = clone(view.defaults.safety_factors);
view.rebuild();
},
}),
);
return section;
}
/* --- 4. 노면 하중 · 지하수 · 지진 --- */
function loadsCard(view: E02View): HTMLElement {
const g = view.ground;
const d = view.defaults;
const field = (label: string, input: HTMLElement): HTMLElement => {
const node = el("label", { className: "e02-field" });
node.append(el("span", { text: label }), input);
return node;
};
const kh = numInput(view, "seismic.kh", g.seismic.kh, (v) => (g.seismic.kh = v as number), {
src: "seismic.kh",
def: d.seismic.kh,
});
kh.disabled = !view.editable || !g.seismic.enabled;
const seismic = createCheckbox(t("E02_Seismic"), { checked: g.seismic.enabled });
seismic.input.dataset.path = "seismic.enabled";
seismic.input.dataset.src = "seismic.enabled";
seismic.input.dataset.def = d.seismic.enabled ? "on" : "off";
seismic.input.disabled = !view.editable;
seismic.input.addEventListener("change", () => {
g.seismic.enabled = seismic.input.checked;
kh.disabled = !view.editable || !g.seismic.enabled;
view.change();
});
const water = WATER_KEYS.map((key) =>
field(
L(`E02_Water_${key}`),
selectInput(
view,
`groundwater.${key}`,
WATER_RULES.map((r) => ({ value: r, text: L(`E02_Rule_${r}`) })),
g.groundwater[key],
(v) => (g.groundwater[key] = v as (typeof WATER_RULES)[number]),
{ src: `groundwater.${key}`, def: L(`E02_Rule_${d.groundwater[key]}`) },
),
),
);
const grid = el("div", { className: "e02-grid" });
grid.append(
field(
t("E02_Surcharge"),
numInput(
view,
"road_surcharge_kpa",
g.road_surcharge_kpa,
(v) => (g.road_surcharge_kpa = v as number),
{
src: "road_surcharge_kpa",
def: d.road_surcharge_kpa,
},
),
),
...water,
seismic.root,
field(t("E02_Kh"), kh),
);
const { section, head } = card("E02_Card_Loads", grid);
headButtons(
view,
head,
createButton({
label: t("E02_Btn_Defaults"),
variant: "ghost",
size: "sm",
onClick: () => {
g.road_surcharge_kpa = d.road_surcharge_kpa;
g.groundwater = clone(d.groundwater);
g.seismic = clone(d.seismic);
view.rebuild();
},
}),
);
return section;
}
/** 본문 전체 — 돌려준 refreshStations = 값만 바뀐 뒤 측점별 적용 표를 다시 그림 */
export function buildCards(view: E02View): { root: HTMLElement; refreshStations: () => void } {
const root = el("div", { className: "e02-cards" });
const zones = zonesCard(view);
root.append(materialsCard(view), zones.section, safetyCard(view), loadsCard(view));
return { root, refreshStations: zones.refresh };
}
+3 -4
View File
@@ -1,7 +1,7 @@
/* =============================================================================
* E02_StabGround_Route.ts
* 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대)
* 길 선언(PLAN 529-1 · 532-3) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E02 지반 조건 페이지
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
@@ -12,6 +12,5 @@ export const ROUTE: DraftRoute = {
workflow: true,
project: true,
footerless: true,
load: async () =>
(await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.E02_STAB_GROUND),
load: async () => (await import("../E02_StabGround_UI_Page")).renderE02StabGround,
};
@@ -0,0 +1,186 @@
/* =============================================================================
* E02_StabGround_Check.ts
* E02 지반 조건 꼴 · 입력 검사 · 측점 적용(요구 347-2 · 3 · 4 · PLAN 532-3) — 순수 함수만.
*
* ⚠ 파이썬·TS 짝 — `checkGround` ↔ `E02_StabGround_Model.py` `check_ground` ·
* `resolveStations` ↔ `resolve_stations`. 한쪽을 고치면 다른 쪽도 · 거울 시험
* `resources/tester/test_532_3_e02_ground.py`.
* ========================================================================== */
export type WaterGroup = "soil" | "rock";
export type WaterRule = "none" | "surface" | "half_height";
/** 물성 한 줄 = E03_StabSection_Types.Material + 지하수 무리 */
export interface Material {
id: string;
name: string;
gamma_t: number;
gamma_sat: number;
c: number;
phi: number;
water: WaterGroup;
}
export interface Station {
chainage_m: number;
ground_type: string;
soil_thickness_m: number | null;
}
export interface Zone {
from_m: number;
to_m: number;
soil: string | null;
rock: string | null;
soil_thickness_m: number | null;
}
export type SlopeFactors = Record<(typeof SLOPES)[number], number>;
export interface Ground {
version: number;
materials: Material[];
label_map: Record<(typeof LABELS)[number], string>;
stations: Station[];
zones: Zone[];
safety_factors: Record<(typeof CASES)[number], SlopeFactors>;
road_surcharge_kpa: number;
groundwater: Record<(typeof WATER_KEYS)[number], WaterRule>;
seismic: { enabled: boolean; kh: number };
}
export type Limits = Record<string, [number, number]>;
export interface GroundDefaults extends Ground {
note: string;
limits: Limits;
sources: Record<string, string>;
}
export interface CheckError {
path: string;
code: string;
}
export interface ResolvedStation {
chainage_m: number;
ground_type: string;
upper: string | null;
lower: string | null;
soil_thickness_m: number | null;
zoned: boolean;
}
export const MATERIAL_FIELDS = ["gamma_t", "gamma_sat", "c", "phi"] as const;
export const WATER_GROUPS: readonly WaterGroup[] = ["soil", "rock"];
export const LABELS = ["soil", "ripping_rock", "blasting_rock", "fill"] as const;
const ROCK_LABELS = ["ripping_rock", "blasting_rock"];
export const CASES = ["dry", "wet", "seismic", "short_term"] as const;
export const SLOPES = ["cut", "fill", "soft"] as const;
export const WATER_KEYS = ["dry", "wet_soil", "wet_rock"] as const;
export const WATER_RULES: readonly WaterRule[] = ["none", "surface", "half_height"];
type Loose = Record<string, unknown>;
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
const asList = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
const isDict = (v: unknown): v is Loose => typeof v === "object" && v !== null && !Array.isArray(v);
const asDict = (v: unknown): Loose => (isDict(v) ? v : {});
/** 틀린 칸 목록 — 빈 목록 = 통과(code 는 파이썬과 같은 낱말) */
export function checkGround(ground: unknown, limits: Limits): CheckError[] {
const errors: CheckError[] = [];
const bad = (path: string, code: string): void => void errors.push({ path, code });
const ranged = (path: string, value: unknown, key: string): void => {
const [low, high] = limits[key];
if (!isNum(value)) bad(path, "number");
else if (!(low <= value && value <= high)) bad(path, "range");
};
const doc = asDict(ground);
const materials = asList(doc.materials);
if (!materials.length) bad("materials", "empty");
const ids: string[] = [];
const names: string[] = [];
materials.forEach((item, i) => {
const base = `materials.${i}`;
if (!isDict(item)) return bad(base, "type");
const mid = item.id;
if (typeof mid !== "string" || !mid || ids.includes(mid)) bad(`${base}.id`, "id_dup");
ids.push(typeof mid === "string" ? mid : "");
const name = typeof item.name === "string" ? item.name.trim() : "";
const [low, high] = limits.name_length;
if (!(low <= name.length && name.length <= high)) bad(`${base}.name`, "name");
else if (names.includes(name)) bad(`${base}.name`, "name_dup");
names.push(name);
for (const field of MATERIAL_FIELDS) ranged(`${base}.${field}`, item[field], field);
if (isNum(item.gamma_t) && isNum(item.gamma_sat) && item.gamma_sat < item.gamma_t)
bad(`${base}.gamma_sat`, "sat_lt_t");
if (!WATER_GROUPS.includes(item.water as WaterGroup)) bad(`${base}.water`, "enum");
});
const labelMap = asDict(doc.label_map);
for (const label of LABELS) {
const ref = labelMap[label];
if (!ref || !ids.includes(ref as string)) bad(`label_map.${label}`, "ref");
}
asList(doc.zones).forEach((zone, i) => {
const base = `zones.${i}`;
if (!isDict(zone)) return bad(base, "type");
ranged(`${base}.from_m`, zone.from_m, "chainage_m");
ranged(`${base}.to_m`, zone.to_m, "chainage_m");
if (isNum(zone.from_m) && isNum(zone.to_m) && zone.from_m >= zone.to_m)
bad(`${base}.to_m`, "order");
for (const layer of ["soil", "rock"]) {
const ref = zone[layer];
if (ref !== null && ref !== undefined && !ids.includes(ref as string))
bad(`${base}.${layer}`, "ref");
}
if (zone.soil_thickness_m !== null && zone.soil_thickness_m !== undefined)
ranged(`${base}.soil_thickness_m`, zone.soil_thickness_m, "soil_thickness_m");
});
const factors = asDict(doc.safety_factors);
for (const c of CASES)
for (const s of SLOPES)
ranged(`safety_factors.${c}.${s}`, asDict(factors[c])[s], "safety_factor");
ranged("road_surcharge_kpa", doc.road_surcharge_kpa, "road_surcharge_kpa");
const water = asDict(doc.groundwater);
for (const key of WATER_KEYS)
if (!WATER_RULES.includes(water[key] as WaterRule)) bad(`groundwater.${key}`, "enum");
const seismic = asDict(doc.seismic);
if (typeof seismic.enabled !== "boolean") bad("seismic.enabled", "enum");
ranged("seismic.kh", seismic.kh, "kh");
return errors;
}
/** 측점마다 적용 지층 — 위 = 토사 연결 · 아래 = 암 측점의 암 연결 · 두께 = B06 · 구간이 덮어씀
* (뒤 구간이 이김 · 양 끝 포함 · 빈 칸은 안 바꿈) · 두께가 없으면 아래 지층 없음 */
export function resolveStations(ground: Ground): ResolvedStation[] {
return ground.stations.map((station) => {
const chainage = station.chainage_m;
let upper: string | null = ground.label_map.soil ?? null;
let lower: string | null = ROCK_LABELS.includes(station.ground_type)
? (ground.label_map[station.ground_type as (typeof LABELS)[number]] ?? null)
: null;
let thickness = station.soil_thickness_m;
let zoned = false;
for (const zone of ground.zones) {
if (!(zone.from_m <= chainage && chainage <= zone.to_m)) continue;
zoned = true;
if (zone.soil != null) upper = zone.soil;
if (zone.rock != null) lower = zone.rock;
if (zone.soil_thickness_m != null) thickness = zone.soil_thickness_m;
}
if (thickness === null) lower = null; // 경계 없음 = 전부 위 지층
return {
chainage_m: chainage,
ground_type: station.ground_type,
upper,
lower,
soil_thickness_m: lower !== null ? thickness : null,
zoned,
};
});
}
@@ -0,0 +1,508 @@
"""E03 2D 한계평형해석(LEM) — 절편 · 네 방법(Fellenius · Bishop 간편 · Janbu 간편+보정 · Spencer).
좌표 x 오른쪽 · z 위 · 활동 방향 +x(왼쪽 위 → 오른쪽 아래). 반대쪽 비탈은 `Prepared(direction=-1)`
이 모형을 좌우로 뒤집어 맞춤 — 결과 좌표를 되돌리는 것은 부르는 쪽 몫(`unflip`).
식 = 지식DB `original/안정성평가/02` 절편 힘 · 모멘트 평형. 네 방법 모두 한 벌 식에서 나옴:
절편 i 연직 평형 → N = [W + λ·B0 − (c·l − u·l·tanφ)·sinα/F] / (m_α − λ·A)
m_α = cosα + sinα·tanφ/F · A = sinα − tanφ·cosα/F · B0 = kh·Ws − (c·l − u·l·tanφ)·cosα/F
λ = 절편간 힘 기울기(X = λ·E) — Bishop · Janbu 는 λ = 0 · Spencer 는 모멘트 · 힘 평형을 함께 맞추는 λ.
모멘트 F_m = Σ a·R / D (점 O 둘레) · 힘 F_f = Σ R·cosα / (Σ(N·sinα + kh·Ws) + Pw) · R = c·l + (N − u·l)·tanφ
외부 코드 없음(NumPy 만) — 상용 · 오픈소스 결과는 대조 시험에서만 씀.
"""
import numpy as np
from E03_StabSection.E03_StabSection_Types import GAMMA_W, groundwater, material, surcharges
_TINY = 1e-9
_PAD_Z = -1e9
#: m_α 가 이 값보다 작은 절편이 있으면 그 활동면을 버림(상용 SW 관례 0.2 — 진출부 급경사 발산 막음).
MALPHA_MIN = 0.2
#: Spencer 절편간 힘 기울기 λ = X/E 범위 — 여기 부호(X = 윗 절편이 아랫 절편에 주는 연직력, 위 +)로
#: 윗 덩이가 아랫 덩이를 내리누르면 λ < 0. 아래 한도 −1.25 = SLOPE/W GLE λ 범위 · 위는 작은 + 만 허용
#: (아랫 덩이를 들어 올리는 해 = 비현실 근 — Yamagami & Ueta 대조에서 λ ≈ +1 근이 F 를 9 % 낮췄음).
LAMBDA_RANGE = (-1.25, 0.25)
#: 이보다 작은 F = 1/F 항이 터진 엉뚱한 근(실제 비탈 F 는 이 위) — 버림.
F_MIN = 0.05
TOL = 1e-6
MAX_ITER = 100
class Prepared:
"""모형 → 절편 계산용 배열. 지층 = 꼭짓점 x 사이 띠마다 연직 구간(바닥 · 윗면 · 물성) — 띠 안은 선형이라 정확."""
def __init__(self, model: dict, direction: int = 1):
self.direction = 1 if direction >= 0 else -1
s = float(self.direction)
mats = [material(m) for m in model["materials"]]
index = {m["id"]: i for i, m in enumerate(mats)}
self.materials = mats
pad = len(mats) # 빈 칸 번호
self.gamma_t = np.array([m["gamma_t"] for m in mats] + [0.0])
self.gamma_sat = np.array([m["gamma_sat"] for m in mats] + [0.0])
self.c = np.array([m["c"] for m in mats] + [np.nan])
self.tanphi = np.array([np.tan(np.radians(m["phi"])) for m in mats] + [np.nan])
polys = []
for region in model["regions"]:
rings = [region["polygon"], *region.get("holes", [])]
edges = []
for ring in rings:
pts = np.asarray(ring, dtype=float) * [s, 1.0]
if np.allclose(pts[0], pts[-1]):
pts = pts[:-1]
edges.append(np.column_stack([pts, np.roll(pts, -1, axis=0)]))
polys.append((np.concatenate(edges), index[region["material_id"]]))
self._build_columns(polys, pad)
gw = groundwater(model.get("groundwater"))
self.gw_mode = gw["mode"]
self.ru = float(gw.get("ru", 0.0))
if self.gw_mode == "line":
line = sorted([s * x, z] for x, z in gw["line"])
self.water_x = np.array([p[0] for p in line])
self.water_z = np.array([p[1] for p in line])
self.kh = float(model.get("seismic_kh") or 0.0)
loads = surcharges(model.get("surcharge"))
self.loads = np.array(
[sorted((s * q["x0"], s * q["x1"])) + [q["q"]] for q in loads], dtype=float
).reshape(-1, 3)
crack = model.get("tension_crack") or {}
self.crack_depth = float(crack.get("depth") or 0.0)
self.crack_water = float(crack.get("water") or 0.0)
def _build_columns(self, polys: list, pad: int) -> None:
xs = np.unique(np.concatenate([e[:, 0] for e, _ in polys]))
self.X = xs
gap = np.diff(xs)
self.xa = xs[:-1] + 1e-7 * gap
self.xb = xs[1:] - 1e-7 * gap
q = np.concatenate([self.xa, self.xb])
bots, tops, mats = [], [], []
for edges, mat in polys:
x1, z1, x2, z2 = edges.T
lo, hi = np.minimum(x1, x2), np.maximum(x1, x2)
cross = (lo[None, :] < q[:, None]) & (q[:, None] < hi[None, :])
with np.errstate(invalid="ignore", divide="ignore"):
z = z1 + (q[:, None] - x1) * (z2 - z1) / (x2 - x1)
z = np.sort(np.where(cross, z, np.nan), axis=1)
count = int(np.max(np.sum(cross, axis=1))) if len(q) else 0
if count < 2:
continue
bots.append(z[:, 0 : count - 1 : 2])
tops.append(z[:, 1:count:2])
mats.append(np.full(bots[-1].shape, mat))
bot = np.concatenate(bots, axis=1)
top = np.concatenate(tops, axis=1)
mat = np.concatenate(mats, axis=1)
mat = np.where(np.isnan(bot), pad, mat)
half = len(self.xa)
order = np.argsort(np.where(np.isnan(bot[:half]), np.inf, bot[:half]), axis=1)
self.bot_a, self.bot_b = (np.take_along_axis(bot[k], order, 1) for k in _halves(half))
self.top_a, self.top_b = (np.take_along_axis(top[k], order, 1) for k in _halves(half))
self.mat = np.take_along_axis(mat[:half], order, 1)
# 지표면 꺾은선(정확) — 띠 양 끝으로 늘린 맨 윗면 · 연직 면은 같은 x 두 점
ta = np.nanmax(self.top_a, axis=1)
tb = np.nanmax(self.top_b, axis=1)
slope = (tb - ta) / (self.xb - self.xa)
left = ta + (xs[:-1] - self.xa) * slope
right = tb + (xs[1:] - self.xb) * slope
sx = np.column_stack([xs[:-1], xs[1:]]).ravel()
sz = np.column_stack([left, right]).ravel()
keep = np.ones(len(sx), bool)
keep[1:] = (np.abs(np.diff(sx)) > 1e-9) | (np.abs(np.diff(sz)) > 1e-6)
self.surface_x, self.surface_z = sx[keep], sz[keep]
ba = np.nanmin(self.bot_a, axis=1)
bb = np.nanmin(self.bot_b, axis=1)
self.bottom_z = float(min(np.min(ba), np.min(bb)))
# 빈 칸 = 아주 낮은 두께 0 구간(nan 처리 없이 바로 셈)
for name in ("bot_a", "bot_b", "top_a", "top_b"):
setattr(self, name, np.nan_to_num(getattr(self, name), nan=_PAD_Z))
# ---- 조회 -------------------------------------------------------------
def surface(self, x: np.ndarray) -> np.ndarray:
"""지표면 높이(연직 면은 낮은 쪽 · 모형 밖은 nan)."""
x = np.asarray(x, dtype=float)
z = np.interp(x, self.surface_x, self.surface_z)
return np.where((x < self.X[0]) | (x > self.X[-1]), np.nan, z)
def columns(self, x: np.ndarray):
j = np.clip(np.searchsorted(self.X, x, side="right") - 1, 0, len(self.xa) - 1)
t = ((x - self.xa[j]) / (self.xb[j] - self.xa[j]))[..., None]
bot = self.bot_a[j] + t * (self.bot_b[j] - self.bot_a[j])
top = self.top_a[j] + t * (self.top_b[j] - self.top_a[j])
return bot, top, self.mat[j]
def water(self, x: np.ndarray) -> np.ndarray:
return np.interp(x, self.water_x, self.water_z)
def _halves(half: int):
return (slice(0, half), slice(half, None))
def slices(prep: Prepared, xe: np.ndarray, ze: np.ndarray) -> dict:
"""활동면(절편 경계 x · 바닥 z, 꼴 (S, n+1)) → 절편 값 (S, n) · 잘못된 면은 valid False."""
xe = np.atleast_2d(np.asarray(xe, float))
ze = np.atleast_2d(np.asarray(ze, float))
b = np.diff(xe, axis=1)
xm = 0.5 * (xe[:, :-1] + xe[:, 1:])
zb = 0.5 * (ze[:, :-1] + ze[:, 1:])
alpha = np.arctan2(ze[:, :-1] - ze[:, 1:], np.maximum(b, _TINY))
bot, top, mat = prep.columns(xm)
lo = np.maximum(bot, zb[..., None])
thick = np.clip(top - lo, 0.0, None)
zt = prep.surface(xm)
if prep.gw_mode == "line":
zw = np.minimum(prep.water(xm), zt)
wet = np.clip(np.minimum(top, zw[..., None]) - lo, 0.0, None)
dry = thick - wet
u = GAMMA_W * np.clip(zw - zb, 0.0, None)
wet_mid = lo + 0.5 * wet
dry_mid = lo + wet + 0.5 * dry
g_dry, g_wet = prep.gamma_t[mat] * dry, prep.gamma_sat[mat] * wet
else:
g_dry, g_wet = prep.gamma_t[mat] * thick, 0.0 * thick
dry_mid = wet_mid = lo + 0.5 * thick
u = np.zeros_like(zb)
weight_per_b = np.sum(g_dry + g_wet, axis=-1)
moment_z = np.sum(g_dry * dry_mid + g_wet * wet_mid, axis=-1)
zg = np.where(weight_per_b > 0, moment_z / np.maximum(weight_per_b, _TINY), zb)
Ws = weight_per_b * b
if prep.gw_mode == "ru":
u = prep.ru * weight_per_b
# 바닥 물성 = 바닥 바로 위 구간(약층 바닥을 따라가는 면은 그 약층)
probe = zb[..., None] + 1e-6
inside = (bot <= probe) & (probe < top) & (top - bot > 1e-9)
base_k = np.argmax(inside, axis=-1)
base_mat = np.take_along_axis(mat, base_k[..., None], -1)[..., 0]
found = np.any(inside, axis=-1)
c = prep.c[base_mat]
tanphi = prep.tanphi[base_mat]
# 상재하중 — 절편 폭과 겹친 길이만큼 정확히 · 작용점 = 겹친 구간 가운데
Q = np.zeros_like(zb)
Mq = np.zeros_like(zb)
for x0, x1, q in prep.loads:
a = np.clip(xe[:, :-1], x0, x1)
bb = np.clip(xe[:, 1:], x0, x1)
ov = bb - a
Q += q * ov
Mq += q * ov * 0.5 * (a + bb)
W = Ws + Q
xw = np.where(W > 0, (Ws * xm + Mq) / np.maximum(W, _TINY), xm)
valid = np.all(found & (zb >= prep.bottom_z - 1e-6) & np.isfinite(zt), axis=1)
valid &= np.all(b > 0, axis=1)
return {
"xe": xe,
"ze": ze,
"b": b,
"xm": xm,
"zb": zb,
"zt": zt,
"alpha": alpha,
"sa": np.sin(alpha),
"ca": np.cos(alpha),
"l": b / np.cos(alpha),
"W": W,
"Ws": Ws,
"Q": Q,
"xw": xw,
"zg": zg,
"u": u,
"c": np.where(found, c, 0.0),
"tanphi": np.where(found, tanphi, 0.0),
"mat": base_mat,
"valid": valid,
"kh": prep.kh,
"Pw": np.zeros(len(xe)),
"zp": ze[:, 0].copy(),
}
def add_crack_water(sl: dict, depth: np.ndarray, fill: float) -> None:
"""인장균열 물 — 머리 쪽 연직 면에 수평 수압 Pw = ½·γw·(f·z_c)² · 바닥에서 f·z_c/3."""
hw = fill * np.asarray(depth, float)
sl["Pw"] = 0.5 * GAMMA_W * hw**2
sl["zp"] = sl["ze"][:, 0] + hw / 3.0
# ---- 방법 -------------------------------------------------------------------
def _parts(sl: dict, F, lam):
"""주어진 F · λ 의 절편 수직력 N · 저항 R · 분모(m_α − λA).
유효 수직력 음(인장) 절편도 그대로 둠(Slide2 와 같음 — 0 으로 자르면 ACADS · Arai & Tagyo 대조가 3~6 % 벗어남).
대신 저항 합이 0 이하인 면은 `_finish` 가 버림.
"""
F = np.asarray(F, float)[:, None]
lam = np.asarray(lam, float)[:, None]
W = sl["W"].copy()
W[:, 0] += lam[:, 0] * sl["Pw"] # 머리 경계 수압은 전단 없음 → X_0 = 0
t = sl["tanphi"]
N, den, A, B0 = _normal(sl, F, lam, W, t)
R = sl["c"] * sl["l"] + (N - sl["u"] * sl["l"]) * t
return N, R, den, A, B0
def _normal(sl, F, lam, W, t):
sa, ca = sl["sa"], sl["ca"]
cu = sl["c"] * sl["l"] - sl["u"] * sl["l"] * t
m = ca + sa * t / F
A = sa - t * ca / F
B0 = sl["kh"] * sl["Ws"] - cu * ca / F
den = m - lam * A
N = (W + lam * B0 - cu * sa / F) / np.where(np.abs(den) < _TINY, _TINY, den)
return N, den, A, B0
def _moment_terms(sl: dict, center: np.ndarray, N: np.ndarray):
xo, zo = center[:, 0:1], center[:, 1:2]
a = (sl["xm"] - xo) * sl["sa"] + (sl["zb"] - zo) * sl["ca"]
D = (
(sl["xw"] - xo) * sl["W"]
+ (sl["zg"] - zo) * sl["kh"] * sl["Ws"]
- (sl["xm"] - xo) * N * sl["ca"]
+ (sl["zb"] - zo) * N * sl["sa"]
).sum(1) + (sl["zp"] - center[:, 1]) * sl["Pw"]
return a, D
def _force_den(sl: dict, N: np.ndarray):
return (N * sl["sa"] + sl["kh"] * sl["Ws"]).sum(1) + sl["Pw"]
def _fm(sl, center, F, lam):
N, R, den, *_ = _parts(sl, F, lam)
a, D = _moment_terms(sl, center, N)
return (a * R).sum(1) / np.where(np.abs(D) < _TINY, -_TINY, D), den
def _ff(sl, F, lam):
N, R, den, *_ = _parts(sl, F, lam)
return (R * sl["ca"]).sum(1) / np.where(
np.abs(_force_den(sl, N)) < _TINY, _TINY, _force_den(sl, N)
), den
def _iterate(fun, F0):
"""F = fun(F) 풀이(묶음 전체) — 수치 미분 뉴턴(h = fun(F) − F) · 안 되면 반쯤 섞은 고정점 걸음.
단순 고정점은 급한 머리 절편에서 진동해 느림(도함수 ≈ −1) — 뉴턴은 몇 번에 끝남.
"""
F = np.asarray(F0, float).copy()
done = np.zeros(len(F), bool)
iters = np.zeros(len(F), int)
den = None
for _ in range(MAX_ITER):
f1, den = fun(F)
h1 = f1 - F
delta = 1e-6 * np.maximum(np.abs(F), 0.1)
f2, _ = fun(F + delta)
dh = ((f2 - (F + delta)) - h1) / delta
newton = F - h1 / np.where(np.abs(dh) < 1e-12, -1.0, dh)
relaxed = 0.5 * (F + f1)
use = np.isfinite(newton) & (dh < -1e-9) & (newton > 0.5 * F) & (newton < 2.0 * F)
Fn = np.where(use, newton, relaxed)
Fn = np.where(np.isfinite(Fn) & (Fn > 0), Fn, np.nan)
conv = np.abs(h1) < TOL * np.maximum(1.0, np.abs(F))
F = np.where(done | conv, F, Fn)
iters += ~done
done |= conv | ~np.isfinite(F)
if done.all():
break
f1, den = fun(np.where(np.isfinite(F), F, 1.0))
return F, iters, done & np.isfinite(F), den
def ordinary(sl: dict, center: np.ndarray) -> dict:
"""Fellenius — 절편간 힘 무시 · 바닥 수직 분해 N = W·cosα − kh·Ws·sinα · 모멘트 평형."""
N = sl["W"] * sl["ca"] - sl["kh"] * sl["Ws"] * sl["sa"]
R = sl["c"] * sl["l"] + (N - sl["u"] * sl["l"]) * sl["tanphi"]
a, D = _moment_terms(sl, center, N)
F = (a * R).sum(1) / np.where(np.abs(D) < _TINY, -_TINY, D)
ok = sl["valid"] & np.isfinite(F) & (F > F_MIN) & (R.sum(1) > 0)
# 같은 꼴 거르기 — 진출부 급경사(m_α < 0.2) 면은 Bishop 처럼 버림
m = sl["ca"] + sl["sa"] * sl["tanphi"] / np.where(ok, F, 1.0)[:, None]
ok &= np.all(m >= MALPHA_MIN, axis=1)
return {"F": np.where(ok, F, np.nan), "iterations": np.ones(len(F), int), "converged": ok}
def bishop(sl: dict, center: np.ndarray, F0=None) -> dict:
F0 = np.full(len(sl["W"]), 1.5) if F0 is None else F0
lam = np.zeros(len(F0))
F, it, conv, _ = _iterate(lambda f: _fm(sl, center, f, lam), F0)
return _finish(sl, F, it, conv, lam)
def janbu(sl: dict, F0=None) -> dict:
"""Janbu 간편(F_0) + 보정 f_0 — 지식DB 02 §2.3 · b1 = 0.31(c=0) · 0.69(φ=0) · 0.50."""
F0 = np.full(len(sl["W"]), 1.5) if F0 is None else F0
lam = np.zeros(len(F0))
F, it, conv, _ = _iterate(lambda f: _ff(sl, f, lam), F0)
out = _finish(sl, F, it, conv, lam)
out["F0"] = out["F"]
out["f0"] = janbu_f0(sl)
out["F"] = out["F0"] * out["f0"]
return out
def janbu_f0(sl: dict) -> np.ndarray:
xe, ze = sl["xe"], sl["ze"]
dx, dz = xe[:, -1:] - xe[:, :1], ze[:, -1:] - ze[:, :1]
L = np.hypot(dx, dz)[:, 0]
dist = np.abs(dz * (xe - xe[:, :1]) - dx * (ze - ze[:, :1]))[:, :] / np.maximum(
L[:, None], _TINY
)
ratio = dist.max(1) / np.maximum(L, _TINY)
c_all0 = np.all(sl["c"] <= 0, axis=1)
phi_all0 = np.all(sl["tanphi"] <= 0, axis=1)
b1 = np.where(c_all0, 0.31, np.where(phi_all0, 0.69, 0.50))
return 1.0 + b1 * (ratio - 1.4 * ratio**2)
def spencer(sl: dict, center: np.ndarray, F0=None, scan: bool = True) -> dict:
"""Spencer — 모멘트 · 힘 두 평형을 함께 맞추는 (F, λ) · 뉴턴(수치 야코비안) · 안 맞으면 λ 훑기.
scan=False — 탐색 고리 안(시험 면 대부분이 버려짐)에서는 훑기를 건너뜀(시간 대부분이 여기).
"""
S = len(sl["W"])
F = np.full(S, 1.5) if F0 is None else np.asarray(F0, float).copy()
start = F.copy()
lam = np.zeros(S)
# 엉뚱한 근(F → 0 쪽) 거르기 — 엄밀해는 출발값(Bishop)의 절반~두 배 안에 있음
near = lambda f, ref: (f > 0.5 * ref) & (f < 2.0 * ref) # noqa: E731
F, lam, it, conv = _spencer_newton(sl, center, F, lam)
conv &= near(F, start)
bad = ~conv & sl["valid"]
if scan and bad.any():
sub = _subset(sl, bad)
Fs, ls = _spencer_scan(sub, center[bad])
F2, l2, it2, c2 = _spencer_newton(sub, center[bad], Fs, ls)
F[bad], lam[bad], it[bad], conv[bad] = F2, l2, it[bad] + it2, c2 & near(F2, start[bad])
out = _finish(sl, F, it, conv, lam)
out["lambda"] = np.where(np.isfinite(out["F"]), lam, np.nan)
return out
def _residual(sl, center, F, lam):
N, R, den, *_ = _parts(sl, F, lam)
a, D = _moment_terms(sl, center, N)
fd = _force_den(sl, N)
rm = ((a * R).sum(1) - F * D) / np.maximum(np.abs(D), _TINY)
rf = ((R * sl["ca"]).sum(1) - F * fd) / np.maximum(np.abs(fd), _TINY)
return rm, rf
def _spencer_newton(sl, center, F, lam, iters=40):
F, lam = F.copy(), lam.copy()
it = np.zeros(len(F), int)
done = np.zeros(len(F), bool)
for _ in range(iters):
rm, rf = _residual(sl, center, F, lam)
hF = 1e-6 * np.maximum(F, 0.1)
hl = 1e-6
rmF, rfF = _residual(sl, center, F + hF, lam)
rml, rfl = _residual(sl, center, F, lam + hl)
j11, j12 = (rmF - rm) / hF, (rml - rm) / hl
j21, j22 = (rfF - rf) / hF, (rfl - rf) / hl
det = j11 * j22 - j12 * j21
det = np.where(np.abs(det) < 1e-14, np.nan, det)
dF = -(j22 * rm - j12 * rf) / det
dl = -(-j21 * rm + j11 * rf) / det
scale = np.minimum(
1.0,
np.minimum(
0.5 * F / np.maximum(np.abs(dF), _TINY), 0.3 / np.maximum(np.abs(dl), _TINY)
),
)
F = np.where(done, F, F + scale * dF)
lam = np.where(done, lam, lam + scale * dl)
it += ~done
small = (np.abs(dF) < TOL * np.maximum(1, np.abs(F))) & (np.abs(dl) < TOL)
done |= small | ~np.isfinite(F)
if done.all():
break
rm, rf = _residual(sl, center, F, lam)
conv = done & np.isfinite(F) & (F > 0) & (np.abs(rm) < 1e-4) & (np.abs(rf) < 1e-4)
conv &= (lam >= LAMBDA_RANGE[0]) & (lam <= LAMBDA_RANGE[1])
return F, lam, it, conv
def _spencer_scan(sl, center):
"""λ 를 훑어 F_m(λ) − F_f(λ) 부호가 바뀌는 곳(λ=0 가까운 쪽)을 출발점으로."""
grid = np.linspace(LAMBDA_RANGE[0], LAMBDA_RANGE[1], 16)
S = len(sl["W"])
diff = np.full((len(grid), S), np.nan)
fm_all = np.full((len(grid), S), np.nan)
F0 = np.full(S, 1.5)
for k, g in enumerate(grid):
lam = np.full(S, g)
fm, *_ = _iterate(lambda f: _fm(sl, center, f, lam), F0)
ff, *_ = _iterate(lambda f: _ff(sl, f, lam), F0)
diff[k], fm_all[k] = fm - ff, fm
best_F, best_l = np.full(S, 1.5), np.zeros(S)
best_gap = np.full(S, np.inf)
for k in range(len(grid) - 1):
d0, d1 = diff[k], diff[k + 1]
cross = np.isfinite(d0) & np.isfinite(d1) & (np.sign(d0) != np.sign(d1))
t = np.where(cross, d0 / np.where(d0 == d1, 1, d0 - d1), 0)
lam_c = grid[k] + t * (grid[k + 1] - grid[k])
F_c = fm_all[k] + t * (fm_all[k + 1] - fm_all[k])
better = cross & (np.abs(lam_c) < best_gap)
best_gap = np.where(better, np.abs(lam_c), best_gap)
best_F = np.where(better, F_c, best_F)
best_l = np.where(better, lam_c, best_l)
return best_F, best_l
def _subset(sl: dict, mask: np.ndarray) -> dict:
out = {
k: (v[mask] if isinstance(v, np.ndarray) and v.shape[:1] == mask.shape else v)
for k, v in sl.items()
}
return out
def _finish(sl, F, it, conv, lam):
_, R, den, *_ = _parts(sl, np.where(np.isfinite(F) & (F > 0), F, 1.0), lam)
malpha_ok = np.all(den >= MALPHA_MIN, axis=1)
ok = sl["valid"] & conv & np.isfinite(F) & (F > F_MIN) & malpha_ok & (R.sum(1) > 0)
return {
"F": np.where(ok, F, np.nan),
"iterations": it,
"converged": conv,
"malpha_ok": malpha_ok,
}
def slice_table(sl: dict, k: int, F: float, lam: float = 0.0, method: str = "bishop") -> list[dict]:
"""한 면(k 번째)의 절편표 — 폭 · 높이 · 경사 · 무게 · 수압 · 강도 · N' · 동원 전단 · (Spencer) E · X."""
one = _subset(sl, np.arange(len(sl["W"])) == k)
if method == "ordinary":
N = one["W"] * one["ca"] - one["kh"] * one["Ws"] * one["sa"]
E = np.zeros((1, N.shape[1] + 1))
else:
N, R, _den, A, B0 = _parts(one, np.array([F]), np.array([lam]))
dE = B0 + N * A
E = np.concatenate([one["Pw"][:, None], one["Pw"][:, None] + np.cumsum(dE, axis=1)], axis=1)
R = one["c"] * one["l"] + (N - one["u"] * one["l"]) * one["tanphi"]
rows = []
for i in range(N.shape[1]):
rows.append(
{
"x_left": float(one["xe"][0, i]),
"x_right": float(one["xe"][0, i + 1]),
"width": float(one["b"][0, i]),
"height": float(one["zt"][0, i] - one["zb"][0, i]),
"alpha_deg": float(np.degrees(one["alpha"][0, i])),
"weight": float(one["W"][0, i]),
"surcharge": float(one["Q"][0, i]),
"pore_pressure": float(one["u"][0, i]),
"base_length": float(one["l"][0, i]),
"material": int(one["mat"][0, i]),
"c": float(one["c"][0, i]),
"phi_deg": float(np.degrees(np.arctan(one["tanphi"][0, i]))),
"normal_eff": float(N[0, i] - one["u"][0, i] * one["l"][0, i]),
"shear_mobilized": float(R[0, i] / F),
"E_left": float(E[0, i]),
"X_left": float(lam * E[0, i]),
}
)
return rows
@@ -0,0 +1,293 @@
"""E03 2D 지층 모델 빌더 — 측점 횡단(지반선 · 설계선 · 소단 · 측구 · 암 경계 · 성토체 · 벽) → 지층 폴리곤.
`build_section_model` = storage 를 모르는 순수 함수(좌표 · 값만) · `section_model_from_b06` = B06 횡단 자료 꼴
(분석 345-D ②)을 읽는 얇은 어댑터. 좌표 x = B06 offset(좌 +) · z = 표고.
설계선(design_line)이 소단 · 측구 · 노면을 이미 담음 — 여기서 다시 그리지 않음. 설계선 밖은 원지반.
암 경계 = 원지반을 연직으로 |offset| 내린 선(B06 면적 가름과 같은 정의 · `Engine_Design.py` 363-377).
"""
import numpy as np
from shapely.geometry import LineString, MultiPolygon, Polygon, box
from shapely.ops import unary_union
from shapely.validation import make_valid
from E03_StabSection.E03_StabSection_Types import (
DEFAULT_MATERIALS,
DEFAULT_ROAD_SURCHARGE_KPA,
)
from E03_StabSection.E03_StabSection_Types import (
groundwater as norm_groundwater,
)
from E03_StabSection.E03_StabSection_Types import (
surcharges as norm_surcharges,
)
#: B06 지반 라벨 → 암 경계 아래 지층(요구서 345 6장 · 분석 D 5-2). 경계 위는 풍화토.
ROCK_LAYER_OF = {"ripping_rock": "weathered_rock", "blasting_rock": "soft_rock"}
SOIL_ID = "weathered_soil"
FILL_ID = "fill"
#: 벽(C군) — 활동면이 못 지나게 강한 물체로(콘크리트 · 석축 대표값). 물성 표에 없으면 이 값을 덧붙임.
WALL_MATERIAL = {
"id": "wall",
"name": "벽체",
"gamma_t": 23.0,
"gamma_sat": 23.0,
"c": 1000.0,
"phi": 45.0,
}
DEFAULT_HALF_WIDTH_M = 40.0
def _polys(geom) -> list[Polygon]:
geom = make_valid(geom)
if isinstance(geom, Polygon):
parts = [geom]
elif isinstance(geom, MultiPolygon):
parts = list(geom.geoms)
else:
parts = [g for g in getattr(geom, "geoms", []) if isinstance(g, Polygon)]
return [p for p in parts if p.area > 1e-6]
def _region(material_id: str, poly: Polygon) -> dict:
out = {"material_id": material_id, "polygon": [list(c) for c in poly.exterior.coords]}
if poly.interiors:
out["holes"] = [[list(c) for c in ring.coords] for ring in poly.interiors]
return out
def final_surface(ground: list, design_line: list) -> list[list[float]]:
"""공사 뒤 지표 = 설계선 범위는 설계선 · 밖은 원지반."""
line = sorted((float(x), float(z)) for x, z in design_line)
if not line:
return [list(p) for p in ground]
x0, x1 = line[0][0], line[-1][0]
left = [p for p in ground if p[0] < x0 - 1e-9]
right = [p for p in ground if p[0] > x1 + 1e-9]
return [list(map(float, p)) for p in left + line + right]
def build_section_model(
ground: list,
design_line: list,
*,
layers: list,
materials: list[dict] | None = None,
fill_material: str = FILL_ID,
walls: list[dict] | None = None,
groundwater: dict | None = None,
seismic_kh: float = 0.0,
surcharge: list[dict] | None = None,
bottom_z: float | None = None,
area_range: tuple[float, float] | None = None,
) -> dict:
"""원지반 · 설계선 · 지층(위부터 [(material_id, 원지반 아래 바닥 깊이 m | None=모형 바닥)]) → 해석 모형.
돌려주는 값 = LEM 모형(materials · regions · surface · groundwater · seismic_kh · surcharge)
+ `areas`(area_range 안 절토 · 성토 · 절토 토사 · 절토 암 ㎡ — B06 면적 대조용).
"""
ground = sorted((float(x), float(z)) for x, z in ground)
surface = final_surface(ground, design_line)
xs = [p[0] for p in ground]
zs = [p[1] for p in ground] + [p[1] for p in surface]
relief = max(zs) - min(zs)
zb = bottom_z if bottom_z is not None else min(zs) - max(5.0, 0.5 * relief)
x0, x1 = xs[0], xs[-1]
bottom = [(x1, zb), (x0, zb)]
below_final = make_valid(Polygon(surface + bottom))
ground_body = make_valid(Polygon(ground + bottom))
natural = below_final.intersection(ground_body)
fill = below_final.difference(ground_body)
cut = ground_body.difference(below_final)
def band(d_top: float, d_bot: float | None) -> Polygon:
top = [(x, z - d_top) for x, z in ground]
if d_bot is None:
low = [(x1, zb - 1.0), (x0, zb - 1.0)]
else:
low = [(x, z - d_bot) for x, z in reversed(ground)]
return make_valid(Polygon(top + low))
wall_geom = unary_union([Polygon(w["polygon"]) for w in walls or []]) if walls else None
regions: list[dict] = []
bands: list = []
depth = 0.0
for material_id, d_bot in layers:
b = band(depth, None if d_bot is None else float(d_bot))
bands.append(b)
part = natural.intersection(b)
if wall_geom is not None:
part = part.difference(wall_geom)
regions += [_region(material_id, p) for p in _polys(part)]
if d_bot is None:
break
depth = float(d_bot)
fill_part = fill.difference(wall_geom) if wall_geom is not None else fill
regions += [_region(fill_material, p) for p in _polys(fill_part)]
mats = [dict(m) for m in (materials or DEFAULT_MATERIALS)]
if wall_geom is not None:
for w in walls:
regions += [
_region(w.get("material_id", "wall"), p) for p in _polys(Polygon(w["polygon"]))
]
if not any(m["id"] == "wall" for m in mats):
mats.append(dict(WALL_MATERIAL))
a0, a1 = area_range or (x0, x1)
clip = box(a0, zb - 1.0, a1, max(zs) + 100.0)
cut_in = cut.intersection(clip)
cut_soil = cut_in.intersection(bands[0]).area if len(bands) > 1 else cut_in.area
return {
"materials": mats,
"regions": regions,
"surface": surface,
"groundwater": norm_groundwater(groundwater),
"seismic_kh": float(seismic_kh or 0.0),
"surcharge": norm_surcharges(surcharge),
"bottom_z": zb,
"areas": {
"cut_m2": cut_in.area,
"fill_m2": fill.intersection(clip).area,
"cut_soil_m2": cut_soil,
"cut_rock_m2": cut_in.area - cut_soil,
},
}
# ---- B06 어댑터 -----------------------------------------------------------------
def ground_from_cross(cross: dict) -> list[tuple[float, float]]:
return sorted(
(float(s["offset_m"]), float(s["elevation_m"]))
for s in cross.get("samples") or []
if s.get("valid") is not False and s.get("elevation_m") is not None
)
def widen_ground(cross: dict, sampler, half_width: float = DEFAULT_HALF_WIDTH_M, step: float = 0.5):
"""해석 폭 — 횡단 표본 바깥을 지형에서 다시 떠 ±half_width 까지 넓힘(지형 밖이 나오면 거기서 멈춤)."""
ground = ground_from_cross(cross)
frame = cross["frame"]
origin = np.array([float(frame["origin"]["x"]), float(frame["origin"]["y"])])
axis = np.array([float(v) for v in frame["left_xy"]])
added = []
for sign, edge in ((-1.0, ground[0][0]), (1.0, ground[-1][0])):
n = int(max(0.0, half_width - abs(edge)) / step)
if n <= 0:
continue
offs = edge + sign * step * np.arange(1, n + 1)
z, valid = sampler.sample_xy(origin[None, :] + axis[None, :] * offs[:, None])
stop = int(np.argmin(valid)) if not valid.all() else len(valid)
added += [(float(o), float(e)) for o, e in zip(offs[:stop], z[:stop])]
return sorted(ground + added)
def side_cases(design: dict, ground: list, line: list) -> list[dict]:
"""좌 · 우 비탈마다 역할(절토 · 성토) · 활동 방향 · 탐색 한계. 좌 = +offset.
탐색 한계 = 노면 반대쪽 끝 ~ 비탈 끝(설계선이 원지반과 만나는 점) 바깥 max(5 m, 2H) — 설계 비탈을
지나는 면만 봄(바깥 자연 비탈 전체 붕괴는 E 광역 · 2차 몫). H = 노면 끝 ~ 비탈 끝 높이차.
"""
mode = design.get("section_mode") or "right_cut"
roles = {
"left_cut": ("cut", "fill"),
"right_cut": ("fill", "cut"),
"both_cut": ("cut", "cut"),
"both_fill": ("fill", "fill"),
}[mode]
edges = design.get("road_edges") or {}
left_edge = float((edges.get("left") or {}).get("offset_m", 2.0))
right_edge = float((edges.get("right") or {}).get("offset_m", -2.0))
gx = np.array([p[0] for p in ground])
gz = np.array([p[1] for p in ground])
lx = np.array([p[0] for p in line])
lz = np.array([p[1] for p in line])
off = np.abs(lz - np.interp(lx, gx, gz)) > 0.005
out = []
for side, role in zip(("left", "right"), roles):
inward = role == "cut" # 절토 = 산 쪽에서 노면으로 · 성토 = 노면에서 바깥으로
sign = 1.0 if side == "left" else -1.0
edge = left_edge if side == "left" else right_edge
# 비탈 끝 = 원지반에서 떨어진 가장 바깥 꼭짓점의 바로 바깥 꼭짓점(다시 원지반에 붙는 점)
idx = [k for k, (x, o) in enumerate(zip(lx, off)) if o and sign * x >= sign * edge - 1e-9]
if idx:
k = max(idx) if sign > 0 else min(idx)
k = min(max(k + int(sign), 0), len(lx) - 1)
day = float(lx[k])
else:
day = edge
H = abs(float(np.interp(day, lx, lz)) - float(np.interp(edge, lx, lz)))
reach = day + sign * max(5.0, 2.0 * H)
reach = min(max(reach, gx[0]), gx[-1])
inner = right_edge if side == "left" else left_edge
limits = tuple(sorted((inner, reach)))
direction = int(-sign) if inward else int(sign)
out.append(
{"side": side, "role": role, "direction": direction, "limits": limits, "height_m": H}
)
return out
def section_model_from_b06(
cross: dict,
design: dict,
*,
materials: list[dict] | None = None,
layers: list | None = None,
ground: list | None = None,
wet: bool = False,
seismic_kh: float = 0.0,
road_surcharge_kpa: float = DEFAULT_ROAD_SURCHARGE_KPA,
walls: list[dict] | None = None,
) -> dict:
"""B06 횡단 파일(samples · frame) + 설계(design) → 해석 모형 + 비탈 목록(`sides`).
ground = 넓혀 다시 뜬 지반선(`widen_ground`) · 없으면 횡단 표본 그대로.
layers 기본 = 토사 측점 풍화토 한 층 · 암 측점 풍화토(암 경계까지) + 풍화암 / 연암.
wet = 우기 — 지하수위 = 공사 뒤 지표(KDS 11 70 05 표 4.3-3 · 토사 · 풍화암 지표 포화).
노면 하중 = 노면 끝 사이 등분포. 벽 도형(walls)은 B06 화면 산식(TS)이 그리므로 부르는 쪽이 넘김.
"""
ground = ground or ground_from_cross(cross)
line = [
(float(p["offset_m"]), float(p["elevation_m"])) for p in design.get("design_line") or []
]
if layers is None:
rock = ROCK_LAYER_OF.get(design.get("ground_type"))
offset = design.get("rock_boundary_offset_m")
if rock and offset is not None:
layers = [(SOIL_ID, abs(float(offset))), (rock, None)]
elif rock:
layers = [(rock, None)]
else:
layers = [(SOIL_ID, None)]
edges = design.get("road_edges") or {}
loads = []
if road_surcharge_kpa and edges.get("left") and edges.get("right"):
loads = [
{
"x0": edges["right"]["offset_m"],
"x1": edges["left"]["offset_m"],
"q": road_surcharge_kpa,
}
]
surface = final_surface(ground, line)
gw = {"mode": "line", "line": surface} if wet else {"mode": "none"}
model = build_section_model(
ground,
line,
layers=layers,
materials=materials,
walls=walls,
groundwater=gw,
seismic_kh=seismic_kh,
surcharge=loads,
area_range=(line[0][0], line[-1][0]) if line else None,
)
model["sides"] = side_cases(design, ground, line or ground)
model["station"] = {"chainage_m": cross.get("chainage_m"), "label": cross.get("label")}
return model
def polygon_closed(region: dict) -> bool:
"""폴리곤 닫힘 · 자기 교차 없음 · 넓이 > 0 (시험용)."""
ring = region["polygon"]
poly = Polygon(ring, region.get("holes"))
return bool(poly.is_valid and poly.area > 0 and LineString(ring).is_closed)
@@ -0,0 +1,634 @@
"""E03 임계 활동면 탐색 — 원호(Grid & Radius · Entry & Exit · 다듬기) · 비원호(블록 + 꼭짓점 최적화).
활동 방향 +x 로 맞춘 `Prepared` 위에서 찾고 좌표를 원래 방향으로 되돌려 냄.
다듬기 · 비원호 = 차분진화(best/1/bin · 무리 전체를 한 번에 평가 · seed 고정 → 같은 입력 같은 답)
+ 비원호 마지막 국부 다듬기 = SciPy Nelder-Mead. SciPy differential_evolution 은 개체마다 파이썬 돌연변이 고리라
한 비탈 시간의 30 % 를 먹어 직접 묶음 꼴로 씀.
상용 SW 대응: Grid & Radius(SLOPE/W · Slide) · Entry & Exit(SLOPE/W) · Auto Refine(Slide) ·
Block Search + Optimize(Slide · SLOPE/W 꼭짓점 최적화).
"""
import time
import numpy as np
from scipy.optimize import minimize
from E03_StabSection import E03_StabSection_Engine_LEM as lem
METHODS = ("ordinary", "bishop", "janbu", "spencer")
_BIG = 1e3
_SURFACE_TOL = 1e-3 # 활동면이 지표면 위로 나온 높이 허용(m)
# ---- 면 → 절편 ---------------------------------------------------------------
def _crack_start(prep, xE, xX, base_fn):
"""인장균열 — 머리 쪽에서 활동면 깊이가 균열 깊이에 닿는 x (없으면 원래 진입점)."""
if prep.crack_depth <= 0:
return xE, np.zeros(len(xE))
t = np.linspace(0.0, 1.0, 201)
xs = xE[:, None] + (xX - xE)[:, None] * t
depth = prep.surface(xs) - base_fn(xs)
reach = depth >= prep.crack_depth
k = np.where(reach.any(1), np.argmax(reach, 1), 0)
k0 = np.maximum(k - 1, 0)
rows = np.arange(len(xE))
d0, d1 = depth[rows, k0], depth[rows, k]
w = np.where(d1 > d0, (prep.crack_depth - d0) / np.maximum(d1 - d0, 1e-12), 0.0)
x_new = np.where(k > 0, xs[rows, k0] + np.clip(w, 0, 1) * (xs[rows, k] - xs[rows, k0]), xE)
return x_new, np.where(k > 0, prep.crack_depth, 0.0)
def _finish_slices(prep, xe, ze, crack, min_depth):
sl = lem.slices(prep, xe, ze)
if prep.crack_depth > 0:
lem.add_crack_water(sl, crack, prep.crack_water)
height = sl["zt"] - sl["zb"]
sl["valid"] &= np.all(height > -_SURFACE_TOL, axis=1)
sl["valid"] &= np.nanmax(height, axis=1) >= max(min_depth, 1e-3)
return sl
def circle_slices(prep, xc, zc, R, xE, xX, n, min_depth=0.0):
xc, zc, R = (np.asarray(v, float) for v in (xc, zc, R))
def base(x):
return zc[:, None] - np.sqrt(np.clip(R[:, None] ** 2 - (x - xc[:, None]) ** 2, 0, None))
xE, crack = _crack_start(prep, np.asarray(xE, float), np.asarray(xX, float), base)
t = np.linspace(0.0, 1.0, n + 1)
xe = xE[:, None] + (xX - xE)[:, None] * t
sl = _finish_slices(prep, xe, base(xe), crack, min_depth)
sl["valid"] &= (xE >= xc - R) & (xX <= xc + R)
return sl
def poly_base(vx, vz, x):
"""꼭짓점(S, V) 꺾은선을 x(S, m) 에서 — 행마다 선형 보간(양 끝 밖은 끝 마디 연장)."""
z = None
for k in range(vx.shape[1] - 1):
x0, x1 = vx[:, k : k + 1], vx[:, k + 1 : k + 2]
t = (x - x0) / np.where(x1 - x0 > 1e-12, x1 - x0, 1e-12)
seg = vz[:, k : k + 1] + t * (vz[:, k + 1 : k + 2] - vz[:, k : k + 1])
z = seg if z is None else np.where(x >= x0, seg, z)
return z
def poly_slices(prep, vx, vz, n, min_depth=0.0):
vx, vz = np.asarray(vx, float), np.asarray(vz, float)
def base(x):
return poly_base(vx, vz, x)
xE, crack = _crack_start(prep, vx[:, 0], vx[:, -1], base)
# 절편 경계 = 고른 간격 + 안쪽 꼭짓점(꺾임이 절편 안에 묻히지 않게 — SLOPE/W 와 같은 생각)
t = np.linspace(0.0, 1.0, n + 1 - (vx.shape[1] - 2))
xe = xE[:, None] + (vx[:, -1] - xE)[:, None] * t
inner = np.clip(vx[:, 1:-1], xE[:, None], vx[:, -1:])
xe = np.sort(np.concatenate([xe, inner], axis=1), axis=1)
sl = _finish_slices(prep, xe, base(xe), crack, min_depth)
sl["valid"] &= np.all(np.diff(vx, axis=1) > 1e-6, axis=1)
return sl
def three_point_center(vx, vz):
"""비원호 모멘트 중심 = 진입 · 가장 깊은 꼭짓점 · 진출을 지나는 원의 중심(곧은 면은 위로 멀리)."""
p1 = np.column_stack([vx[:, 0], vz[:, 0]])
p3 = np.column_stack([vx[:, -1], vz[:, -1]])
chord_z = vz[:, :1] + (vx - vx[:, :1]) * (
(vz[:, -1:] - vz[:, :1]) / np.maximum(vx[:, -1:] - vx[:, :1], 1e-9)
)
k = np.argmax(chord_z - vz, axis=1)
p2 = np.column_stack([vx[np.arange(len(vx)), k], vz[np.arange(len(vx)), k]])
ax, ay = p1.T
bx, by = p2.T
cx, cy = p3.T
d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
ux = (
(ax**2 + ay**2) * (by - cy) + (bx**2 + by**2) * (cy - ay) + (cx**2 + cy**2) * (ay - by)
) / np.where(np.abs(d) < 1e-9, 1, d)
uy = (
(ax**2 + ay**2) * (cx - bx) + (bx**2 + by**2) * (ax - cx) + (cx**2 + cy**2) * (bx - ax)
) / np.where(np.abs(d) < 1e-9, 1, d)
far = np.abs(d) < 1e-9
span = np.hypot(cx - ax, cy - ay)
ux = np.where(far, 0.5 * (ax + cx), ux)
uy = np.where(far, np.maximum(ay, cy) + 10 * span, uy)
return np.column_stack([ux, uy])
# ---- 방법 묶음 평가 ------------------------------------------------------------
def evaluate(sl, center, methods=METHODS, scan=True) -> dict:
out = {}
base = lem.ordinary(sl, center)
seed = np.where(np.isfinite(base["F"]), base["F"], 1.5)
if "ordinary" in methods:
out["ordinary"] = base
if "bishop" in methods or "spencer" in methods:
out["bishop"] = lem.bishop(sl, center, seed)
seed = np.where(np.isfinite(out["bishop"]["F"]), out["bishop"]["F"], seed)
if "janbu" in methods:
out["janbu"] = lem.janbu(sl, seed)
if "spencer" in methods:
out["spencer"] = lem.spencer(sl, center, seed, scan)
return {m: out[m] for m in methods}
def _objective(method):
def fun(sl, center):
return evaluate(sl, center, (method,), scan=False)[method]["F"]
return fun
# ---- 원호 ---------------------------------------------------------------------
def circle_from_chord(prep, xE, xX, theta):
"""진입 · 진출 x 와 중심각 θ → 중심 · 반지름(중심은 현 위쪽 · 두 끝이 중심보다 아래여야)."""
zE, zX = prep.surface(xE), prep.surface(xX)
dx, dz = xX - xE, zX - zE
c = np.hypot(dx, dz)
R = c / (2 * np.sin(theta / 2))
d = R * np.cos(theta / 2)
nx, nz = -dz / np.maximum(c, 1e-12), dx / np.maximum(c, 1e-12)
flip = nz < 0
nx, nz = np.where(flip, -nx, nx), np.where(flip, -nz, nz)
xc = 0.5 * (xE + xX) + d * nx
zc = 0.5 * (zE + zX) + d * nz
ok = (zE <= zc) & (zX <= zc) & (dx > 0) & np.isfinite(zE) & np.isfinite(zX)
return xc, zc, R, ok
def circle_cross_surface(prep, xc, zc, R):
"""원 아래 반쪽과 지표면의 교점 중 지표면 아래로 들어간 가장 넓은 구간 → (진입, 진출)."""
sx, sz = prep.surface_x, prep.surface_z
ax, az, bx, bz = sx[:-1], sz[:-1], sx[1:], sz[1:]
dx, dz = bx - ax, bz - az
fx, fz = ax[None, :] - xc[:, None], az[None, :] - zc[:, None]
a = dx**2 + dz**2
b = 2 * (fx * dx + fz * dz)
c = fx**2 + fz**2 - R[:, None] ** 2
disc = b**2 - 4 * a * c
root = np.sqrt(np.clip(disc, 0, None))
roots = []
for sign in (-1, 1):
t = (-b + sign * root) / (2 * np.maximum(a, 1e-12))
x = ax + t * dx
z = az + t * dz
keep = (disc >= 0) & (t >= 0) & (t <= 1) & (z <= zc[:, None] + 1e-9)
roots.append(np.where(keep, x, np.nan))
xs = np.sort(np.concatenate(roots, axis=1), axis=1)
mids = 0.5 * (xs[:, :-1] + xs[:, 1:])
arc = zc[:, None] - np.sqrt(np.clip(R[:, None] ** 2 - (mids - xc[:, None]) ** 2, 0, None))
below = np.isfinite(mids) & (prep.surface(np.nan_to_num(mids)) > arc)
width = np.where(below, xs[:, 1:] - xs[:, :-1], -1.0)
k = np.argmax(width, axis=1)
rows = np.arange(len(xc))
ok = width[rows, k] > 0
return xs[rows, k], xs[rows, k + 1], ok
def entry_exit_circles(prep, limits, n_points=25, thetas_deg=(10, 20, 35, 50, 65, 80, 100, 120)):
xl, xr = limits
pts = np.linspace(xl, xr, n_points)
i, j = np.triu_indices(n_points, k=1)
xE, xX = pts[i], pts[j]
xE = np.repeat(xE, len(thetas_deg))
xX = np.repeat(xX, len(thetas_deg))
th = np.tile(np.radians(thetas_deg), len(i))
xc, zc, R, ok = circle_from_chord(prep, xE, xX, th)
return xc[ok], zc[ok], R[ok], xE[ok], xX[ok]
def grid_centers(prep, limits, nx=15, nz=15):
xl, xr = limits
z_in = prep.surface(np.linspace(xl, xr, 50))
H = max(np.nanmax(z_in) - np.nanmin(z_in), 1.0)
L = max(xr - xl, 1.0)
gx = np.linspace(xl, xr + 0.25 * L, nx)
gz = np.linspace(np.nanmax(z_in) + 0.1 * H, np.nanmax(z_in) + max(1.5 * H, 0.6 * L), nz)
return gx, gz
def grid_radius_circles(prep, limits, gx, gz, n_radii=10):
cx, cz = np.meshgrid(gx, gz)
cx, cz = cx.ravel(), cz.ravel()
sx = np.linspace(limits[0], limits[1], 80)
sz = prep.surface(sx)
dist = np.sqrt((sx[None, :] - cx[:, None]) ** 2 + (sz[None, :] - cz[:, None]) ** 2)
r0 = np.nanmin(dist, axis=1) + 0.05
r1 = np.maximum(cz - prep.bottom_z, r0 + 0.1)
frac = np.linspace(0.0, 1.0, n_radii + 1)[1:]
R = (r0[:, None] + (r1 - r0)[:, None] * frac).ravel()
owner = np.repeat(np.arange(len(cx)), n_radii)
xc, zc = cx[owner], cz[owner]
xE, xX, ok = circle_cross_surface(prep, xc, zc, R)
ok &= (xE >= limits[0] - 1e-6) & (xX <= limits[1] + 1e-6)
return xc[ok], zc[ok], R[ok], xE[ok], xX[ok], owner[ok]
def _circle_eval(prep, xc, zc, R, xE, xX, n, min_depth, methods, chunk=4000):
res = {m: np.full(len(xc), np.nan) for m in methods}
for s in range(0, len(xc), chunk):
e = slice(s, s + chunk)
sl = circle_slices(prep, xc[e], zc[e], R[e], xE[e], xX[e], n, min_depth)
center = np.column_stack([xc[e], zc[e]])
out = evaluate(sl, center, methods)
for m in methods:
res[m][e] = out[m]["F"]
return res
def refine_circle(prep, limits, method, seeds, n, min_depth, seed=0, maxiter=60):
"""원호 다듬기 — (진입 x, 진출 x, 중심각) 차분진화 · 첫 무리 = 거친 탐색의 좋은 면."""
fun = _objective(method)
lo = np.array([limits[0], limits[0], np.radians(3)])
hi = np.array([limits[1], limits[1], np.radians(170)])
def obj(p):
xc, zc, R, ok = circle_from_chord(prep, p[:, 0], p[:, 1], p[:, 2])
F = np.full(len(p), _BIG)
if ok.any():
sl = circle_slices(prep, xc[ok], zc[ok], R[ok], p[ok, 0], p[ok, 1], n, min_depth)
f = fun(sl, np.column_stack([xc[ok], zc[ok]]))
F[ok] = np.where(np.isfinite(f), f, _BIG)
return F
x, fmin, _pop, _f = _de(obj, lo, hi, _population(seeds, lo, hi, 30, seed), maxiter, seed)
xc, zc, R, _ = circle_from_chord(prep, x[0:1], x[1:2], x[2:3])
return float(fmin), (float(xc[0]), float(zc[0]), float(R[0]), float(x[0]), float(x[1]))
def _de(obj, lo, hi, init, maxiter, seed, cr=0.7, tol=1e-6, patience=25):
"""묶음 차분진화(best/1/bin · 축척 0.5~1.0 흔들기 = SciPy 기본 전략) — 최소 x · 값 · 마지막 무리 · 값."""
rng = np.random.default_rng(seed)
pop = np.clip(np.asarray(init, float), lo, hi)
f = obj(pop)
P, D = pop.shape
best_hist = f.min()
still = 0
for _ in range(maxiter):
b = pop[np.argmin(f)]
r1 = rng.integers(0, P, P)
r2 = (r1 + rng.integers(1, P, P)) % P
trial = b + rng.uniform(0.5, 1.0) * (pop[r1] - pop[r2])
cross = rng.random((P, D)) < cr
cross[np.arange(P), rng.integers(0, D, P)] = True
trial = np.clip(np.where(cross, trial, pop), lo, hi)
ft = obj(trial)
better = ft <= f
pop[better], f[better] = trial[better], ft[better]
still = still + 1 if best_hist - f.min() < tol else 0
best_hist = min(best_hist, f.min())
if still >= patience:
break
k = int(np.argmin(f))
return pop[k].copy(), float(f[k]), pop, f
def _local_search(obj, starts, lo, hi, step, maxiter, seed, lam=24, starts_keep=8):
"""여러 출발점 (1+λ) 국부 탐색 — 출발점마다 λ 개를 흔들어 나아지면 옮기고 걸음 ×1.5 · 아니면 ×0.6.
상용 SW 의 꼭짓점 이동 최적화(SLOPE/W Optimization · Slide Surface Altering)와 같은 생각.
모든 출발점 · 후보를 한 번에 평가. 돌려주는 값 = 최소 x · 값 · 출발점별 마지막 x · 값.
"""
rng = np.random.default_rng(seed)
x = np.clip(starts[:starts_keep], lo, hi)
f = obj(x)
sig = np.tile(step, (len(x), 1))
for _ in range(maxiter):
cand = x[:, None, :] + rng.normal(size=(len(x), lam, x.shape[1])) * sig[:, None, :]
cand = np.clip(cand, lo, hi).reshape(-1, x.shape[1])
fc = obj(cand).reshape(len(x), lam)
k = np.argmin(fc, axis=1)
best = fc[np.arange(len(x)), k]
up = best < f - 1e-9
x[up] = cand.reshape(len(x), lam, -1)[np.arange(len(x)), k][up]
f[up] = best[up]
sig *= np.where(up, 1.5, 0.6)[:, None]
if np.all(sig < step * 1e-3):
break
i = int(np.argmin(f))
return x[i].copy(), float(f[i]), x, f
def _population(seeds, lo, hi, size, seed, spread=0.0):
"""첫 무리 = 씨앗 + (spread > 0 이면 씨앗 둘레 흔든 것 80 % · 고른 무작위 20 %)."""
rng = np.random.default_rng(seed)
seeds = np.clip(np.asarray(seeds, float).reshape(-1, len(lo)), lo, hi)
extra = max(size - len(seeds), 5)
rand = lo + rng.random((extra, len(lo))) * (hi - lo)
if spread > 0 and len(seeds):
k = int(0.8 * extra)
base = seeds[rng.integers(0, len(seeds), k)]
rand[:k] = np.clip(base + rng.normal(0, spread, (k, len(lo))) * (hi - lo), lo, hi)
return np.vstack([seeds, rand])
# ---- 비원호(블록 + 꼭짓점 최적화) -------------------------------------------------
def _decode(prep, p, m):
"""변수 [진입 x, 진출 x, 위치 s_1..s_m, 깊이 d_1..d_m] → 꼭짓점(S, m+2)."""
xE, xX = p[:, 0], p[:, 1]
s = np.sort(p[:, 2 : 2 + m], axis=1)
d = p[:, 2 + m : 2 + 2 * m]
ix = xE[:, None] + s * (xX - xE)[:, None]
vx = np.column_stack([xE, ix, xX])
vz = np.column_stack([prep.surface(xE), prep.surface(ix) - d, prep.surface(xX)])
return vx, vz
def _convex(vx, vz, tol_deg=2.0):
ang = np.degrees(np.arctan2(vz[:, :-1] - vz[:, 1:], np.maximum(np.diff(vx, axis=1), 1e-9)))
return np.all(np.diff(ang, axis=1) <= tol_deg, axis=1)
def layer_bottom_depth(prep, x, material):
"""x 마다 그 지층(번호) 바닥 바로 위까지 깊이 — 없으면 nan(블록 씨앗: 약층 바닥을 따라 미끄러짐)."""
x = np.atleast_1d(x)
bot, top, mat = prep.columns(x[None, :])
zt = prep.surface(x)[None, :]
z = np.where((mat == material) & (top > bot), bot, np.nan)
with np.errstate(all="ignore"):
zb = np.nanmax(z, axis=-1)
return (zt - zb - 0.02)[0]
def optimize_polyline(
prep, limits, method, seeds, n, min_depth, m=4, seed=0, maxiter=60, polish=True
):
"""비원호 꼭짓점 최적화 — 차분진화 뒤 가장 좋은 면을 Nelder-Mead 로 국부 다듬기(상용 SAO · Optimize 대응)."""
fun = _objective(method)
xl, xr = limits
depth_max = max(prep.surface(np.linspace(xl, xr, 50)).max() - prep.bottom_z, 1.0)
lo = np.array([xl, xl] + [0.02] * m + [0.0] * m)
hi = np.array([xr, xr] + [0.98] * m + [depth_max] * m)
def obj(p):
p = np.atleast_2d(p)
vx, vz = _decode(prep, p, m)
ok = (p[:, 1] - p[:, 0] > 0.2) & _convex(vx, vz)
F = np.full(len(p), _BIG)
if ok.any():
sl = poly_slices(prep, vx[ok], vz[ok], n, min_depth)
f = fun(sl, three_point_center(vx[ok], vz[ok]))
F[ok] = np.where(np.isfinite(f), f, _BIG)
return F
step = np.array([0.5, 0.5] + [0.03] * m + [0.3] * m)
x, fmin, pop, f = _local_search(obj, np.asarray(seeds, float), lo, hi, step, maxiter, seed)
if polish and fmin < _BIG:
r = minimize(
lambda q: float(obj(np.clip(q, lo, hi))[0]),
x,
method="Nelder-Mead",
options={"maxfev": 200, "xatol": 1e-4, "fatol": 1e-6},
)
if r.fun < fmin:
x, fmin = np.clip(r.x, lo, hi), float(r.fun)
order = np.argsort(f)
vx, vz = _decode(prep, x[None, :], m)
return fmin, x, (vx[0], vz[0]), [x, *pop[order]]
def _best_seeds(prep, seeds, m, n, min_depth, keep):
"""씨앗을 Janbu 간편으로 한 번 셈 → 좋은 것 keep 개(최적화 첫 무리 · 나쁜 씨앗이 무리를 흐리지 않게)."""
p = np.asarray(seeds, float)
vx, vz = _decode(prep, p, m)
ok = (p[:, 1] - p[:, 0] > 0.2) & _convex(vx, vz)
F = np.full(len(p), np.inf)
if ok.any():
sl = poly_slices(prep, vx[ok], vz[ok], n, min_depth)
f = lem.janbu(sl)["F"]
F[ok] = np.where(np.isfinite(f), f, np.inf)
order = np.argsort(F)[:keep]
return [seeds[i] for i in order if np.isfinite(F[i])] or seeds[:keep]
def circle_to_params(prep, circle, m):
xc, zc, R, xE, xX = circle
s = (np.arange(1, m + 1) / (m + 1)).tolist()
ix = xE + np.array(s) * (xX - xE)
arc = zc - np.sqrt(np.clip(R**2 - (ix - xc) ** 2, 0, None))
d = np.clip(prep.surface(ix) - arc, 0, None)
return [xE, xX, *s, *d.tolist()]
def _ray_to_surface(prep, x0, z0, angle_deg, sign):
"""(x0, z0) 에서 수평과 angle 로 위로(sign = −1 왼쪽 · +1 오른쪽) 쏜 반직선이 지표와 만나는 x."""
t = np.linspace(0.0, 1.0, 400)[None, :] * 200.0
dx = sign * np.cos(np.radians(angle_deg))
dz = np.sin(np.radians(angle_deg))
xs = x0[:, None] + dx * t
above = (z0[:, None] + dz * t) >= prep.surface(xs)
k = np.argmax(above, axis=1)
hit = above[np.arange(len(x0)), k]
return np.where(hit & (k > 0), xs[np.arange(len(x0)), k], np.nan)
def block_seeds(prep, limits, m, n_points=12, angles=((55, 35), (65, 25))):
"""블록 씨앗(Slide Block Search 대응) — 지층마다 바닥선 위 두 점을 잇는 토막 + 머리 쪽 능동 쐐기 ·
끝 쪽 수동 쐐기를 지표까지 올림. 안쪽 꼭짓점 = 토막 두 끝 + 사이 (m − 2) 점."""
xl, xr = limits
xs = np.linspace(xl, xr, n_points)
i, j = np.triu_indices(n_points, k=1)
xa, xb = xs[i], xs[j]
out = []
for mat in range(len(prep.materials)):
da, db = layer_bottom_depth(prep, xa, mat), layer_bottom_depth(prep, xb, mat)
ok = np.isfinite(da) & np.isfinite(db)
if not ok.any():
continue
a, b = xa[ok], xb[ok]
za, zb = prep.surface(a) - da[ok], prep.surface(b) - db[ok]
for act, pas in angles:
xE = _ray_to_surface(prep, a, za, act, -1.0)
xX = _ray_to_surface(prep, b, zb, pas, 1.0)
good = np.isfinite(xE) & np.isfinite(xX) & (xE >= xl) & (xX <= xr)
for e, x0, x1, xx in zip(xE[good], a[good], b[good], xX[good]):
ix = np.linspace(x0, x1, m)
d = layer_bottom_depth(prep, ix, mat)
if np.isnan(d).any():
continue
span = xx - e
out.append([e, xx, *((ix - e) / span).tolist(), *np.clip(d, 0.0, None).tolist()])
return out
# ---- 결과 꾸미기 ----------------------------------------------------------------
def describe(prep, kind, geom, method, n, min_depth) -> dict:
s = prep.direction
if kind == "circle":
xc, zc, R, xE, xX = geom
sl = circle_slices(prep, [xc], [zc], [R], [xE], [xX], n, min_depth)
center = np.array([[xc, zc]])
xs = sl["xe"][0]
pts = np.column_stack([xs, sl["ze"][0]])
else:
vx, vz = geom
sl = poly_slices(prep, vx[None, :], vz[None, :], n, min_depth)
center = three_point_center(vx[None, :], vz[None, :])
pts = np.column_stack([vx, vz])
res = evaluate(sl, center, (method,))[method]
F = float(res["F"][0])
lam = float(res.get("lambda", [0.0])[0]) if method == "spencer" else 0.0
rows = lem.slice_table(sl, 0, F, lam, method) if np.isfinite(F) else []
for r in rows:
r["material"] = (
prep.materials[r["material"]]["id"] if r["material"] < len(prep.materials) else None
)
r["x_left"], r["x_right"] = sorted((s * r["x_left"], s * r["x_right"]))
out = {
"F": F if np.isfinite(F) else None,
"kind": kind,
"points": [[float(s * x), float(z)] for x, z in (pts if s > 0 else pts[::-1])],
"entry": [float(s * sl["xe"][0, 0]), float(sl["ze"][0, 0])],
"exit": [float(s * sl["xe"][0, -1]), float(sl["ze"][0, -1])],
"iterations": int(res["iterations"][0]),
"converged": bool(res["converged"][0]),
"slices": rows,
}
if kind == "circle":
out["center"] = [float(s * center[0, 0]), float(center[0, 1])]
out["radius"] = float(geom[2])
if method == "spencer":
out["lambda"] = lam
out["theta_deg"] = float(np.degrees(np.arctan(lam)))
if method == "janbu":
out["F0"] = float(res["F0"][0]) if np.isfinite(res["F0"][0]) else None
out["f0"] = float(res["f0"][0])
if prep.crack_depth > 0:
out["crack_water_force"] = float(sl["Pw"][0])
return out
# ---- 한 비탈 해석 ----------------------------------------------------------------
def analyze(
model: dict,
*,
direction: int = 1,
limits=None,
methods=METHODS,
circular=True,
noncircular=True,
n_slices=50,
coarse_slices=30,
min_depth=0.0,
grid=(15, 15, 10),
entry_exit_points=25,
polyline_vertices=4,
seed=0,
) -> dict:
"""한 비탈(활동 방향 하나)의 방법별 최소 안전율 · 임계면 · 절편표 · 등안전율 격자 · 수렴 정보."""
t0 = time.perf_counter()
prep = lem.Prepared(model, direction)
s = prep.direction
if limits is None:
lim = (float(prep.surface_x[0]), float(prep.surface_x[-1]))
else:
lim = tuple(sorted((s * float(limits[0]), s * float(limits[1]))))
results: dict = {m: {} for m in methods}
counts = {}
contour = None
best_circles: dict = {}
if circular:
a = entry_exit_circles(prep, lim, entry_exit_points)
gx, gz = grid_centers(prep, lim, grid[0], grid[1])
b = grid_radius_circles(prep, lim, gx, gz, grid[2])
coarse = tuple(np.concatenate([a[k], b[k]]) for k in range(5))
cheap = tuple(m for m in methods if m != "spencer") or ("bishop",)
F = _circle_eval(prep, *coarse, coarse_slices, min_depth, cheap)
counts["circles"] = int(len(coarse[0]))
counts["circles_valid"] = int(np.sum(np.isfinite(F[cheap[0]])))
# 등안전율 격자 — 격자 중심마다 최소(Bishop 우선)
key = "bishop" if "bishop" in F else cheap[0]
fb = F[key][len(a[0]) :]
cmin = np.full(len(gx) * len(gz), np.nan)
for o, f in zip(b[5], fb):
if np.isfinite(f) and not (f >= cmin[o]):
cmin[o] = f
contour = {
"method": key,
"x": [float(s * v) for v in gx],
"z": [float(v) for v in gz],
"F": [
[None if not np.isfinite(v) else float(v) for v in row]
for row in cmin.reshape(len(gz), len(gx))
],
}
for m in methods:
score = F[m] if m in F else F[key]
order = np.argsort(np.where(np.isfinite(score), score, np.inf))[:30]
order = order[np.isfinite(score[order])]
if not len(order):
continue
seeds = np.column_stack([coarse[3][order], coarse[4][order], _theta(coarse, order)])
fmin, geom = refine_circle(prep, lim, m, seeds, n_slices, min_depth, seed)
if fmin < _BIG:
results[m]["circular"] = describe(prep, "circle", geom, m, n_slices, min_depth)
best_circles[m] = geom
if noncircular:
mv = polyline_vertices
seeds = []
circle = (
best_circles.get("janbu")
or best_circles.get("bishop")
or next(iter(best_circles.values()), None)
)
if circle:
seeds.append(circle_to_params(prep, circle, mv))
seeds += block_seeds(prep, lim, mv)
seeds = _best_seeds(prep, seeds, mv, n_slices, min_depth, 40)
# 힘 평형 방법(Janbu) 먼저 · Spencer 는 그 마지막 무리에서 이어 찾음 · 모멘트 방법(Fellenius · Bishop)은
# 비원호 중심이 임의라 따로 최적화하지 않고 찾은 면에서 값만 셈(상용 SW 도 비원호 최적화 = Janbu · Spencer · GLE)
found = []
for m in [k for k in ("janbu", "spencer") if k in methods]:
fmin, _x, geom, top = optimize_polyline(
prep, lim, m, seeds, n_slices, min_depth, mv, seed
)
seeds = [*top, *seeds]
if fmin < _BIG:
results[m]["noncircular"] = describe(prep, "polyline", geom, m, n_slices, min_depth)
found.append(geom)
for m in [k for k in ("ordinary", "bishop") if k in methods]:
cands = [describe(prep, "polyline", g, m, n_slices, min_depth) for g in found]
cands = [c for c in cands if c["F"] is not None]
if cands:
results[m]["noncircular"] = min(cands, key=lambda c: c["F"])
for m in methods:
cands = [v for v in results[m].values() if v.get("F") is not None]
results[m]["min"] = min(cands, key=lambda v: v["F"]) if cands else None
return {
"direction": s,
"limits": sorted([s * lim[0], s * lim[1]]),
"methods": results,
"contour": contour,
"counts": counts,
"elapsed_s": round(time.perf_counter() - t0, 3),
}
def _theta(coarse, order):
xc, zc, R, xE, xX = (v[order] for v in coarse)
zE = zc - np.sqrt(np.clip(R**2 - (xE - xc) ** 2, 0, None))
zX = zc - np.sqrt(np.clip(R**2 - (xX - xc) ** 2, 0, None))
chord = np.hypot(xX - xE, zX - zE)
return 2 * np.arcsin(np.clip(chord / (2 * R), 0, 1))
def evaluate_specified(
model: dict, *, circle=None, polyline=None, direction=1, methods=METHODS, n_slices=50
):
"""지정 활동면 하나(원 [xc, zc, R] 또는 꺾은선 [[x, z], …])의 방법별 안전율 · 절편표 — 대조 · 검산용."""
prep = lem.Prepared(model, direction)
s = prep.direction
out = {}
if circle is not None:
xc, zc, R = s * float(circle[0]), float(circle[1]), float(circle[2])
xE, xX, ok = circle_cross_surface(prep, np.array([xc]), np.array([zc]), np.array([R]))
if not ok[0]:
raise ValueError("지정 원이 지표면을 지나지 않음")
geom, kind = (xc, zc, R, float(xE[0]), float(xX[0])), "circle"
else:
pts = sorted((s * float(x), float(z)) for x, z in polyline)
geom, kind = (np.array([p[0] for p in pts]), np.array([p[1] for p in pts])), "polyline"
for m in methods:
out[m] = describe(prep, kind, geom, m, n_slices, 0.0)
return out
@@ -0,0 +1,75 @@
"""E03 측점 하나 해석 — 좌 · 우 비탈 × 조건(건기 · 우기 · 지진) × 방법.
조건 = KDS 11 70 05 표 4.3-3: 건기 u = 0 · 우기 지하수위 = 지표 · 지진 = 건기 수위 + kh.
판정(기준안전율 대조)은 화면 · 보고서 몫 — 여기는 값만 냄.
"""
import time
from E03_StabSection.E03_StabSection_Engine_Model import section_model_from_b06
from E03_StabSection.E03_StabSection_Engine_Search import METHODS, analyze
#: 활동면 최소 깊이(m) — 지표를 얇게 긁는 면(표토 박리)은 사면 붕괴로 보지 않음.
MIN_DEPTH_M = 0.3
def analyze_station(
cross: dict,
design: dict,
*,
materials=None,
layers=None,
ground=None,
conditions=("dry", "wet"),
seismic_kh: float = 0.0,
methods=METHODS,
noncircular: bool = True,
min_depth: float = MIN_DEPTH_M,
**model_kw,
) -> dict:
t0 = time.perf_counter()
out = {"chainage_m": cross.get("chainage_m"), "label": cross.get("label"), "cases": []}
for cond in conditions:
model = section_model_from_b06(
cross,
design,
materials=materials,
layers=layers,
ground=ground,
wet=cond == "wet",
seismic_kh=seismic_kh if cond == "seismic" else 0.0,
**model_kw,
)
out.setdefault("areas", model["areas"])
for side in model["sides"]:
res = analyze(
model,
direction=side["direction"],
limits=side["limits"],
methods=methods,
noncircular=noncircular,
min_depth=min_depth,
)
mins = {m: (res["methods"][m]["min"] or {}).get("F") for m in methods}
out["cases"].append({"condition": cond, **side, "min_F": mins, "result": res})
out["elapsed_s"] = round(time.perf_counter() - t0, 3)
return out
def analyze_stations(items: list[dict], workers: int | None = None, **kw) -> list[dict]:
"""전 측점 — 측점마다 따로 도니 프로세스로 나눠 돎. items = [{"cross", "design", "ground"?}, …] · 순서 그대로."""
import os
from concurrent.futures import ProcessPoolExecutor
workers = workers or max(1, (os.cpu_count() or 2) - 2)
if workers == 1 or len(items) < 2:
return [
analyze_station(it["cross"], it["design"], ground=it.get("ground"), **kw)
for it in items
]
with ProcessPoolExecutor(max_workers=workers) as pool:
futures = [
pool.submit(analyze_station, it["cross"], it["design"], ground=it.get("ground"), **kw)
for it in items
]
return [f.result() for f in futures]
+2
View File
@@ -80,6 +80,7 @@ from D04_PlanConfirm.D04_PlanConfirm_Link import dashboard_router as d04_link_da
from D04_PlanConfirm.D04_PlanConfirm_Link import router as d04_plan_link_router
from D04_PlanConfirm.D04_PlanConfirm_Router import router as d04_plan_confirm_router
from E01_StabRegister.E01_StabRegister_Router import router as e01_stab_register_router
from E02_StabGround.E02_StabGround_Router import router as e02_stab_ground_router
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router
@@ -486,6 +487,7 @@ app.include_router(d02_plan_base_router, dependencies=protected_with_company)
app.include_router(d03_plan_analysis_router, dependencies=protected_with_company)
app.include_router(d04_plan_confirm_router, dependencies=protected_with_company)
app.include_router(d04_plan_link_router, dependencies=protected_with_company)
app.include_router(e02_stab_ground_router, dependencies=protected_with_company)
app.include_router(b05_corridor_router, dependencies=protected_with_company)
app.include_router(b05_route_replan_router, dependencies=protected_with_company)
app.include_router(b05_route_terrain_router, dependencies=protected_with_company)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
# E05 보고서 양식 자리표 — `stab_report.json`
빌더(534-3 뒤쪽)가 읽는 표. 시트 id · 칸 주소 · 서식 번호는 양식 그대로 쓰고 바꾸지 않음.
## 공통
- 몫: 사용자 몫(표지 · 개요) = 처음 만들 때만 채움 · 다시 만들 때(보고서 갱신) 안 건드림. 프로그램 몫(입력조건 · 측점표 · 대표단면 · 3D요약) = 갱신 때 통째 다시 씀.
- 서식 번호(`서식` 표): 1 title · 2 sub · 3 section · 4 label · 5 text · 6 head · 7 body(가운데 글) · 8 fs(0.00) · 9 int(0) · 10 ng(N.G 빨강 채움) · 11 ok(O.K 초록) · 12 notice · 13 num1(#,##0.0) · 14 num0(#,##0) · 15 para(위 맞춤 긴 글) · 16 prop(0.0) · 17 chain(#,##0.00).
- 판정 글(`O.K` · `N.G`) 칸 = 엔진 판정을 값으로 씀 · N.G 칸은 서식 10 · O.K 칸은 서식 11 을 직접 붙임 — 인쇄 HTML · xlsx 는 `조건부서식` 을 안 먹이므로(화면만). 양식의 `조건부서식`(글포함 `N.G` → 빨강)은 사용자가 화면에서 고친 판정 글용.
- 수식이 보는 끝 행 = 20000(측점표 · 3D요약). 측점이 이보다 많으면 개요 수식의 `20000` 을 같이 늘림.
- 빈 값은 칸을 안 적거나 `값` 을 안 둠(빈 글 `""` 금지).
- 그림 열쇠 = `fig/<측점 키>.png`(측점 키 = `common_util_station_key`) · 3D 띠 = `fig/3d_band.png`. 양식의 `fig/{측점키}.png` 는 자리 표시일 뿐 — 빌더가 목록을 새로 씀.
## s1 표지 (사용자 몫)
| 칸 | 내용 |
| --- | --- |
| B4:E4 | 제목(고정 글 · 서식 1) |
| C8:E8 · C9:E9 · C10:E10 · C11:E11 · C12:E12 | 사업명 · 노선명 · 발주처 · 작성일 · 작성자 값(병합 · 서식 5) — 왼위 칸 `C8` … `C12` 에 값 |
| B14:E14 | 「문헌 추정치 · 현장 시험 없음」(고정 글 · 건드리지 않음) |
## s2 개요 (사용자 몫 · 수식은 양식에 들어 있음)
- 빌더가 쓸 것: 없음(수식이 `측점표` · `입력조건` 을 가리킴). 의견 칸 `A22:F30`(병합 · 서식 15) = 사용자 글.
- 수식 칸: `B5` 대상 측점 수 · `B6` 연장 · `B10:B12` 조건별 최소 Fs · `C10:C12` = `입력조건!B12:B14` · `D10:D12` 판정 · `E10`·`E11`·`E12` 최소 측점 · `B15` N.G 수 · `B16` 종합 판정 · `B17`(병합 B17:F19) 판정 글.
- 지진을 안 쓰면 `측점표!F` 열을 비움 → 개요 지진 행은 `-`.
## s3 입력조건 (프로그램 몫)
| 구역 | 칸 | 내용 |
| --- | --- | --- |
| 해석 방법 | `B5` `B6` `B7` `B8` (각 B:G 병합) | 해석 기법 · 활동면 탐색 · 3D 해석 · 적용 기준 글 |
| 기준 안전율 | `B12` `B13` `B14` | 건기 · 우기 · 지진 기준 값(수 · 서식 8) · 지진 끔 = 글 `-` |
| | `C12` `C13` `C14` (C:G 병합) | 비고 글 |
| 지하수 규칙 | `B17` `B18` `B19` (B:G 병합) | 건기 · 우기 · 지진 시 규칙 글 |
| 노면 하중 | `B22` | 수(kPa · 서식 16) |
| 지반 물성치 | **26 행부터 한 줄씩** · 머리 25 행(머리 줄 반복) | A 지층 · B 적용 구간(전체 또는 구간 글) · C γt · D γsat · E c · F φ(서식 16) · G 출처(서식 5) · A·B = 서식 7 |
- 지반 물성치가 표의 맨 끝이라 줄 수가 늘어도 다른 칸이 안 밀림. 구간별 덮어쓰기 줄 = 같은 표에 줄 추가(B 열에 구간).
## s4 측점표 (프로그램 몫 · 머리 줄 4~5 행 쪽마다 반복 · 가로 · 폭 1쪽)
- 자료는 **6 행부터 측점 하나 = 한 줄** · 서식은 6 행 서식을 줄마다 그대로.
| 열 | 내용 | 서식 |
| --- | --- | --- |
| A | 측점 이름 | 7 |
| B | 누가거리(m · 수) | 17 |
| C | 구분(`깎기` · `쌓기`) | 7 |
| D · E · F | 건기 · 우기 · 지진 안전율 Fs(지진 끔 = 칸 없음) | 8 |
| G · H · I | 건기 · 우기 · 지진 기준 안전율 | 8 |
| J | 판정 `O.K` · `N.G`(엔진 판정 값) | N.G = 10 · O.K = 11 |
- 개요 · 3D 수식이 `A6:A20000` · `B` · `D` · `E` · `F` · `J` 열을 읽음 → 이 열 배치 바꾸지 않음.
- 줄 수 = 측점 수(시험: 측점 수 = 줄 수). 쪽 나눔은 자동(`인쇄.쪽나눔` 안 씀).
## s5 대표단면 (프로그램 몫 · 단면 하나 = 28 행 덩이 = 한 쪽)
덩이 `k`(0 부터)의 시작 오프셋 `dr = 28 × k` — 양식의 1~28 행 덩이를 `dr` 만큼 아래로 복사해 단면 수만큼 만듦(칸 · 병합 · 행 높이 · `조건부서식` 범위 포함).
| 덩이 안 행 | 칸 | 내용 |
| --- | --- | --- |
| 1 | `A1:H1` 병합 | 단면 제목(`대표 단면 ① — 측점 · 구분 · 높이`) |
| 2 | `B2:H2` 병합 | 선정 사유 글 |
| 3~22 | 그림 `A3:H22` | 그림 한 장 — 그림 칸 크기 약 712 × 533 px(가로 : 세로 = 4 : 3) · 열쇠 `fig/<측점 키>.png` · 닻은 `dr` 만큼 내림 |
| 23 | 머리(고정 글) | 조건 · 해석 방법 · 안전율 · 기준 · 판정 · 활동면 반경 R · 중심 x · 중심 y |
| 24 · 25 · 26 | A = 건기 · 우기 · 지진(고정 글) | B 해석 방법 글(서식 7) · C Fs(8) · D 기준(8) · E 판정(7→ N.G 10 · O.K 11) · F 반경 · G 중심 x · H 중심 y(13) |
| 27 | `B27:H27` 병합 | 비고 글 |
| 28 | 빈 줄 | 쪽 끝 |
- `인쇄.쪽나눔` = `[28, 56, …, 28 × 단면 수]`(마지막 덩이 끝 쪽 나눔은 있어도 빈 쪽이 안 생김).
- 지진을 안 쓰면 26 행(덩이 안) 칸을 비움.
## s6 3D요약 (프로그램 몫 · 머리 줄 19 행 반복)
| 칸 | 내용 |
| --- | --- |
| 그림 `A5:H16` | 평면 위험 띠 — 그림 칸 약 684 × 320 px(2.1 : 1) · 열쇠 `fig/3d_band.png` |
| 20 행부터 구간 하나 = 한 줄 | A 구간 · B 시작 측점 · C 끝 측점(서식 7) · D 3D 최소 Fs · E 2D 최소 Fs(서식 8) · **F 3D/2D 비 = 수식 `IF(E{r}>0,D{r}/E{r},"")`**(서식 8) · G 임계 체적 m³(서식 14) · H 판정(서식 7 → N.G 10 · O.K 11) |
- 20 행 서식을 줄마다 복사.
@@ -0,0 +1,73 @@
{
"version": 1,
"note": "문헌 추정치 · 현장 시험 없음 · 값은 사용자가 바꿀 수 있음(E02 지반 조건)",
"materials": [
{ "id": "weathered_soil", "name": "풍화토", "gamma_t": 18.0, "gamma_sat": 19.0, "c": 15.0, "phi": 30.0, "water": "soil" },
{ "id": "weathered_rock", "name": "풍화암", "gamma_t": 20.0, "gamma_sat": 21.0, "c": 30.0, "phi": 33.0, "water": "soil" },
{ "id": "soft_rock", "name": "연암", "gamma_t": 24.0, "gamma_sat": 24.5, "c": 150.0, "phi": 35.0, "water": "rock" },
{ "id": "hard_rock", "name": "경암", "gamma_t": 26.0, "gamma_sat": 26.0, "c": 300.0, "phi": 40.0, "water": "rock" },
{ "id": "fill", "name": "성토", "gamma_t": 18.0, "gamma_sat": 19.0, "c": 10.0, "phi": 28.0, "water": "soil" },
{ "id": "colluvium", "name": "붕적토", "gamma_t": 17.5, "gamma_sat": 18.5, "c": 5.0, "phi": 27.0, "water": "soil" }
],
"label_map": { "soil": "weathered_soil", "ripping_rock": "weathered_rock", "blasting_rock": "soft_rock", "fill": "fill" },
"stations": [],
"zones": [],
"safety_factors": {
"dry": { "cut": 1.5, "fill": 1.5, "soft": 1.3 },
"wet": { "cut": 1.2, "fill": 1.3, "soft": 1.2 },
"seismic": { "cut": 1.1, "fill": 1.1, "soft": 1.1 },
"short_term": { "cut": 1.1, "fill": 1.1, "soft": 1.1 }
},
"road_surcharge_kpa": 10.0,
"groundwater": { "dry": "none", "wet_soil": "surface", "wet_rock": "half_height" },
"seismic": { "enabled": false, "kh": 0.0 },
"limits": {
"gamma_t": [10.0, 30.0],
"gamma_sat": [10.0, 30.0],
"c": [0.0, 1000.0],
"phi": [0.0, 60.0],
"safety_factor": [1.0, 3.0],
"road_surcharge_kpa": [0.0, 100.0],
"kh": [0.0, 0.5],
"soil_thickness_m": [0.0, 50.0],
"chainage_m": [0.0, 100000.0],
"name_length": [1, 30]
},
"sources": {
"materials.weathered_soil.gamma_t": "07 §3.3(1) 서울시 「지반조사편람」(2006) 풍화토 17~20 · §3.2(2) 「도로교시방서」 표 〈N값과 단위중량〉 사질토 N 10~30 = 18 · §4 종합 18~19",
"materials.weathered_soil.gamma_sat": "07 §3.2(2) 「도로교시방서」 γsat ≈ γ′ + 10 · N 10~30 = 18~19 · §4 종합 19~20",
"materials.weathered_soil.c": "07 §3.3(1) 「지반조사편람」 풍화토 비탈면 실무 5~20 kPa · §4 종합 5~20",
"materials.weathered_soil.phi": "07 §3.3(1) 「지반조사편람」 풍화토 25~30°(조밀 32°) · §4 종합 27~32",
"materials.weathered_rock.gamma_t": "07 §3.3(2) 한국지반공학회 사면안정 학술발표회(1996~2003) 풍화암 19.5~21.0 · §4 종합",
"materials.weathered_rock.gamma_sat": "07 §4 종합 풍화암 20.5~22.0",
"materials.weathered_rock.c": "07 §3.3(2) 사면안정 학술발표회 비탈면 해석용 풍화암 10~30 kPa(1996 · 2001 = 30)",
"materials.weathered_rock.phi": "07 §3.3(2) 사면안정 학술발표회 풍화암 30~36° · §4 종합 30~36",
"materials.soft_rock.gamma_t": "07 §4 종합 연암 22~24(범위 20~25) · §3.4(3) Hoek & Bray 연질 퇴적암 실무 22~24",
"materials.soft_rock.gamma_sat": "07 §4 종합 연암 23~25",
"materials.soft_rock.c": "07 §3.4(1) 한국도로공사 「도로설계실무편람」(1996) 연암 · 보통암(중간 파쇄) 150 kPa",
"materials.soft_rock.phi": "07 §3.4(1) 「도로설계실무편람」 연암 · 보통암(중간 파쇄) 35°",
"materials.hard_rock.gamma_t": "07 §3.4(3) Hoek & Bray 화성암 · 변성암 실무 25~27 · §4 종합 25~27",
"materials.hard_rock.gamma_sat": "07 §4 종합 경암 25.5~27.5",
"materials.hard_rock.c": "07 §3.4(2) 「건설공사 비탈면 설계기준 해설」 Bieniawski RMR Ⅱ등급 300~400 kPa · §3.4(1) 도로설계실무편람 양호 경암 200 이상",
"materials.hard_rock.phi": "07 §3.4(1) 「도로설계실무편람」 양호한 연암 · 경암 40° · §4 종합 40~45",
"materials.fill.gamma_t": "07 §4 종합 성토 18~19 · §3.1 「도로설계요령」 표 〈토사의 개략적인 토질 정수〉 밀실 사질토 19",
"materials.fill.gamma_sat": "07 §4 종합 성토 19~20",
"materials.fill.c": "07 §4 종합 성토 0~15 kPa(비탈면 실무 5~15 보수 적용)",
"materials.fill.phi": "07 §4 종합 성토 28~30 · §3.1 「도로설계요령」 밀실 사질토 30° — 아래값 28",
"materials.colluvium.gamma_t": "07 §3.1 「도로설계요령」 밀실치 않은 사질토(붕적토) 17 · §3.2(2) 「도로교시방서」 N < 10 = 17 · §4 종합 17~18",
"materials.colluvium.gamma_sat": "07 §3.2(2) 「도로교시방서」 N < 10 γsat 17~18 · §4 종합 18~19",
"materials.colluvium.c": "07 §3.3(1) 「지반조사편람」 퇴적토 · 붕적토 0~15 · §4 종합 0~10",
"materials.colluvium.phi": "07 §3.1 「도로설계요령」 붕적토 25° · §3.3(1) 25~30 · §4 종합 25~28",
"safety_factors.dry": "KDS 11 70 05 표 4.3-1(쌓기 일반) · 4.3-2(연약지반 쌓기) · 4.3-3(깎기) 건기",
"safety_factors.wet": "KDS 11 70 05 표 4.3-1 · 4.3-2 · 4.3-3 우기 — 깎기 1.2 = 지하수위 결정 방식(강우침투 해석이면 1.3)",
"safety_factors.seismic": "KDS 11 70 05 표 4.3-1 · 4.3-2 · 4.3-3 지진 시",
"safety_factors.short_term": "KDS 11 70 05 표 4.3-1 · 4.3-2 · 4.3-3 단기(1년 미만 · 시공 중)",
"road_surcharge_kpa": "옹벽 배면 상재하중 10 kN/㎡ 준용(지식DB technical_info 구조물_안정계산 · KDS 11 80 05) · 임도 차량하중 고유 근거 없음",
"groundwater.dry": "KDS 11 70 05 표 4.3-3 건기 = 지하수 없음",
"groundwater.wet_soil": "KDS 11 70 05 표 4.3-3 우기 토층 · 풍화암 = 지하수위 결정(지표 포화 가정)",
"groundwater.wet_rock": "KDS 11 70 05 표 4.3-3 우기 연암 · 경암 = 비탈면 높이 ½ 심도까지 포화",
"seismic.enabled": "KDS 11 90 00 1.8.1 ② 영향 범위에 주구조물 없으면 비적용 · 임도는 발주자 협의 — 기본 끔",
"seismic.kh": "유사정적 수평지진계수 — 지반가속도의 비율로 사용자 입력(KDS 11 70 05 표 4.3 지진 시)",
"stations": "B06 횡단 암 경계(지반선 평행 · 기본 −0.5 m · 물량 가름용 값) — 토사 측점은 경계 없음"
}
}
@@ -138,12 +138,18 @@ def _e01(monkeypatch, rows):
return "stab-id"
monkeypatch.setattr(e01, "_sources", fake_sources)
async def fake_copy(project_id, source_id):
seen["copied"] = (project_id, source_id)
return {"source_fingerprint": "f" * 40}
monkeypatch.setattr(e01, "create_kind_project", fake_create)
monkeypatch.setattr(e01, "_copy_source", fake_copy)
async def fake_seed(project_id):
seen["seeded"] = project_id
monkeypatch.setattr(e01, "seed_initial", fake_seed)
payload = e01.CreateStabRequest(source_project_id="s" * 36)
return seen, lambda: asyncio.run(e01.post_stab_project(None, payload, USER))
@@ -163,6 +169,7 @@ def test_e01_requires_b06(monkeypatch):
assert seen["copied"] == ("stab-id", "s" * 36)
assert seen["kind"] == "STABILITY" and seen["source_project_id"] == "s" * 36
assert seen["name"] == "임도 안정성평가"
assert seen["seeded"] == "stab-id" # E02 초기값(532-3)
def _access(monkeypatch, method, assigned, owner_company=2):
@@ -0,0 +1,348 @@
# -*- coding: utf-8 -*-
"""532-2 E03 2D 한계평형 솔버 — 공개 검증 예제 대조(±3 %).
출처(기대값 = 원문 그대로):
- S2VM = Rocscience Slide2 Slope Stability Verification Manual (2022) — 지식DB
`original/안정성평가/참고문헌/Rocscience_Slide2_SlopeStabilityVerification.pdf` · 문제 번호 = 그 책 번호.
- ACADS = Giam & Donald (1989) ACADS 사면 시험문제(S2VM #1~#9 에 실림).
- FK = Fredlund & Krahn (1977) Can. Geotech. J. 14(3) (S2VM #21).
- AT = Arai & Tagyo (1985) · YU = Yamagami & Ueta (1988) · LOUK = Loukidis et al. (2003) (S2VM #14~#17 · #62).
- 지정 활동면(원 · 꺾은선)은 방법 대조 · 탐색 문제는 상용 SW 탐색 결과 대조.
- Ordinary(Fellenius) 간극수압 = F&K 식 N = W·cosα − u·l (지식DB 02 §2.1) — Slide 는 (W − u·b)·cosα 라 r_u 문제 값이 다름.
./venv/Scripts/python.exe -m pytest resources/tester/test_532_2_stab_lem_benchmarks.py -q
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from shapely.geometry import Polygon, box
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from E03_StabSection.E03_StabSection_Engine_Search import analyze, evaluate_specified # noqa: E402
TOL = 0.03
def mats(*rows):
return [{"id": i, "name": i, "gamma_t": g, "c": c, "phi": p} for i, g, c, p in rows]
def near(got, expected, tol=TOL):
assert got is not None, "안전율 없음"
assert abs(got / expected - 1) <= tol, (
f"{got:.4f} vs {expected} ({(got / expected - 1) * 100:+.2f} %)"
)
def circ(res, method):
return (res["methods"][method].get("circular") or {}).get("F")
def nonc(res, method):
return (res["methods"][method].get("noncircular") or {}).get("F")
ACADS_EXT = [[20, 20], [70, 20], [70, 35], [50, 35], [30, 25], [20, 25]]
ACADS1C_REGIONS = [
{
"material_id": "soil1",
"polygon": [[30, 25], [50, 35], [70, 35], [70, 31], [54, 31], [50, 29], [40, 27]],
},
{
"material_id": "soil2",
"polygon": [[40, 27], [50, 29], [54, 31], [70, 31], [70, 24], [52, 24]],
},
{
"material_id": "soil3",
"polygon": [[20, 20], [70, 20], [70, 24], [52, 24], [40, 27], [30, 25], [20, 25]],
},
]
ACADS1C_MATS = mats(("soil1", 19.5, 0, 38), ("soil2", 19.5, 5.3, 23), ("soil3", 19.5, 7.2, 20))
def test_acads_1a_homogeneous_circular_search():
# S2VM #1 (ACADS 1a) — Bishop 0.987 · Spencer 0.986 · Janbu 보정 0.990 · 심판값 1.00
m = {
"materials": mats(("s", 20, 3, 19.6)),
"regions": [{"material_id": "s", "polygon": ACADS_EXT}],
}
r = analyze(m, direction=-1, noncircular=False)
near(circ(r, "bishop"), 0.987)
near(circ(r, "spencer"), 0.986)
near(circ(r, "janbu"), 0.990)
def test_acads_1b_tension_crack_full_of_water():
# S2VM #2 (ACADS 1b) — 균열 깊이 4 m · 물 가득 — Bishop 1.596 · Spencer 1.592 · Janbu 보정 1.489
m = {
"materials": mats(("s", 20, 32, 10)),
"regions": [{"material_id": "s", "polygon": ACADS_EXT}],
"tension_crack": {"depth": 4.0, "water": 1.0},
}
r = analyze(m, direction=-1, noncircular=False)
near(circ(r, "bishop"), 1.596)
near(circ(r, "spencer"), 1.592)
near(circ(r, "janbu"), 1.489)
def test_acads_1c_three_layers():
# S2VM #3 (ACADS 1c) — Bishop 1.405 · Spencer 1.375 · Janbu 보정 1.357
r = analyze(
{"materials": ACADS1C_MATS, "regions": ACADS1C_REGIONS}, direction=-1, noncircular=False
)
near(circ(r, "bishop"), 1.405)
near(circ(r, "spencer"), 1.375)
near(circ(r, "janbu"), 1.357)
def test_acads_1d_three_layers_seismic():
# S2VM #4 (ACADS 1d) — kh 0.15 — Bishop 1.016 · Spencer 0.991 · Janbu 보정 0.965
m = {"materials": ACADS1C_MATS, "regions": ACADS1C_REGIONS, "seismic_kh": 0.15}
r = analyze(m, direction=-1, noncircular=False)
near(circ(r, "bishop"), 1.016)
near(circ(r, "spencer"), 0.991)
near(circ(r, "janbu"), 0.965)
def test_acads_2b_embankment_dam_specified_circle():
# S2VM #6 (ACADS 2b Talbingo 댐 · 성토) — 지정 원 (100.3, 291.0, R 278.8)
# Bishop 2.208 · Spencer 2.292 · Janbu 보정 2.073 · 심판값 2.29
ext = [
[0, 0], [315.5, 162], [319.5, 162], [321.6, 162], [327.6, 162], [386.9, 130.6], [394.1, 130.6],
[453.4, 97.9], [460.6, 97.9], [515, 65.3], [521.1, 65.3], [577.9, 31.4], [585.1, 31.4], [648, 0],
] # fmt: skip
core = [
[200.7, 0],
[311.9, 130.6],
[319.5, 162],
[321.6, 162],
[328.8, 146.1],
[331.3, 130.6],
[307.1, 0],
]
rest = Polygon(ext).difference(Polygon(core))
regions = [{"material_id": "core", "polygon": core}] + [
{"material_id": "rockfill", "polygon": list(p.exterior.coords)}
for p in getattr(rest, "geoms", [rest])
]
m = {"materials": mats(("rockfill", 20.4, 0, 45), ("core", 18.1, 85, 23)), "regions": regions}
r = evaluate_specified(m, circle=[100.3, 291.0, 278.8], direction=-1)
near(r["bishop"]["F"], 2.208)
near(r["spencer"]["F"], 2.292)
near(r["janbu"]["F"], 2.073)
def _acads3_model():
ext = Polygon([[20, 20], [84, 20], [84, 40], [67.5, 40], [43, 27.75], [20, 27.75]])
regions = [
{
"material_id": "soil",
"polygon": list(ext.intersection(box(0, 27, 100, 100)).exterior.coords),
},
{
"material_id": "seam",
"polygon": list(ext.intersection(box(0, 26.5, 100, 27)).exterior.coords),
},
{
"material_id": "soil",
"polygon": list(ext.intersection(box(0, 0, 100, 26.5)).exterior.coords),
},
]
return {
"materials": mats(("soil", 18.84, 28.5, 20), ("seam", 18.84, 0, 10)),
"regions": regions,
"groundwater": {"mode": "line", "line": [[20, 26.5], [84, 26.5]]},
}
def test_acads_3b_weak_seam_specified_polyline():
# S2VM #8 (ACADS 3b) — 약층 + 지하수위 · 지정 비원호 — Spencer 1.277 · Janbu 보정 1.294
r = evaluate_specified(
_acads3_model(),
polyline=[[41.85, 27.75], [44.0, 26.5], [63.5, 27.0], [73.31, 40.0]],
direction=-1,
methods=("janbu", "spencer"),
)
near(r["spencer"]["F"], 1.277)
near(r["janbu"]["F"], 1.294)
def test_acads_3a_weak_seam_noncircular_search():
# S2VM #7 (ACADS 3a) — 블록 탐색 Janbu 보정 1.275 · Spencer 1.258(심판값 1.24~1.27)
r = analyze(_acads3_model(), direction=-1, methods=("janbu", "spencer"))
near(nonc(r, "janbu"), 1.275)
near(nonc(r, "spencer"), 1.258)
def test_acads_4_inclined_seam_water_surcharge():
# S2VM #9 (ACADS 4) — 기운 약층 · 지하수위선 · 등분포 20 kPa + 선형 20→40 kPa
# 최적화 비원호 Spencer 0.707 · Janbu 보정 0.699 (약층 좌표 = 저해상 그림 판독 — 출처 ±5 % 권함)
ext = Polygon([[20, 15], [84, 15], [84, 40], [67.5, 40], [43, 27.75], [20, 27.75]])
seam = Polygon([[20, 18.26], [84, 36.2], [84, 36.8], [20, 18.88]])
regions = [{"material_id": "seam", "polygon": list(ext.intersection(seam).exterior.coords)}] + [
{"material_id": "soil", "polygon": list(p.exterior.coords)}
for p in ext.difference(seam).geoms
]
linear = [{"x0": 70 + i, "x1": 71 + i, "q": 20 + 20 * (i + 0.5) / 10} for i in range(10)]
m = {
"materials": mats(("soil", 18.84, 28.5, 20), ("seam", 18.84, 0, 10)),
"regions": regions,
"surcharge": [{"x0": 23, "x1": 43, "q": 20}] + linear,
"groundwater": {
"mode": "line",
"line": [
[20, 27.75],
[43, 27.75],
[49, 29.8],
[60, 34],
[66, 35.8],
[74, 37.6],
[80, 38.4],
[84, 38.4],
],
},
}
r = analyze(m, direction=-1, methods=("janbu", "spencer"))
near(nonc(r, "spencer"), 0.707)
near(nonc(r, "janbu"), 0.699)
FK_EXT = [[0, 0], [180, 0], [180, 20], [140, 20], [60, 60], [0, 60]]
def test_fredlund_krahn_specified_circle_dry_and_ru():
# S2VM #21 (F&K 1977, ft · lbf) — 지정 원 (120, 90, R 80)
# 건기 Ordinary 1.928 · Bishop 2.080 · Spencer 2.073 / r_u 0.25 Ordinary 1.607 · Bishop 1.766 · Spencer 1.761
m = {
"materials": mats(("s", 120, 600, 20)),
"regions": [{"material_id": "s", "polygon": FK_EXT}],
}
r = evaluate_specified(
m, circle=[120, 90, 80], direction=1, methods=("ordinary", "bishop", "spencer")
)
near(r["ordinary"]["F"], 1.928)
near(r["bishop"]["F"], 2.080)
near(r["spencer"]["F"], 2.073)
m["groundwater"] = {"mode": "ru", "ru": 0.25}
r = evaluate_specified(
m, circle=[120, 90, 80], direction=1, methods=("ordinary", "bishop", "spencer")
)
near(r["ordinary"]["F"], 1.607)
near(r["bishop"]["F"], 1.766)
near(r["spencer"]["F"], 1.761)
def test_loukidis_seismic_with_ru():
# S2VM #62 — 건기 kh 0.432: Bishop 0.991 · Spencer 1.001 / r_u 0.5 + kh 0.132: Bishop 0.987 · Spencer 1.001
ext = [[-50, -25], [150, -25], [150, 25], [75, 25], [0, 0], [-50, 0]]
m = {
"materials": mats(("c", 20, 25, 30)),
"regions": [{"material_id": "c", "polygon": ext}],
"seismic_kh": 0.432,
}
r = analyze(m, direction=-1, noncircular=False, methods=("bishop", "spencer"))
near(circ(r, "bishop"), 0.991)
near(circ(r, "spencer"), 1.001)
m.update(seismic_kh=0.132, groundwater={"mode": "ru", "ru": 0.5})
r = analyze(m, direction=-1, noncircular=False, methods=("bishop", "spencer"))
near(circ(r, "bishop"), 0.987)
near(circ(r, "spencer"), 1.001)
AT_EXT = [[0, 0], [66, 0], [66, 35], [48, 35], [18, 15], [0, 15]]
def test_arai_tagyo_3_water_table():
# S2VM #16 — 지하수위선 · 원호 Bishop 1.118 · Spencer 1.118 · Janbu 보정 1.131 /
# 비원호 Janbu 보정 1.050 · Spencer 1.094
m = {
"materials": mats(("s", 18.82, 41.65, 15)),
"regions": [{"material_id": "s", "polygon": AT_EXT}],
"groundwater": {"mode": "line", "line": [[0, 15], [18, 15], [30, 23], [48, 29], [66, 32]]},
}
r = analyze(m, direction=-1)
near(circ(r, "bishop"), 1.118)
near(circ(r, "spencer"), 1.118)
near(circ(r, "janbu"), 1.131)
near(nonc(r, "janbu"), 1.050)
near(nonc(r, "spencer"), 1.094)
def test_arai_tagyo_2_weak_middle_layer():
# S2VM #15 — 약한 가운데 층 · Bishop 0.420 · Spencer 0.409 · Janbu 보정 0.423
regions = [
{"material_id": "upper", "polygon": [[24, 19], [48, 35], [72, 35]]},
{
"material_id": "middle",
"polygon": [[0, 3], [96, 35], [72, 35], [24, 19], [18, 15], [0, 15]],
},
{"material_id": "lower", "polygon": [[0, 3], [96, 3], [96, 35]]},
]
m = {
"materials": mats(
("upper", 18.82, 29.4, 12), ("middle", 18.82, 9.8, 5), ("lower", 18.82, 294, 40)
),
"regions": regions,
}
r = analyze(m, direction=-1, noncircular=False)
near(circ(r, "bishop"), 0.420)
near(circ(r, "spencer"), 0.409)
near(circ(r, "janbu"), 0.423)
def test_yamagami_ueta_ordinary_vs_bishop():
# S2VM #17 — Bishop 1.344 · Ordinary 1.278 / 비원호 Spencer 1.325
ext = [[0, 0], [25, 0], [25, 10], [15, 10], [5, 5], [0, 5]]
m = {
"materials": mats(("s", 17.64, 9.8, 10)),
"regions": [{"material_id": "s", "polygon": ext}],
}
r = analyze(m, direction=-1, methods=("ordinary", "bishop", "spencer"))
near(circ(r, "bishop"), 1.344)
near(circ(r, "ordinary"), 1.278)
near(nonc(r, "spencer"), 1.325)
def test_mirror_direction_same_factor():
# 좌우 뒤집은 같은 비탈 = 같은 안전율 · 임계면 좌표도 거울
m = {
"materials": mats(("s", 20, 3, 19.6)),
"regions": [{"material_id": "s", "polygon": ACADS_EXT}],
}
mirrored = {**m, "regions": [{"material_id": "s", "polygon": [[-x, z] for x, z in ACADS_EXT]}]}
a = evaluate_specified(m, circle=[29.104, 55.527, 30.527], direction=-1)
b = evaluate_specified(mirrored, circle=[-29.104, 55.527, 30.527], direction=1)
for k in ("ordinary", "bishop", "janbu", "spencer"):
assert abs(a[k]["F"] - b[k]["F"]) < 1e-9
assert abs(a["bishop"]["entry"][0] + b["bishop"]["entry"][0]) < 1e-9
near(a["bishop"]["F"], 0.987) # S2VM #1 Bishop 임계 원 그대로
def test_slice_table_and_convergence_info():
m = {
"materials": mats(("s", 20, 3, 19.6)),
"regions": [{"material_id": "s", "polygon": ACADS_EXT}],
}
r = evaluate_specified(m, circle=[29.104, 55.527, 30.527], direction=-1, methods=("spencer",))[
"spencer"
]
assert r["converged"] and r["iterations"] > 0 and -1.25 <= r["lambda"] <= 0.25
rows = r["slices"]
assert len(rows) == 50 and all(row["width"] > 0 for row in rows)
# 절편간 힘 E — 머리(0) 에서 시작해 발끝에서 다시 0 근처(힘 평형이 닫힘)
weights = sum(row["weight"] for row in rows)
assert abs(rows[0]["E_left"]) < 1e-9
assert {"alpha_deg", "pore_pressure", "normal_eff", "shear_mobilized", "material"} <= rows[
0
].keys()
assert weights > 0
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-q"]))
@@ -0,0 +1,155 @@
# -*- coding: utf-8 -*-
"""532-2 E03 2D 지층 모델 빌더 — 폴리곤 닫힘 · 지층 · 성토체 · 절성토 면적 = B06 · 비탈 목록 · 측점 해석 꼴.
- 순수 함수(`build_section_model`)는 만든 단면으로 · B06 대조는 검증_sub4 초기값 스냅샷(읽기만 · 없으면 건너뜀).
./venv/Scripts/python.exe -m pytest resources/tester/test_532_2_stab_section_model.py -q
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
from shapely.geometry import Polygon
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from E03_StabSection.E03_StabSection_Engine_Model import ( # noqa: E402
build_section_model,
polygon_closed,
section_model_from_b06,
side_cases,
)
from E03_StabSection.E03_StabSection_Engine_Station import analyze_station # noqa: E402
SUB4 = ROOT / "storage/1/3/c725d747-50ae-46b0-87ce-4d4e56a53cf3/initial_snapshot"
# 오른쪽(−) 산 · 왼쪽(+) 골 · 편절편성: 노면 −2~2 · 우 절토 1:0.5 · 좌 성토 1:1.5
GROUND = [(x / 2, 10.0 - 0.6 * (x / 2)) for x in range(-40, 41)] # 10 − 0.6x (좌로 내려감)
DESIGN = [
(-20.0, 22.0),
(-6.0, 13.6),
(-2.6, 10.0),
(-2.0, 10.0),
(2.0, 10.0),
(14.0, 2.0),
(20.0, -2.0),
]
def _trapezoid(ground, line, sign):
"""설계선 꼭짓점 · 지반 표본 합집합 위 사다리꼴(B06 `_trapezoid_areas` 와 같은 정의) — 절토(+) / 성토(−)."""
import numpy as np
gx, gz = zip(*ground)
lx, lz = zip(*line)
xs = sorted({*gx, *lx})
xs = [x for x in xs if lx[0] <= x <= lx[-1]]
d = np.interp(xs, gx, gz) - np.interp(xs, lx, lz)
area = 0.0
for x0, x1, d0, d1 in zip(xs, xs[1:], d, d[1:]):
if d0 * d1 < 0:
xm = x0 + (x1 - x0) * d0 / (d0 - d1)
area += 0.5 * (xm - x0) * max(sign * d0, 0) + 0.5 * (x1 - xm) * max(sign * d1, 0)
else:
area += 0.5 * (x1 - x0) * (max(sign * d0, 0) + max(sign * d1, 0))
return area
def test_build_model_polygons_layers_fill_and_areas():
m = build_section_model(
GROUND,
DESIGN,
layers=[("weathered_soil", 0.5), ("weathered_rock", None)],
area_range=(-20.0, 20.0),
)
assert m["regions"] and all(polygon_closed(r) for r in m["regions"])
ids = {r["material_id"] for r in m["regions"]}
assert ids == {"weathered_soil", "weathered_rock", "fill"}
# 지층 사이 겹침 없음 · 합 = 공사 뒤 지표 아래 전체
polys = [Polygon(r["polygon"], r.get("holes")) for r in m["regions"]]
total = sum(p.area for p in polys)
union = polys[0]
for p in polys[1:]:
union = union.union(p)
assert abs(union.area - total) < 1e-6
a = m["areas"]
assert abs(a["cut_m2"] - _trapezoid(GROUND, DESIGN, 1)) < 1e-6
assert abs(a["fill_m2"] - _trapezoid(GROUND, DESIGN, -1)) < 1e-6
assert (
0 < a["cut_soil_m2"] < a["cut_m2"]
and abs(a["cut_soil_m2"] + a["cut_rock_m2"] - a["cut_m2"]) < 1e-9
)
def test_side_cases_roles_directions_limits():
design = {
"section_mode": "right_cut",
"road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}},
}
left, right = side_cases(design, GROUND, DESIGN)
assert (left["role"], left["direction"]) == ("fill", 1) # 좌 성토 → 바깥(+x)으로
assert (right["role"], right["direction"]) == ("cut", 1) # 우 절토 → 노면(+x)으로
assert left["limits"][0] == -2.0 and right["limits"][1] == 2.0
assert left["height_m"] > 0 and right["height_m"] > 0
def test_analyze_station_shape():
cross = {
"chainage_m": 0.0,
"label": "STA.0+000",
"samples": [{"offset_m": x, "elevation_m": z} for x, z in GROUND],
}
design = {
"section_mode": "right_cut",
"ground_type": "ripping_rock",
"rock_boundary_offset_m": -0.5,
"design_line": [{"offset_m": x, "elevation_m": z} for x, z in DESIGN],
"road_edges": {
"left": {"offset_m": 2.0, "elevation_m": 10.0},
"right": {"offset_m": -2.0, "elevation_m": 10.0},
},
}
res = analyze_station(
cross, design, conditions=("dry",), methods=("bishop",), noncircular=False
)
assert len(res["cases"]) == 2
for case in res["cases"]:
F = case["min_F"]["bishop"]
assert F is not None and 0.3 < F < 20
best = case["result"]["methods"]["bishop"]["circular"]
assert best["slices"] and best["center"] and best["radius"] > 0
lo, hi = case["limits"]
assert (
lo - 1e-6 <= min(best["entry"][0], best["exit"][0])
and max(best["entry"][0], best["exit"][0]) <= hi + 1e-6
)
assert res["cases"][0]["result"]["contour"]["F"]
@pytest.mark.skipif(not (SUB4 / "db.json").exists(), reason="검증_sub4 스냅샷 없음")
def test_b06_areas_match_all_stations():
"""검증_sub4 전 측점 — 모델 절토 · 성토 · 토사 · 암 면적 = B06 저장값(반올림 4 자리 안)."""
rows = json.loads((SUB4 / "db.json").read_text(encoding="utf-8"))["cross_sections"]
assert rows
for row in rows:
design = json.loads(row["data"])["design"]
name = Path(row["cross_section_file_path"]).name
cross = json.loads(
(SUB4 / "B06_Section__cross_sections" / name).read_text(encoding="utf-8")
)
m = section_model_from_b06(cross, design)
assert all(polygon_closed(r) for r in m["regions"]), name
a = m["areas"]
assert abs(a["cut_m2"] - design["cut_area_m2"]) < 2e-3, name
assert abs(a["fill_m2"] - design["fill_area_m2"]) < 2e-3, name
assert abs(a["cut_soil_m2"] - design["cut_soil_area_m2"]) < 2e-3, name
assert abs(a["cut_rock_m2"] - design["cut_rock_area_m2"]) < 2e-3, name
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-q"]))
+296
View File
@@ -0,0 +1,296 @@
# -*- coding: utf-8 -*-
"""532-3 E02 지반 조건 — 기본값 json 꼴 = E03 Types · KDS 표 · 검사 · 측점 적용 거울(파이썬 ↔ TS) ·
[저장] · [초기화] · 지층 추가 → 저장 → 다시 읽음(임시 storage · DB 대신 측점 가짜).
"""
import asyncio
import copy
import json
import subprocess
import sys
from pathlib import Path
import pytest
from fastapi import HTTPException
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from E02_StabGround import E02_StabGround_Model as model # noqa: E402
from E02_StabGround import E02_StabGround_Router as router # noqa: E402
from E03_StabSection import E03_StabSection_Types as types # noqa: E402
TS = PROJECT_ROOT / "E02_StabGround" / "logic" / "E02_StabGround_Check.ts"
TYPESCRIPT = PROJECT_ROOT / "config" / "node_modules" / "typescript"
DEFAULTS = model.load_defaults()
LIMITS = DEFAULTS["limits"]
STATIONS = [
{"chainage_m": 0.0, "ground_type": "soil", "soil_thickness_m": None},
{"chainage_m": 20.0, "ground_type": "ripping_rock", "soil_thickness_m": 0.5},
{"chainage_m": 40.0, "ground_type": "blasting_rock", "soil_thickness_m": 0.8},
{"chainage_m": 60.0, "ground_type": "ripping_rock", "soil_thickness_m": 0.5},
]
def _ground(**changes):
ground = model.build_initial(DEFAULTS, copy.deepcopy(STATIONS))
ground.update(changes)
return ground
def _bad(mutate):
ground = _ground()
mutate(ground)
return ground
# 거울 시험 판 — 통과 하나 + 틀린 꼴 여럿(NaN 은 JSON 으로 못 넘겨 null · 글 · 참거짓으로)
CHECK_CASES = [
_ground(),
_bad(lambda g: g["materials"][0].update(gamma_t=9.9)),
_bad(lambda g: g["materials"][0].update(gamma_sat=17.0)),
_bad(lambda g: g["materials"][1].update(c=None, phi="33")),
_bad(lambda g: g["materials"][2].update(phi=True, water="sand")),
_bad(lambda g: g["materials"][3].update(name=" ", id="soft_rock")),
_bad(lambda g: g["materials"][4].update(name="풍화토")),
_bad(lambda g: g["materials"][5].update(name="가" * 31)),
_bad(lambda g: g["materials"].append(7)),
_bad(lambda g: g.update(materials=[])),
_bad(lambda g: g["label_map"].update(soil="nope", fill="")),
_bad(lambda g: g.update(zones=[{"from_m": 50, "to_m": 50, "soil": "x", "rock": None}])),
_bad(
lambda g: g.update(
zones=[
{
"from_m": -1,
"to_m": 10,
"soil": None,
"rock": "hard_rock",
"soil_thickness_m": 51,
},
"zone",
]
)
),
_bad(lambda g: g["safety_factors"]["wet"].update(cut=0.9, soft=3.1)),
_bad(lambda g: g["safety_factors"].pop("seismic")),
_bad(lambda g: g.update(road_surcharge_kpa=101)),
_bad(lambda g: g["groundwater"].update(wet_rock="full")),
_bad(lambda g: g.update(seismic={"enabled": "yes", "kh": 0.6})),
_bad(lambda g: g.update(label_map=[], safety_factors=None, groundwater="x")),
]
RESOLVE_CASES = [
_ground(),
_ground(
zones=[
{"from_m": 0, "to_m": 20, "soil": "colluvium", "rock": None, "soil_thickness_m": None},
{"from_m": 20, "to_m": 50, "soil": None, "rock": "hard_rock", "soil_thickness_m": 2.5},
{"from_m": 55, "to_m": 70, "soil": None, "rock": None, "soil_thickness_m": None},
]
),
_ground(
zones=[{"from_m": 0, "to_m": 10, "soil": None, "rock": "soft_rock", "soil_thickness_m": 1}]
),
]
SCRIPT = r"""
const ts = require(process.argv[1]);
const fs = require("fs");
const src = fs.readFileSync(process.argv[2], "utf8");
const opts = { compilerOptions: { module: ts.ModuleKind.CommonJS } };
const js = ts.transpileModule(src, opts).outputText;
const box = { exports: {} };
new Function("exports", "module", js)(box.exports, box);
const m = box.exports;
const input = JSON.parse(fs.readFileSync(0, "utf8"));
process.stdout.write(JSON.stringify({
check: input.check.map((g) => m.checkGround(g, input.limits)),
resolve: input.resolve.map((g) => m.resolveStations(g)),
}));
"""
def test_기본값_꼴은_Types_그대로():
assert model.check_ground(DEFAULTS, LIMITS) == []
assert [m["id"] for m in DEFAULTS["materials"]] == [m["id"] for m in types.DEFAULT_MATERIALS]
for item in DEFAULTS["materials"]:
assert types.material(item) == {k: item[k] for k in types.Material.__annotations__}
for field in model.MATERIAL_FIELDS:
assert DEFAULTS["sources"][f"materials.{item['id']}.{field}"].startswith("07 ")
assert DEFAULTS["road_surcharge_kpa"] == types.DEFAULT_ROAD_SURCHARGE_KPA == 10.0
assert DEFAULTS["groundwater"] == {
"dry": "none",
"wet_soil": "surface",
"wet_rock": "half_height",
}
assert DEFAULTS["seismic"] == {"enabled": False, "kh": 0.0}
# 07 §4 종합 성토 φ 28~30 — 브레인 기본 25 는 범위 밖이라 아래값 28
assert next(m for m in DEFAULTS["materials"] if m["id"] == "fill")["phi"] == 28.0
assert types.groundwater({"mode": "none"}) == {"mode": "none"}
def test_기준안전율_KDS_11_70_05():
assert DEFAULTS["safety_factors"] == {
"dry": {"cut": 1.5, "fill": 1.5, "soft": 1.3},
"wet": {"cut": 1.2, "fill": 1.3, "soft": 1.2},
"seismic": {"cut": 1.1, "fill": 1.1, "soft": 1.1},
"short_term": {"cut": 1.1, "fill": 1.1, "soft": 1.1},
}
def test_범위_검사():
codes = lambda g: {(e["path"], e["code"]) for e in model.check_ground(g, LIMITS)} # noqa: E731
assert codes(CHECK_CASES[1]) == {("materials.0.gamma_t", "range")}
assert codes(CHECK_CASES[2]) == {("materials.0.gamma_sat", "sat_lt_t")}
assert ("materials.4.name", "name_dup") in codes(CHECK_CASES[6])
assert ("zones.0.to_m", "order") in codes(CHECK_CASES[11])
assert ("zones.0.soil", "ref") in codes(CHECK_CASES[11])
assert codes(CHECK_CASES[13]) == {
("safety_factors.wet.cut", "range"),
("safety_factors.wet.soft", "range"),
}
assert codes(CHECK_CASES[17]) == {("seismic.enabled", "enum"), ("seismic.kh", "range")}
def test_파이썬과_TS_같음():
payload = json.dumps({"limits": LIMITS, "check": CHECK_CASES, "resolve": RESOLVE_CASES})
proc = subprocess.run(
["node", "-e", SCRIPT, str(TYPESCRIPT), str(TS)],
input=payload,
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
out = json.loads(proc.stdout)
assert out["check"] == [model.check_ground(g, LIMITS) for g in CHECK_CASES]
assert out["resolve"] == [model.resolve_stations(g) for g in RESOLVE_CASES]
assert all(out["check"][1:]) # 틀린 판은 모두 걸림
def test_측점_적용():
rows = model.resolve_stations(RESOLVE_CASES[1])
assert [(r["upper"], r["lower"], r["soil_thickness_m"]) for r in rows] == [
("colluvium", None, None), # 토사 측점 · 두께 없음 = 전부 위 지층
("colluvium", "hard_rock", 2.5), # 20 = 두 구간 끝 · 뒤 구간이 이김
("weathered_soil", "hard_rock", 2.5),
("weathered_soil", "weathered_rock", 0.5), # 빈 구간 = B06 그대로
]
assert [r["zoned"] for r in rows] == [True, True, True, True]
assert model.resolve_stations(RESOLVE_CASES[2])[0]["lower"] == "soft_rock"
def _write_copy(root: Path) -> None:
"""E01 동결 복사본 꼴(copy_manifest.json + db.json cross_sections) — 측점은 여기서만 읽음."""
source = root / "E01_StabRegister" / "source"
source.mkdir(parents=True)
(source / "copy_manifest.json").write_text("{}", encoding="utf-8")
rows = [
{"chainage_m": 20.0, "data": json.dumps({"design": {"ground_type": "ripping_rock"}})},
{"chainage_m": 0.0, "data": {"design": {"ground_type": "soil"}}},
{
"chainage_m": 60.0,
"data": {"design": {"ground_type": "ripping_rock", "rock_boundary_offset_m": -0.5}},
},
{
"chainage_m": 40.0,
"data": {"design": {"ground_type": "blasting_rock", "rock_boundary_offset_m": -0.8}},
},
]
(source / "db.json").write_text(json.dumps({"cross_sections": rows}), encoding="utf-8")
@pytest.fixture
def project(tmp_path, monkeypatch):
async def fake_project(_project_id):
return tmp_path, {"company_id": 1, "project_kind": "STABILITY"}
monkeypatch.setattr(router, "_project", fake_project)
_write_copy(tmp_path)
return tmp_path
PID = "9872e9a7-e483-427b-a6b2-72cdbc063bc3"
SESSION = {"role": "USER", "company_id": 1}
def test_저장_다시읽음_초기화(project):
from uuid import UUID
pid = UUID(PID)
first = asyncio.run(router.get_ground(pid, SESSION))
assert first["saved"] is False and first["editable"] is True
assert first["ground"]["stations"] == STATIONS
assert model.initial_path(project).is_file() and not model.ground_path(project).is_file()
ground = copy.deepcopy(first["ground"])
ground["materials"].append(
{
"id": "user_1",
"name": " 점토층 ",
"gamma_t": 16.5,
"gamma_sat": 17.5,
"c": 20,
"phi": 18,
"water": "soil",
"extra": 1,
}
)
ground["materials"][0]["c"] = 12.0
ground["zones"] = [
{"from_m": 0, "to_m": 30, "soil": "user_1", "rock": None, "soil_thickness_m": 1.2}
]
ground["safety_factors"]["wet"]["cut"] = 1.3
ground["stations"] = [] # 브라우저 값은 안 받음
asyncio.run(router.put_ground(pid, router.GroundRequest(ground=ground)))
stored = json.loads(model.ground_path(project).read_text(encoding="utf-8"))
assert stored["materials"][-1] == {
"id": "user_1",
"name": "점토층",
"gamma_t": 16.5,
"gamma_sat": 17.5,
"c": 20,
"phi": 18,
"water": "soil",
}
assert stored["materials"][0]["c"] == 12.0
assert stored["safety_factors"]["wet"]["cut"] == 1.3
assert stored["stations"] == STATIONS
assert set(stored) == set(model.GROUND_KEYS)
for item in stored["materials"]: # 엔진이 바로 읽음
types.material(item)
again = asyncio.run(router.get_ground(pid, SESSION))
assert again["saved"] is True and again["ground"] == stored
bad = copy.deepcopy(stored)
bad["materials"][0]["phi"] = 75
with pytest.raises(HTTPException) as caught:
asyncio.run(router.put_ground(pid, router.GroundRequest(ground=bad)))
assert caught.value.status_code == 422
assert caught.value.detail["errors"] == [{"path": "materials.0.phi", "code": "range"}]
assert json.loads(model.ground_path(project).read_text(encoding="utf-8")) == stored
reset = asyncio.run(router.reset_ground(pid))
initial = json.loads(model.initial_path(project).read_text(encoding="utf-8"))
assert reset["ground"] == initial == model.build_initial(DEFAULTS, STATIONS)
assert json.loads(model.ground_path(project).read_text(encoding="utf-8")) == initial
def test_복사본_없으면_측점_없음(tmp_path):
assert router._b06_stations(tmp_path) == []
_write_copy(tmp_path)
assert router._b06_stations(tmp_path) == STATIONS # 누가 차례 · 암 경계 없으면 −0.5
def test_다른_회사는_보기만(project):
from uuid import UUID
body = asyncio.run(router.get_ground(UUID(PID), {"role": "USER", "company_id": 2}))
assert body["editable"] is False
admin = asyncio.run(router.get_ground(UUID(PID), {"role": "SYSTEM_ADMIN", "company_id": None}))
assert admin["editable"] is True
@@ -0,0 +1,252 @@
"""534-3(양식) — E05 안정성평가 보고서 양식 `resources/master_template/report/stab_report.json`.
계약 검사(키 · 서식 번호 · A1 · 병합 · 쪽 나눔) + 가짜 값 사본으로 수식 · 인쇄 HTML · PDF 쪽 수 · xlsx 되읽기.
"""
import copy
import io
import json
import re
import shutil
from pathlib import Path
import pytest
from openpyxl import load_workbook
from common_util import common_util_spreadsheet as spreadsheet
from common_util.common_util_print_pdf import PrintError
from common_util.common_util_svg_png import svgs_to_pngs
from common_util.common_util_workbook_xlsx import workbook_to_xlsx
ROOT = Path(__file__).resolve().parents[2]
FORM = ROOT / "resources/master_template/report/stab_report.json"
BLOCK = 28 # 대표단면 한 쪽 = 28 행
SVG = (
'<svg xmlns="http://www.w3.org/2000/svg" width="712" height="533">'
'<rect width="712" height="533" fill="#cde"/></svg>'
)
BOOK_KEYS = {"종류", "판", "열", "기본", "서식", "시트", "활성", "계산값"}
SHEET_KEYS = {
"id", "이름", "칸", "병합", "열", "행", "틀고정", "보기", "기본", "comments", "필터",
"조건부서식", "유효성", "이름정의", "숨김", "탭색", "연결", "인쇄", "그림",
} # fmt: skip
STYLE_KEYS = {
"글꼴", "크기", "굵게", "기울임", "밑줄", "취소선", "글자색", "채움", "가로", "세로", "줄바꿈",
"테두리", "형식",
} # fmt: skip
PRINT_KEYS = {"용지", "방향", "배율", "맞춤", "여백", "머리행", "쪽나눔"}
A1 = re.compile(r"^([A-Z]{1,3})(\d+)$")
NAMES = ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약"]
needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def _form() -> dict:
return json.loads(FORM.read_text(encoding="utf-8"))
def _rc(a1: str) -> tuple[int, int]:
m = A1.match(a1)
assert m, a1
col = 0
for ch in m.group(1):
col = col * 26 + ord(ch) - 64
return int(m.group(2)), col
def _span(text: str) -> tuple[int, int, int, int]:
a, _, b = text.partition(":")
(r0, c0), (r1, c1) = _rc(a), _rc(b or a)
assert r0 <= r1 and c0 <= c1, text
return r0, c0, r1, c1
def _contract(book: dict) -> None:
assert set(book) <= BOOK_KEYS and book["종류"] == "통합문서" and book["판"] == 1
assert book["서식"][0] == {}
for st in book["서식"]:
assert set(st) <= STYLE_KEYS
ids = [s["id"] for s in book["시트"]]
assert len(set(ids)) == len(ids) and book["활성"] in ids
for sheet in book["시트"]:
assert set(sheet) <= SHEET_KEYS
for a1, cell in sheet["칸"].items():
_rc(a1)
assert set(cell) <= {"값", "식", "서식"} and not ({"값", "식"} <= set(cell))
assert 0 <= cell.get("서식", 0) < len(book["서식"])
used: set[tuple[int, int]] = set()
for m in sheet.get("병합", []):
r0, c0, r1, c1 = _span(m)
box = {(r, c) for r in range(r0, r1 + 1) for c in range(c0, c1 + 1)}
assert len(box) > 1 and not (used & box), m
used |= box
pr = sheet.get("인쇄", {})
assert set(pr) <= PRINT_KEYS
assert pr.get("쪽나눔", []) == sorted(set(pr.get("쪽나눔", [])))
if "머리행" in pr:
assert 1 <= pr["머리행"][0] <= pr["머리행"][1]
for fig in sheet.get("그림", []):
_span(fig["닻"])
assert fig["열쇠"]
for rule in sheet.get("조건부서식", []):
assert rule["조건"]["종류"] == "글포함" and rule["범위"]
# ── 가짜 값 사본 ─────────────────────────────────────────────────────────────
def _shift(a1: str, dr: int) -> str:
m = A1.match(a1)
return f"{m.group(1)}{int(m.group(2)) + dr}"
def _fill(book: dict, stations: int, sections: int) -> tuple[dict, int]:
"""측점표 `stations` 줄 · 대표단면 `sections` 쪽 · 3D 두 줄을 채운 사본 · N.G 수."""
book = copy.deepcopy(book)
by = {s["이름"]: s for s in book["시트"]}
s3, s4, s5, s6 = by["입력조건"], by["측점표"], by["대표단면"], by["3D요약"]
for r, v in ((12, 1.5), (13, 1.2), (14, 1.1)):
s3["칸"][f"B{r}"]["값"] = v
ng = 0
for i in range(stations):
r = 6 + i
bad = i % 7 == 3
ng += bad
fs = [1.32 if bad else 1.8 + i / 100, 1.5 + i / 100, 1.3 + i / 100]
for letter, v, st in (
("A", f"No.{i}", 7), ("B", 20.0 * i, 17), ("C", "깎기" if i % 2 else "쌓기", 7),
("D", fs[0], 8), ("E", fs[1], 8), ("F", fs[2], 8),
("G", 1.5, 8), ("H", 1.2, 8), ("I", 1.1, 8), ("J", "N.G" if bad else "O.K", 7),
): # fmt: skip
s4["칸"][f"{letter}{r}"] = {"값": v, "서식": st}
for k in range(1, sections): # 첫 덩이를 아래로 복사
dr = BLOCK * k
for a1, cell in list(s5["칸"].items()):
if int(A1.match(a1).group(2)) <= BLOCK:
s5["칸"][_shift(a1, dr)] = copy.deepcopy(cell)
for m in list(s5["병합"]):
if int(re.search(r"\d+", m).group()) <= BLOCK:
s5["병합"].append(":".join(_shift(p, dr) for p in m.split(":")))
for r, info in list(s5["행"].items()):
if int(r) <= BLOCK:
s5["행"][str(int(r) + dr)] = dict(info)
a, _, b = s5["그림"][0]["닻"].partition(":")
s5["그림"].append({"열쇠": f"fig/s{k}.png", "닻": f"{_shift(a, dr)}:{_shift(b, dr)}"})
s5["인쇄"]["쪽나눔"].append(BLOCK * (k + 1))
s5["그림"][0]["열쇠"] = "fig/s0.png"
for r, row in (
(20, ("1구간", "No.0", "No.9", 1.9, 1.7, 1200)),
(21, ("2구간", "No.9", "No.19", 1.4, 1.5, 800)),
):
for letter, v in zip("ABCDEG", row):
s6["칸"][f"{letter}{r}"] = {"값": v, "서식": s6["칸"][f"{letter}20"]["서식"]}
s6["칸"][f"F{r}"] = {"식": f'IF(E{r}>0,D{r}/E{r},"")', "서식": s6["칸"]["F20"]["서식"]}
s6["칸"][f"H{r}"] = {"값": "O.K" if r == 20 else "N.G", "서식": s6["칸"]["H20"]["서식"]}
return book, ng
# ── 시험 ─────────────────────────────────────────────────────────────────────
def test_양식_계약():
book = _form()
_contract(book)
assert [s["이름"] for s in book["시트"]] == NAMES
assert [s["id"] for s in book["시트"]] == ["s1", "s2", "s3", "s4", "s5", "s6"]
def test_양식_인쇄_쪽나눔_머리행_그림():
by = {s["이름"]: s for s in _form()["시트"]}
for sheet in by.values():
pr = sheet["인쇄"]
assert pr["용지"] == "A4" and pr["맞춤"]["폭"] == 1 and pr["여백"]
assert by["측점표"]["인쇄"]["방향"] == "landscape" and by["측점표"]["인쇄"]["머리행"] == [4, 5]
assert by["대표단면"]["인쇄"]["쪽나눔"] == [BLOCK]
figs = {n: len(s.get("그림", [])) for n, s in by.items()}
assert figs == {"표지": 0, "개요": 0, "입력조건": 0, "측점표": 0, "대표단면": 1, "3D요약": 1}
# N.G 빨강 조건부 서식 — 측점표 판정 열 · 서식 표에 N.G 칸 서식(빌더가 인쇄 · xlsx 용으로 직접 씀)
assert by["측점표"]["조건부서식"][0]["범위"][0]["c0"] == 9
assert any(st.get("글자색") == "#C00000" and st.get("채움") for st in _form()["서식"])
# 안전율 칸 숫자 형식 0.00
assert _form()["서식"][by["측점표"]["칸"]["D6"]["서식"]]["형식"] == "0.00"
@needs_node
def test_빈_양식도_풀림():
book = _form()
vals = spreadsheet.recalc_workbooks([book])[0]
assert vals["s2"]["B5"] == {"수": "0"}
assert vals["s2"]["B10"] == {"글": "-"}
assert vals["s2"]["B16"] == {"글": "-"}
assert vals["s2"]["B17"] == {"글": "해석 결과 없음"}
assert not [v for s in vals.values() for v in s.values() if "오류" in v]
@needs_node
def test_채운_양식_수식():
book, ng = _fill(_form(), 40, 2)
s2 = spreadsheet.recalc_workbooks([book])[0]["s2"]
assert s2["B5"] == {"수": "40"} and s2["B6"] == {"수": "780"}
assert s2["B15"] == {"수": str(ng)} and ng == 6
assert (
s2["B10"] == {"수": "1.32"} and s2["D10"] == {"글": "N.G"} and s2["E10"] == {"글": "No.3"}
)
assert s2["D11"] == {"글": "O.K"} and s2["B11"] == {"수": "1.5"}
assert s2["B16"] == {"글": "N.G"}
assert s2["B17"]["글"].startswith("대상 측점 40개소 중 6개소가")
s6 = spreadsheet.recalc_workbooks([book])[0]["s6"]
assert s6["F21"] == {"수": "0.933333333333333"} or s6["F21"]["수"].startswith("0.93333")
@pytest.fixture(scope="module")
def png() -> bytes:
try:
return svgs_to_pngs([SVG])[0]
except PrintError as error:
pytest.skip(f"브라우저 없음 — {error}")
@needs_node
def test_견본_pdf_쪽수_머리행(png):
book, _ = _fill(_form(), 40, 2)
figs = {"fig/s0.png": png, "fig/s1.png": png, "fig/3d_band.png": png}
try:
pdf = spreadsheet.workbook_pdf(book, figs)
except PrintError as error:
pytest.skip(f"브라우저 없음 — {error}")
from pypdf import PdfReader
pages = PdfReader(io.BytesIO(pdf)).pages
texts = [p.extract_text() for p in pages]
stab = [i for i, t in enumerate(texts) if "측점별 안전율" in t or "산정 안전율" in t]
assert len(stab) >= 2 and all("산정 안전율" in texts[i] for i in stab) # 머리 줄 쪽마다
assert (
texts[0].count("비탈면 안정성 검토 보고서") == 1
and "문헌 추정치 · 현장 시험 없음" in texts[0]
)
sections = [i for i, t in enumerate(texts) if "대표 단면" in t and "안전율 Fs" in t]
assert len(sections) == 2 # 단면마다 한 쪽
# 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 2 + 3D 1
assert len(pages) == 1 + 1 + 1 + len(stab) + 2 + 1
assert all(float(pages[i].mediabox.width) > float(pages[i].mediabox.height) for i in stab)
html = spreadsheet.print_htmls([{"book": book, "figures": {k: png for k in figs}}])[0]
assert html.count("<img ") == 3 # 단면 2 + 3D 띠 1
@needs_node
def test_견본_xlsx_되읽기(png):
book, _ = _fill(_form(), 40, 2)
figs = {"fig/s0.png": png, "fig/s1.png": png, "fig/3d_band.png": png}
wb = load_workbook(io.BytesIO(workbook_to_xlsx(book, figs)))
assert wb.sheetnames == NAMES
ws = wb["측점표"]
assert ws.print_title_rows == "$4:$5" and ws.page_setup.orientation == "landscape"
assert ws["D6"].number_format == "0.00" and ws["J6"].value in ("O.K", "N.G")
assert "A4:A5" in {str(m) for m in ws.merged_cells.ranges}
sec = wb["대표단면"]
assert [b.id for b in sec.row_breaks.brk] == [BLOCK, BLOCK * 2] and len(sec._images) == 2
assert len(wb["3D요약"]._images) == 1 and wb["3D요약"].print_title_rows == "$19:$19"
assert wb["개요"]["B5"].value.startswith("=COUNTA(")
assert wb["표지"]["B14"].value == "문헌 추정치 · 현장 시험 없음"
def test_자리표_시트_모두_적음():
slots = (FORM.parent / "stab_report_slots.md").read_text(encoding="utf-8")
for sheet in _form()["시트"]:
assert f"## {sheet['id']} {sheet['이름']}" in slots
+1 -1
View File
@@ -34,6 +34,6 @@
"@kninnug/constrainautor": ["config/node_modules/@kninnug/constrainautor"],
}
},
"include": ["A00_Common", "A0*", "B0*", "B1*", "M0*", "D0*", "E0*", "Z0*", "ui_template", "config/**/*", "common_util"],
"include": ["A00_Common", "A0*", "B0*", "B1*", "M0*", "D0*/**/*", "E0*/**/*", "Z0*", "ui_template", "config/**/*", "common_util"],
"exclude": ["0_old", "node_modules", "venv", "dist"]
}
+2
View File
@@ -30,6 +30,7 @@ import { ui_locales_b5 } from "./ui_template_locale_b5";
import { ui_locales_m1 } from "./ui_template_locale_m1";
import { ui_locales_m2 } from "./ui_template_locale_m2";
import { ui_locales_de } from "./ui_template_locale_de";
import { ui_locales_e } from "./ui_template_locale_e";
/** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */
export const LANGUAGES = ["ko", "en"] as const;
@@ -68,6 +69,7 @@ export const ui_locales = {
...ui_locales_m1,
...ui_locales_m2,
...ui_locales_de,
...ui_locales_e,
} as const;
export type LocaleKey = keyof typeof ui_locales;
+11
View File
@@ -99,4 +99,15 @@ export const ui_locales_de = {
],
DE_Reg_Submit: ["등록", "Register"],
DE_Reg_Success: ["프로젝트를 만들었습니다.", "Project created."],
/* --- 대시보드 표 · 받은 예정노선(528-2) --- */
DE_Kind_Column: ["종류", "Kind"],
DE_Assigned_Title: ["받은 예정노선", "Assigned Planned Routes"],
DE_Assigned_Year: ["연도", "Year"],
DE_Assigned_State: ["연결 상태", "Link"],
DE_Assigned_Linked: ["연결됨", "Linked"],
DE_Assigned_Unlinked: ["미연결", "Not linked"],
DE_Assigned_Update: ["업데이트 있음", "Update available"],
DE_Assigned_View: ["보기", "View"],
DE_Assigned_Link: ["연결", "Link"],
} as const satisfies Record<string, LocaleEntry>;
+116
View File
@@ -0,0 +1,116 @@
/* =============================================================================
* ui_template_locale_e.ts
* 다국어 사전 — E 안정성평가 페이지 본문(E02 지반 조건 · 요구 347-2 · 3 · 4)
*
* 신규 문구는 해당 섹션 최하단에 추가. 단계 막대 · 등록 화면 문구는 ui_template_locale_de.ts.
* ========================================================================== */
import type { LocaleEntry } from "./ui_template_locale_common";
export const ui_locales_e = {
/* --- E02 지반 조건 — 카드 --- */
E02_Card_Materials: ["지반물성치", "Material Properties"],
E02_Card_Zones: ["층 두께 · 구간 덮어쓰기", "Layer Thickness · Zone Overrides"],
E02_Card_Stations: ["측점별 적용 지층", "Layers per Station"],
E02_Card_Safety: ["기준안전율", "Required Factors of Safety"],
E02_Card_Loads: ["노면 하중 · 지하수 · 지진", "Surcharge · Groundwater · Seismic"],
E02_Note: [
"문헌 추정치 · 현장 시험 없음 — 값은 바꿀 수 있습니다. 칸에 마우스를 올리면 근거가 보입니다.",
"Literature estimates without field tests — values are editable. Hover a cell for its source.",
],
/* --- 물성 표 --- */
E02_Col_Name: ["지층", "Layer"],
E02_Col_GammaT: ["γt (kN/m³)", "γt (kN/m³)"],
E02_Col_GammaSat: ["γsat (kN/m³)", "γsat (kN/m³)"],
E02_Col_C: ["c (kPa)", "c (kPa)"],
E02_Col_Phi: ["φ (°)", "φ (°)"],
E02_Col_Water: ["지하수 무리", "Water group"],
E02_Water_Soil: ["토사 · 풍화암", "Soil · weathered rock"],
E02_Water_Rock: ["암반", "Rock"],
E02_Btn_AddLayer: ["지층 추가", "Add layer"],
E02_Btn_Defaults: ["기본값", "Defaults"],
E02_Btn_Remove: ["지움", "Remove"],
E02_NewLayer: ["새 지층", "New layer"],
E02_InUse: [
"쓰이는 지층은 지울 수 없습니다(B06 지반 연결 · 구간).",
"A layer in use cannot be removed (B06 mapping · zones).",
],
E02_LabelMap: ["B06 지반 → 지층", "B06 ground → layer"],
E02_Label_soil: ["토사 (경계 위)", "Soil (above boundary)"],
E02_Label_ripping_rock: ["리핑암 (경계 아래)", "Ripping rock (below)"],
E02_Label_blasting_rock: ["발파암 (경계 아래)", "Blasting rock (below)"],
E02_Label_fill: ["성토체", "Embankment"],
/* --- 구간 · 측점 표 --- */
E02_Col_From: ["시작 (m)", "From (m)"],
E02_Col_To: ["끝 (m)", "To (m)"],
E02_Col_Upper: ["위 지층", "Upper layer"],
E02_Col_Lower: ["아래 지층", "Lower layer"],
E02_Col_Thickness: ["토층 두께 (m)", "Soil thickness (m)"],
E02_Col_Station: ["측점", "Station"],
E02_Col_GroundType: ["B06 지반", "B06 ground"],
E02_Col_B06Thickness: ["B06 토층 두께 (m)", "B06 thickness (m)"],
E02_Btn_AddZone: ["구간 추가", "Add zone"],
E02_Zone_Keep: ["B06 그대로", "Keep B06"],
E02_Zone_Empty: [
"구간 덮어쓰기 없음 — 모든 측점이 B06 값 그대로입니다.",
"No zone overrides — every station keeps its B06 values.",
],
E02_Zone_Hint: [
"구간 안 측점(양 끝 포함)은 고른 지층 · 두께로 바뀝니다. 빈 칸은 B06 그대로 · 뒤 줄이 앞 줄을 이깁니다.",
"Stations inside a zone (ends included) take its layers and thickness. Blank keeps B06 · later rows win.",
],
E02_Stations_Empty: ["B06 측점이 없습니다.", "No B06 stations."],
E02_Ground_soil: ["토사", "Soil"],
E02_Ground_ripping_rock: ["리핑암", "Ripping rock"],
E02_Ground_blasting_rock: ["발파암", "Blasting rock"],
E02_AllUpper: ["전부 위 지층", "All upper layer"],
/* --- 기준안전율 --- */
E02_Case_dry: ["건기", "Dry"],
E02_Case_wet: ["우기", "Wet"],
E02_Case_seismic: ["지진 시", "Seismic"],
E02_Case_short_term: ["단기", "Short-term"],
E02_Slope_cut: ["깎기", "Cut"],
E02_Slope_fill: ["쌓기", "Fill"],
E02_Slope_soft: ["쌓기(연약지반)", "Fill (soft ground)"],
/* --- 하중 · 지하수 · 지진 --- */
E02_Surcharge: ["노면 하중 (kPa)", "Road surcharge (kPa)"],
E02_Water_dry: ["건기 지하수", "Dry groundwater"],
E02_Water_wet_soil: ["우기 · 토사 · 풍화암", "Wet · soil · weathered rock"],
E02_Water_wet_rock: ["우기 · 암반", "Wet · rock"],
E02_Rule_none: ["없음", "None"],
E02_Rule_surface: ["지표 포화", "Saturated to surface"],
E02_Rule_half_height: ["비탈 높이 ½ 포화", "Saturated to ½ height"],
E02_Seismic: ["지진 검토", "Seismic check"],
E02_Kh: ["수평지진계수 kh", "Horizontal coefficient kh"],
/* --- 상태 · 알림 --- */
E02_Unsaved: ["저장 안 된 변경이 있습니다.", "There are unsaved changes."],
E02_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."],
E02_Saved: ["저장했습니다.", "Saved."],
E02_ResetDone: ["초기값으로 되돌렸습니다.", "Restored to initial values."],
E02_ResetConfirm: [
"초기값(생성 때 기본값 + B06 측점)으로 되돌릴까요? 저장한 값이 사라집니다.",
"Restore initial values (defaults + B06 at creation)? Saved values will be lost.",
],
E02_Invalid: ["입력값을 확인하세요 — 틀린 칸", "Check inputs — invalid cells"],
E02_DefaultIs: ["기본값", "Default"],
E02_UserLayer: ["사용자 지층 — 출처 없음", "User layer — no source"],
E02_NoProject: ["프로젝트를 먼저 고르세요.", "Pick a project first."],
/* --- 입력 검사(코드 = 파이썬 check_ground 와 같은 낱말) --- */
E02_Err_number: ["숫자를 넣으세요", "Enter a number"],
E02_Err_range: ["범위 밖", "Out of range"],
E02_Err_order: ["끝은 시작보다 커야 합니다", "End must exceed start"],
E02_Err_sat_lt_t: ["γsat 는 γt 이상이어야 합니다", "γsat must be ≥ γt"],
E02_Err_name: ["이름은 1~30자", "Name must be 1–30 characters"],
E02_Err_name_dup: ["같은 이름이 있습니다", "Duplicate name"],
E02_Err_id_dup: ["지층 번호가 겹칩니다", "Duplicate layer id"],
E02_Err_enum: ["값을 고르세요", "Pick a value"],
E02_Err_ref: ["지층을 고르세요", "Pick a layer"],
E02_Err_empty: ["지층이 하나는 있어야 합니다", "At least one layer is required"],
E02_Err_type: ["꼴이 틀립니다", "Invalid shape"],
} as const satisfies Record<string, LocaleEntry>;