From 244a2bf8eb424e997f10c58c39f71a19f4df3c10 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 19:57:13 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(528-2):=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=ED=91=9C=20=EC=A2=85=EB=A5=98=20=EC=97=B4=20=C2=B7?= =?UTF-8?q?=20=EC=A2=85=EB=A5=98=EB=B3=84=20=EB=8B=A8=EA=B3=84=20=EB=A7=89?= =?UTF-8?q?=EB=8C=80=20=C2=B7=20=E3=80=8C=EB=B0=9B=EC=9D=80=20=EC=98=88?= =?UTF-8?q?=EC=A0=95=EB=85=B8=EC=84=A0=E3=80=8D=20=EA=B5=AC=EC=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프로젝트 표 「종류」 열(임도설계 · 예정노선 · 안정성평가 · 사방설계 · 칸 없음 = 임도) - 단계 막대 = 종류별(ROAD B03~B09 그대로 · PLAN D02~D04 · STABILITY E02~E05) · 누름 = 그 단계 화면 - 받은 예정노선(회사관리자 · 사용자 · 받은 것 있을 때만) = 예정노선 · 연도 · 연결 상태 · 업데이트 배지 · [보기] = D02 · [연결] 자리만(531-3) - E01 [등록] 고르기 칸 setError 없음 오류 걷음(목록 비면 단추 꺼짐) - tsconfig D0* · E0* → 폴더 안까지(D0*/**/*) — 앞 판은 폴더 안 파일을 안 잡아 타입 검사가 빠졌음 Co-Authored-By: Claude Opus 5.5 --- A00_Common/group_workflow.ts | 8 +++ B01_Dashboard/B01_Dashboard_Api_Fetch.ts | 2 + .../B01_Dashboard_UI_AssignedPlans.ts | 70 +++++++++++++++++++ B01_Dashboard/B01_Dashboard_UI_Page.ts | 21 +++++- B01_Dashboard/B01_Dashboard_UI_Projects.ts | 22 +++++- E01_StabRegister/E01_StabRegister_UI_Page.ts | 3 +- tsconfig.json | 2 +- ui_template/ui_template_locale_de.ts | 11 +++ 8 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 B01_Dashboard/B01_Dashboard_UI_AssignedPlans.ts diff --git a/A00_Common/group_workflow.ts b/A00_Common/group_workflow.ts index a056c69cf..0fa1d7e21 100644 --- a/A00_Common/group_workflow.ts +++ b/A00_Common/group_workflow.ts @@ -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, diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index a4d41a743..bc3fbb138 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -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; diff --git a/B01_Dashboard/B01_Dashboard_UI_AssignedPlans.ts b/B01_Dashboard/B01_Dashboard_UI_AssignedPlans.ts new file mode 100644 index 000000000..f5ab0a7ad --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_AssignedPlans.ts @@ -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 { + 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, + ); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index ce3d5bdee..770e60116 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -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 { allJoinRequests: [], auditLogs: [], resources: null, + assignedPlans: [], adminCompanyId: null, }; await loadRoleData(state); @@ -134,12 +142,14 @@ async function loadRoleData(state: DashboardState): Promise { }); 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[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(), diff --git a/B01_Dashboard/B01_Dashboard_UI_Projects.ts b/B01_Dashboard/B01_Dashboard_UI_Projects.ts index a07fe8071..313aeac3f 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Projects.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Projects.ts @@ -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]), }, ); } diff --git a/E01_StabRegister/E01_StabRegister_UI_Page.ts b/E01_StabRegister/E01_StabRegister_UI_Page.ts index 6425818f2..461f78469 100644 --- a/E01_StabRegister/E01_StabRegister_UI_Page.ts +++ b/E01_StabRegister/E01_StabRegister_UI_Page.ts @@ -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 }, diff --git a/tsconfig.json b/tsconfig.json index c2c101de7..2964abb60 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"] } diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index 93d459f31..3f4f1d1f7 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -62,4 +62,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; From f0c5232f8006a8b2aa9bf8674084d0764a28d6ca Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 20:00:44 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(532-3):=20E02=20=EC=A7=80=EB=B0=98=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=E2=80=94=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B8=20=C2=B7=20=EB=AC=BC=EC=84=B1=20=ED=91=9C(?= =?UTF-8?q?=EC=A7=80=EC=B8=B5=20=EC=B6=94=EA=B0=80=20=C2=B7=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80)=20=C2=B7=20=EA=B5=AC=EA=B0=84=20=EB=8D=AE=EC=96=B4?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20=C2=B7=20KDS=20=EA=B8=B0=EC=A4=80=EC=95=88?= =?UTF-8?q?=EC=A0=84=EC=9C=A8=20=C2=B7=20=EB=85=B8=EB=A9=B4=20=ED=95=98?= =?UTF-8?q?=EC=A4=91=20=C2=B7=20=EC=A7=80=ED=95=98=EC=88=98=20=C2=B7=20?= =?UTF-8?q?=EC=A7=80=EC=A7=84=20=C2=B7=20=EC=A0=80=EC=9E=A5=20=C2=B7=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기본값 정본 resources/master_template/stability/ground_defaults.json — 지층 여섯 · 값마다 출처(07 표 · 절) · KDS 11 70 05 표 4.3-1~3 · 노면 10 kPa · 지하수 규칙 · 지진 끔 · 범위 - 07 원문 대조 — 성토 φ 25 → 28(07 §4 종합 28~30 아래값) · 나머지 범위 안 - API GET · PUT /api/projects/{id}/stab/ground · POST …/reset — 작업본 E02_StabGround/ground.json · 초기값 initial_snapshot/E02_StabGround/ground.json(E 생성 직후 기본값 + B06 측점 암 경계) · 측점은 서버 값 - 검사 · 측점 적용 = 파이썬 · TS 짝(E02_StabGround_Model.py ↔ logic/E02_StabGround_Check.ts) · 거울 시험 - 화면 — 카드 넷 · 칸 위 근거 · 기본값 툴팁 · 틀린 칸 표시 · sessionStorage 캐시 · 자동저장 없음 - test_528 E01 시험 — 생성 뒤 E02 초기값 부름 확인 더함 Co-Authored-By: Claude Opus 5.5 --- E01_StabRegister/E01_StabRegister_Router.py | 2 + E02_StabGround/E02_StabGround_Model.py | 230 +++++++++ E02_StabGround/E02_StabGround_Router.py | 156 ++++++ E02_StabGround/E02_StabGround_UI_Page.ts | 244 +++++++++ E02_StabGround/E02_StabGround_UI_Style.css | 153 ++++++ E02_StabGround/E02_StabGround_UI_Tables.ts | 488 ++++++++++++++++++ E02_StabGround/draft/E02_StabGround_Route.ts | 7 +- E02_StabGround/logic/E02_StabGround_Check.ts | 186 +++++++ main.py | 2 + .../stability/ground_defaults.json | 73 +++ resources/tester/test_528_project_groups.py | 6 + resources/tester/test_532_3_e02_ground.py | 285 ++++++++++ ui_template/ui_template_locale.ts | 2 + ui_template/ui_template_locale_e.ts | 116 +++++ 14 files changed, 1946 insertions(+), 4 deletions(-) create mode 100644 E02_StabGround/E02_StabGround_Model.py create mode 100644 E02_StabGround/E02_StabGround_Router.py create mode 100644 E02_StabGround/E02_StabGround_UI_Page.ts create mode 100644 E02_StabGround/E02_StabGround_UI_Style.css create mode 100644 E02_StabGround/E02_StabGround_UI_Tables.ts create mode 100644 E02_StabGround/logic/E02_StabGround_Check.ts create mode 100644 resources/master_template/stability/ground_defaults.json create mode 100644 resources/tester/test_532_3_e02_ground.py create mode 100644 ui_template/ui_template_locale_e.ts diff --git a/E01_StabRegister/E01_StabRegister_Router.py b/E01_StabRegister/E01_StabRegister_Router.py index 0d89c5822..2c7caa5f3 100644 --- a/E01_StabRegister/E01_StabRegister_Router.py +++ b/E01_StabRegister/E01_StabRegister_Router.py @@ -12,6 +12,7 @@ from pydantic import BaseModel, Field from common_util.common_util_auth import require_company from common_util.common_util_project_kind import create_kind_project from config.config_db import get_db_pool +from E02_StabGround.E02_StabGround_Router import seed_initial router = APIRouter(prefix="/api/e01", tags=["E01_StabRegister"]) @@ -84,4 +85,5 @@ async def post_stab_project( project_year=source["project_year"], source_project_id=source["project_id"], ) + await seed_initial(project_id) # E02 초기값(기본값 + B06 측점) return {"project_id": project_id} diff --git a/E02_StabGround/E02_StabGround_Model.py b/E02_StabGround/E02_StabGround_Model.py new file mode 100644 index 000000000..4db6ce336 --- /dev/null +++ b/E02_StabGround/E02_StabGround_Model.py @@ -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 diff --git a/E02_StabGround/E02_StabGround_Router.py b/E02_StabGround/E02_StabGround_Router.py new file mode 100644 index 000000000..aa25b9ace --- /dev/null +++ b/E02_StabGround/E02_StabGround_Router.py @@ -0,0 +1,156 @@ +"""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 B05_Profile.B05_Profile_Repository import get_latest_route +from common_util.common_util_auth import verify_session +from common_util.common_util_json import atomic_write_json +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 E02_StabGround import E02_StabGround_Model as model + +router = APIRouter(prefix="/api/projects", tags=["E02 Stab Ground"]) + + +class GroundRequest(BaseModel): + ground: dict[str, Any] + + +async def _b06_stations(connection: aiomysql.Connection, project_ids: list[str]) -> list[dict]: + """측점 · 지반 라벨 · 토층 두께(암 측점 = 암 경계 깊이 · 토사 = 없음). + + 자기 복사본(532-1)이 먼저 · 없으면 원본 임도.""" + for project_id in project_ids: + route = await get_latest_route(connection, UUID(project_id)) + if not route: + continue + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT chainage_m, data FROM cross_sections WHERE route_id = %s" + " ORDER BY chainage_m", + (route["id"],), + ) + rows = await cursor.fetchall() + stations = [] + for chainage, data in rows: + data = json.loads(data) if isinstance(data, str) else 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(chainage), 3), + "ground_type": ground_type, + "soil_thickness_m": round(abs(float(offset)), 3) if rock else None, + } + ) + if stations: + return stations + return [] + + +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, source_project_id 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 + + +async def ensure_initial(project_root: Path, project_id: str, source_id: str | None) -> dict: + """초기값이 없을 때만 한 번 — 기본값 + B06 측점(E 생성 직후 · 옛 E 프로젝트는 첫 GET).""" + path = model.initial_path(project_root) + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + async with get_db_pool().acquire() as connection: + ids = [project_id] + ([source_id] if source_id else []) + stations = await _b06_stations(connection, ids) + ground = model.build_initial(model.load_defaults(), stations) + atomic_write_json(path, ground) + return ground + + +async def seed_initial(project_id: str) -> None: + """E01 생성 직후 부름 — 초기값 한 벌.""" + root, project = await _project(UUID(project_id)) + await ensure_initial(root, project_id, project["source_project_id"]) + + +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 = await ensure_initial(root, str(project_id), project["source_project_id"]) + 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, project = 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 await ensure_initial( + root, str(project_id), project["source_project_id"] + ) + 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, project = await _project(project_id) + initial = await ensure_initial(root, str(project_id), project["source_project_id"]) + atomic_write_json(model.ground_path(root), initial) + return {"ground": initial} diff --git a/E02_StabGround/E02_StabGround_UI_Page.ts b/E02_StabGround/E02_StabGround_UI_Page.ts new file mode 100644 index 000000000..8bb750ca9 --- /dev/null +++ b/E02_StabGround/E02_StabGround_UI_Page.ts @@ -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 { + 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(".is-invalid")) { + node.classList.remove("is-invalid"); + delete node.dataset.error; + } + for (const error of errors) { + const node = main.querySelector(`[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 { + 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 { + 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"), + }), + ); + } + })(); +} diff --git a/E02_StabGround/E02_StabGround_UI_Style.css b/E02_StabGround/E02_StabGround_UI_Style.css new file mode 100644 index 000000000..7a5602ff1 --- /dev/null +++ b/E02_StabGround/E02_StabGround_UI_Style.css @@ -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); +} diff --git a/E02_StabGround/E02_StabGround_UI_Tables.ts b/E02_StabGround/E02_StabGround_UI_Tables.ts new file mode 100644 index 000000000..1614b16a7 --- /dev/null +++ b/E02_StabGround/E02_StabGround_UI_Tables.ts @@ -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 = (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 }; +} diff --git a/E02_StabGround/draft/E02_StabGround_Route.ts b/E02_StabGround/draft/E02_StabGround_Route.ts index 13e8f23bb..1bf53ce98 100644 --- a/E02_StabGround/draft/E02_StabGround_Route.ts +++ b/E02_StabGround/draft/E02_StabGround_Route.ts @@ -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, }; diff --git a/E02_StabGround/logic/E02_StabGround_Check.ts b/E02_StabGround/logic/E02_StabGround_Check.ts new file mode 100644 index 000000000..99a4baf75 --- /dev/null +++ b/E02_StabGround/logic/E02_StabGround_Check.ts @@ -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; + +export interface GroundDefaults extends Ground { + note: string; + limits: Limits; + sources: Record; +} + +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; +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, + }; + }); +} diff --git a/main.py b/main.py index b7b8c5dce..79205adca 100644 --- a/main.py +++ b/main.py @@ -76,6 +76,7 @@ from D01_PlanRegister.D01_PlanRegister_Router import dashboard_router as d01_das from D01_PlanRegister.D01_PlanRegister_Router import router as d01_plan_register_router from D03_PlanAnalysis.D03_PlanAnalysis_Router import router as d03_plan_analysis_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 @@ -478,6 +479,7 @@ app.include_router(tiles_router, dependencies=protected_with_company) app.include_router(b05_route_router, dependencies=protected_with_company) app.include_router(b05_route_lifecycle_router, dependencies=protected_with_company) app.include_router(d03_plan_analysis_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) diff --git a/resources/master_template/stability/ground_defaults.json b/resources/master_template/stability/ground_defaults.json new file mode 100644 index 000000000..65ceb41f6 --- /dev/null +++ b/resources/master_template/stability/ground_defaults.json @@ -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 · 물량 가름용 값) — 토사 측점은 경계 없음" + } +} diff --git a/resources/tester/test_528_project_groups.py b/resources/tester/test_528_project_groups.py index b88997378..9f226bcf2 100644 --- a/resources/tester/test_528_project_groups.py +++ b/resources/tester/test_528_project_groups.py @@ -138,7 +138,12 @@ def _e01(monkeypatch, rows): return "stab-id" monkeypatch.setattr(e01, "_sources", fake_sources) + + async def fake_seed(project_id): + seen["seeded"] = project_id + monkeypatch.setattr(e01, "create_kind_project", fake_create) + 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)) @@ -157,6 +162,7 @@ def test_e01_requires_b06(monkeypatch): assert call() == {"project_id": "stab-id"} 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): diff --git a/resources/tester/test_532_3_e02_ground.py b/resources/tester/test_532_3_e02_ground.py new file mode 100644 index 000000000..bb04b9a17 --- /dev/null +++ b/resources/tester/test_532_3_e02_ground.py @@ -0,0 +1,285 @@ +# -*- 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" + + +@pytest.fixture +def project(tmp_path, monkeypatch): + async def fake_project(_project_id): + return tmp_path, {"company_id": 1, "project_kind": "STABILITY", "source_project_id": None} + + async def fake_stations(_connection, _ids): + return copy.deepcopy(STATIONS) + + class FakePool: + def acquire(self): + class Ctx: + async def __aenter__(self): + return None + + async def __aexit__(self, *_): + return False + + return Ctx() + + monkeypatch.setattr(router, "_project", fake_project) + monkeypatch.setattr(router, "_b06_stations", fake_stations) + monkeypatch.setattr(router, "get_db_pool", lambda: FakePool()) + 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_다른_회사는_보기만(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 diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 042d85609..e0ade76ab 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -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; diff --git a/ui_template/ui_template_locale_e.ts b/ui_template/ui_template_locale_e.ts new file mode 100644 index 000000000..1fad828b7 --- /dev/null +++ b/ui_template/ui_template_locale_e.ts @@ -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; From bc933a61cf40b9ccb3fa4921630e9b945b15595f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 20:01:57 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(E05):=20=EC=95=88=EC=A0=95=EC=84=B1?= =?UTF-8?q?=ED=8F=89=EA=B0=80=20=EB=B3=B4=EA=B3=A0=EC=84=9C=20=EC=96=91?= =?UTF-8?q?=EC=8B=9D(=EC=8B=9C=ED=8A=B8=20=EC=97=AC=EC=84=AF)=20=C2=B7=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=ED=91=9C=20=C2=B7=20=EC=8B=9C=ED=97=98=20(53?= =?UTF-8?q?4-3=20=EC=96=91=EC=8B=9D=20=EB=B6=80=EB=B6=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../master_template/report/stab_report.json | 2606 +++++++++++++++++ .../report/stab_report_slots.md | 81 + .../tester/test_534_3_stab_report_form.py | 252 ++ 3 files changed, 2939 insertions(+) create mode 100644 resources/master_template/report/stab_report.json create mode 100644 resources/master_template/report/stab_report_slots.md create mode 100644 resources/tester/test_534_3_stab_report_form.py diff --git a/resources/master_template/report/stab_report.json b/resources/master_template/report/stab_report.json new file mode 100644 index 000000000..9ded03dad --- /dev/null +++ b/resources/master_template/report/stab_report.json @@ -0,0 +1,2606 @@ +{ + "종류": "통합문서", + "판": 1, + "열": "stab_report", + "기본": { + "글꼴": "맑은 고딕", + "크기": 10, + "행높이": 18 + }, + "서식": [ + {}, + { + "크기": 18, + "굵게": true, + "가로": "center", + "세로": "center" + }, + { + "크기": 10, + "글자색": "#555555", + "가로": "center", + "세로": "center", + "줄바꿈": true + }, + { + "크기": 11, + "굵게": true, + "세로": "center", + "테두리": { + "아래": { + "선": "medium" + } + } + }, + { + "굵게": true, + "채움": "#F2F2F2", + "가로": "center", + "세로": "center", + "줄바꿈": true, + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + }, + { + "가로": "left", + "세로": "center", + "줄바꿈": true, + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + }, + { + "굵게": true, + "채움": "#DDEBF7", + "가로": "center", + "세로": "center", + "줄바꿈": true, + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + }, + { + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + }, + { + "가로": "center", + "세로": "center", + "형식": "0.00", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + }, + { + "가로": "center", + "세로": "center", + "형식": "0", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + }, + { + "굵게": true, + "글자색": "#C00000", + "채움": "#FFC7CE", + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + }, + { + "굵게": true, + "글자색": "#006100", + "채움": "#E2EFDA", + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + }, + { + "크기": 12, + "굵게": true, + "글자색": "#C00000", + "가로": "center", + "세로": "center", + "줄바꿈": true + }, + { + "가로": "center", + "세로": "center", + "형식": "#,##0.0", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + }, + { + "가로": "center", + "세로": "center", + "형식": "#,##0", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + }, + { + "가로": "left", + "세로": "top", + "줄바꿈": true, + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + }, + { + "가로": "center", + "세로": "center", + "형식": "0.0", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + }, + { + "가로": "center", + "세로": "center", + "형식": "#,##0.00", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + } + ], + "시트": [ + { + "id": "s1", + "이름": "표지", + "칸": { + "B4": { + "값": "비탈면 안정성 검토 보고서", + "서식": 1 + }, + "C4": { + "서식": 1 + }, + "D4": { + "서식": 1 + }, + "E4": { + "서식": 1 + }, + "B5": { + "값": "임도 노선 비탈면 안정해석 결과", + "서식": 2 + }, + "C5": { + "서식": 2 + }, + "D5": { + "서식": 2 + }, + "E5": { + "서식": 2 + }, + "B8": { + "값": "사업명", + "서식": 4 + }, + "C8": { + "서식": 5 + }, + "D8": { + "서식": 5 + }, + "E8": { + "서식": 5 + }, + "B9": { + "값": "노선명", + "서식": 4 + }, + "C9": { + "서식": 5 + }, + "D9": { + "서식": 5 + }, + "E9": { + "서식": 5 + }, + "B10": { + "값": "발주처", + "서식": 4 + }, + "C10": { + "서식": 5 + }, + "D10": { + "서식": 5 + }, + "E10": { + "서식": 5 + }, + "B11": { + "값": "작성일", + "서식": 4 + }, + "C11": { + "서식": 5 + }, + "D11": { + "서식": 5 + }, + "E11": { + "서식": 5 + }, + "B12": { + "값": "작성자", + "서식": 4 + }, + "C12": { + "서식": 5 + }, + "D12": { + "서식": 5 + }, + "E12": { + "서식": 5 + }, + "B14": { + "값": "문헌 추정치 · 현장 시험 없음", + "서식": 12 + }, + "C14": { + "서식": 12 + }, + "D14": { + "서식": 12 + }, + "E14": { + "서식": 12 + }, + "B15": { + "값": "※ 지반 물성치는 문헌 · 기준값을 적용한 추정치이며 현장 조사 · 시험 성과를 반영하지 않았음.", + "서식": 2 + }, + "C15": { + "서식": 2 + }, + "D15": { + "서식": 2 + }, + "E15": { + "서식": 2 + } + }, + "병합": [ + "B4:E4", + "B5:E5", + "C8:E8", + "C9:E9", + "C10:E10", + "C11:E11", + "C12:E12", + "B14:E14", + "B15:E15" + ], + "열": { + "A": { + "폭": 4 + }, + "B": { + "폭": 18 + }, + "C": { + "폭": 22 + }, + "D": { + "폭": 22 + }, + "E": { + "폭": 22 + }, + "F": { + "폭": 4 + } + }, + "행": { + "4": { + "높이": 60 + }, + "5": { + "높이": 24 + }, + "14": { + "높이": 30 + }, + "15": { + "높이": 30 + }, + "8": { + "높이": 30 + }, + "9": { + "높이": 30 + }, + "10": { + "높이": 30 + }, + "11": { + "높이": 30 + }, + "12": { + "높이": 30 + } + }, + "보기": { + "눈금선": false + }, + "인쇄": { + "용지": "A4", + "방향": "portrait", + "맞춤": { + "폭": 1, + "높이": 0 + }, + "여백": { + "위": 25, + "아래": 20, + "왼": 20, + "오른": 20 + } + } + }, + { + "id": "s2", + "이름": "개요", + "칸": { + "A1": { + "값": "개요 및 판정 요약", + "서식": 1 + }, + "B1": { + "서식": 1 + }, + "C1": { + "서식": 1 + }, + "D1": { + "서식": 1 + }, + "E1": { + "서식": 1 + }, + "F1": { + "서식": 1 + }, + "A2": { + "값": "수치는 「측점표」 시트를 따라감 — 해석이 바뀌면 [보고서 갱신] 뒤 새 값으로 바뀜", + "서식": 2 + }, + "B2": { + "서식": 2 + }, + "C2": { + "서식": 2 + }, + "D2": { + "서식": 2 + }, + "E2": { + "서식": 2 + }, + "F2": { + "서식": 2 + }, + "A4": { + "값": "1. 해석 대상", + "서식": 3 + }, + "B4": { + "서식": 3 + }, + "C4": { + "서식": 3 + }, + "D4": { + "서식": 3 + }, + "E4": { + "서식": 3 + }, + "F4": { + "서식": 3 + }, + "A5": { + "값": "대상 측점 수", + "서식": 4 + }, + "B5": { + "식": "COUNTA(측점표!A6:A20000)", + "서식": 9 + }, + "C5": { + "값": "개소", + "서식": 7 + }, + "A6": { + "값": "해석 구간 연장", + "서식": 4 + }, + "B6": { + "식": "IF(B5=0,0,MAX(측점표!B6:B20000)-MIN(측점표!B6:B20000))", + "서식": 13 + }, + "C6": { + "값": "m", + "서식": 7 + }, + "A8": { + "값": "2. 조건별 최소 안전율", + "서식": 3 + }, + "B8": { + "서식": 3 + }, + "C8": { + "서식": 3 + }, + "D8": { + "서식": 3 + }, + "E8": { + "서식": 3 + }, + "F8": { + "서식": 3 + }, + "A9": { + "값": "조건", + "서식": 6 + }, + "B9": { + "값": "최소 안전율", + "서식": 6 + }, + "C9": { + "값": "기준 안전율", + "서식": 6 + }, + "D9": { + "값": "판정", + "서식": 6 + }, + "E9": { + "값": "최소 안전율 측점", + "서식": 6 + }, + "F9": { + "서식": 6 + }, + "A10": { + "값": "건기", + "서식": 4 + }, + "B10": { + "식": "IF(COUNT(측점표!D6:D20000)=0,\"-\",MIN(측점표!D6:D20000))", + "서식": 8 + }, + "C10": { + "식": "입력조건!B12", + "서식": 8 + }, + "D10": { + "식": "IF(B10=\"-\",\"-\",IF(B10>=C10,\"O.K\",\"N.G\"))", + "서식": 7 + }, + "E10": { + "식": "IF(B10=\"-\",\"-\",INDEX(측점표!A6:A20000,MATCH(B10,측점표!D6:D20000,0)))", + "서식": 7 + }, + "F10": { + "서식": 7 + }, + "A11": { + "값": "우기", + "서식": 4 + }, + "B11": { + "식": "IF(COUNT(측점표!E6:E20000)=0,\"-\",MIN(측점표!E6:E20000))", + "서식": 8 + }, + "C11": { + "식": "입력조건!B13", + "서식": 8 + }, + "D11": { + "식": "IF(B11=\"-\",\"-\",IF(B11>=C11,\"O.K\",\"N.G\"))", + "서식": 7 + }, + "E11": { + "식": "IF(B11=\"-\",\"-\",INDEX(측점표!A6:A20000,MATCH(B11,측점표!E6:E20000,0)))", + "서식": 7 + }, + "F11": { + "서식": 7 + }, + "A12": { + "값": "지진", + "서식": 4 + }, + "B12": { + "식": "IF(COUNT(측점표!F6:F20000)=0,\"-\",MIN(측점표!F6:F20000))", + "서식": 8 + }, + "C12": { + "식": "입력조건!B14", + "서식": 8 + }, + "D12": { + "식": "IF(B12=\"-\",\"-\",IF(B12>=C12,\"O.K\",\"N.G\"))", + "서식": 7 + }, + "E12": { + "식": "IF(B12=\"-\",\"-\",INDEX(측점표!A6:A20000,MATCH(B12,측점표!F6:F20000,0)))", + "서식": 7 + }, + "F12": { + "서식": 7 + }, + "A14": { + "값": "3. 종합 판정", + "서식": 3 + }, + "B14": { + "서식": 3 + }, + "C14": { + "서식": 3 + }, + "D14": { + "서식": 3 + }, + "E14": { + "서식": 3 + }, + "F14": { + "서식": 3 + }, + "A15": { + "값": "기준 미달(N.G) 측점 수", + "서식": 4 + }, + "B15": { + "식": "COUNTIF(측점표!J6:J20000,\"N.G\")", + "서식": 9 + }, + "C15": { + "값": "개소", + "서식": 7 + }, + "A16": { + "값": "종합 판정", + "서식": 4 + }, + "B16": { + "식": "IF(B5=0,\"-\",IF(B15=0,\"O.K\",\"N.G\"))", + "서식": 7 + }, + "A17": { + "값": "판정 글", + "서식": 4 + }, + "B17": { + "식": "IF(B5=0,\"해석 결과 없음\",\"대상 측점 \"&B5&\"개소 중 \"&B15&\"개소가 기준 안전율에 미달함. \"&IF(B15=0,\"전 구간이 기준을 만족함.\",\"미달 구간은 보강 대책 검토가 필요함.\"))", + "서식": 15 + }, + "C17": { + "서식": 15 + }, + "D17": { + "서식": 15 + }, + "E17": { + "서식": 15 + }, + "F17": { + "서식": 15 + }, + "B18": { + "서식": 15 + }, + "C18": { + "서식": 15 + }, + "D18": { + "서식": 15 + }, + "E18": { + "서식": 15 + }, + "F18": { + "서식": 15 + }, + "B19": { + "서식": 15 + }, + "C19": { + "서식": 15 + }, + "D19": { + "서식": 15 + }, + "E19": { + "서식": 15 + }, + "F19": { + "서식": 15 + }, + "A18": { + "서식": 4 + }, + "A19": { + "서식": 4 + }, + "A21": { + "값": "4. 의견", + "서식": 3 + }, + "B21": { + "서식": 3 + }, + "C21": { + "서식": 3 + }, + "D21": { + "서식": 3 + }, + "E21": { + "서식": 3 + }, + "F21": { + "서식": 3 + }, + "A22": { + "서식": 15 + }, + "B22": { + "서식": 15 + }, + "C22": { + "서식": 15 + }, + "D22": { + "서식": 15 + }, + "E22": { + "서식": 15 + }, + "F22": { + "서식": 15 + }, + "A23": { + "서식": 15 + }, + "B23": { + "서식": 15 + }, + "C23": { + "서식": 15 + }, + "D23": { + "서식": 15 + }, + "E23": { + "서식": 15 + }, + "F23": { + "서식": 15 + }, + "A24": { + "서식": 15 + }, + "B24": { + "서식": 15 + }, + "C24": { + "서식": 15 + }, + "D24": { + "서식": 15 + }, + "E24": { + "서식": 15 + }, + "F24": { + "서식": 15 + }, + "A25": { + "서식": 15 + }, + "B25": { + "서식": 15 + }, + "C25": { + "서식": 15 + }, + "D25": { + "서식": 15 + }, + "E25": { + "서식": 15 + }, + "F25": { + "서식": 15 + }, + "A26": { + "서식": 15 + }, + "B26": { + "서식": 15 + }, + "C26": { + "서식": 15 + }, + "D26": { + "서식": 15 + }, + "E26": { + "서식": 15 + }, + "F26": { + "서식": 15 + }, + "A27": { + "서식": 15 + }, + "B27": { + "서식": 15 + }, + "C27": { + "서식": 15 + }, + "D27": { + "서식": 15 + }, + "E27": { + "서식": 15 + }, + "F27": { + "서식": 15 + }, + "A28": { + "서식": 15 + }, + "B28": { + "서식": 15 + }, + "C28": { + "서식": 15 + }, + "D28": { + "서식": 15 + }, + "E28": { + "서식": 15 + }, + "F28": { + "서식": 15 + }, + "A29": { + "서식": 15 + }, + "B29": { + "서식": 15 + }, + "C29": { + "서식": 15 + }, + "D29": { + "서식": 15 + }, + "E29": { + "서식": 15 + }, + "F29": { + "서식": 15 + }, + "A30": { + "서식": 15 + }, + "B30": { + "서식": 15 + }, + "C30": { + "서식": 15 + }, + "D30": { + "서식": 15 + }, + "E30": { + "서식": 15 + }, + "F30": { + "서식": 15 + } + }, + "병합": [ + "A1:F1", + "A2:F2", + "A4:F4", + "A8:F8", + "E9:F9", + "E10:F10", + "E11:F11", + "E12:F12", + "A14:F14", + "B17:F19", + "A17:A19", + "A21:F21", + "A22:F30" + ], + "열": { + "A": { + "폭": 20 + }, + "B": { + "폭": 16 + }, + "C": { + "폭": 16 + }, + "D": { + "폭": 14 + }, + "E": { + "폭": 16 + }, + "F": { + "폭": 16 + } + }, + "행": { + "1": { + "높이": 36 + }, + "2": { + "높이": 20 + }, + "9": { + "높이": 22 + }, + "5": { + "높이": 22 + }, + "6": { + "높이": 22 + }, + "10": { + "높이": 22 + }, + "11": { + "높이": 22 + }, + "12": { + "높이": 22 + }, + "15": { + "높이": 22 + }, + "16": { + "높이": 22 + }, + "17": { + "높이": 22 + }, + "18": { + "높이": 22 + }, + "19": { + "높이": 22 + } + }, + "보기": { + "눈금선": false + }, + "인쇄": { + "용지": "A4", + "방향": "portrait", + "맞춤": { + "폭": 1, + "높이": 0 + }, + "여백": { + "위": 20, + "아래": 15, + "왼": 15, + "오른": 15 + } + }, + "조건부서식": [ + { + "id": "ng_d", + "범위": [ + { + "r0": 9, + "c0": 3, + "r1": 11, + "c1": 3 + } + ], + "조건": { + "종류": "글포함", + "글": "N.G" + }, + "스타일": { + "채움": "#FFC7CE", + "글자색": "#C00000", + "굵게": true + } + }, + { + "id": "ng_b16", + "범위": [ + { + "r0": 15, + "c0": 1, + "r1": 15, + "c1": 1 + } + ], + "조건": { + "종류": "글포함", + "글": "N.G" + }, + "스타일": { + "채움": "#FFC7CE", + "글자색": "#C00000", + "굵게": true + } + } + ] + }, + { + "id": "s3", + "이름": "입력조건", + "칸": { + "A1": { + "값": "입력 조건", + "서식": 1 + }, + "B1": { + "서식": 1 + }, + "C1": { + "서식": 1 + }, + "D1": { + "서식": 1 + }, + "E1": { + "서식": 1 + }, + "F1": { + "서식": 1 + }, + "G1": { + "서식": 1 + }, + "A2": { + "값": "문헌 추정치 · 현장 시험 없음 — 지반 물성치는 문헌 · 기준값 적용", + "서식": 2 + }, + "B2": { + "서식": 2 + }, + "C2": { + "서식": 2 + }, + "D2": { + "서식": 2 + }, + "E2": { + "서식": 2 + }, + "F2": { + "서식": 2 + }, + "G2": { + "서식": 2 + }, + "A4": { + "값": "1. 해석 방법", + "서식": 3 + }, + "B4": { + "서식": 3 + }, + "C4": { + "서식": 3 + }, + "D4": { + "서식": 3 + }, + "E4": { + "서식": 3 + }, + "F4": { + "서식": 3 + }, + "G4": { + "서식": 3 + }, + "A5": { + "값": "해석 기법", + "서식": 4 + }, + "B5": { + "서식": 5 + }, + "C5": { + "서식": 5 + }, + "D5": { + "서식": 5 + }, + "E5": { + "서식": 5 + }, + "F5": { + "서식": 5 + }, + "G5": { + "서식": 5 + }, + "A6": { + "값": "활동면 탐색", + "서식": 4 + }, + "B6": { + "서식": 5 + }, + "C6": { + "서식": 5 + }, + "D6": { + "서식": 5 + }, + "E6": { + "서식": 5 + }, + "F6": { + "서식": 5 + }, + "G6": { + "서식": 5 + }, + "A7": { + "값": "3D 해석", + "서식": 4 + }, + "B7": { + "서식": 5 + }, + "C7": { + "서식": 5 + }, + "D7": { + "서식": 5 + }, + "E7": { + "서식": 5 + }, + "F7": { + "서식": 5 + }, + "G7": { + "서식": 5 + }, + "A8": { + "값": "적용 기준", + "서식": 4 + }, + "B8": { + "서식": 5 + }, + "C8": { + "서식": 5 + }, + "D8": { + "서식": 5 + }, + "E8": { + "서식": 5 + }, + "F8": { + "서식": 5 + }, + "G8": { + "서식": 5 + }, + "A10": { + "값": "2. 기준 안전율", + "서식": 3 + }, + "B10": { + "서식": 3 + }, + "C10": { + "서식": 3 + }, + "D10": { + "서식": 3 + }, + "E10": { + "서식": 3 + }, + "F10": { + "서식": 3 + }, + "G10": { + "서식": 3 + }, + "A11": { + "값": "조건", + "서식": 6 + }, + "B11": { + "값": "기준 안전율", + "서식": 6 + }, + "C11": { + "값": "비고", + "서식": 6 + }, + "D11": { + "서식": 6 + }, + "E11": { + "서식": 6 + }, + "F11": { + "서식": 6 + }, + "G11": { + "서식": 6 + }, + "A12": { + "값": "건기", + "서식": 4 + }, + "B12": { + "서식": 8 + }, + "C12": { + "서식": 5 + }, + "D12": { + "서식": 5 + }, + "E12": { + "서식": 5 + }, + "F12": { + "서식": 5 + }, + "G12": { + "서식": 5 + }, + "A13": { + "값": "우기", + "서식": 4 + }, + "B13": { + "서식": 8 + }, + "C13": { + "서식": 5 + }, + "D13": { + "서식": 5 + }, + "E13": { + "서식": 5 + }, + "F13": { + "서식": 5 + }, + "G13": { + "서식": 5 + }, + "A14": { + "값": "지진", + "서식": 4 + }, + "B14": { + "서식": 8 + }, + "C14": { + "서식": 5 + }, + "D14": { + "서식": 5 + }, + "E14": { + "서식": 5 + }, + "F14": { + "서식": 5 + }, + "G14": { + "서식": 5 + }, + "A16": { + "값": "3. 지하수 규칙", + "서식": 3 + }, + "B16": { + "서식": 3 + }, + "C16": { + "서식": 3 + }, + "D16": { + "서식": 3 + }, + "E16": { + "서식": 3 + }, + "F16": { + "서식": 3 + }, + "G16": { + "서식": 3 + }, + "A17": { + "값": "건기", + "서식": 4 + }, + "B17": { + "서식": 5 + }, + "C17": { + "서식": 5 + }, + "D17": { + "서식": 5 + }, + "E17": { + "서식": 5 + }, + "F17": { + "서식": 5 + }, + "G17": { + "서식": 5 + }, + "A18": { + "값": "우기", + "서식": 4 + }, + "B18": { + "서식": 5 + }, + "C18": { + "서식": 5 + }, + "D18": { + "서식": 5 + }, + "E18": { + "서식": 5 + }, + "F18": { + "서식": 5 + }, + "G18": { + "서식": 5 + }, + "A19": { + "값": "지진 시", + "서식": 4 + }, + "B19": { + "서식": 5 + }, + "C19": { + "서식": 5 + }, + "D19": { + "서식": 5 + }, + "E19": { + "서식": 5 + }, + "F19": { + "서식": 5 + }, + "G19": { + "서식": 5 + }, + "A21": { + "값": "4. 노면 하중", + "서식": 3 + }, + "B21": { + "서식": 3 + }, + "C21": { + "서식": 3 + }, + "D21": { + "서식": 3 + }, + "E21": { + "서식": 3 + }, + "F21": { + "서식": 3 + }, + "G21": { + "서식": 3 + }, + "A22": { + "값": "노면 하중", + "서식": 4 + }, + "B22": { + "서식": 16 + }, + "C22": { + "값": "kPa", + "서식": 7 + }, + "A24": { + "값": "5. 지반 물성치", + "서식": 3 + }, + "B24": { + "서식": 3 + }, + "C24": { + "서식": 3 + }, + "D24": { + "서식": 3 + }, + "E24": { + "서식": 3 + }, + "F24": { + "서식": 3 + }, + "G24": { + "서식": 3 + }, + "A25": { + "값": "지층", + "서식": 6 + }, + "B25": { + "값": "적용 구간", + "서식": 6 + }, + "C25": { + "값": "γt (kN/m³)", + "서식": 6 + }, + "D25": { + "값": "γsat (kN/m³)", + "서식": 6 + }, + "E25": { + "값": "c (kPa)", + "서식": 6 + }, + "F25": { + "값": "φ (°)", + "서식": 6 + }, + "G25": { + "값": "출처", + "서식": 6 + }, + "A26": { + "서식": 7 + }, + "B26": { + "서식": 7 + }, + "C26": { + "서식": 16 + }, + "D26": { + "서식": 16 + }, + "E26": { + "서식": 16 + }, + "F26": { + "서식": 16 + }, + "G26": { + "서식": 5 + } + }, + "병합": [ + "A1:G1", + "A2:G2", + "A4:G4", + "B5:G5", + "B6:G6", + "B7:G7", + "B8:G8", + "A10:G10", + "C11:G11", + "C12:G12", + "C13:G13", + "C14:G14", + "A16:G16", + "B17:G17", + "B18:G18", + "B19:G19", + "A21:G21", + "A24:G24" + ], + "열": { + "A": { + "폭": 18 + }, + "B": { + "폭": 16 + }, + "C": { + "폭": 12 + }, + "D": { + "폭": 12 + }, + "E": { + "폭": 12 + }, + "F": { + "폭": 12 + }, + "G": { + "폭": 28 + } + }, + "행": { + "1": { + "높이": 36 + }, + "2": { + "높이": 20 + }, + "25": { + "높이": 32 + }, + "5": { + "높이": 22 + }, + "6": { + "높이": 22 + }, + "7": { + "높이": 22 + }, + "8": { + "높이": 22 + }, + "12": { + "높이": 22 + }, + "13": { + "높이": 22 + }, + "14": { + "높이": 22 + }, + "17": { + "높이": 22 + }, + "18": { + "높이": 22 + }, + "19": { + "높이": 22 + } + }, + "보기": { + "눈금선": false + }, + "인쇄": { + "용지": "A4", + "방향": "portrait", + "맞춤": { + "폭": 1, + "높이": 0 + }, + "여백": { + "위": 15, + "아래": 15, + "왼": 10, + "오른": 10 + }, + "머리행": [ + 25, + 25 + ] + } + }, + { + "id": "s4", + "이름": "측점표", + "칸": { + "A1": { + "값": "측점별 안전율", + "서식": 1 + }, + "B1": { + "서식": 1 + }, + "C1": { + "서식": 1 + }, + "D1": { + "서식": 1 + }, + "E1": { + "서식": 1 + }, + "F1": { + "서식": 1 + }, + "G1": { + "서식": 1 + }, + "H1": { + "서식": 1 + }, + "I1": { + "서식": 1 + }, + "J1": { + "서식": 1 + }, + "A2": { + "값": "판정 — 모든 조건의 안전율이 기준 이상이면 O.K · 하나라도 미달이면 N.G", + "서식": 2 + }, + "B2": { + "서식": 2 + }, + "C2": { + "서식": 2 + }, + "D2": { + "서식": 2 + }, + "E2": { + "서식": 2 + }, + "F2": { + "서식": 2 + }, + "G2": { + "서식": 2 + }, + "H2": { + "서식": 2 + }, + "I2": { + "서식": 2 + }, + "J2": { + "서식": 2 + }, + "A4": { + "값": "측점", + "서식": 6 + }, + "A5": { + "서식": 6 + }, + "B4": { + "값": "누가거리 (m)", + "서식": 6 + }, + "B5": { + "서식": 6 + }, + "C4": { + "값": "구분", + "서식": 6 + }, + "C5": { + "서식": 6 + }, + "D4": { + "값": "산정 안전율 Fs", + "서식": 6 + }, + "E4": { + "서식": 6 + }, + "F4": { + "서식": 6 + }, + "G4": { + "값": "기준 안전율", + "서식": 6 + }, + "H4": { + "서식": 6 + }, + "I4": { + "서식": 6 + }, + "J4": { + "값": "판정", + "서식": 6 + }, + "J5": { + "서식": 6 + }, + "D5": { + "값": "건기", + "서식": 6 + }, + "E5": { + "값": "우기", + "서식": 6 + }, + "F5": { + "값": "지진", + "서식": 6 + }, + "G5": { + "값": "건기", + "서식": 6 + }, + "H5": { + "값": "우기", + "서식": 6 + }, + "I5": { + "값": "지진", + "서식": 6 + }, + "A6": { + "서식": 7 + }, + "B6": { + "서식": 17 + }, + "C6": { + "서식": 7 + }, + "D6": { + "서식": 8 + }, + "E6": { + "서식": 8 + }, + "F6": { + "서식": 8 + }, + "G6": { + "서식": 8 + }, + "H6": { + "서식": 8 + }, + "I6": { + "서식": 8 + }, + "J6": { + "서식": 7 + } + }, + "병합": [ + "A1:J1", + "A2:J2", + "A4:A5", + "B4:B5", + "C4:C5", + "D4:F4", + "G4:I4", + "J4:J5" + ], + "열": { + "A": { + "폭": 16 + }, + "B": { + "폭": 13 + }, + "C": { + "폭": 9 + }, + "D": { + "폭": 9 + }, + "E": { + "폭": 9 + }, + "F": { + "폭": 9 + }, + "G": { + "폭": 9 + }, + "H": { + "폭": 9 + }, + "I": { + "폭": 9 + }, + "J": { + "폭": 9 + } + }, + "행": { + "1": { + "높이": 36 + }, + "2": { + "높이": 20 + }, + "3": { + "높이": 8 + }, + "4": { + "높이": 22 + }, + "5": { + "높이": 22 + } + }, + "틀고정": { + "행": 5 + }, + "보기": { + "눈금선": false + }, + "인쇄": { + "용지": "A4", + "방향": "landscape", + "맞춤": { + "폭": 1, + "높이": 0 + }, + "여백": { + "위": 15, + "아래": 15, + "왼": 10, + "오른": 10 + }, + "머리행": [ + 4, + 5 + ] + }, + "조건부서식": [ + { + "id": "ng_j", + "범위": [ + { + "r0": 5, + "c0": 9, + "r1": 19999, + "c1": 9 + } + ], + "조건": { + "종류": "글포함", + "글": "N.G" + }, + "스타일": { + "채움": "#FFC7CE", + "글자색": "#C00000", + "굵게": true + } + } + ] + }, + { + "id": "s5", + "이름": "대표단면", + "칸": { + "A1": { + "값": "대표 단면 ①", + "서식": 3 + }, + "B1": { + "서식": 3 + }, + "C1": { + "서식": 3 + }, + "D1": { + "서식": 3 + }, + "E1": { + "서식": 3 + }, + "F1": { + "서식": 3 + }, + "G1": { + "서식": 3 + }, + "H1": { + "서식": 3 + }, + "A2": { + "값": "선정 사유", + "서식": 4 + }, + "B2": { + "서식": 5 + }, + "C2": { + "서식": 5 + }, + "D2": { + "서식": 5 + }, + "E2": { + "서식": 5 + }, + "F2": { + "서식": 5 + }, + "G2": { + "서식": 5 + }, + "H2": { + "서식": 5 + }, + "A23": { + "값": "조건", + "서식": 6 + }, + "B23": { + "값": "해석 방법", + "서식": 6 + }, + "C23": { + "값": "안전율 Fs", + "서식": 6 + }, + "D23": { + "값": "기준", + "서식": 6 + }, + "E23": { + "값": "판정", + "서식": 6 + }, + "F23": { + "값": "활동면 반경 R (m)", + "서식": 6 + }, + "G23": { + "값": "중심 x (m)", + "서식": 6 + }, + "H23": { + "값": "중심 y (m)", + "서식": 6 + }, + "A24": { + "값": "건기", + "서식": 4 + }, + "B24": { + "서식": 7 + }, + "C24": { + "서식": 8 + }, + "D24": { + "서식": 8 + }, + "E24": { + "서식": 7 + }, + "F24": { + "서식": 13 + }, + "G24": { + "서식": 13 + }, + "H24": { + "서식": 13 + }, + "A25": { + "값": "우기", + "서식": 4 + }, + "B25": { + "서식": 7 + }, + "C25": { + "서식": 8 + }, + "D25": { + "서식": 8 + }, + "E25": { + "서식": 7 + }, + "F25": { + "서식": 13 + }, + "G25": { + "서식": 13 + }, + "H25": { + "서식": 13 + }, + "A26": { + "값": "지진", + "서식": 4 + }, + "B26": { + "서식": 7 + }, + "C26": { + "서식": 8 + }, + "D26": { + "서식": 8 + }, + "E26": { + "서식": 7 + }, + "F26": { + "서식": 13 + }, + "G26": { + "서식": 13 + }, + "H26": { + "서식": 13 + }, + "A27": { + "값": "비고", + "서식": 4 + }, + "B27": { + "서식": 5 + }, + "C27": { + "서식": 5 + }, + "D27": { + "서식": 5 + }, + "E27": { + "서식": 5 + }, + "F27": { + "서식": 5 + }, + "G27": { + "서식": 5 + }, + "H27": { + "서식": 5 + } + }, + "병합": [ + "A1:H1", + "B2:H2", + "B27:H27" + ], + "열": { + "A": { + "폭": 12 + }, + "B": { + "폭": 12 + }, + "C": { + "폭": 12 + }, + "D": { + "폭": 12 + }, + "E": { + "폭": 12 + }, + "F": { + "폭": 12 + }, + "G": { + "폭": 12 + }, + "H": { + "폭": 12 + } + }, + "행": { + "1": { + "높이": 26 + }, + "2": { + "높이": 22 + }, + "3": { + "높이": 20 + }, + "4": { + "높이": 20 + }, + "5": { + "높이": 20 + }, + "6": { + "높이": 20 + }, + "7": { + "높이": 20 + }, + "8": { + "높이": 20 + }, + "9": { + "높이": 20 + }, + "10": { + "높이": 20 + }, + "11": { + "높이": 20 + }, + "12": { + "높이": 20 + }, + "13": { + "높이": 20 + }, + "14": { + "높이": 20 + }, + "15": { + "높이": 20 + }, + "16": { + "높이": 20 + }, + "17": { + "높이": 20 + }, + "18": { + "높이": 20 + }, + "19": { + "높이": 20 + }, + "20": { + "높이": 20 + }, + "21": { + "높이": 20 + }, + "22": { + "높이": 20 + }, + "23": { + "높이": 32 + }, + "24": { + "높이": 22 + }, + "25": { + "높이": 22 + }, + "26": { + "높이": 22 + }, + "27": { + "높이": 22 + }, + "28": { + "높이": 6 + } + }, + "그림": [ + { + "열쇠": "fig/{측점키}.png", + "닻": "A3:H22", + "이름": "대표 단면 그림" + } + ], + "보기": { + "눈금선": false + }, + "인쇄": { + "용지": "A4", + "방향": "portrait", + "맞춤": { + "폭": 1, + "높이": 0 + }, + "여백": { + "위": 15, + "아래": 15, + "왼": 10, + "오른": 10 + }, + "쪽나눔": [ + 28 + ] + }, + "조건부서식": [ + { + "id": "ng_e", + "범위": [ + { + "r0": 23, + "c0": 4, + "r1": 25, + "c1": 4 + } + ], + "조건": { + "종류": "글포함", + "글": "N.G" + }, + "스타일": { + "채움": "#FFC7CE", + "글자색": "#C00000", + "굵게": true + } + } + ] + }, + { + "id": "s6", + "이름": "3D요약", + "칸": { + "A1": { + "값": "3D 결과 요약", + "서식": 1 + }, + "B1": { + "서식": 1 + }, + "C1": { + "서식": 1 + }, + "D1": { + "서식": 1 + }, + "E1": { + "서식": 1 + }, + "F1": { + "서식": 1 + }, + "G1": { + "서식": 1 + }, + "H1": { + "서식": 1 + }, + "A2": { + "값": "3D 최소 안전율은 2D 결과와 대조 — 3D/2D 비가 1 보다 크면 2D 가 안전 쪽", + "서식": 2 + }, + "B2": { + "서식": 2 + }, + "C2": { + "서식": 2 + }, + "D2": { + "서식": 2 + }, + "E2": { + "서식": 2 + }, + "F2": { + "서식": 2 + }, + "G2": { + "서식": 2 + }, + "H2": { + "서식": 2 + }, + "A4": { + "값": "1. 평면 위험 띠", + "서식": 3 + }, + "B4": { + "서식": 3 + }, + "C4": { + "서식": 3 + }, + "D4": { + "서식": 3 + }, + "E4": { + "서식": 3 + }, + "F4": { + "서식": 3 + }, + "G4": { + "서식": 3 + }, + "H4": { + "서식": 3 + }, + "A18": { + "값": "2. 구간별 3D 안전율", + "서식": 3 + }, + "B18": { + "서식": 3 + }, + "C18": { + "서식": 3 + }, + "D18": { + "서식": 3 + }, + "E18": { + "서식": 3 + }, + "F18": { + "서식": 3 + }, + "G18": { + "서식": 3 + }, + "H18": { + "서식": 3 + }, + "A19": { + "값": "구간", + "서식": 6 + }, + "B19": { + "값": "시작 측점", + "서식": 6 + }, + "C19": { + "값": "끝 측점", + "서식": 6 + }, + "D19": { + "값": "3D 최소 Fs", + "서식": 6 + }, + "E19": { + "값": "2D 최소 Fs", + "서식": 6 + }, + "F19": { + "값": "3D/2D 비", + "서식": 6 + }, + "G19": { + "값": "임계 체적 (m³)", + "서식": 6 + }, + "H19": { + "값": "판정", + "서식": 6 + }, + "A20": { + "서식": 7 + }, + "B20": { + "서식": 7 + }, + "C20": { + "서식": 7 + }, + "D20": { + "서식": 8 + }, + "E20": { + "서식": 8 + }, + "F20": { + "식": "IF(E20>0,D20/E20,\"\")", + "서식": 8 + }, + "G20": { + "서식": 14 + }, + "H20": { + "서식": 7 + } + }, + "병합": [ + "A1:H1", + "A2:H2", + "A4:H4", + "A18:H18" + ], + "열": { + "A": { + "폭": 12 + }, + "B": { + "폭": 13 + }, + "C": { + "폭": 13 + }, + "D": { + "폭": 11 + }, + "E": { + "폭": 11 + }, + "F": { + "폭": 10 + }, + "G": { + "폭": 13 + }, + "H": { + "폭": 9 + } + }, + "행": { + "1": { + "높이": 36 + }, + "2": { + "높이": 20 + }, + "4": { + "높이": 22 + }, + "18": { + "높이": 22 + }, + "19": { + "높이": 32 + }, + "5": { + "높이": 20 + }, + "6": { + "높이": 20 + }, + "7": { + "높이": 20 + }, + "8": { + "높이": 20 + }, + "9": { + "높이": 20 + }, + "10": { + "높이": 20 + }, + "11": { + "높이": 20 + }, + "12": { + "높이": 20 + }, + "13": { + "높이": 20 + }, + "14": { + "높이": 20 + }, + "15": { + "높이": 20 + }, + "16": { + "높이": 20 + } + }, + "그림": [ + { + "열쇠": "fig/3d_band.png", + "닻": "A5:H16", + "이름": "평면 위험 띠" + } + ], + "보기": { + "눈금선": false + }, + "인쇄": { + "용지": "A4", + "방향": "portrait", + "맞춤": { + "폭": 1, + "높이": 0 + }, + "여백": { + "위": 15, + "아래": 15, + "왼": 10, + "오른": 10 + }, + "머리행": [ + 19, + 19 + ] + }, + "조건부서식": [ + { + "id": "ng_h", + "범위": [ + { + "r0": 19, + "c0": 7, + "r1": 19999, + "c1": 7 + } + ], + "조건": { + "종류": "글포함", + "글": "N.G" + }, + "스타일": { + "채움": "#FFC7CE", + "글자색": "#C00000", + "굵게": true + } + } + ] + } + ], + "활성": "s1" +} diff --git a/resources/master_template/report/stab_report_slots.md b/resources/master_template/report/stab_report_slots.md new file mode 100644 index 000000000..3fb11f4c7 --- /dev/null +++ b/resources/master_template/report/stab_report_slots.md @@ -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 행 서식을 줄마다 복사. diff --git a/resources/tester/test_534_3_stab_report_form.py b/resources/tester/test_534_3_stab_report_form.py new file mode 100644 index 000000000..89ac63e32 --- /dev/null +++ b/resources/tester/test_534_3_stab_report_form.py @@ -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 = ( + '' + '' +) +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(" Date: Sun, 25 Oct 2026 20:07:14 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(E03):=202D=20=EC=A7=80=EC=B8=B5=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EB=B9=8C=EB=8D=94=20=C2=B7=20=ED=95=9C?= =?UTF-8?q?=EA=B3=84=ED=8F=89=ED=98=95=20=EC=86=94=EB=B2=84(Fellenius=20?= =?UTF-8?q?=C2=B7=20Bishop=20=C2=B7=20Janbu=20=EB=B3=B4=EC=A0=95=20=C2=B7?= =?UTF-8?q?=20Spencer)=20=C2=B7=20=EC=9B=90=ED=98=B8=20=C2=B7=20=EB=B9=84?= =?UTF-8?q?=EC=9B=90=ED=98=B8=20=ED=83=90=EC=83=89=20(532-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모델: B06 횡단(지반선 · 설계선 · 암 경계 · 성토체 · 벽) → 지층 폴리곤 · 지하수 · 노면 하중 · 비탈별 방향 · 탐색 한계 - 솔버: 한 벌 절편 식(λ = 0 Bishop · Janbu / Spencer 모멘트 · 힘 동시) · 간극수압 none · r_u · 지하수위선 · kh · 상재하중 · 인장균열 - 탐색: Entry & Exit · Grid & Radius · 차분진화 다듬기 · 블록 씨앗 + 꼭짓점 국부 최적화 + Nelder-Mead - 출력: 방법별 최소 안전율 · 임계면 좌표 · 절편표 · 등안전율 격자 · 수렴 정보 - 시험: Slide2 검증 예제 12 문제(ACADS 1a~4 · F&K · Loukidis · Arai & Tagyo · Yamagami & Ueta) ±3 % · 면적 = B06 Co-Authored-By: Claude Opus 5.5 --- E03_StabSection/E03_StabSection_Engine_LEM.py | 508 ++++++++++++++ .../E03_StabSection_Engine_Model.py | 293 ++++++++ .../E03_StabSection_Engine_Search.py | 634 ++++++++++++++++++ .../E03_StabSection_Engine_Station.py | 75 +++ .../tester/test_532_2_stab_lem_benchmarks.py | 348 ++++++++++ .../tester/test_532_2_stab_section_model.py | 155 +++++ 6 files changed, 2013 insertions(+) create mode 100644 E03_StabSection/E03_StabSection_Engine_LEM.py create mode 100644 E03_StabSection/E03_StabSection_Engine_Model.py create mode 100644 E03_StabSection/E03_StabSection_Engine_Search.py create mode 100644 E03_StabSection/E03_StabSection_Engine_Station.py create mode 100644 resources/tester/test_532_2_stab_lem_benchmarks.py create mode 100644 resources/tester/test_532_2_stab_section_model.py diff --git a/E03_StabSection/E03_StabSection_Engine_LEM.py b/E03_StabSection/E03_StabSection_Engine_LEM.py new file mode 100644 index 000000000..b68ec84ba --- /dev/null +++ b/E03_StabSection/E03_StabSection_Engine_LEM.py @@ -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 diff --git a/E03_StabSection/E03_StabSection_Engine_Model.py b/E03_StabSection/E03_StabSection_Engine_Model.py new file mode 100644 index 000000000..c706dca2c --- /dev/null +++ b/E03_StabSection/E03_StabSection_Engine_Model.py @@ -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) diff --git a/E03_StabSection/E03_StabSection_Engine_Search.py b/E03_StabSection/E03_StabSection_Engine_Search.py new file mode 100644 index 000000000..3ce607bb3 --- /dev/null +++ b/E03_StabSection/E03_StabSection_Engine_Search.py @@ -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 diff --git a/E03_StabSection/E03_StabSection_Engine_Station.py b/E03_StabSection/E03_StabSection_Engine_Station.py new file mode 100644 index 000000000..e6f9438dd --- /dev/null +++ b/E03_StabSection/E03_StabSection_Engine_Station.py @@ -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] diff --git a/resources/tester/test_532_2_stab_lem_benchmarks.py b/resources/tester/test_532_2_stab_lem_benchmarks.py new file mode 100644 index 000000000..bc5da1f29 --- /dev/null +++ b/resources/tester/test_532_2_stab_lem_benchmarks.py @@ -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"])) diff --git a/resources/tester/test_532_2_stab_section_model.py b/resources/tester/test_532_2_stab_section_model.py new file mode 100644 index 000000000..909ed9544 --- /dev/null +++ b/resources/tester/test_532_2_stab_section_model.py @@ -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"])) From deaacc11003e50eed622660dfcac849ff73f0b61 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 20:07:38 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix(532-3):=20E02=20=EC=B8=A1=EC=A0=90(B06?= =?UTF-8?q?=20=EC=95=94=20=EA=B2=BD=EA=B3=84)=EC=9D=84=20E01=20=EB=8F=99?= =?UTF-8?q?=EA=B2=B0=20=EB=B3=B5=EC=82=AC=EB=B3=B8=20db.json=20=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=9D=BD=EC=9D=8C=20=E2=80=94=20=EC=9B=90=EB=B3=B8?= =?UTF-8?q?=20=EC=9E=84=EB=8F=84=20DB=20=EC=95=88=20=EC=9D=BD=EC=9D=8C=20?= =?UTF-8?q?=C2=B7=20=EB=B3=B5=EC=82=AC=EB=B3=B8=20=EC=97=86=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=EB=B9=88=20=EB=AA=A9=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- E02_StabGround/E02_StabGround_Router.py | 84 ++++++++++------------- resources/tester/test_532_3_e02_ground.py | 45 +++++++----- 2 files changed, 64 insertions(+), 65 deletions(-) diff --git a/E02_StabGround/E02_StabGround_Router.py b/E02_StabGround/E02_StabGround_Router.py index aa25b9ace..8b9ad1663 100644 --- a/E02_StabGround/E02_StabGround_Router.py +++ b/E02_StabGround/E02_StabGround_Router.py @@ -5,7 +5,7 @@ · 측점(B06 암 경계)은 서버 값 - `POST …/stab/ground/reset` — [초기화] 초기값을 작업본에 덮어씀(재계산 아님) -초기값 = E 생성 직후 마스터 기본값 + B06 측점(`ensure_initial`) · 그 밖에는 쓰지 않음. +초기값 = E 생성 직후 마스터 기본값 + 복사본 B06 측점(`ensure_initial`) · 그 밖에는 쓰지 않음. 공유 회사(보기만)는 `require_project_access` 가 PUT · POST 를 막음. """ @@ -21,12 +21,13 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path -from B05_Profile.B05_Profile_Repository import get_latest_route 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"]) @@ -36,38 +37,30 @@ class GroundRequest(BaseModel): ground: dict[str, Any] -async def _b06_stations(connection: aiomysql.Connection, project_ids: list[str]) -> list[dict]: +def _b06_stations(project_root: Path) -> list[dict]: """측점 · 지반 라벨 · 토층 두께(암 측점 = 암 경계 깊이 · 토사 = 없음). - 자기 복사본(532-1)이 먼저 · 없으면 원본 임도.""" - for project_id in project_ids: - route = await get_latest_route(connection, UUID(project_id)) - if not route: - continue - async with connection.cursor() as cursor: - await cursor.execute( - "SELECT chainage_m, data FROM cross_sections WHERE route_id = %s" - " ORDER BY chainage_m", - (route["id"],), - ) - rows = await cursor.fetchall() - stations = [] - for chainage, data in rows: - data = json.loads(data) if isinstance(data, str) else 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(chainage), 3), - "ground_type": ground_type, - "soil_thickness_m": round(abs(float(offset)), 3) if rock else None, - } - ) - if stations: - return stations - return [] + 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]]: @@ -78,7 +71,7 @@ async def _project(project_id: UUID) -> tuple[Path, dict[str, Any]]: raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") from error async with connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( - "SELECT company_id, project_kind, source_project_id FROM projects WHERE id = %s", + "SELECT company_id, project_kind FROM projects WHERE id = %s", (str(project_id),), ) project = await cursor.fetchone() @@ -87,23 +80,20 @@ async def _project(project_id: UUID) -> tuple[Path, dict[str, Any]]: return Path(resolve_stored_project_path(stored)), project -async def ensure_initial(project_root: Path, project_id: str, source_id: str | None) -> dict: - """초기값이 없을 때만 한 번 — 기본값 + B06 측점(E 생성 직후 · 옛 E 프로젝트는 첫 GET).""" +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")) - async with get_db_pool().acquire() as connection: - ids = [project_id] + ([source_id] if source_id else []) - stations = await _b06_stations(connection, ids) - ground = model.build_initial(model.load_defaults(), stations) + 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, project = await _project(UUID(project_id)) - await ensure_initial(root, project_id, project["source_project_id"]) + root, _ = await _project(UUID(project_id)) + ensure_initial(root) def _read_working(project_root: Path) -> dict[str, Any] | None: @@ -120,7 +110,7 @@ async def get_ground( project_id: UUID, session: dict[str, Any] = Depends(verify_session) ) -> dict[str, Any]: root, project = await _project(project_id) - initial = await ensure_initial(root, str(project_id), project["source_project_id"]) + initial = ensure_initial(root) working = _read_working(root) return { "ground": working or initial, @@ -132,16 +122,14 @@ async def get_ground( @router.put("/{project_id}/stab/ground") async def put_ground(project_id: UUID, payload: GroundRequest) -> dict[str, Any]: - root, project = await _project(project_id) + 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 await ensure_initial( - root, str(project_id), project["source_project_id"] - ) + 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) @@ -150,7 +138,7 @@ async def put_ground(project_id: UUID, payload: GroundRequest) -> dict[str, Any] @router.post("/{project_id}/stab/ground/reset") async def reset_ground(project_id: UUID) -> dict[str, Any]: - root, project = await _project(project_id) - initial = await ensure_initial(root, str(project_id), project["source_project_id"]) + root, _ = await _project(project_id) + initial = ensure_initial(root) atomic_write_json(model.ground_path(root), initial) return {"ground": initial} diff --git a/resources/tester/test_532_3_e02_ground.py b/resources/tester/test_532_3_e02_ground.py index bb04b9a17..ce9b1741d 100644 --- a/resources/tester/test_532_3_e02_ground.py +++ b/resources/tester/test_532_3_e02_ground.py @@ -183,28 +183,33 @@ def test_측점_적용(): 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", "source_project_id": None} - - async def fake_stations(_connection, _ids): - return copy.deepcopy(STATIONS) - - class FakePool: - def acquire(self): - class Ctx: - async def __aenter__(self): - return None - - async def __aexit__(self, *_): - return False - - return Ctx() + return tmp_path, {"company_id": 1, "project_kind": "STABILITY"} monkeypatch.setattr(router, "_project", fake_project) - monkeypatch.setattr(router, "_b06_stations", fake_stations) - monkeypatch.setattr(router, "get_db_pool", lambda: FakePool()) + _write_copy(tmp_path) return tmp_path @@ -276,6 +281,12 @@ def test_저장_다시읽음_초기화(project): 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