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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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"]))
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user